fix(agent-core): surface user interruptions to the model instead of a neutral abort (#236)

When the user interrupts running tools or parallel subagents, the tool_result
fed back to the model was a neutral `Tool "X" was aborted` or a weak "stopped by
the user", so the model treated it as a system fault and speculated about
capacity/concurrency limits instead of recognizing a deliberate stop.

Carry a UserCancellationError as the AbortSignal reason from the cancel sites
(Turn.cancel/abortTurn, SessionSubagentHost.cancelAll) through to the message
sites (tool-call settle paths and the AgentTool catches), which now emit an
explicit "deliberate user action, not a system error/timeout/capacity limit"
message. Aborts propagated from another signal (e.g. a subagent's deadline via
waitForCurrentTurn) carry their original reason, so a timeout is not mislabeled
as a user interruption. The telemetry outcome classifier matches the new
"manually interrupted" phrase to keep counting these as cancelled.
This commit is contained in:
Kai 2026-05-30 09:53:21 +08:00 committed by GitHub
parent a24bfb1df3
commit 933cf6727e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 266 additions and 23 deletions

View file

@ -32,7 +32,7 @@ import {
} from '../../loop/index';
import type { AgentEvent, TurnEndedEvent } from '../../rpc';
import type { TelemetryPropertyValue } from '../../telemetry';
import { abortable } from '../../utils/abort';
import { abortable, userCancellationReason } from '../../utils/abort';
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
@ -147,13 +147,18 @@ export class TurnFlow {
this.activeTurn = 'resuming';
}
cancel(turnId?: number): void {
cancel(turnId?: number, reason?: unknown): void {
this.agent.records.logRecord({ type: 'turn.cancel', turnId });
if (turnId !== undefined && turnId !== this.currentId) {
return; // Ignore cancel for non-active turn
}
this.abortTurn();
this.agent.subagentHost?.cancelAll();
// A direct cancel (RPC / replay) is the user pressing stop. When the cancel
// is propagated from an aborting signal (e.g. a subagent's deadline via
// waitForCurrentTurn), carry that original reason instead so a timeout is
// not mislabeled to the model as a deliberate user interruption.
const cancelReason = reason ?? userCancellationReason();
this.abortTurn(cancelReason);
this.agent.subagentHost?.cancelAll(cancelReason);
}
get currentId() {
@ -174,7 +179,7 @@ export class TurnFlow {
const turnId = this.currentId;
const onAbort = (): void => {
this.agent.turn.cancel(turnId);
this.agent.turn.cancel(turnId, signal.reason);
};
signal.addEventListener('abort', onAbort, { once: true });
@ -183,9 +188,13 @@ export class TurnFlow {
});
}
private abortTurn() {
private abortTurn(reason: unknown) {
if (this.activeTurn !== 'resuming') {
this.activeTurn?.controller.abort();
// The reason (a user cancellation by default, or the originating signal's
// reason when propagated) travels as signal.reason so tools settling on
// this signal can report a deliberate user interruption distinctly from a
// timeout/system abort. linkAbortSignal forwards it to linked subagents.
this.activeTurn?.controller.abort(reason);
}
this.activeTurn = null;
}
@ -798,7 +807,11 @@ type ToolTelemetryResult = Extract<LoopEvent, { type: 'tool.result' }>['result']
function telemetryToolOutcome(result: ToolTelemetryResult): 'success' | 'error' | 'cancelled' {
if (result.isError !== true) return 'success';
const text = toolResultText(result).toLowerCase();
return text.includes('aborted') || text.includes('cancelled') ? 'cancelled' : 'error';
return text.includes('aborted') ||
text.includes('cancelled') ||
text.includes('manually interrupted')
? 'cancelled'
: 'error';
}
function telemetryToolErrorType(result: ToolTelemetryResult): string {

View file

@ -24,6 +24,7 @@ import {
} from '../tools/args-validator';
import { PathSecurityError } from '../tools/policies/path-access';
import { isUserCancellation } from '../utils/abort';
import { errorMessage, isAbortError } from './errors';
import type { LoopEventDispatcher, LoopToolCallEvent } from './events';
import type { LLM, LLMChatResponse } from './llm';
@ -46,6 +47,19 @@ const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.';
const validators = new WeakMap<ExecutableTool, ToolArgsValidator>();
/**
* Output for an aborted tool call. When the abort carries a user-cancellation
* reason (the user pressed stop), say so explicitly so the model treats it as a
* deliberate interruption instead of a system fault to theorise about or retry.
* Any other abort keeps the neutral wording.
*/
function abortedToolOutput(toolName: string, signal: AbortSignal): string {
if (isUserCancellation(signal.reason)) {
return `The user manually interrupted "${toolName}" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.`;
}
return `Tool "${toolName}" was aborted`;
}
export interface ToolCallStepContext {
readonly tools?: readonly ExecutableTool[] | undefined;
readonly hooks?: LoopHooks | undefined;
@ -285,7 +299,7 @@ async function prepareToolCall(
const displayFields = toolCallDisplayFieldsFromExecution(execution);
const settleAborted = (): Promise<PreparedToolCallTask> =>
settleError(effectiveArgs, `Tool "${call.toolName}" was aborted`, displayFields);
settleError(effectiveArgs, abortedToolOutput(call.toolName, step.signal), displayFields);
if (step.signal.aborted) return settleAborted();
@ -452,7 +466,7 @@ async function runRunnableToolCall(
const { toolCall, toolName } = call;
if (signal.aborted) {
return makeErrorToolResult(call, effectiveArgs, `Tool "${toolName}" was aborted`);
return makeErrorToolResult(call, effectiveArgs, abortedToolOutput(toolName, signal));
}
let toolResult: ExecutableToolResult;
@ -469,7 +483,7 @@ async function runRunnableToolCall(
});
}
const output = aborted
? `Tool "${toolName}" was aborted`
? abortedToolOutput(toolName, signal)
: `Tool "${toolName}" failed: ${errorMessage(error)}`;
return makeErrorToolResult(call, effectiveArgs, output);
}

View file

@ -8,7 +8,7 @@ import {
prepareSystemPromptContext,
type ResolvedAgentProfile,
} from '../profile';
import { linkAbortSignal } from '../utils/abort';
import { linkAbortSignal, userCancellationReason } from '../utils/abort';
import { collectGitContext } from './git-context';
import type { Session } from './index';
import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md';
@ -167,13 +167,15 @@ export class SessionSubagentHost {
};
}
cancelAll(): void {
cancelAll(reason: unknown = userCancellationReason()): void {
const foregroundChildren = Array.from(this.activeChildren).filter(
([, child]) => !child.runInBackground,
);
for (const [childId, child] of foregroundChildren) {
this.session.agents.get(childId)?.subagentHost?.cancelAll();
child.controller.abort();
this.session.agents.get(childId)?.subagentHost?.cancelAll(reason);
// Abort with the cancel reason (a user interruption by default) so the
// subagent's in-flight tools report the cause accurately to the model.
child.controller.abort(reason);
}
}

View file

@ -25,7 +25,11 @@ import { isAbortError } from '../../../loop/errors';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ResolvedAgentProfile } from '../../../profile';
import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host';
import { createDeadlineAbortSignal, type DeadlineAbortSignal } from '../../../utils/abort';
import {
createDeadlineAbortSignal,
isUserCancellation,
type DeadlineAbortSignal,
} from '../../../utils/abort';
import type { BackgroundProcessManager } from '../../background/manager';
import { toInputJsonSchema } from '../../support/input-schema';
import { matchesGlobRuleSubject } from '../../support/rule-match';
@ -303,8 +307,11 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
let message: string;
if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) {
message = `Agent timed out after ${args.timeout}s.`;
} else if (isUserCancellation(signal.reason)) {
message =
'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.';
} else if (isAbortError(error)) {
message = 'The subagent was stopped by the user.';
message = 'The subagent was stopped before it finished.';
} else {
message = error instanceof Error ? error.message : String(error);
}
@ -321,8 +328,11 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
let message: string;
if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) {
message = `Agent timed out after ${args.timeout}s.`;
} else if (isUserCancellation(signal.reason)) {
message =
'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.';
} else if (isAbortError(error)) {
message = 'The subagent was stopped by the user.';
message = 'The subagent was stopped before it finished.';
} else {
message = error instanceof Error ? error.message : String(error);
}

View file

@ -4,6 +4,34 @@ export function abortError(): Error {
return error;
}
/**
* Marks an abort the user triggered deliberately (e.g. pressing ESC to
* interrupt the agent), as distinct from a timeout, an internal error, or any
* other programmatic abort. It travels as the AbortSignal's `reason`, so code
* that settles an interrupted operation can tell a user interruption apart from
* a failure and report it to the model accordingly instead of emitting a
* neutral "was aborted" that the model mistakes for a system problem.
*
* `name` stays 'AbortError' so existing `isAbortError()` checks (and
* `AbortSignal.throwIfAborted()`) keep treating it as an abort.
*/
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 isUserCancellation(value: unknown): value is UserCancellationError {
return value instanceof UserCancellationError;
}
export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
return new Promise<T>((resolve, reject) => {

View file

@ -1261,8 +1261,8 @@ describe('Agent turn flow', () => {
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);
@ -1390,8 +1390,8 @@ describe('Agent turn flow', () => {
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);

View file

@ -11,6 +11,7 @@ import { inputTotal } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { LLMChatResponse, LoopHooks } from '../../src/loop/index';
import { userCancellationReason } from '../../src/utils/abort';
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { runTurn } from './fixtures/helpers';
import { EchoTool, GatedTool, markReadFileAccesses, SlowTool } from './fixtures/tools';
@ -211,6 +212,36 @@ describe('runTurn — abort handling', () => {
});
});
it('tells the model a running tool was interrupted by the user, not by a system fault', async () => {
// When the user presses stop, the tool_result fed back to the model must
// convey "the user deliberately interrupted this" — not the neutral
// `Tool "X" was aborted`, which the model mistakes for a system problem
// (e.g. "too many parallel agents") and then theorises about / retries.
const slow = new SlowTool();
const controller = new AbortController();
const turnPromise = runTurn({
tools: [slow],
responses: [
makeToolUseResponse([makeToolCall('slow', {}, 'tc-1')]),
makeEndTurnResponse('unreachable'),
],
signal: controller.signal,
});
await slow.started.promise;
controller.abort(userCancellationReason());
const { result, sink } = await turnPromise;
expect(result.stopReason).toBe('aborted');
const toolResult = sink.byType('tool.result').find((e) => e.toolCallId === 'tc-1');
const output = toolResult?.result.output;
expect(typeof output).toBe('string');
expect(output).not.toBe('Tool "slow" was aborted');
expect(output).toContain('not a system error');
expect(output).toContain("wait for the user");
});
it('every tool.call still has a matching tool.result when aborted mid-batch', async () => {
// Transcript-balance contract: even when the turn is aborted while
// multiple tool tasks are running, every dispatched tool.call must be

View file

@ -13,7 +13,8 @@ import type { SDKSessionRPC } from '../../src/rpc';
import { Session } from '../../src/session';
import { collectGitContext } from '../../src/session/git-context';
import { SessionSubagentHost } from '../../src/session/subagent-host';
import { testAgent } from '../agent/harness/agent';
import { abortError, userCancellationReason } from '../../src/utils/abort';
import { testAgent, type AgentTestContext } from '../agent/harness/agent';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
// Git context collection is exercised in git-context.test.ts; here it is
@ -360,6 +361,69 @@ describe('SessionSubagentHost', () => {
);
});
it("tells a cancelled subagent's in-flight tools the user interrupted them", async () => {
const parent = testAgent();
parent.configure();
parent.newEvents();
const controller = new AbortController();
const child = testAgent();
child.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
const session = fakeSession(parent.agent, child.agent);
const host = new SessionSubagentHost(session, 'main');
const handle = await host.spawn('explore', {
parentToolCallId: 'call_agent',
prompt: 'Keep working',
description: 'Long task',
runInBackground: false,
signal: controller.signal,
});
await child.untilApprovalRequest();
// The parent turn signal aborts with a user-cancellation reason; linkAbortSignal
// forwards it to the child exactly as Turn.cancel does on a real ESC.
controller.abort(userCancellationReason());
await expect(handle.completion).rejects.toThrow();
await child.untilTurnEnd();
const output = childBashToolResultOutput(child);
expect(output).toContain('manually interrupted');
expect(output).toContain('not a system error');
});
it('does not mislabel a non-user subagent abort (e.g. a deadline) as a user interruption', async () => {
const parent = testAgent();
parent.configure();
parent.newEvents();
const controller = new AbortController();
const child = testAgent();
child.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
const session = fakeSession(parent.agent, child.agent);
const host = new SessionSubagentHost(session, 'main');
const handle = await host.spawn('explore', {
parentToolCallId: 'call_agent',
prompt: 'Keep working',
description: 'Long task',
runInBackground: false,
signal: controller.signal,
});
await child.untilApprovalRequest();
// A generic (non-user) abort — e.g. a foreground subagent's deadline timeout
// propagating through waitForCurrentTurn — must NOT be reported to the
// child's tools as a deliberate user interruption.
controller.abort(abortError());
await expect(handle.completion).rejects.toThrow();
await child.untilTurnEnd();
const output = childBashToolResultOutput(child);
expect(output).toBe('Tool "Bash" was aborted');
expect(output).not.toContain('manually interrupted');
});
it('cancelAll leaves background children running until their task signal aborts', async () => {
const parent = testAgent();
parent.configure();
@ -1068,6 +1132,22 @@ async function writeWire(homedir: string, records: readonly Record<string, unkno
await writeFile(join(homedir, 'wire.jsonl'), text.length === 0 ? '' : `${text}\n`, 'utf-8');
}
function childBashToolResultOutput(child: AgentTestContext): string | undefined {
for (const entry of child.allEvents) {
if (entry.type !== '[wire]' || entry.event !== 'context.append_loop_event') continue;
const loopEvent = (
entry.args as {
event?: { type?: string; toolCallId?: string; result?: { output?: unknown } };
}
).event;
if (loopEvent?.type === 'tool.result' && loopEvent.toolCallId === 'call_bash') {
const output = loopEvent.result?.output;
return typeof output === 'string' ? output : undefined;
}
}
return undefined;
}
function bashCall(): ToolCall {
return {
type: 'function',

View file

@ -6,6 +6,7 @@ import type { ResolvedAgentProfile } from '../../src/profile';
import type { SessionSubagentHost } from '../../src/session/subagent-host';
import { BackgroundProcessManager } from '../../src/tools/background/manager';
import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent';
import { userCancellationReason } from '../../src/utils/abort';
import { executeTool } from './fixtures/execute-tool';
const signal = new AbortController().signal;
@ -666,6 +667,47 @@ describe('AgentTool', () => {
]);
});
it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => {
const controller = new AbortController();
const host = mockSubagentHost({
spawn: vi.fn((_profileName: string, options: { signal: AbortSignal }) =>
Promise.resolve({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>((_resolve, reject) => {
const onAbort = (): void => {
reject(options.signal.reason);
};
if (options.signal.aborted) onAbort();
else options.signal.addEventListener('abort', onAbort, { once: true });
}),
}),
),
});
const tool = new AgentTool(host);
const resultPromise = executeTool(tool, {
turnId: '0',
toolCallId: 'call_agent',
args: { prompt: 'Investigate', description: 'Find cause' },
signal: controller.signal,
});
// Let spawn wire up and the tool reach `await handle.completion`.
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort(userCancellationReason());
const result = await resultPromise;
expect(result.isError).toBe(true);
expect(result.output).toContain('status: failed');
// The old message ("The subagent was stopped by the user.") is too weak —
// the model still blamed a "system limit". The new message rules that out.
expect(result.output).not.toContain('was stopped by the user');
expect(result.output).toContain('not a system error');
expect(result.output).toContain('capacity');
expect(result.output).toContain('wait for the user');
});
it('returns the spawned agent id when a foreground subagent times out', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { isAbortError } from '../../src/loop/errors';
import { abortError, isUserCancellation, userCancellationReason } from '../../src/utils/abort';
describe('userCancellationReason', () => {
it('is recognised as a deliberate user cancellation', () => {
expect(isUserCancellation(userCancellationReason())).toBe(true);
});
it('stays an AbortError so abort detection keeps treating it as an abort', () => {
expect(isAbortError(userCancellationReason())).toBe(true);
});
it('is distinguishable from a generic abort, an ordinary error, and undefined', () => {
// A generic abort (timeout, internal) must NOT read as a user cancellation —
// that distinction is the whole point: the model needs to know a user
// pressed stop, not that "something aborted".
expect(isUserCancellation(abortError())).toBe(false);
expect(isUserCancellation(new Error('boom'))).toBe(false);
expect(isUserCancellation(undefined)).toBe(false);
});
});