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/steer-background-waits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow steering messages to interrupt waits for background tasks.
2 changes: 2 additions & 0 deletions docs/en/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ The input box remains usable while the agent is thinking or calling tools, and s
- **`Esc` / `Ctrl-C`**: interrupt the current turn
- **`Ctrl-O`**: globally toggle the collapsed/expanded state of tool output and compaction summaries

When the agent is waiting for background tasks through `WaitFor`, pressing `Ctrl-S` ends that wait early. Background tasks keep running and existing tool results are preserved. If other foreground tools remain in the same batch, the agent processes your message after they return.

## External editor

Press `Ctrl-G` to send the current input content to an external editor. When you save and close, the text is written back into the input box; if you close without saving, the original content is preserved. This is handy when you need to enter large blocks of text or content with complex formatting.
Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest

**`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state.

**`WaitFor`** suspends the current turn until a background task finishes or the timeout elapses. Parameters: `timeout` (required, in seconds, max 600) and optional `task_id`. Without `task_id`, the wait ends as soon as any background task that was running at call time finishes; when no background tasks are running, it returns immediately. A timeout is not an error — the result lists the tasks still running, and the Agent can wait again or do other work meanwhile. A task whose result was reported by `WaitFor` does not also produce an automatic completion notification.
**`WaitFor`** suspends the current turn until a background task finishes, the timeout elapses, or a steer message arrives. Parameters: `timeout` (required, in seconds, max 600) and optional `task_id`. Without `task_id`, the wait ends as soon as any background task that was running at call time finishes; when no background tasks are running, it returns immediately. A timeout is not an error — the result lists the tasks still running, and the Agent can wait again or do other work meanwhile. Steering (`Ctrl-S` in the terminal) ends the wait early; background tasks keep running and still notify the agent on completion. A task whose result was reported by `WaitFor` does not also produce an automatic completion notification.

## Scheduled Tasks

Expand Down
2 changes: 2 additions & 0 deletions docs/zh/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ Agent 思考或调用工具时,输入框仍然可用,支持以下额外操
- **`Esc` / `Ctrl-C`**:中断当前轮次
- **`Ctrl-O`**:全局切换工具输出和压缩摘要的折叠状态

Agent 正通过 `WaitFor` 等待后台任务时,按 `Ctrl-S` 会提前结束本次等待。后台任务继续运行,已有工具结果保留;如果同批还有其他前台工具,Agent 会在它们返回后处理新消息。

## 外部编辑器

按 `Ctrl-G` 把当前输入内容发给外部编辑器,保存后回填到输入框,不保存则保持原样。适合需要输入大段文本或带格式内容的场景。
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只

**`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。

**`WaitFor`** 把当前轮次挂起,直到后台任务结束或超时。参数:`timeout`(必填,单位秒,上限 600)和可选的 `task_id`。不传 `task_id` 时,调用时刻运行中的任意一个后台任务结束即返回;当前没有运行中的后台任务时立即返回。超时不是错误——结果会列出仍在运行的任务,Agent 可以再次等待,也可以先处理其他工作。已通过 `WaitFor` 汇报结果的任务不会再推送自动完成通知。
**`WaitFor`** 把当前轮次挂起,直到后台任务结束、超时或收到 steer 消息。参数:`timeout`(必填,单位秒,上限 600)和可选的 `task_id`。不传 `task_id` 时,调用时刻运行中的任意一个后台任务结束即返回;当前没有运行中的后台任务时立即返回。超时不是错误——结果会列出仍在运行的任务,Agent 可以再次等待,也可以先处理其他工作。Steer(终端中按 `Ctrl-S`)会提前结束本次等待,后台任务继续运行,完成后仍会自动通知。已通过 `WaitFor` 汇报结果的任务不会再推送自动完成通知。

## 定时任务

Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
initialTurnId: this.states.get(turnKey).nextTurnId,
trace: () => this.activeRequestTrace,
toolTurnId: () => this.active?.id,
steerSignal: () => this.active?.steerController.signal,
source: () =>
this.active === undefined
? undefined
Expand Down Expand Up @@ -218,11 +219,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
const id = prompt.promptId ?? randomUUID();
this.nudges.push({
contextMessage: message,
steer: true,
bypassMaxSteps: false,
turnScoped: false,
onConsume: prompt.onMaterialize,
onDrop: undefined,
});
active.steerController.abort(abortError('Steered by new input'));
this.machineEngine().submit({ id, message: machineUserMessage(message) });
this.machineEngine().steer(id);
return active.turn;
Expand Down Expand Up @@ -503,6 +506,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
if (turn.stopRequested) return { type: 'fail' };
if (turn.failedStep !== undefined) return { type: 'fail' };
const consumed = this.mirrorConsumedNudges(turn);
if (
turn.steerController.signal.aborted &&
!this.nudges.slice(this.nudgeCursor).some((nudge) => nudge.steer && !nudge.dropped)
) {
turn.steerController = new AbortController();
}
if (turn.toolStopRequested && consumed.live === 0) return { type: 'fail' };
const stepOrdinal = Math.max(this.engine?.currentStep() ?? 0, turn.steps + 1);
const maxSteps = this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxStepsPerTurn;
Expand Down Expand Up @@ -652,6 +661,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
id,
reservation,
controller: reservation.controller,
steerController: new AbortController(),
turn,
startedAt: Date.now(),
steps: 0,
Expand Down Expand Up @@ -1528,6 +1538,7 @@ interface TurnReservation {

interface Nudge {
readonly contextMessage?: ContextMessage;
readonly steer?: boolean;
readonly bypassMaxSteps: boolean;
readonly turnScoped: boolean;
readonly onConsume?: () => void;
Expand Down Expand Up @@ -1566,6 +1577,7 @@ interface ActiveTurn {
readonly id: number;
readonly reservation: TurnReservation;
readonly controller: AbortController;
steerController: AbortController;
readonly turn: MutableTurn;
readonly startedAt: number;
steps: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/loop/machine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface CreateMachineEngineOptions {
readonly trace?: () => LLMRequestTrace | undefined;
readonly source?: () => AgentLLMRequestSource | undefined;
readonly toolTurnId?: () => number | undefined;
readonly steerSignal?: () => AbortSignal | undefined;
readonly gate?: (signal: AbortSignal) => Promise<MachineRequesterGateDecision>;
readonly onTrace?: (trace: LLMRequestTrace) => void;
readonly onEvent?: (event: MachineEngineEvent) => void;
Expand Down Expand Up @@ -253,6 +254,7 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin
toolExecutor: options.toolExecutor,
toolInfos: options.toolInfos,
turnId: () => options.toolTurnId?.() ?? 0,
steerSignal: options.steerSignal,
trace: options.trace,
onToolCall: (payload) => {
publish({
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/loop/machine/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface CreateMachineToolsOptions {
readonly toolExecutor: IAgentToolExecutorService;
readonly toolInfos: readonly ToolInfo[];
readonly turnId: () => number;
readonly steerSignal?: () => AbortSignal | undefined;
readonly trace?: () => LLMRequestTrace | undefined;
readonly onToolCall?: (payload: ToolCallStartedPayload) => void;
readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void;
Expand Down Expand Up @@ -104,6 +105,7 @@ export function createMachineTools(options: CreateMachineToolsOptions): MachineT
try {
const stream = options.toolExecutor.execute(calls, {
signal,
steerSignal: options.steerSignal?.(),
turnId: options.turnId(),
trace: options.trace?.(),
onToolCall: options.onToolCall,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ToolCallStartedPayload {

export interface ToolExecutorExecuteOptions {
readonly signal: AbortSignal;
readonly steerSignal?: AbortSignal;
readonly turnId: number;
readonly trace?: LLMRequestTrace;
readonly onToolCall?: (payload: ToolCallStartedPayload) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
trace: options.trace,
metadata,
signal,
steerSignal: options.steerSignal,
onUpdate: (update) => {
if (signal.aborted) return;
this.dispatchToolProgress(call, update, options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { formatPlainObject } from '#/agent/task/tools/format';
import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool';
import { IFlagService } from '#/app/flag/flag';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { abortError, linkAbortSignal } from '#/_base/utils/abort';
import { abortError, isAbortError, linkAbortSignal } from '#/_base/utils/abort';
import { WAIT_FOR_FLAG_ID } from './flag';
import { IWaitForTool, WaitForInputSchema, type WaitForInput } from './task-wait';
import WAIT_FOR_DESCRIPTION from './task-wait.md?raw';
Expand All @@ -26,7 +26,7 @@ const PAGING_HINT_LINES = 300;

const PROGRESS_INTERVAL_MS = 1_000;

type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted';
type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted' | 'interrupted';

function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined {
if (info.status === 'timed_out') return 'timed_out';
Expand Down Expand Up @@ -166,13 +166,23 @@ export class WaitForTool implements IWaitForTool {
}

let waited: AgentTaskInfo | undefined;
const signal = ctx.steerSignal === undefined
? ctx.signal
: AbortSignal.any([ctx.signal, ctx.steerSignal]);
const progress = startWaitProgress(args, this.tasks, ctx.onUpdate, startedAt);
try {
waited =
args.task_id === undefined
? await this.waitAny(runningAtStart, timeoutMs, ctx.signal)
: await this.tasks.wait(args.task_id, timeoutMs, ctx.signal);
? await this.waitAny(runningAtStart, timeoutMs, signal)
: await this.tasks.wait(args.task_id, timeoutMs, signal);
} catch (error) {
if (
!ctx.signal.aborted && ctx.steerSignal?.aborted &&
(error === ctx.steerSignal.reason || isAbortError(error))
) {
this.track(args, startedAt, timeoutMs, 'interrupted', 0);
return { output: this.formatInterrupted(args, startedAt, timeoutMs), isError: false };
}
this.track(args, startedAt, timeoutMs, 'aborted', 0);
throw error;
} finally {
Expand Down Expand Up @@ -254,6 +264,24 @@ export class WaitForTool implements IWaitForTool {
return lines.join('\n');
}

private formatInterrupted(args: WaitForInput, startedAt: number, timeoutMs: number): string {
const lines = [
formatPlainObject({
waitStatus: 'interrupted',
reason: 'steer',
taskId: args.task_id,
waitedMs: Date.now() - startedAt,
timeoutMs,
}),
'New input ended this wait early. Read the new input before deciding what to do next. Background tasks have not been stopped; completion still arrives via automatic notification.',
];
const running = this.tasks.list(true);
if (running.length > 0) {
lines.push('', '[still_running]', formatTaskList(running, true));
}
return lines.join('\n');
}

private async formatCompleted(
finished: AgentTaskInfo,
extras: readonly AgentTaskInfo[],
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/app/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export interface BackgroundTaskCompletedEvent {
}

export interface WaitForCompletedEvent {
outcome: 'completed' | 'timed_out' | 'task_not_found' | 'aborted';
outcome: 'completed' | 'timed_out' | 'task_not_found' | 'aborted' | 'interrupted';
timeout_ms: number;
waited_ms: number;
has_task_id: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/tool/toolContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface ExecutableToolContext {
readonly trace?: LLMRequestTrace;
readonly metadata?: unknown;
readonly signal: AbortSignal;
readonly steerSignal?: AbortSignal;
readonly onUpdate?: ((update: ToolUpdate) => void) | undefined;
readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined;
}
Expand Down
Loading
Loading