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
5 changes: 5 additions & 0 deletions .changeset/report-task-stop-reasons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Report when users stop tasks and preserve other stop reasons in model context.
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/agent/rpc/rpcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/task/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 26 additions & 6 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
31 changes: 22 additions & 9 deletions packages/agent-core-v2/src/session/subagent/tools/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
6 changes: 2 additions & 4 deletions packages/agent-core-v2/test/agent/task/rpc-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/task/taskManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});

Expand Down Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)),
Expand Down
34 changes: 30 additions & 4 deletions packages/agent-core-v2/test/tool/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/src/routes/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
},
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/test/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading