diff --git a/.changeset/swarm-abort-user-cancellation.md b/.changeset/swarm-abort-user-cancellation.md new file mode 100644 index 00000000000..3a0c1e9f854 --- /dev/null +++ b/.changeset/swarm-abort-user-cancellation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a crash where interrupting an AgentSwarm with Esc killed the whole process with exit 1. diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts index 7662966b21d..89b13854669 100644 --- a/packages/agent-core-v2/src/_base/utils/abort.ts +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -22,7 +22,11 @@ export function userCancellationReason(): UserCancellationError { } export function isUserCancellation(value: unknown): value is UserCancellationError { - return value instanceof UserCancellationError; + if (value instanceof UserCancellationError) return true; + return ( + value instanceof Error && + (value as { readonly userCancelled?: unknown }).userCancelled === true + ); } export function abortable(promise: Promise, signal: AbortSignal): Promise { diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 33d015f8cae..a4434b2c5ed 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -97,10 +97,12 @@ export class SessionSwarmService implements ISessionSwarmService { }; const maxConcurrency = resolveSwarmMaxConcurrency(); const promise = new AgentRunBatch(launcher, linkedTasks, { maxConcurrency }).run(); - void promise.finally(() => { - for (const unlink of unlinks) unlink(); - if (this.inFlight.get(callerAgentId) === controller) this.inFlight.delete(callerAgentId); - }); + void promise + .finally(() => { + for (const unlink of unlinks) unlink(); + if (this.inFlight.get(callerAgentId) === controller) this.inFlight.delete(callerAgentId); + }) + .catch(() => {}); return promise; } diff --git a/packages/agent-core-v2/src/human/agent/machine.ts b/packages/agent-core-v2/src/human/agent/machine.ts index 015e1681168..0cbd8bc8895 100644 --- a/packages/agent-core-v2/src/human/agent/machine.ts +++ b/packages/agent-core-v2/src/human/agent/machine.ts @@ -22,7 +22,7 @@ import { turnStarted, } from './events'; import { createSystemEntry, createUserEntry } from './turn'; -import { createAbortScope, withAbort, type AbortScope } from '#/utils/abort'; +import { createAbortScope, withAbort, userCancellationReason, type AbortScope } from '#/utils/abort'; import type { createTurnMachine, HistoryMessage, TurnLlmEvent, TurnOutput, UserEntry } from './turn'; import { storeActor } from '#/eventStore/actor'; import type { AgentEventStore, AgentStoreState, QueuedPrompt } from './slices'; @@ -300,7 +300,7 @@ export function createAgentMachine({ for (const toolCall of event.toolCalls) { const entry = context.turnTools[toolCall.id]; if (entry !== undefined) { - entry.scope.abort(); + entry.scope.abort(userCancellationReason()); enqueue.sendTo(entry.ref, { type: 'tool.abort' as const }); } } @@ -312,12 +312,24 @@ export function createAgentMachine({ enqueue.sendTo(entry.ref, { type: 'tool.abort' as const }); } }), + abortTurnToolsUserCancelled: enqueueActions(({ context, enqueue }) => { + for (const entry of Object.values(context.turnTools)) { + entry.scope.abort(userCancellationReason()); + enqueue.sendTo(entry.ref, { type: 'tool.abort' as const }); + } + }), stopTurnTools: enqueueActions(({ context, enqueue }) => { for (const [toolCallId, entry] of Object.entries(context.turnTools)) { entry.scope.abort(); enqueue.stopChild(toolCallId); } }), + stopTurnToolsUserCancelled: enqueueActions(({ context, enqueue }) => { + for (const [toolCallId, entry] of Object.entries(context.turnTools)) { + entry.scope.abort(userCancellationReason()); + enqueue.stopChild(toolCallId); + } + }), }, delays: { abortTimeout: abortTimeoutMs ?? 10_000, @@ -677,20 +689,24 @@ export function createAgentMachine({ }, 'input.abort': { target: 'aborting', - actions: ['abortTurn', 'abortTurnTools', emit({ type: 'turn.aborting' as const })], + actions: [ + 'abortTurn', + 'abortTurnToolsUserCancelled', + emit({ type: 'turn.aborting' as const }), + ], }, }, }, aborting: { after: { - abortTimeout: { actions: ['abortTurn', 'stopTurnTools'] }, + abortTimeout: { actions: ['abortTurn', 'stopTurnToolsUserCancelled'] }, }, on: { 'turn.spawn_tools': { actions: ['spawnTurnTools', 'abortSpawnedTools'], }, 'input.abort': { - actions: ['abortTurn', 'stopTurnTools'], + actions: ['abortTurn', 'stopTurnToolsUserCancelled'], }, }, }, diff --git a/packages/agent-core-v2/src/human/test/agent/machine.test.ts b/packages/agent-core-v2/src/human/test/agent/machine.test.ts index 6916fe02d6c..79ae2f2bd4a 100644 --- a/packages/agent-core-v2/src/human/test/agent/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/agent/machine.test.ts @@ -37,6 +37,7 @@ import type { Tree } from '#/store/tree'; import { waitForTool } from '#/tool/wait-for'; import { defineTool, type ToolDefinition } from '#/tool/tool'; import type { ToolResult } from '#/tool/executor'; +import { UserCancellationError } from '#/utils/abort'; const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; @@ -1173,6 +1174,34 @@ describe('agent machine input.abort', () => { ]); }); + it('aborts turn tools with the user cancellation reason', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + ]); + const signals: AbortSignal[] = []; + const tools = stubTools(({ signal }) => { + signals.push(signal); + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('tool stopped'))); + }); + }, 'slow_tool'); + const store = await testStore(); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model }, store }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(signals).toHaveLength(1); + }); + actor.send({ type: 'input.abort' }); + await waitFor(actor, (s) => s.matches('idle'), { timeout: 5000 }); + + expect(signals[0]?.aborted).toBe(true); + expect(signals[0]?.reason).toBeInstanceOf(UserCancellationError); + }); + it('waits for the real outcome of a tool that settles after the abort signal', async () => { const requester = createStubRequester([ createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), diff --git a/packages/agent-core-v2/src/human/utils/abort.ts b/packages/agent-core-v2/src/human/utils/abort.ts index 218fe79b649..5c8caa8fd76 100644 --- a/packages/agent-core-v2/src/human/utils/abort.ts +++ b/packages/agent-core-v2/src/human/utils/abort.ts @@ -3,6 +3,19 @@ export interface AbortScope { abort(reason?: unknown): void; } +export class UserCancellationError extends Error { + readonly userCancelled = true; + + constructor() { + super('Aborted by the user'); + this.name = 'AbortError'; + } +} + +export function userCancellationReason(): UserCancellationError { + return new UserCancellationError(); +} + export function createAbortScope(): AbortScope { const controller = new AbortController(); return { signal: controller.signal, abort: (reason) => controller.abort(reason) }; diff --git a/packages/agent-core-v2/test/_base/utils/abort.test.ts b/packages/agent-core-v2/test/_base/utils/abort.test.ts index 310e6378967..b899599713b 100644 --- a/packages/agent-core-v2/test/_base/utils/abort.test.ts +++ b/packages/agent-core-v2/test/_base/utils/abort.test.ts @@ -23,6 +23,18 @@ describe('userCancellationReason', () => { expect(isUserCancellation(undefined)).toBe(false); }); + it('recognises user cancellations flagged by another copy of the class', () => { + const foreign = new Error('Aborted by the user') as Error & { userCancelled: boolean }; + foreign.name = 'AbortError'; + foreign.userCancelled = true; + expect(isUserCancellation(foreign)).toBe(true); + expect(isAbortError(foreign)).toBe(true); + + const flagged = new Error('boom') as Error & { userCancelled: boolean }; + flagged.userCancelled = false; + expect(isUserCancellation(flagged)).toBe(false); + }); + it('keeps custom system abort messages classified as AbortError', () => { expect(abortError('Session closed')).toMatchObject({ name: 'AbortError', diff --git a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts index cfe1069ba3f..3ad59d5d32c 100644 --- a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts @@ -1005,6 +1005,28 @@ describe('SessionSwarmService metadata compatibility', () => { }); }); + it('cleans up without an unhandled rejection when the batch fails', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const service = ix.get(ISessionSwarmService); + const running = service.run({ + callerAgentId: 'main', + tasks: [spawnSessionTask('src/a.ts'), spawnSessionTask('src/b.ts')], + }); + const expectation = expect(running).rejects.toThrow(); + service.cancel({ callerAgentId: 'main' }); + await expectation; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + it('keeps v1 resume ownership errors inside the per-subagent result', async () => { agents['other-child'] = { labels: { parentAgentId: 'other', swarmItem: 'src/other.ts' },