mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-09-07 08:43:00 +00:00
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:
parent
ba36c6a563
commit
09e855401b
13 changed files with 110 additions and 31 deletions
5
.changeset/report-task-stop-reasons.md
Normal file
5
.changeset/report-task-stop-reasons.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Report when users stop tasks and preserve other stop reasons in model context.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue