diff --git a/.changeset/report-task-stop-reasons.md b/.changeset/report-task-stop-reasons.md new file mode 100644 index 0000000000..9d6075f4ca --- /dev/null +++ b/.changeset/report-task-stop-reasons.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Report when users stop tasks and preserve other stop reasons in model context. diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 8f9c35c56f..d8c9d95495 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -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); } diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 124b8c4e88..9e091502fa 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -92,6 +92,7 @@ export interface IAgentTaskService { suppressTerminalNotification(taskId: string): Promise; detach(taskId: string): AgentTaskInfo | undefined; stop(taskId: string, reason?: string): Promise; + stopByUser(taskId: string): Promise; stopAll(reason?: string): Promise; stopAllOnExit(reason: string): Promise; wait( diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index f3fab796d2..32bb4945f7 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -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 { + 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((done) => { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index f29eb63d19..f7d018992c 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -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 { 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 diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index 00d94b15a8..5999e9d0e1 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -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 { @@ -428,11 +432,7 @@ export class AgentTool implements BuiltinTool { 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; +} diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 50001b9683..ffd132c726 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -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 () => { diff --git a/packages/agent-core-v2/test/agent/task/taskManager.test.ts b/packages/agent-core-v2/test/agent/task/taskManager.test.ts index eede8e5a3c..212bfdd1c9 100644 --- a/packages/agent-core-v2/test/agent/task/taskManager.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskManager.test.ts @@ -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); }); diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 02ed68b70b..97051a38b3 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -220,6 +220,10 @@ class FakeTaskService implements IAgentTaskService { return entry.info; } + async stopByUser(taskId: string): Promise { + return this.stop(taskId, 'Aborted by the user'); + } + async stopAll(reason?: string): Promise { const stopped = await Promise.all( Array.from(this.entries.keys()).map((taskId) => this.stop(taskId, reason)), diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 6b296221c5..6994c01ccc 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -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 = 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 { + return service.stop(taskId, userCancellationReason().message); + }, + async stopAll(reason?: string): Promise { const results = await Promise.all( Array.from(tasks.keys()).map((taskId) => service.stop(taskId, reason)), diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index bd0b8d8493..0e2fde768f 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -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 () => { diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index 24bb285ff9..3a664a4713 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -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)); }, diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index c617a4b45d..c7a77c79d2 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -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.