|
1 | | -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; |
2 | | -import { z } from 'zod'; |
3 | | - |
4 | | -import log from '@apify/log'; |
5 | | - |
6 | | -import { ApifyClient, createApifyClientWithSkyfireSupport } from '../apify-client.js'; |
7 | | -import { |
8 | | - CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG, |
9 | | - HelperTools, |
10 | | - TOOL_STATUS, |
11 | | -} from '../const.js'; |
12 | | -import { connectMCPClient } from '../mcp/client.js'; |
13 | | -import { getWidgetConfig, WIDGET_URIS } from '../resources/widgets.js'; |
14 | | -import type { |
15 | | - InternalToolArgs, |
16 | | - ToolEntry, |
17 | | - ToolInputSchema, |
18 | | - UiMode, |
19 | | -} from '../types.js'; |
20 | | -import { getActorMcpUrlCached } from '../utils/actor.js'; |
21 | | -import { compileSchema } from '../utils/ajv.js'; |
22 | | -import { logHttpError } from '../utils/logging.js'; |
23 | | -import { buildMCPResponse, buildUsageMeta } from '../utils/mcp.js'; |
24 | | -import { callActorGetDataset } from './core/actor-execution.js'; |
25 | | -import { buildActorResponseContent } from './core/actor-response.js'; |
26 | | -import { getActorsAsTools } from './core/actor-tools-factory.js'; |
27 | | -import { callActorOutputSchema } from './structured-output-schemas.js'; |
28 | | -import { actorNameToToolName } from './utils.js'; |
| 1 | +/** |
| 2 | + * Adapter for call-actor tool — delegates to the appropriate mode-specific variant. |
| 3 | + * |
| 4 | + * The original monolithic call-actor implementation has been split into: |
| 5 | + * - `default/call-actor.ts` — sync execution, references default tools |
| 6 | + * - `openai/call-actor.ts` — forced async, widget metadata, references internal tools |
| 7 | + * - `core/call-actor-common.ts` — shared pre-execution logic (arg parsing, MCP handling, validation) |
| 8 | + * |
| 9 | + * This adapter file maintains backward compatibility for existing imports. |
| 10 | + * PR #4 will wire variants directly into the category registry, making this adapter unnecessary. |
| 11 | + */ |
| 12 | +import type { HelperTool, InternalToolArgs, ToolEntry, UiMode } from '../types.js'; |
| 13 | +import { defaultCallActor } from './default/call-actor.js'; |
| 14 | +import { openaiCallActor } from './openai/call-actor.js'; |
29 | 15 |
|
30 | 16 | // Re-exports to maintain backward compatibility and support other modules |
31 | 17 | export { callActorGetDataset, type CallActorGetDatasetResult } from './core/actor-execution.js'; |
32 | 18 | export { getActorsAsTools } from './core/actor-tools-factory.js'; |
33 | 19 |
|
34 | | -const callActorArgs = z.object({ |
35 | | - actor: z.string() |
36 | | - .describe(`The name of the Actor to call. Format: "username/name" (e.g., "apify/rag-web-browser"). |
37 | | -
|
38 | | -For MCP server Actors, use format "actorName:toolName" to call a specific tool (e.g., "apify/actors-mcp-server:fetch-apify-docs").`), |
39 | | - input: z.object({}).passthrough() |
40 | | - .describe('The input JSON to pass to the Actor. Required.'), |
41 | | - async: z.boolean() |
42 | | - .optional() |
43 | | - .describe(`When true: starts the run and returns immediately with runId. When false or not provided: waits for completion and returns results immediately. Default: true when UI mode is enabled (enforced), false otherwise. IMPORTANT: Only set async to true if the user explicitly asks to run the Actor in the background or does not need immediate results. When the user asks for data or results, always use async: false (default) so the results are returned immediately.`), |
44 | | - previewOutput: z.boolean() |
45 | | - .optional() |
46 | | - .describe('When true (default): includes preview items. When false: metadata only (reduces context). Use when fetching fields via get-actor-output.'), |
47 | | - callOptions: z.object({ |
48 | | - memory: z.number() |
49 | | - .min(128, 'Memory must be at least 128 MB') |
50 | | - .max(32768, 'Memory cannot exceed 32 GB (32768 MB)') |
51 | | - .optional() |
52 | | - .describe(`Memory allocation for the Actor in MB. Must be a power of 2 (e.g., 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768). Minimum: 128 MB, Maximum: 32768 MB (32 GB).`), |
53 | | - timeout: z.number() |
54 | | - .min(0, 'Timeout must be 0 or greater') |
55 | | - .optional() |
56 | | - .describe(`Maximum runtime for the Actor in seconds. After this time elapses, the Actor will be automatically terminated. Use 0 for infinite timeout (no time limit). Minimum: 0 seconds (infinite).`), |
57 | | - }).optional() |
58 | | - .describe('Optional call options for the Actor run configuration.'), |
59 | | -}); |
60 | | - |
61 | 20 | /** |
62 | | - * This is a bit of a hacky way to deal with it, but let's use it for now |
| 21 | + * Returns the call-actor description for the given UI mode. |
| 22 | + * Maintained for backward compatibility with tools-loader.ts which mutates the description at load time. |
| 23 | + * PR #4 will remove this in favor of direct variant registration. |
63 | 24 | */ |
64 | 25 | export function getCallActorDescription(uiMode?: UiMode): string { |
65 | | - const isUiMode = uiMode === 'openai'; |
66 | | - const schemaTool = isUiMode ? HelperTools.ACTOR_GET_DETAILS_INTERNAL : HelperTools.ACTOR_GET_DETAILS; |
67 | | - const searchTool = isUiMode ? HelperTools.STORE_SEARCH_INTERNAL : HelperTools.STORE_SEARCH; |
68 | | - const searchToolWarning = isUiMode |
69 | | - ? `Do NOT use ${HelperTools.STORE_SEARCH} for name resolution when the next step is running an Actor.` |
70 | | - : ''; |
71 | | - |
72 | | - return `Call any Actor from the Apify Store. |
73 | | -
|
74 | | -WORKFLOW: |
75 | | -1. Use ${schemaTool} to get the Actor's input schema |
76 | | -2. Call this tool with the actor name and proper input based on the schema |
77 | | -
|
78 | | -If the actor name is not in "username/name" format, use ${searchTool} to resolve the correct Actor first. |
79 | | -${searchToolWarning} |
80 | | -
|
81 | | -For MCP server Actors: |
82 | | -- Use fetch-actor-details with output={ mcpTools: true } to list available tools |
83 | | -- Call using format: "actorName:toolName" (e.g., "apify/actors-mcp-server:fetch-apify-docs") |
84 | | -
|
85 | | -IMPORTANT: |
86 | | -- Typically returns a datasetId and preview of output items |
87 | | -- Use ${HelperTools.ACTOR_OUTPUT_GET} tool with the datasetId to fetch full results |
88 | | -- Use dedicated Actor tools when available (e.g., ${actorNameToToolName('apify/rag-web-browser')}) for better experience |
89 | | -
|
90 | | -There are two ways to run Actors: |
91 | | -1. Dedicated Actor tools (e.g., ${actorNameToToolName('apify/rag-web-browser')}): These are pre-configured tools, offering a simpler and more direct experience. |
92 | | -2. Generic call-actor tool (${HelperTools.ACTOR_CALL}): Use this when a dedicated tool is not available or when you want to run any Actor dynamically. This tool is especially useful if you do not want to add specific tools or your client does not support dynamic tool registration. |
93 | | -
|
94 | | -USAGE: |
95 | | -- Always use dedicated tools when available (e.g., ${actorNameToToolName('apify/rag-web-browser')}) |
96 | | -- Use the generic call-actor tool only if a dedicated tool does not exist for your Actor. |
97 | | -
|
98 | | -- This tool supports async execution via the \`async\` parameter: |
99 | | - - **When \`async: false\` or not provided** (default): Waits for completion and returns results immediately with dataset preview. Use this whenever the user asks for data or results. |
100 | | - - **When \`async: true\`**: Starts the run and returns immediately with runId. Only use this when the user explicitly asks to run the Actor in the background or does not need immediate results. When UI mode is enabled, async is always enforced and the widget automatically tracks progress. |
101 | | -
|
102 | | -EXAMPLES: |
103 | | -- user_input: Get instagram posts using apify/instagram-scraper`; |
| 26 | + const variant = uiMode === 'openai' ? openaiCallActor : defaultCallActor; |
| 27 | + return variant.description ?? ''; |
104 | 28 | } |
105 | 29 |
|
| 30 | +const defaultVariant = defaultCallActor as HelperTool; |
| 31 | + |
| 32 | +/** |
| 33 | + * Adapter call-actor tool that dispatches to the correct mode-specific variant at runtime. |
| 34 | + * |
| 35 | + * The tool definition (name, inputSchema, outputSchema, etc.) uses the default variant's metadata. |
| 36 | + * The `call` handler inspects `apifyMcpServer.options.uiMode` to delegate to the right implementation. |
| 37 | + * |
| 38 | + * Note: The description may be overridden by tools-loader.ts at load time for openai mode. |
| 39 | + */ |
106 | 40 | export const callActor: ToolEntry = { |
107 | | - type: 'internal', |
108 | | - name: HelperTools.ACTOR_CALL, |
109 | | - description: getCallActorDescription(), |
110 | | - inputSchema: z.toJSONSchema(callActorArgs) as ToolInputSchema, |
111 | | - outputSchema: callActorOutputSchema, |
112 | | - ajvValidate: compileSchema({ |
113 | | - ...z.toJSONSchema(callActorArgs), |
114 | | - // Additional props true to allow skyfire-pay-id |
115 | | - additionalProperties: true, |
116 | | - }), |
117 | | - requiresSkyfirePayId: true, |
118 | | - _meta: { |
119 | | - ...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta, |
120 | | - }, |
121 | | - annotations: { |
122 | | - title: 'Call Actor', |
123 | | - readOnlyHint: false, |
124 | | - destructiveHint: true, |
125 | | - idempotentHint: false, |
126 | | - openWorldHint: true, |
127 | | - }, |
128 | | - execution: { |
129 | | - // Support long-running tasks |
130 | | - taskSupport: 'optional', |
131 | | - }, |
| 41 | + ...defaultVariant, |
132 | 42 | call: async (toolArgs: InternalToolArgs) => { |
133 | | - const { args, apifyToken, progressTracker, extra, apifyMcpServer, mcpSessionId } = toolArgs; |
134 | | - const { actor: actorName, input, async, previewOutput = true, callOptions } = callActorArgs.parse(args); |
135 | | - |
136 | | - // Parse special format: actor:tool |
137 | | - const mcpToolMatch = actorName.match(/^(.+):(.+)$/); |
138 | | - let baseActorName = actorName; |
139 | | - let mcpToolName: string | undefined; |
140 | | - |
141 | | - if (mcpToolMatch) { |
142 | | - baseActorName = mcpToolMatch[1]; |
143 | | - mcpToolName = mcpToolMatch[2]; |
144 | | - } |
145 | | - |
146 | | - // For definition resolution we always use token-based client; Skyfire is only for actual Actor runs |
147 | | - const apifyClientForDefinition = new ApifyClient({ token: apifyToken }); |
148 | | - // Resolve MCP server URL |
149 | | - const mcpServerUrlOrFalse = await getActorMcpUrlCached(baseActorName, apifyClientForDefinition); |
150 | | - const isActorMcpServer = mcpServerUrlOrFalse && typeof mcpServerUrlOrFalse === 'string'; |
151 | | - |
152 | | - // Standby Actors, thus MCPs, are not supported in Skyfire mode |
153 | | - if (isActorMcpServer && apifyMcpServer.options.skyfireMode) { |
154 | | - return buildMCPResponse({ |
155 | | - texts: [`This Actor (${actorName}) is an MCP server and cannot be accessed using a Skyfire token. To use this Actor, please provide a valid Apify token instead of a Skyfire token.`], |
156 | | - isError: true, |
157 | | - toolStatus: TOOL_STATUS.SOFT_FAIL, |
158 | | - }); |
159 | | - } |
160 | | - |
161 | | - try { |
162 | | - // Determine execution mode: always async when UI mode is enabled, otherwise respect the parameter |
163 | | - const isAsync = apifyMcpServer.options.uiMode === 'openai' |
164 | | - ? true |
165 | | - : async ?? false; |
166 | | - |
167 | | - // Handle the case where LLM does not respect instructions when calling MCP server Actors |
168 | | - // and does not provide the tool name. |
169 | | - const isMcpToolNameInvalid = mcpToolName === undefined || mcpToolName.trim().length === 0; |
170 | | - if (isActorMcpServer && isMcpToolNameInvalid) { |
171 | | - return buildMCPResponse({ |
172 | | - texts: [CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG], |
173 | | - isError: true, |
174 | | - }); |
175 | | - } |
176 | | - |
177 | | - // Handle MCP tool calls |
178 | | - if (mcpToolName) { |
179 | | - if (!isActorMcpServer) { |
180 | | - return buildMCPResponse({ |
181 | | - texts: [`Actor '${baseActorName}' is not an MCP server.`], |
182 | | - isError: true, |
183 | | - }); |
184 | | - } |
185 | | - |
186 | | - // Validate input for MCP tool calls |
187 | | - if (!input) { |
188 | | - return buildMCPResponse({ |
189 | | - texts: [`Input is required for MCP tool '${mcpToolName}'. Please provide the input parameter based on the tool's input schema.`], |
190 | | - isError: true, |
191 | | - }); |
192 | | - } |
193 | | - |
194 | | - const mcpServerUrl = mcpServerUrlOrFalse; |
195 | | - let client: Client | null = null; |
196 | | - try { |
197 | | - client = await connectMCPClient(mcpServerUrl, apifyToken, mcpSessionId); |
198 | | - if (!client) { |
199 | | - return buildMCPResponse({ |
200 | | - texts: [`Failed to connect to MCP server ${mcpServerUrl}`], |
201 | | - isError: true, |
202 | | - }); |
203 | | - } |
204 | | - |
205 | | - const result = await client.callTool({ |
206 | | - name: mcpToolName, |
207 | | - arguments: input, |
208 | | - }); |
209 | | - |
210 | | - return { content: result.content }; |
211 | | - } catch (error) { |
212 | | - logHttpError(error, `Failed to call MCP tool '${mcpToolName}' on Actor '${baseActorName}'`, { |
213 | | - actorName: baseActorName, |
214 | | - toolName: mcpToolName, |
215 | | - }); |
216 | | - return buildMCPResponse({ |
217 | | - texts: [`Failed to call MCP tool '${mcpToolName}' on Actor '${baseActorName}': ${error instanceof Error ? error.message : String(error)}. The MCP server may be temporarily unavailable.`], |
218 | | - isError: true, |
219 | | - }); |
220 | | - } finally { |
221 | | - if (client) await client.close(); |
222 | | - } |
223 | | - } |
224 | | - |
225 | | - const apifyClient = createApifyClientWithSkyfireSupport(apifyMcpServer, args, apifyToken); |
226 | | - |
227 | | - // Handle regular Actor calls - fetch actor early to provide schema in error messages |
228 | | - const [actor] = await getActorsAsTools([actorName], apifyClient, { mcpSessionId }); |
229 | | - |
230 | | - if (!actor) { |
231 | | - return buildMCPResponse({ |
232 | | - texts: [`Actor '${actorName}' was not found. |
233 | | -Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists. |
234 | | -You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], |
235 | | - isError: true, |
236 | | - toolStatus: TOOL_STATUS.SOFT_FAIL, |
237 | | - }); |
238 | | - } |
239 | | - |
240 | | - // Validate input parameter is provided (now with schema available) |
241 | | - if (!input) { |
242 | | - const content = [ |
243 | | - `Input is required for Actor '${actorName}'. Please provide the input parameter based on the Actor's input schema.`, |
244 | | - `The input schema for this Actor was retrieved and is shown below:`, |
245 | | - `\`\`\`json\n${JSON.stringify(actor.inputSchema)}\n\`\`\``, |
246 | | - ]; |
247 | | - return buildMCPResponse({ texts: content, isError: true }); |
248 | | - } |
249 | | - |
250 | | - if (!actor.ajvValidate(input)) { |
251 | | - const { errors } = actor.ajvValidate; |
252 | | - const content = [ |
253 | | - `Input validation failed for Actor '${actorName}'. Please ensure your input matches the Actor's input schema.`, |
254 | | - `Input schema:\n\`\`\`json\n${JSON.stringify(actor.inputSchema)}\n\`\`\``, |
255 | | - ]; |
256 | | - if (errors && errors.length > 0) { |
257 | | - content.push(`Validation errors: ${errors.map((e) => (e as { message?: string; }).message).join(', ')}`); |
258 | | - } |
259 | | - return buildMCPResponse({ texts: content, isError: true }); |
260 | | - } |
261 | | - |
262 | | - // Async mode: start run and return immediately with runId |
263 | | - if (isAsync) { |
264 | | - const actorClient = apifyClient.actor(actorName); |
265 | | - const actorRun = await actorClient.start(input, callOptions); |
266 | | - |
267 | | - log.debug('Started Actor run (async)', { actorName, runId: actorRun.id, mcpSessionId }); |
268 | | - |
269 | | - const structuredContent = { |
270 | | - runId: actorRun.id, |
271 | | - actorName, // Full name with username (e.g., "apify/rag-web-browser") |
272 | | - status: actorRun.status, |
273 | | - startedAt: actorRun.startedAt?.toISOString() || '', |
274 | | - input, |
275 | | - }; |
276 | | - |
277 | | - // Build response text - simplified for widget auto-polling |
278 | | - let responseText = `Started Actor "${actorName}" (Run ID: ${actorRun.id}).`; |
279 | | - |
280 | | - if (apifyMcpServer.options.uiMode === 'openai') { |
281 | | - responseText += ` |
282 | | -
|
283 | | -A live progress widget has been rendered that automatically tracks this run and refreshes status every few seconds until completion. |
284 | | -
|
285 | | -The widget will update the context with run status and datasetId when the run completes. Once complete (or if the user requests results), use ${HelperTools.ACTOR_OUTPUT_GET} with the datasetId to retrieve the output. |
286 | | -
|
287 | | -Do NOT proactively poll using ${HelperTools.ACTOR_RUNS_GET}. Wait for the widget state update or user instructions. Ask the user what they would like to do next.`; |
288 | | - } |
289 | | - |
290 | | - const response: { content: { type: 'text'; text: string }[]; structuredContent?: unknown; _meta?: unknown } = { |
291 | | - content: [{ |
292 | | - type: 'text', |
293 | | - text: responseText, |
294 | | - }], |
295 | | - structuredContent, |
296 | | - }; |
297 | | - |
298 | | - if (apifyMcpServer.options.uiMode === 'openai') { |
299 | | - const widgetConfig = getWidgetConfig(WIDGET_URIS.ACTOR_RUN); |
300 | | - response._meta = { |
301 | | - ...widgetConfig?.meta, |
302 | | - 'openai/widgetDescription': `Actor run progress for ${actorName}`, |
303 | | - }; |
304 | | - } |
305 | | - |
306 | | - return response; |
307 | | - } |
308 | | - |
309 | | - const callResult = await callActorGetDataset({ |
310 | | - actorName, |
311 | | - input, |
312 | | - apifyClient, |
313 | | - callOptions, |
314 | | - progressTracker, |
315 | | - abortSignal: extra.signal, |
316 | | - previewOutput, |
317 | | - mcpSessionId, |
318 | | - }); |
319 | | - |
320 | | - if (!callResult) { |
321 | | - // Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request |
322 | | - // https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements |
323 | | - return {}; |
324 | | - } |
325 | | - |
326 | | - const { content, structuredContent } = buildActorResponseContent(actorName, callResult, previewOutput); |
327 | | - |
328 | | - const _meta = buildUsageMeta(callResult); |
329 | | - return { |
330 | | - content, |
331 | | - structuredContent, |
332 | | - ...(_meta && { _meta }), |
333 | | - }; |
334 | | - } catch (error) { |
335 | | - logHttpError(error, 'Failed to call Actor', { actorName, async: async ?? (apifyMcpServer.options.uiMode === 'openai') }); |
336 | | - // Let the server classify the error; we only mark it as an MCP error response |
337 | | - return buildMCPResponse({ |
338 | | - texts: [`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}. |
339 | | -Please verify the Actor name, input parameters, and ensure the Actor exists. |
340 | | -You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`], |
341 | | - isError: true, |
342 | | - }); |
343 | | - } |
| 43 | + const variant = (toolArgs.apifyMcpServer.options.uiMode === 'openai' |
| 44 | + ? openaiCallActor |
| 45 | + : defaultCallActor) as HelperTool; |
| 46 | + return variant.call(toolArgs); |
344 | 47 | }, |
345 | 48 | }; |
0 commit comments