fix: report task stop reasons to the model (#1781)

* fix: report task stop reasons to the model

* chore: add task stop reason changeset
This commit is contained in:
_Kerman 2026-07-16 18:32:06 +08:00 committed by GitHub
parent ba36c6a563
commit 09e855401b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 110 additions and 31 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Report when users stop tasks and preserve other stop reasons in model context.

View file

@ -241,6 +241,10 @@ export class AgentRPCService implements IAgentRPCService {
}
stopTask(payload: StopTaskPayload): void {
if (payload.reason === undefined) {
void this.tasks.stopByUser(payload.taskId);
return;
}
void this.tasks.stop(payload.taskId, payload.reason);
}

View file

@ -92,6 +92,7 @@ export interface IAgentTaskService {
suppressTerminalNotification(taskId: string): Promise<void>;
detach(taskId: string): AgentTaskInfo | undefined;
stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined>;
stopByUser(taskId: string): Promise<AgentTaskInfo | undefined>;
stopAll(reason?: string): Promise<readonly AgentTaskInfo[]>;
stopAllOnExit(reason: string): Promise<readonly AgentTaskInfo[]>;
wait(

View file

@ -36,7 +36,10 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { ContentPart } from '#/app/llmProtocol/message';
import { Disposable } from '#/_base/di/lifecycle';
import { abortable } from '#/_base/utils/abort';
import {
abortable,
userCancellationReason,
} from '#/_base/utils/abort';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event/eventBus';
import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types';
@ -167,7 +170,6 @@ function outputLimitReason(): string {
const SIGTERM_GRACE_MS = 5_000;
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
const USER_INTERRUPT_REASON = 'Interrupted by user';
const SESSION_CLOSED_REASON = 'Session closed';
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
@ -625,6 +627,17 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
});
}
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
const entry = this.tasks.get(taskId);
if (entry === undefined) return undefined;
const reason = userCancellationReason();
return this.terminateWithGrace(entry, {
stopReason: reason.message,
abortReason: reason,
finalStatus: 'killed',
});
}
private async terminateWithGrace(
entry: ManagedTask,
options: {
@ -1098,8 +1111,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
const abortFromSignal = (): void => {
if (this.isDetached(entry)) return;
const userReason = userCancellationReason();
void this.terminateWithGrace(entry, {
stopReason: USER_INTERRUPT_REASON,
stopReason: userReason.message,
abortReason: signal.reason,
finalStatus: 'killed',
});
@ -1229,9 +1243,11 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {
const baseLine =
info.status === 'timed_out'
? `${info.description} timed out.`
: info.stopReason
? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${info.stopReason}.`
: `${info.description} ${info.status}.`;
: info.status === 'killed' && isSerializedUserCancellation(info.stopReason)
? `${info.description} was stopped by user.`
: info.stopReason
? `${info.description} ${info.status === 'killed' ? 'was stopped' : info.status}. Reason: ${info.stopReason}`
: `${info.description} ${info.status}.`;
if (info.kind !== 'agent') return baseLine;
if (info.status === 'completed') return baseLine;
@ -1263,6 +1279,10 @@ function normalizeReason(reason: string | undefined): string | undefined {
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}
function isSerializedUserCancellation(reason: string | undefined): boolean {
return reason === userCancellationReason().message;
}
function createForegroundRelease(): ForegroundRelease {
let resolve!: (reason: ForegroundTaskReleaseReason) => void;
const promise = new Promise<ForegroundTaskReleaseReason>((done) => {

View file

@ -50,6 +50,7 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { userCancellationReason } from '#/_base/utils/abort';
import bashDescriptionTemplate from './bash.md?raw';
import { ProcessTask } from './process-task';
@ -58,7 +59,6 @@ const DEFAULT_TIMEOUT_S = 60;
const MAX_TIMEOUT_S = 5 * 60;
const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
const USER_INTERRUPT_REASON = 'Interrupted by user';
export const BashInputSchema = z
.object({
@ -395,8 +395,11 @@ export class BashTool implements BuiltinTool<BashInput> {
result = builder.error(`Command killed by timeout (${timeoutLabel})`, {
brief: `Killed by timeout (${timeoutLabel})`,
});
} else if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
result = builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON });
} else if (
current?.status === 'killed' &&
current.stopReason === userCancellationReason().message
) {
result = builder.error('Interrupted by user', { brief: 'Interrupted by user' });
} else if (
(current?.status === 'failed' || current?.status === 'killed') &&
current.stopReason !== undefined

View file

@ -17,7 +17,11 @@
import { z } from 'zod';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { isUserCancellation } from '#/_base/utils/abort';
import {
isAbortError,
isUserCancellation,
userCancellationReason,
} from '#/_base/utils/abort';
import { toInputJsonSchema } from '#/tool/input-schema';
import { matchesGlobRuleSubject } from '#/tool/rule-match';
import {
@ -29,7 +33,6 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentUserToolService } from '#/agent/userTool/userTool';
import { isAbortError } from '#/_base/utils/abort';
import {
ToolAccesses,
type BuiltinTool,
@ -127,7 +130,8 @@ const BACKGROUND_AGENT_UNAVAILABLE =
const RESUME_WITH_TYPE_UNAVAILABLE =
'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
const USER_INTERRUPTED_SUBAGENT_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.";
'The subagent was stopped before it finished by user.';
const SUBAGENT_STOPPED_MESSAGE = 'The subagent was stopped before it finished.';
export class AgentTool implements BuiltinTool<AgentToolInput> {
@ -428,11 +432,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
const timedOut = info?.status === 'timed_out';
const message = timedOut
? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.`
: info?.stopReason === 'Interrupted by user'
? USER_INTERRUPTED_SUBAGENT_MESSAGE
: info?.stopReason !== undefined
? info.stopReason
: 'The subagent was stopped before it finished.';
: formatSubagentStoppedMessage(info?.stopReason);
return {
output: formatForegroundAgentFailure(handle, message, timedOut),
isError: true,
@ -515,6 +515,19 @@ function formatForegroundAgentFailure(
function launchErrorMessage(error: unknown, signal: AbortSignal): string {
if (isUserCancellation(signal.reason)) return USER_INTERRUPTED_SUBAGENT_MESSAGE;
if (isAbortError(error)) return 'The subagent was stopped before it finished.';
if (isAbortError(error)) return formatSubagentStoppedMessage(errorMessage(signal.reason));
return error instanceof Error ? error.message : String(error);
}
function formatSubagentStoppedMessage(reason: string | undefined): string {
const normalized = reason?.trim();
if (normalized === userCancellationReason().message) return USER_INTERRUPTED_SUBAGENT_MESSAGE;
if (normalized === undefined || normalized.length === 0) return SUBAGENT_STOPPED_MESSAGE;
return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${normalized}`;
}
function errorMessage(error: unknown): string | undefined {
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
return undefined;
}

View file

@ -502,7 +502,7 @@ describe('AgentTaskService — notification delivery', () => {
const { agent, ctx, manager } = createAgentTaskService();
const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task');
await manager.stop(taskId);
await manager.stopByUser(taskId);
await vi.waitFor(() => {
expect(notifiedCount(ctx)).toBe(1);
@ -516,9 +516,7 @@ describe('AgentTaskService — notification delivery', () => {
status: 'killed',
notificationId: `task:${taskId}:killed`,
});
expect(message.content[0]!.text).toContain(
'Background process killed',
);
expect(message.content[0]!.text).toContain('long shell task was stopped by user.');
});
it('TaskStopTool suppresses the real terminal notification for model-requested stops', async () => {

View file

@ -477,7 +477,7 @@ describe('AgentTaskService', () => {
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
expect(manager.getTask(taskId)).toMatchObject({
status: 'killed',
stopReason: 'Interrupted by user',
stopReason: 'Aborted by the user',
});
});
@ -507,7 +507,7 @@ describe('AgentTaskService', () => {
const info = await manager.wait(taskId);
expect(info).toMatchObject({
status: 'killed',
stopReason: 'Interrupted by user',
stopReason: 'Aborted by the user',
});
expect(isUserCancellation(subagentController.signal.reason)).toBe(true);
});

View file

@ -220,6 +220,10 @@ class FakeTaskService implements IAgentTaskService {
return entry.info;
}
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
return this.stop(taskId, 'Aborted by the user');
}
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
const stopped = await Promise.all(
Array.from(this.entries.keys()).map((taskId) => this.stop(taskId, reason)),

View file

@ -28,6 +28,7 @@ import {
type RegisterAgentTaskOptions,
} from '#/agent/task/task';
import type { AgentTaskSettlement } from '#/agent/task/types';
import { userCancellationReason } from '#/_base/utils/abort';
import type { IConfigService } from '#/app/config/config';
import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
@ -313,7 +314,6 @@ const TERMINAL_STATUSES: ReadonlySet<AgentTaskStatus> = new Set([
'lost',
]);
const SIGTERM_GRACE_MS = 5_000;
const USER_INTERRUPT_REASON = 'Interrupted by user';
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
interface ForegroundRelease {
@ -542,7 +542,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
const signal = registerOptions.signal;
const abortFromSignal = (): void => {
if (entry.foregroundRelease === undefined) return;
void stopEntry(entry, USER_INTERRUPT_REASON);
void stopEntry(entry, userCancellationReason().message);
};
if (signal.aborted) {
abortFromSignal();
@ -612,6 +612,10 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
return stopEntry(entry, reason);
},
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
return service.stop(taskId, userCancellationReason().message);
},
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
const results = await Promise.all(
Array.from(tasks.keys()).map((taskId) => service.stop(taskId, reason)),

View file

@ -1256,10 +1256,36 @@ describe('Agent tool execution contract', () => {
expect(result.isError).toBe(true);
expect(result.output).toContain('status: failed');
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');
expect(result.output).toContain('The subagent was stopped before it finished by user.');
});
it('reports the reason when a foreground subagent is stopped for another cause', async () => {
const lifecycle = createAgentLifecycleStub({
createAgentIds: ['agent-child'],
runCompletion: (_agentId, _request, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
once: true,
});
}),
});
const context = createAgentToolContext(lifecycle);
const resultPromise = executeAgentTool(context, {
prompt: 'Investigate',
description: 'Find cause',
});
await vi.waitFor(() => {
expect(context.get(IAgentTaskService).list(false)).toHaveLength(1);
});
const [task] = context.get(IAgentTaskService).list(false);
await context.get(IAgentTaskService).stop(task!.taskId, 'Session closed');
const result = await resultPromise;
expect(result.isError).toBe(true);
expect(result.output).toContain(
'The subagent was stopped before it finished. Reason: Session closed',
);
});
it('returns the spawned agent id when a foreground subagent times out', async () => {

View file

@ -256,7 +256,7 @@ export function registerTasksRoutes(app: TasksRouteHost, core: Scope): void {
return;
}
await resolved.tasks?.stop(task_id);
await resolved.tasks?.stopByUser(task_id);
requestLog(req)?.info({ session_id, task_id }, 'task cancelled');
reply.send(okEnvelope({ cancelled: true as const }, req.id));
},

View file

@ -258,6 +258,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => {
);
expect(cancelled.body.code).toBe(0);
expect(cancelled.body.data).toEqual({ cancelled: true });
expect(tasks.getTask(taskId)?.stopReason).toBe('Aborted by the user');
// The task is now terminal (killed → cancelled); a second cancel is a
// conflict with the idempotent envelope shape.