Skip to content
Merged
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
29 changes: 21 additions & 8 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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 });

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 17 additions & 3 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.`;
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse user-cancel wording for hook aborts

When the user presses stop while a tool hook is pending (for example an approval/authorization hook waiting on input), the hook catch paths still return neutral messages such as Tool "X" was aborted during authorizeToolExecution hook instead of going through this new user-cancellation wording. Since TurnFlow.cancel() now marks direct cancels with UserCancellationError, that user-initiated path can still persist a neutral tool result for the model to see on the next turn, which undercuts the fix for common cancellation points before the tool actually starts executing.

Useful? React with 👍 / 👎.

Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover grace-timeout aborts with user-cancel wording

For tools that observe neither the abort signal nor settle within the 2s grace window after the user presses stop, raceExecuteWithGraceTimeout() resolves a synthetic Tool "X" aborted by grace timeout result, so runRunnableToolCall() never calls this helper and the model still sees a timeout/system-flavored abort. This means user interruptions of non-cooperative tools still bypass the new deliberate-user-action message and can trigger the retry/speculation behavior this change is trying to prevent.

Useful? React with 👍 / 👎.

Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize abort results returned by tools

Some tools handle an aborted signal by returning their own error result instead of throwing (for example Grep returns Grep aborted when ensureRgPath({ signal }) is cancelled), and runRunnableToolCall() treats that as a normal tool result rather than applying this user-cancellation helper. When the user presses stop during those tool-specific abort paths, the persisted result still uses neutral abort wording, so the model can again interpret the interruption as a system/tool failure despite the new UserCancellationError reason being present on the signal.

Useful? React with 👍 / 👎.

}
return `Tool "${toolName}" was aborted`;
}

export interface ToolCallStepContext {
readonly tools?: readonly ExecutableTool[] | undefined;
readonly hooks?: LoopHooks | undefined;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
10 changes: 6 additions & 4 deletions packages/agent-core/src/session/subagent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down
16 changes: 13 additions & 3 deletions packages/agent-core/src/tools/builtin/collaboration/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-core/src/utils/abort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
`);
Expand Down Expand Up @@ -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" }
`);
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core/test/loop/abort.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading