Skip to content

Commit b5b3af6

Browse files
committed
refactor: split call-actor into default/openai variants
Extract the mode-specific branches from actor.ts into: - default/call-actor.ts — sync execution, full results, text response - openai/call-actor.ts — forced async, widget metadata, abbreviated text Shared args schema, MCP tool handling, actor validation, and pre-execution pipeline extracted to core/call-actor-common.ts. The original file becomes a thin adapter dispatching based on uiMode, preserving existing exports (callActor, getCallActorDescription, callActorGetDataset, getActorsAsTools).
1 parent 180112f commit b5b3af6

File tree

4 files changed

+609
-331
lines changed

4 files changed

+609
-331
lines changed

src/tools/actor.ts

Lines changed: 34 additions & 331 deletions
Original file line numberDiff line numberDiff line change
@@ -1,345 +1,48 @@
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';
2915

3016
// Re-exports to maintain backward compatibility and support other modules
3117
export { callActorGetDataset, type CallActorGetDatasetResult } from './core/actor-execution.js';
3218
export { getActorsAsTools } from './core/actor-tools-factory.js';
3319

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-
6120
/**
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.
6324
*/
6425
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 ?? '';
10428
}
10529

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+
*/
10640
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,
13242
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);
34447
},
34548
};

0 commit comments

Comments
 (0)