Skip to content

Commit 8b19378

Browse files
claudejirispilka
authored andcommitted
feat: Split call-actor into data + -widget tools
Step 5 of 6 in the #577 umbrella. Mirrors the decoupled-pattern recipe from #716/PR #722 (fetch-actor-details) and #723 (search-actors): - call-actor now always returns pure data in both modes. No tool-level widget _meta anywhere, and buildStartAsyncResponse({ widget: false }) in apps mode returns runId without response-level widget _meta. The apps variant still runs asynchronously. - New call-actor-widget (apps-only) renders the live progress widget. Input is strict: { actor, input, callOptions? } only — async and previewOutput are rejected. Tool- and response-level widget _meta (ui.resourceUri = ui://widget/actor-run.html). - Apps server instructions reshaped: the "never poll get-actor-run" rule now scopes to call-actor-widget only; polling after the silent call-actor is fine. Added a third disambiguation bullet pairing call-actor with call-actor-widget using the same "interactive UI element / widget" vocabulary as the other two splits. - buildCallActorDescription alwaysAsync branch flipped to describe the silent-async behavior and point to call-actor-widget for UI. https://claude.ai/code/session_01LPzCFY7ReLm8wvmFHJLyun
1 parent 3f2503b commit 8b19378

File tree

11 files changed

+318
-24
lines changed

11 files changed

+318
-24
lines changed

src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const USER_AGENT_ORIGIN = 'Origin/mcp-server';
2525
export enum HelperTools {
2626
ACTOR_ADD = 'add-actor',
2727
ACTOR_CALL = 'call-actor',
28+
ACTOR_CALL_WIDGET = 'call-actor-widget',
2829
ACTOR_GET_DETAILS = 'fetch-actor-details',
2930
ACTOR_GET_DETAILS_WIDGET = 'fetch-actor-details-widget',
3031
ACTOR_OUTPUT_GET = 'get-actor-output',

src/tools/apps/call_actor.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import log from '@apify/log';
22

33
import { HelperTools } from '../../const.js';
4-
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
54
import type { InternalToolArgs, ToolEntry } from '../../types.js';
65
import { extractActorId } from '../../utils/tools.js';
76
import {
@@ -24,8 +23,8 @@ const CALL_ACTOR_APPS_DESCRIPTION = buildCallActorDescription({
2423

2524
/**
2625
* Apps mode call-actor tool.
27-
* Always runs asynchronously — starts the run and returns immediately with widget metadata.
28-
* The widget automatically tracks progress and updates the UI.
26+
* Always runs asynchronously — starts the run and returns immediately with runId.
27+
* Renders no widget; for a live progress UI, use the call-actor-widget sibling.
2928
*/
3029
export const appsCallActor: ToolEntry = Object.freeze({
3130
type: 'internal',
@@ -35,10 +34,6 @@ export const appsCallActor: ToolEntry = Object.freeze({
3534
outputSchema: callActorOutputSchema,
3635
ajvValidate: callActorAjvValidate,
3736
paymentRequired: true,
38-
// apps-only tool; apps-mode _meta (ui/* and openai/* keys) stripped in non-apps mode by stripWidgetMeta() in src/utils/tools.ts
39-
_meta: {
40-
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
41-
},
4237
annotations: {
4338
title: 'Call Actor',
4439
readOnlyHint: false,
@@ -77,7 +72,7 @@ export const appsCallActor: ToolEntry = Object.freeze({
7772
actorName: baseActorName,
7873
actorRun,
7974
input,
80-
widget: true,
75+
widget: false,
8176
});
8277
return {
8378
...response,
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import dedent from 'dedent';
2+
import { z } from 'zod';
3+
4+
import log from '@apify/log';
5+
6+
import { HelperTools } from '../../const.js';
7+
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
8+
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
9+
import { compileSchema } from '../../utils/ajv.js';
10+
import { extractActorId } from '../../utils/tools.js';
11+
import {
12+
buildCallActorErrorResponse,
13+
buildStartAsyncResponse,
14+
callActorPreExecute,
15+
resolveAndValidateActor,
16+
} from '../core/call_actor_common.js';
17+
import { callActorOutputSchema } from '../structured_output_schemas.js';
18+
19+
/**
20+
* Widget-only input: `actor` + `input` + optional `callOptions`. `.strict()` rejects stray keys
21+
* (e.g. `async`, `previewOutput`) so callers can't smuggle the base tool's options into the
22+
* widget variant. The widget is always async.
23+
*/
24+
const callActorWidgetArgsSchema = z.object({
25+
actor: z.string()
26+
.describe(dedent`
27+
The name of the Actor to call. Format: "username/name" (e.g., "apify/rag-web-browser").
28+
29+
For MCP server Actors, use format "actorName:toolName" to call a specific tool (e.g., "apify/actors-mcp-server:fetch-apify-docs").
30+
`),
31+
input: z.object({}).passthrough()
32+
.describe('The input JSON to pass to the Actor. Required.'),
33+
callOptions: z.object({
34+
memory: z.number()
35+
.min(128, 'Memory must be at least 128 MB')
36+
.max(32768, 'Memory cannot exceed 32 GB (32768 MB)')
37+
.optional()
38+
.describe(dedent`
39+
Memory allocation for the Actor in MB. Must be a power of 2 (e.g., 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768).
40+
Minimum: 128 MB, Maximum: 32768 MB (32 GB).
41+
`),
42+
timeout: z.number()
43+
.min(0, 'Timeout must be 0 or greater')
44+
.optional()
45+
.describe(dedent`
46+
Maximum runtime for the Actor in seconds. After this time elapses, the Actor will be automatically terminated.
47+
Use 0 for infinite timeout (no time limit). Minimum: 0 seconds (infinite).
48+
`),
49+
}).optional()
50+
.describe('Optional call options for the Actor run configuration.'),
51+
}).strict();
52+
53+
const CALL_ACTOR_WIDGET_DESCRIPTION = dedent`
54+
Render an interactive UI element (widget) that displays live Actor run progress for the user.
55+
56+
Use this tool ONLY when the user explicitly wants to see run progress visually
57+
(e.g., "run apify/rag-web-browser and show progress", "start this Actor with a progress view").
58+
The response renders as an interactive widget that automatically tracks run status until
59+
completion — do NOT poll or call any other tool after this.
60+
61+
For silent async starts where no UI is needed (e.g., "start this in the background",
62+
or when your next step is to fetch results via ${HelperTools.ACTOR_OUTPUT_GET}), use
63+
${HelperTools.ACTOR_CALL} instead — it returns the same runId without rendering a widget.
64+
65+
WORKFLOW:
66+
1. Use ${HelperTools.ACTOR_GET_DETAILS} to get the Actor's input schema
67+
2. Call this tool with the actor name and proper input based on the schema
68+
69+
If the actor name is not in "username/name" format, use ${HelperTools.STORE_SEARCH} to resolve the correct Actor first.
70+
71+
Input: actor name and input JSON; callOptions (memory, timeout) are optional.
72+
`;
73+
74+
export const appsCallActorWidget: ToolEntry = Object.freeze({
75+
type: 'internal',
76+
name: HelperTools.ACTOR_CALL_WIDGET,
77+
description: CALL_ACTOR_WIDGET_DESCRIPTION,
78+
inputSchema: z.toJSONSchema(callActorWidgetArgsSchema) as ToolInputSchema,
79+
outputSchema: callActorOutputSchema,
80+
// Allow arbitrary keys inside `input` (dynamic Actor input) while keeping the outer shape strict.
81+
ajvValidate: compileSchema(z.toJSONSchema(callActorWidgetArgsSchema)),
82+
paymentRequired: true,
83+
// Tool-level widget meta; only registered in apps mode so stripWidgetMeta is a no-op here.
84+
_meta: {
85+
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
86+
},
87+
annotations: {
88+
title: 'Call Actor (widget)',
89+
readOnlyHint: false,
90+
destructiveHint: true,
91+
idempotentHint: false,
92+
openWorldHint: true,
93+
},
94+
call: async (toolArgs: InternalToolArgs) => {
95+
const preResult = await callActorPreExecute(toolArgs);
96+
if ('earlyResponse' in preResult) {
97+
return preResult.earlyResponse;
98+
}
99+
100+
const { parsed, baseActorName } = preResult;
101+
const { input, callOptions } = parsed;
102+
103+
let resolvedActorId: string | undefined;
104+
try {
105+
const resolution = await resolveAndValidateActor({
106+
actorName: baseActorName,
107+
input: input as Record<string, unknown>,
108+
toolArgs,
109+
});
110+
if ('error' in resolution) {
111+
return resolution.error;
112+
}
113+
114+
resolvedActorId = extractActorId(resolution.actor);
115+
const { apifyClient } = toolArgs;
116+
117+
const actorClient = apifyClient.actor(baseActorName);
118+
const actorRun = await actorClient.start(input, callOptions);
119+
log.debug('Started Actor run (widget)', { actorName: baseActorName, runId: actorRun.id, mcpSessionId: toolArgs.mcpSessionId });
120+
const response = buildStartAsyncResponse({
121+
actorName: baseActorName,
122+
actorRun,
123+
input,
124+
widget: true,
125+
});
126+
return {
127+
...response,
128+
toolTelemetry: { actorId: resolvedActorId },
129+
};
130+
} catch (error) {
131+
return buildCallActorErrorResponse({
132+
actorName: baseActorName,
133+
error,
134+
actorId: resolvedActorId,
135+
isAsync: true,
136+
mcpSessionId: toolArgs.mcpSessionId,
137+
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
138+
storeSearchTool: HelperTools.STORE_SEARCH,
139+
});
140+
}
141+
},
142+
} as const);

src/tools/categories.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import type { ToolEntry } from '../types.js';
1717
import { ServerMode } from '../types.js';
1818
import { appsCallActor } from './apps/call_actor.js';
19+
import { appsCallActorWidget } from './apps/call_actor_widget.js';
1920
import { fetchActorDetailsWidgetTool } from './apps/fetch_actor_details_widget.js';
2021
import { appsGetActorRun } from './apps/get_actor_run.js';
2122
import { searchActorsWidgetTool } from './apps/search_actors_widget.js';
@@ -74,6 +75,7 @@ export const toolCategories = {
7475
ui: [
7576
{ apps: searchActorsWidgetTool },
7677
{ apps: fetchActorDetailsWidgetTool },
78+
{ apps: appsCallActorWidget },
7779
],
7880
docs: [
7981
searchApifyDocsTool,

src/tools/core/call_actor_common.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ export function buildCallActorDescription(params: CallActorDescriptionParams): s
107107
if (alwaysAsync) {
108108
sections.push(dedent`
109109
IMPORTANT:
110-
- This tool always runs asynchronously — it starts the Actor and returns immediately with a runId. A live widget automatically tracks the run progress.
111-
- After calling this tool, do NOT poll or call any other tool. Wait for the user to respond — the widget will update them when the run completes.
110+
- This tool always runs asynchronously — it starts the Actor and returns immediately with a runId. It renders no UI.
111+
- For a live progress widget the user can watch, call ${HelperTools.ACTOR_CALL_WIDGET} instead.
112+
- To check status or wait for completion, poll ${HelperTools.ACTOR_RUNS_GET} with the runId.
112113
- Once the run completes, use ${HelperTools.ACTOR_OUTPUT_GET} tool with the datasetId to fetch full results.
113114
- Use dedicated Actor tools when available for better experience
114115
`);

src/tools/default/call_actor.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import log from '@apify/log';
22

33
import { HelperTools } from '../../const.js';
4-
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
54
import type { InternalToolArgs, ToolEntry } from '../../types.js';
65
import { buildUsageMeta } from '../../utils/mcp.js';
76
import { extractActorId } from '../../utils/tools.js';
@@ -38,10 +37,6 @@ export const defaultCallActor: ToolEntry = Object.freeze({
3837
outputSchema: callActorOutputSchema,
3938
ajvValidate: callActorAjvValidate,
4039
paymentRequired: true,
41-
// openai/* and ui keys are stripped in non-apps mode by stripWidgetMeta() in src/utils/tools.ts
42-
_meta: {
43-
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
44-
},
4540
annotations: {
4641
title: 'Call Actor',
4742
readOnlyHint: false,

src/utils/server-instructions/apps.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,21 @@ import { getCommonInstructions } from './common.js';
99
const WORKFLOW_RULES = `
1010
## CRITICAL: Apps Mode Workflow Rules
1111
12-
**NEVER call \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL}\` in apps mode.**
12+
**NEVER call \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL_WIDGET}\` in apps mode.**
1313
14-
When you call \`${HelperTools.ACTOR_CALL}\` in async mode (apps mode), the response will include a widget that automatically polls for status updates. You must NOT call \`${HelperTools.ACTOR_RUNS_GET}\` or any other tool after this - your task is complete. The widget handles everything automatically.
14+
When you call \`${HelperTools.ACTOR_CALL_WIDGET}\`, the response renders a widget that automatically polls for status updates. You must NOT call \`${HelperTools.ACTOR_RUNS_GET}\` or any other tool after this your task is complete. The widget handles everything automatically.
1515
16-
This is FORBIDDEN and will result in unnecessary duplicate polling. Always stop after receiving the \`${HelperTools.ACTOR_CALL}\` response in apps mode.
16+
This is FORBIDDEN and will result in unnecessary duplicate polling. Always stop after receiving the \`${HelperTools.ACTOR_CALL_WIDGET}\` response.
17+
18+
Polling \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL}\` (the silent async variant, no widget) is fine — that tool renders no UI, so polling is expected when you need the run status.
1719
1820
`;
1921

2022
const TOOL_DISAMBIGUATION = `
2123
- **Data vs widget Actor tools:**
2224
- \`${HelperTools.STORE_SEARCH}\` is a silent data lookup (Actor list for name resolution) with no UI; \`${HelperTools.STORE_SEARCH_WIDGET}\` renders an interactive UI element (widget) with Actor search results for the user to browse — use it only when the user explicitly asks to search or discover Actors
2325
- \`${HelperTools.ACTOR_GET_DETAILS}\` is a silent data lookup (input schema, README, metadata) with no UI; \`${HelperTools.ACTOR_GET_DETAILS_WIDGET}\` renders an interactive UI element (widget) with Actor details for the user to view — use it only when the user explicitly asks to see or browse the Actor
26+
- \`${HelperTools.ACTOR_CALL}\` is a silent async start (returns runId, no UI); \`${HelperTools.ACTOR_CALL_WIDGET}\` renders an interactive UI element (widget) that tracks live Actor run progress for the user — use it only when the user explicitly asks to see progress
2427
- When the next step is running an Actor, ALWAYS use \`${HelperTools.STORE_SEARCH}\` for name resolution, never \`${HelperTools.STORE_SEARCH_WIDGET}\``;
2528

2629
/** Returns server instructions for apps (MCP Apps UI) mode. */

tests/integration/suite.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ function validateStructuredOutputForTool(result: unknown, toolName: string, mode
142142

143143
/** Validates that the listed tools have widget metadata (_meta) with MCP Apps ui.* keys. */
144144
function expectWidgetToolMeta(tools: { tools: { name: string; _meta?: Record<string, unknown> }[] }): void {
145-
const toolNames = [HelperTools.STORE_SEARCH, HelperTools.ACTOR_GET_DETAILS, HelperTools.ACTOR_CALL];
145+
const toolNames = [HelperTools.STORE_SEARCH_WIDGET, HelperTools.ACTOR_GET_DETAILS_WIDGET, HelperTools.ACTOR_CALL_WIDGET];
146146
for (const toolName of toolNames) {
147147
const tool = tools.tools.find((t) => t.name === toolName);
148148
expect(tool).toBeDefined();
@@ -2463,6 +2463,7 @@ export function createIntegrationTestsSuite(
24632463
// Verify that apps-only internal tools are present in apps mode
24642464
expect(toolNames).toContain(HelperTools.ACTOR_GET_DETAILS_WIDGET);
24652465
expect(toolNames).toContain(HelperTools.STORE_SEARCH_WIDGET);
2466+
expect(toolNames).toContain(HelperTools.ACTOR_CALL_WIDGET);
24662467

24672468
// Verify that tools have widget metadata when UI mode is enabled
24682469
expectWidgetToolMeta(tools);
@@ -2479,6 +2480,7 @@ export function createIntegrationTestsSuite(
24792480
// Verify that apps-only internal tools are present in apps mode
24802481
expect(toolNames).toContain(HelperTools.ACTOR_GET_DETAILS_WIDGET);
24812482
expect(toolNames).toContain(HelperTools.STORE_SEARCH_WIDGET);
2483+
expect(toolNames).toContain(HelperTools.ACTOR_CALL_WIDGET);
24822484

24832485
// Verify that tools have widget metadata when UI mode is enabled via URL parameter
24842486
expectWidgetToolMeta(tools);
@@ -2497,6 +2499,7 @@ export function createIntegrationTestsSuite(
24972499
// Verify that apps-only internal tools are present when ui=true is used
24982500
expect(toolNames).toContain(HelperTools.ACTOR_GET_DETAILS_WIDGET);
24992501
expect(toolNames).toContain(HelperTools.STORE_SEARCH_WIDGET);
2502+
expect(toolNames).toContain(HelperTools.ACTOR_CALL_WIDGET);
25002503

25012504
// Verify that tools have widget metadata when ui=true is used
25022505
expectWidgetToolMeta(tools);

tests/unit/tools.call_actor_common.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('call_actor_common', () => {
2424
expect(description).not.toContain(`Do NOT use ${HelperTools.STORE_SEARCH} for name resolution`);
2525
});
2626

27-
it('builds the apps description with public search helper and async guidance', () => {
27+
it('builds the apps description with public search helper and silent-async guidance pointing to the widget sibling', () => {
2828
const description = buildCallActorDescription({
2929
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
3030
storeSearchTool: HelperTools.STORE_SEARCH,
@@ -35,7 +35,9 @@ describe('call_actor_common', () => {
3535
expect(description).toContain(`Use ${HelperTools.ACTOR_GET_DETAILS} to get the Actor's input schema`);
3636
expect(description).toContain(`use ${HelperTools.STORE_SEARCH} to resolve the correct Actor first`);
3737
expect(description).toContain('always runs asynchronously');
38-
expect(description).toContain('do NOT poll or call any other tool');
38+
expect(description).toContain('It renders no UI');
39+
expect(description).toContain(`call ${HelperTools.ACTOR_CALL_WIDGET} instead`);
40+
expect(description).toContain(`poll ${HelperTools.ACTOR_RUNS_GET} with the runId`);
3941
expect(description).not.toContain(`Do NOT use ${HelperTools.STORE_SEARCH} for name resolution`);
4042
expect(description).not.toContain('When `async: false` or not provided');
4143
});

0 commit comments

Comments
 (0)