Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swarm-abort-user-cancellation.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/_base/utils/abort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
26 changes: 21 additions & 5 deletions packages/agent-core-v2/src/human/agent/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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'],
},
},
},
Expand Down
29 changes: 29 additions & 0 deletions packages/agent-core-v2/src/human/test/agent/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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')]),
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core-v2/src/human/utils/abort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core-v2/test/_base/utils/abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading