Skip to content

Commit 54881ba

Browse files
jirispilkaclaudeMQ37
authored
feat: Modify get-actor-run - add waitSec and progress (#823)
`get-actor-run` returns the canonical run shape (status, storages, stats, summary, nextStep) across all 8 Apify states. Text content carries `summary \n nextStep`; structuredContent has the full shape — no inlined items or record bodies. Adds `waitSecs` (0–45, default 30) and emits `notifications/progress` during the wait when `_meta.progressToken` is supplied. `get-actor-run-widget` uses `waitSecs=0` so initial render is non-blocking; widget self-polls. `SUCCEEDED` with `itemCount=0` runs a one-item probe with one retry to absorb Apify's ~5s pagination-counter lag — no false "no output" on a run that did write items. KV shows up in `summary` only; never recommended as `nextStep`, since it rarely carries real output (mostly SDK state). Follow-up PR #825 replaces `call-actor`'s `async` flag with the same `waitSecs` and reshapes `storages` to a plural alias-map. Stacks on this branch and lands together. **Follow-up #847** — consolidate duplicated MCP server test fixtures across three test files (surfaced during review); left out per "one thing per change". ## Why this PR is bulky Splitting the pieces below would leave master in a broken half-state: - New shared module `src/tools/core/actor_run_response.ts` centralizes the canonical shape, 8 status templates, wait+progress loop, lag-fallback probe, and abort race. #825 (`call-actor` v4) reuses every helper. - Test coverage for the new contract — shape, 8 templates, wait/progress, abort, lag-fallback. - Widget UI in `src/web/src/pages/ActorRun/ActorRun.tsx` switched to a `get-dataset-items` preview fetch with its own polling cadence — v4 dropped inlined items, so the UI is broken until the server stops inlining. - Spec, structured-output schema, tool-loader auto-inject, and widget dev stub round out the v4 contract across modes. Closes #822 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jakub Kopecký <themq37@gmail.com>
1 parent ef8d234 commit 54881ba

40 files changed

Lines changed: 3060 additions & 2045 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Breaking changes must be coordinated; check whether updates are needed in `apify
6666
- **Follow [CONTRIBUTING.md](./CONTRIBUTING.md) for all naming and coding standards.** It is the single source of truth for naming rules (function verbs, boolean prefixes, type suffixes, enumerations, file names, etc.), string formatting, parameters, error handling, and anti-patterns. Read it before writing code.
6767
- **Validate tool inputs with Zod.** No ad-hoc shape checks.
6868
- **Reference tool names via the `HelperTools` enum**, not hardcoded strings (exception: integration tests).
69+
- **Apps vs default mode**: only `*-widget` tools differ between modes. All non-widget tools (`call-actor`, `get-actor-run`, direct actor tools, `search-actors`, `fetch-actor-details`) share a single implementation across modes.
6970
- Always follow the latest [MCP spec](https://modelcontextprotocol.io/specification/2025-11-25) and [MCP Apps spec](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).
7071

7172
## Further reading

res/call_actor_redesign_v4.md

Lines changed: 134 additions & 103 deletions
Large diffs are not rendered by default.

src/mcp/server.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,11 @@ import { createResourceService } from '../resources/resource_service.js';
6565
import type { AvailableWidget } from '../resources/widgets.js';
6666
import { resolveAvailableWidgets } from '../resources/widgets.js';
6767
import { getTelemetryEnv, trackToolCall } from '../telemetry.js';
68-
import { appsActorExecutor } from '../tools/apps/actor_executor.js';
68+
import { actorExecutor } from '../tools/actor_executor.js';
6969
import { buildPermissionApprovalResponse, isPermissionApprovalError } from '../tools/core/call_actor_common.js';
70-
import { defaultActorExecutor } from '../tools/default/actor_executor.js';
7170
import { getActorsAsTools } from '../tools/index.js';
7271
import { decodeDotPropertyNames, legacyToolNameToNew } from '../tools/utils.js';
7372
import type {
74-
ActorExecutor,
7573
ActorsMcpServerOptions,
7674
ActorStore,
7775
ApifyRequestParams,
@@ -103,12 +101,6 @@ import { connectMCPClient } from './client.js';
103101
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js';
104102
import { createTaskCancellationWatcher, isTaskCancelled, parseInputParamsFromUrl } from './utils.js';
105103

106-
/** Mode → actor executor. Add new modes here. */
107-
const actorExecutorsByMode: Record<ServerMode, ActorExecutor> = {
108-
[ServerMode.DEFAULT]: defaultActorExecutor,
109-
[ServerMode.APPS]: appsActorExecutor,
110-
};
111-
112104
/**
113105
* Returns true when the initialize request advertises the MCP Apps UI extension
114106
* with the widget MIME type. Used to resolve `'auto'` server mode.
@@ -173,8 +165,6 @@ export class ActorsMcpServer {
173165
* client's capabilities are known. Effectively set-once per connection.
174166
*/
175167
public serverMode: ServerMode;
176-
/** Mode-specific executor for direct actor tools (`type: 'actor'`). Finalized with `serverMode`. */
177-
private actorExecutor: ActorExecutor;
178168
/**
179169
* Raw option captured from `options.serverMode` (or the legacy `uiMode`). Re-resolved
180170
* inside the initialize handler when set to `'auto'`; explicit `'default'`/`'apps'`
@@ -227,7 +217,6 @@ export class ActorsMcpServer {
227217
// client capabilities are known (only for 'auto').
228218
this.serverMode = resolveServerMode(this.serverModeOption, false);
229219
this.serverModeResolved = this.serverModeOption !== 'auto';
230-
this.actorExecutor = actorExecutorsByMode[this.serverMode];
231220

232221
const { setupSigintHandler = true } = options;
233222
this.server = new Server(
@@ -324,7 +313,6 @@ export class ActorsMcpServer {
324313
const resolved = resolveServerMode('auto', this.clientSupportsUi);
325314
if (resolved !== this.serverMode) {
326315
this.serverMode = resolved;
327-
this.actorExecutor = actorExecutorsByMode[this.serverMode];
328316
}
329317
this.serverModeResolved = true;
330318
}
@@ -1015,8 +1003,11 @@ export class ActorsMcpServer {
10151003

10161004
// Handle internal tool
10171005
if (tool.type === 'internal') {
1018-
// Only create a progress tracker for call-actor tool
1019-
const progressTracker = tool.name === 'call-actor'
1006+
// Tools that may emit notifications/progress during a sync wait must be opted in here.
1007+
// call-actor: emits during start+waitForFinish. get-actor-run: emits when waitSecs > 0.
1008+
const progressTrackerOptIn = tool.name === HelperTools.ACTOR_CALL
1009+
|| tool.name === HelperTools.ACTOR_RUNS_GET;
1010+
const progressTracker = progressTrackerOptIn
10201011
? createProgressTracker(progressToken, extra.sendNotification)
10211012
: null;
10221013

@@ -1130,14 +1121,15 @@ export class ActorsMcpServer {
11301121

11311122
try {
11321123
log.info('Calling Actor', { toolName: tool.name, actorName: tool.actorFullName, mcpSessionId, input: logSafeArgs });
1133-
const executorResult = await this.actorExecutor.executeActorTool({
1124+
const executorResult = await actorExecutor.executeActorTool({
11341125
actorFullName: tool.actorFullName,
11351126
input: toolArgs!,
11361127
apifyClient: apifyClient!,
11371128
callOptions: { memory: tool.memoryMbytes },
11381129
progressTracker,
11391130
abortSignal: extra.signal,
11401131
mcpSessionId,
1132+
datasetItemsSchema: tool.datasetItemsSchema,
11411133
});
11421134

11431135
if (!executorResult) {
@@ -1434,6 +1426,7 @@ export class ActorsMcpServer {
14341426
userRentedActorIds,
14351427
progressTracker,
14361428
mcpSessionId,
1429+
taskMode: true,
14371430
}) as Record<string, unknown>;
14381431

14391432
const diag = extractToolTelemetry(res, actorName, actorId);
@@ -1453,14 +1446,16 @@ export class ActorsMcpServer {
14531446

14541447
try {
14551448
log.info('Calling Actor for task', { taskId, toolName: tool.name, actorName: tool.actorFullName, mcpSessionId, input: logSafeArgs });
1456-
const executorResult = await this.actorExecutor.executeActorTool({
1449+
const executorResult = await actorExecutor.executeActorTool({
14571450
actorFullName: tool.actorFullName,
14581451
input: toolArgs,
14591452
apifyClient,
14601453
callOptions: { memory: tool.memoryMbytes },
14611454
progressTracker,
14621455
abortSignal: cancelWatcher.signal,
14631456
mcpSessionId,
1457+
datasetItemsSchema: tool.datasetItemsSchema,
1458+
taskMode: true,
14641459
});
14651460

14661461
if (!executorResult) {

src/mcp/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { loadToolsFromInput } from '../utils/tools_loader.js';
1414
* @param url The URL to process
1515
* @param apifyClient The Apify client instance
1616
* @param mode Server mode for tool variant resolution
17-
* @param actorStore
17+
* @param actorStore Optional store used to enrich direct actor tools' outputSchema with per-Actor itemsSchema.
1818
*/
1919
export async function processParamsGetTools(
2020
url: string,

src/tools/actor_executor.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import log from '@apify/log';
2+
3+
import type { ActorExecutionParams, ActorExecutionResult, ActorExecutor } from '../types.js';
4+
import { redactSkyfirePayId } from '../utils/logging.js';
5+
import { abortRunOnSignal, CALL_ACTOR_WAIT_SECS_DEFAULT, fetchActorRunData } from './core/actor_run_response.js';
6+
import { buildGetActorRunSuccessResponse } from './core/get_actor_run_common.js';
7+
8+
/**
9+
* Direct actor tool executor. Mode-agnostic — used in both default and apps modes.
10+
* Returns the canonical `RunResponse` shape; dataset items are not inlined — the LLM
11+
* follows `nextStep` to `get-dataset-items`.
12+
*
13+
* Wait contract matches `call-actor`: default 30 s, max 45, task mode waits until terminal.
14+
*/
15+
export const actorExecutor: ActorExecutor = {
16+
async executeActorTool(params: ActorExecutionParams): Promise<ActorExecutionResult> {
17+
const { actorFullName, apifyClient, mcpSessionId, abortSignal, progressTracker, taskMode } = params;
18+
// Strip `waitSecs` from the Actor's input — it's an MCP-injected opt-in, not an
19+
// Actor field — so `actor.start()` doesn't reject or silently pass it through.
20+
const { waitSecs: argsWaitSecs, ...actorInput } = params.input as { waitSecs?: number } & Record<string, unknown>;
21+
// Task mode waits until terminal; honoring waitSecs would let the task complete
22+
// before the Actor produced output. Mirrors executeCallActor.
23+
// AJV doesn't fill `default` values, so apply the 30 s default here when the LLM omits waitSecs.
24+
const waitSecs = taskMode ? undefined : (argsWaitSecs ?? CALL_ACTOR_WAIT_SECS_DEFAULT);
25+
const redactedInput = redactSkyfirePayId(params.input);
26+
27+
if (abortSignal?.aborted) {
28+
log.info('Actor run aborted by client before start', {
29+
actorName: actorFullName,
30+
mcpSessionId,
31+
input: redactedInput,
32+
});
33+
return null;
34+
}
35+
36+
const actorRun = await apifyClient.actor(actorFullName).start(actorInput, params.callOptions);
37+
38+
log.debug('Started Actor run (direct actor tool)', {
39+
actorName: actorFullName,
40+
runId: actorRun.id,
41+
mcpSessionId,
42+
waitSecs,
43+
});
44+
45+
if (abortSignal?.aborted) {
46+
await abortRunOnSignal(actorRun.id, apifyClient);
47+
log.info('Actor run aborted by client', {
48+
actorName: actorFullName,
49+
mcpSessionId,
50+
runId: actorRun.id,
51+
input: redactedInput,
52+
});
53+
return null;
54+
}
55+
56+
const fetchResult = await fetchActorRunData({
57+
runId: actorRun.id,
58+
waitSecs,
59+
actorName: actorFullName,
60+
client: apifyClient,
61+
progressTracker,
62+
abortSignal,
63+
mcpSessionId,
64+
onAbort: abortRunOnSignal,
65+
});
66+
67+
if ('aborted' in fetchResult) {
68+
log.info('Actor run aborted by client', {
69+
actorName: actorFullName,
70+
mcpSessionId,
71+
runId: actorRun.id,
72+
input: redactedInput,
73+
});
74+
return null;
75+
}
76+
if ('error' in fetchResult) return fetchResult.error as ActorExecutionResult;
77+
78+
// Mirror the tool's declared `itemsSchema` into the runtime response so the response
79+
// matches its outputSchema. Only direct actor tools know the row shape up front.
80+
const dataset = fetchResult.result.structuredContent.storages?.datasets?.default;
81+
if (dataset && params.datasetItemsSchema) {
82+
dataset.itemsSchema = { type: 'object', properties: params.datasetItemsSchema };
83+
}
84+
85+
return buildGetActorRunSuccessResponse({ ...fetchResult.result, widget: false }) as ActorExecutionResult;
86+
},
87+
};

src/tools/apps/actor_executor.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/tools/apps/call_actor.ts

Lines changed: 9 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,25 @@
1-
import log from '@apify/log';
2-
31
import { HelperTools } from '../../const.js';
42
import type { InternalToolArgs, ToolEntry } from '../../types.js';
5-
import { extractActorId } from '../../utils/tools.js';
63
import {
7-
buildCallActorDescription,
8-
buildCallActorErrorResponse,
9-
buildStartAsyncResponse,
4+
buildCallActorAppsDescription,
105
callActorAjvValidate,
116
callActorInputSchema,
12-
callActorPreExecute,
13-
resolveAndValidateActor,
7+
executeCallActor,
148
} from '../core/call_actor_common.js';
15-
import { callActorOutputSchema } from '../structured_output_schemas.js';
9+
import { getActorRunOutputSchema } from '../structured_output_schemas.js';
1610

17-
const CALL_ACTOR_APPS_DESCRIPTION = buildCallActorDescription({
18-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
19-
alwaysAsync: true,
20-
});
11+
const CALL_ACTOR_APPS_DESCRIPTION = buildCallActorAppsDescription();
2112

2213
/**
2314
* Apps mode call-actor tool.
24-
* Always runs asynchronously — starts the run and returns immediately with runId.
2515
* Renders no widget; for a live progress UI, use the call-actor-widget sibling.
2616
*/
2717
export const appsCallActor: ToolEntry = Object.freeze({
2818
type: 'internal',
2919
name: HelperTools.ACTOR_CALL,
3020
description: CALL_ACTOR_APPS_DESCRIPTION,
3121
inputSchema: callActorInputSchema,
32-
outputSchema: callActorOutputSchema,
22+
outputSchema: getActorRunOutputSchema,
3323
ajvValidate: callActorAjvValidate,
3424
paymentRequired: true,
3525
annotations: {
@@ -39,52 +29,9 @@ export const appsCallActor: ToolEntry = Object.freeze({
3929
idempotentHint: false,
4030
openWorldHint: true,
4131
},
42-
call: async (toolArgs: InternalToolArgs) => {
43-
const preResult = await callActorPreExecute(toolArgs, { route: HelperTools.ACTOR_CALL });
44-
if ('earlyResponse' in preResult) {
45-
return preResult.earlyResponse;
46-
}
47-
48-
const { parsed, baseActorName } = preResult;
49-
const { input, callOptions } = parsed;
50-
51-
let resolvedActorId: string | undefined;
52-
try {
53-
const resolution = await resolveAndValidateActor({
54-
actorName: baseActorName,
55-
input: input as Record<string, unknown>,
56-
toolArgs,
57-
});
58-
if ('error' in resolution) {
59-
return resolution.error;
60-
}
61-
62-
resolvedActorId = extractActorId(resolution.actor);
63-
const { apifyClient } = toolArgs;
64-
65-
// Apps mode always runs asynchronously
66-
const actorClient = apifyClient.actor(baseActorName);
67-
const actorRun = await actorClient.start(input, callOptions);
68-
log.debug('Started Actor run (async)', { actorName: baseActorName, runId: actorRun.id, mcpSessionId: toolArgs.mcpSessionId });
69-
const response = buildStartAsyncResponse({
70-
actorName: baseActorName,
71-
actorRun,
72-
input,
73-
widget: false,
74-
});
75-
return {
76-
...response,
77-
toolTelemetry: { actorId: resolvedActorId },
78-
};
79-
} catch (error) {
80-
return buildCallActorErrorResponse({
81-
actorName: baseActorName,
82-
error,
83-
actorId: resolvedActorId,
84-
isAsync: true,
85-
mcpSessionId: toolArgs.mcpSessionId,
86-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
87-
});
88-
}
32+
execution: {
33+
// Support long-running tasks
34+
taskSupport: 'optional',
8935
},
36+
call: async (toolArgs: InternalToolArgs) => executeCallActor(toolArgs),
9037
} as const);

0 commit comments

Comments
 (0)