Skip to content

Commit be98520

Browse files
committed
feat: call-actor can be cancelled
1 parent 6c63a14 commit be98520

File tree

4 files changed

+69
-32
lines changed

4 files changed

+69
-32
lines changed

src/main.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ if (STANDBY_MODE) {
4444
await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input');
4545
}
4646
const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions;
47-
const { items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
47+
const result = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
4848

49-
await Actor.pushData(items);
50-
log.info('Pushed items to dataset', { itemCount: items.count });
49+
if (result && result.items) {
50+
await Actor.pushData(result.items);
51+
log.info('Pushed items to dataset', { itemCount: result.items.count });
52+
}
5153
await Actor.exit();
5254
}
5355

src/mcp/server.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,8 @@ export class ActorsMcpServer {
534534
);
535535

536536
if (!result) {
537-
// If the actor was aborted by the client, we don't want to return anything
537+
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
538+
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
538539
return { };
539540
}
540541

@@ -549,12 +550,6 @@ export class ActorsMcpServer {
549550
});
550551
content.push(...itemContents);
551552
return { content };
552-
} catch (error) {
553-
if (error instanceof Error && error.message === 'Operation cancelled') {
554-
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
555-
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
556-
return { };
557-
}
558553
} finally {
559554
if (progressTracker) {
560555
progressTracker.stop();

src/tools/actor.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,6 @@ export type CallActorGetDatasetResult = {
4949
* @returns {Promise<CallActorGetDatasetResult | null>} - A promise that resolves to an object containing the actor run and dataset items.
5050
* @throws {Error} - Throws an error if the `APIFY_TOKEN` is not set
5151
*/
52-
export async function callActorGetDataset(
53-
actorName: string,
54-
input: unknown,
55-
apifyToken: string,
56-
callOptions: ActorCallOptions | undefined,
57-
progressTracker?: ProgressTracker | null,
58-
): Promise<CallActorGetDatasetResult>; // Without abort signal Result or Error is returned
59-
export async function callActorGetDataset(
60-
actorName: string,
61-
input: unknown,
62-
apifyToken: string,
63-
callOptions: ActorCallOptions | undefined,
64-
progressTracker: ProgressTracker | null,
65-
abortSignal: AbortSignal,
66-
): Promise<CallActorGetDatasetResult | null>; // With abort signal, null is returned if the actor was aborted by the client
6752
export async function callActorGetDataset(
6853
actorName: string,
6954
input: unknown,
@@ -90,7 +75,7 @@ export async function callActorGetDataset(
9075
abortSignal?.addEventListener('abort', async () => {
9176
// Abort the actor run via API
9277
try {
93-
await client.run(actorRun.id).abort({ gracefully: true });
78+
await client.run(actorRun.id).abort({ gracefully: false });
9479
} catch (e) {
9580
log.error('Error aborting Actor run', { error: e, runId: actorRun.id });
9681
}
@@ -335,7 +320,7 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
335320
inputSchema: zodToJsonSchema(callActorArgs),
336321
ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)),
337322
call: async (toolArgs) => {
338-
const { args, apifyToken, progressTracker } = toolArgs;
323+
const { args, apifyToken, progressTracker, extra } = toolArgs;
339324
const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args);
340325

341326
try {
@@ -384,14 +369,23 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
384369
}
385370
}
386371

387-
const { runId, datasetId, items } = await callActorGetDataset(
372+
const result = await callActorGetDataset(
388373
actorName,
389374
input,
390375
apifyToken,
391376
callOptions,
392377
progressTracker,
378+
extra.signal,
393379
);
394380

381+
if (!result) {
382+
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
383+
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
384+
return { };
385+
}
386+
387+
const { runId, datasetId, items } = result;
388+
395389
const content = [
396390
{ type: 'text', text: `Actor finished with runId: ${runId}, datasetId ${datasetId}` },
397391
];

tests/integration/suite.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -631,8 +631,9 @@ export function createIntegrationTestsSuite(
631631
});
632632

633633
// Cancellation test: start a long-running actor and cancel immediately, then verify it was aborted
634-
it('should abort actor run when request is cancelled', async () => {
635-
const ACTOR_NAME = 'michal.kalita/test-timeout';
634+
// Is not possible to run this test in parallel
635+
it.runIf(options.transport === 'streamable-http')('should abort actor run on notifications/cancelled', async () => {
636+
const ACTOR_NAME = 'apify/rag-web-browser';
636637
const selectedToolName = actorNameToToolName(ACTOR_NAME);
637638
const client = await createClientFn({ enableAddingActors: true });
638639

@@ -646,7 +647,7 @@ export function createIntegrationTestsSuite(
646647
method: 'tools/call' as const,
647648
params: {
648649
name: selectedToolName,
649-
arguments: { timeout: 30 },
650+
arguments: { query: 'restaurants in San Francisco', maxResults: 10 },
650651
},
651652
}, CallToolResultSchema, { signal: controller.signal })
652653
// Ignores error "AbortError: This operation was aborted"
@@ -672,7 +673,52 @@ export function createIntegrationTestsSuite(
672673
return run.status === 'ABORTED' || run.status === 'ABORTING';
673674
}
674675
return false;
675-
}, { timeout: 30000, interval: 1000 });
676+
}, { timeout: 3000, interval: 500 });
677+
});
678+
679+
// Cancellation test using call-actor tool: start a long-running actor via call-actor and cancel immediately, then verify it was aborted
680+
it.runIf(options.transport === 'streamable-http')('should abort call-actor tool on notifications/cancelled', async () => {
681+
const ACTOR_NAME = 'apify/rag-web-browser';
682+
const client = await createClientFn({ tools: ['actors'] });
683+
684+
// Build request and cancel immediately via AbortController
685+
const controller = new AbortController();
686+
687+
const requestPromise = client.request({
688+
method: 'tools/call' as const,
689+
params: {
690+
name: HelperTools.ACTOR_CALL,
691+
arguments: {
692+
actor: ACTOR_NAME,
693+
step: 'call',
694+
input: { query: 'restaurants in San Francisco', maxResults: 10 },
695+
},
696+
},
697+
}, CallToolResultSchema, { signal: controller.signal })
698+
// Ignores error "AbortError: This operation was aborted"
699+
.catch(() => undefined);
700+
701+
// Abort right away
702+
setTimeout(() => controller.abort(), 1000);
703+
704+
// Ensure the request completes/cancels before proceeding
705+
await requestPromise;
706+
707+
// Verify via Apify API that a recent run for this actor was aborted
708+
const api = new ApifyClient({ token: process.env.APIFY_TOKEN as string });
709+
const actor = await api.actor(ACTOR_NAME).get();
710+
expect(actor).toBeDefined();
711+
const actId = actor!.id as string;
712+
713+
// Poll up to 30s for the latest run for this actor to reach ABORTED/ABORTING
714+
await vi.waitUntil(async () => {
715+
const runsList = await api.runs().list({ limit: 5, desc: true });
716+
const run = runsList.items.find((r) => r.actId === actId);
717+
if (run) {
718+
return run.status === 'ABORTED' || run.status === 'ABORTING';
719+
}
720+
return false;
721+
}, { timeout: 3000, interval: 500 });
676722
});
677723

678724
// Environment variable tests - only applicable to stdio transport

0 commit comments

Comments
 (0)