From 3c929e3d2e74f9a460ee4ca55c959eeb2d38532b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 13 Jul 2026 14:49:09 +0800 Subject: [PATCH 1/6] feat: auto-background timed-out foreground bash commands --- .changeset/bash-timeout-auto-background.md | 5 ++ docs/en/configuration/config-files.md | 1 + docs/zh/configuration/config-files.md | 1 + .../agent-core/src/agent/background/index.ts | 55 ++++++++++++++---- packages/agent-core/src/agent/tool/index.ts | 2 + packages/agent-core/src/config/schema.ts | 5 ++ .../src/tools/builtin/shell/bash.md | 4 +- .../src/tools/builtin/shell/bash.ts | 30 +++++++--- .../test/agent/background/manager.test.ts | 58 +++++++++++++++++++ packages/agent-core/test/agent/basic.test.ts | 6 +- packages/agent-core/test/agent/config.test.ts | 4 +- .../agent-core/test/agent/permission.test.ts | 18 +++--- packages/agent-core/test/agent/plan.test.ts | 12 ++-- packages/agent-core/test/agent/turn.test.ts | 14 ++--- .../agent-core/test/config/configs.test.ts | 2 + packages/agent-core/test/tools/bash.test.ts | 57 +++++++++++++++++- 16 files changed, 224 insertions(+), 50 deletions(-) create mode 100644 .changeset/bash-timeout-auto-background.md diff --git a/.changeset/bash-timeout-auto-background.md b/.changeset/bash-timeout-auto-background.md new file mode 100644 index 0000000000..ac228999b0 --- /dev/null +++ b/.changeset/bash-timeout-auto-background.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Move foreground Bash commands that hit their timeout to the background instead of killing them, so long-running commands survive the timeout and report back on completion. Set `bash_auto_background_on_timeout = false` under `[background]` in config.toml to restore the kill-on-timeout behavior. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0528a1e475..25b6ffd89d 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -209,6 +209,7 @@ You can also switch models temporarily without touching the config file — by s | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently | | `keep_alive_on_exit` | `boolean` | `false` | Whether to keep still-running background tasks when the session closes. By default, Kimi Code requests that all background tasks stop before the process exits; set this to `true` only when you want tasks to outlive the session. In print mode (`kimi -p`), this is only a legacy fallback used when `print_background_mode` is unset: `true` is equivalent to `print_background_mode = "drain"` | +| `bash_auto_background_on_timeout` | `boolean` | `true` | When a foreground `Bash` command hits its timeout, move it to a background task instead of killing it — the agent is notified when it completes, and the backgrounded command is bounded by the 600s default background timeout. Set to `false` to kill timed-out foreground commands instead | | `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | Print mode (`kimi -p`) only. Governs how pending background tasks are handled once the main agent's turn ends: `"exit"` exits immediately; `"drain"` waits for every background task to reach a terminal state before exiting (results are not fed back to the main agent); `"steer"` stays alive so a completing background task — like a background subagent — injects a synthetic user message that steers the main agent into a new turn, looping until a turn ends with no pending background tasks or a limit is hit. Takes precedence over the `keep_alive_on_exit` print fallback | | `print_wait_ceiling_s` | `integer` | `3600` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"`. Has no effect outside print mode or when it is `"exit"` | | `print_max_turns` | `integer` | `50` | In print mode (`kimi -p`) with `print_background_mode = "steer"`, the maximum number of new turns that may be triggered by background-task completions, to keep the steering loop bounded | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 14c9d14671..e573b3c11d 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -209,6 +209,7 @@ display_name = "Kimi for Coding (custom)" | --- | --- | --- | --- | | `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 | | `keep_alive_on_exit` | `boolean` | `false` | 会话关闭时是否保留仍在运行的后台任务。默认情况下,Kimi Code 会在进程退出前请求停止所有后台任务;只有希望任务在会话结束后继续运行时才设为 `true`。在 print 模式(`kimi -p`)下,本字段仅作为 `print_background_mode` 未设置时的兼容回退:`true` 等价于 `print_background_mode = "drain"` | +| `bash_auto_background_on_timeout` | `boolean` | `true` | 前台 `Bash` 命令触及超时时间时,将其转为后台任务而不是直接终止:命令完成时 agent 会收到通知,转入后台的命令受 600s 默认后台超时约束。设为 `false` 则恢复超时即终止的行为 | | `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | 仅 print 模式(`kimi -p`)生效,决定主 agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给主 agent);`"steer"` 不退出,让后台任务完成时像后台子代理一样以合成 user 消息 steer 主 agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 | | `print_wait_ceiling_s` | `integer` | `3600` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒)。在非 print 模式或 `"exit"` 时无效 | | `print_max_turns` | `integer` | `50` | print 模式(`kimi -p`)且 `print_background_mode = "steer"` 时,允许由后台任务完成触发的新 turn 的最大数量,防止 steer 循环失控 | diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index ec3db4c9e5..c9cb88fc7b 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -217,11 +217,17 @@ export interface RegisterBackgroundTaskOptions { * foreground timeout run longer once it is moved to the background. */ readonly detachTimeoutMs?: number; + /** + * When true, a foreground task whose deadline fires is detached to the + * background (re-armed to `detachTimeoutMs`) instead of being killed. + * Only meaningful for non-detached registrations. + */ + readonly autoBackgroundOnTimeout?: boolean; /** Foreground caller signal. Ignored for tasks created already detached. */ readonly signal?: AbortSignal; } -export type ForegroundTaskReleaseReason = 'detached' | 'terminal'; +export type ForegroundTaskReleaseReason = 'detached' | 'timeout_detached' | 'terminal'; interface StopRequest { readonly reason?: string; @@ -299,6 +305,14 @@ export class BackgroundManager { return entry.foregroundRelease === undefined; } + /** + * Foreground tasks opted into auto-background survive their first deadline + * by detaching to the background instead of being killed. + */ + private canAutoBackgroundOnTimeout(entry: ManagedTask): boolean { + return entry.options.autoBackgroundOnTimeout === true && !this.isDetached(entry); + } + registerTask(task: BackgroundTask, options: RegisterBackgroundTaskOptions = {}): string { const detached = options.detached ?? true; const timeoutMs = options.timeoutMs ?? task.timeoutMs; @@ -306,6 +320,7 @@ export class BackgroundManager { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, + autoBackgroundOnTimeout: options.autoBackgroundOnTimeout, signal: detached ? undefined : options.signal, }; this.assertCanRegister(detached); @@ -449,7 +464,12 @@ export class BackgroundManager { await this.persistLive(entry); } - detach(taskId: string): BackgroundTaskInfo | undefined { + /** + * Move a foreground task to the background, releasing its tool-call waiter. + * `viaTimeout` marks an automatic detach triggered by the task deadline (vs. + * an explicit user/RPC detach) so the waiter can word its result. + */ + detach(taskId: string, viaTimeout = false): BackgroundTaskInfo | undefined { const entry = this.tasks.get(taskId); if (entry === undefined) return this.ghosts.get(taskId); if (TERMINAL_STATUSES.has(entry.status)) return this.toInfo(entry); @@ -470,7 +490,7 @@ export class BackgroundManager { this.startOutputPersist(entry); void this.persistLive(entry); this.emitTaskStarted(this.toInfo(entry)); - foregroundRelease.resolve('detached'); + foregroundRelease.resolve(viaTimeout ? 'timeout_detached' : 'detached'); return this.toInfo(entry); } @@ -857,17 +877,30 @@ export class BackgroundManager { }); }); - const timeout = resettableTimeoutOutcome(entry.options.timeoutMs, { kind: 'timeout' as const }); + let timeout = resettableTimeoutOutcome(entry.options.timeoutMs, { kind: 'timeout' as const }); entry.timeoutHandle = timeout; - const outcome = await Promise.race([ - worker.then((settlement): TerminalOutcome => ({ kind: 'worker', settlement })), - timeout, - entry.stop.then((request): TerminalOutcome => ({ kind: 'stop', request })), - this.signalOutcome(entry), - ]).finally(() => { + let outcome: TerminalOutcome; + try { + while (true) { + outcome = await Promise.race([ + worker.then((settlement): TerminalOutcome => ({ kind: 'worker', settlement })), + timeout, + entry.stop.then((request): TerminalOutcome => ({ kind: 'stop', request })), + this.signalOutcome(entry), + ]); + if (outcome.kind !== 'timeout' || !this.canAutoBackgroundOnTimeout(entry)) break; + // Move the foreground task to the background instead of killing it: + // detach() releases the tool-call waiter, then a fresh deadline is + // armed (reset() is a no-op once the timeout promise has resolved). + this.detach(entry.taskId, true); + timeout.clear(); + timeout = resettableTimeoutOutcome(entry.options.detachTimeoutMs, { kind: 'timeout' as const }); + entry.timeoutHandle = timeout; + } + } finally { timeout.clear(); entry.timeoutHandle = undefined; - }); + } const settlement = await this.settlementForOutcome(entry, outcome, worker); await this.finalizeTask(entry, settlement); } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 8ee3ffab7f..df30c31bf7 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -701,6 +701,8 @@ export class ToolManager { new b.GlobTool(kaos, workspace, this.agent.telemetry), new b.BashTool(kaos, cwd, background, { allowBackground, + autoBackgroundOnTimeout: + this.agent.kimiConfig?.background?.bashAutoBackgroundOnTimeout ?? true, }), (modelCapabilities.image_in || modelCapabilities.video_in) && new b.ReadMediaFileTool( diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 22367c24b0..6c11e3537c 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -128,6 +128,11 @@ export type LoopControl = z.infer; export const BackgroundConfigSchema = z.object({ maxRunningTasks: z.number().int().min(1).optional(), keepAliveOnExit: z.boolean().optional(), + /** + * When a foreground Bash command times out, move it to the background + * instead of killing it. Defaults to true when unset. + */ + bashAutoBackgroundOnTimeout: z.boolean().optional(), killGracePeriodMs: z.number().int().min(0).optional(), printWaitCeilingS: z.number().int().min(1).optional(), printBackgroundMode: z.enum(['exit', 'drain', 'steer']).optional(), diff --git a/packages/agent-core/src/tools/builtin/shell/bash.md b/packages/agent-core/src/tools/builtin/shell/bash.md index cb237f9174..e6d57c76f0 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.md +++ b/packages/agent-core/src/tools/builtin/shell/bash.md @@ -13,11 +13,11 @@ The dedicated tools render in the per-tool permission UI and keep raw stdout out **Output:** The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead. -If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not set `block=true` to wait for a task you just launched, since its completion arrives automatically; reserve `block=true` for when the user explicitly asked you to wait. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. **Guidelines for safety and security:** - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. -- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s. +- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes. - Avoid using `..` to access files or directories outside of the working directory. - Avoid modifying files outside of the working directory unless explicitly instructed to do so. - Never run commands that require superuser privileges unless explicitly instructed to do so. diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 8198a9506b..7e3ec428b9 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -141,8 +141,8 @@ function withoutBackgroundDescription(description: string): string { '\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.', ) .replace( - ` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s.`, - ` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s.`, + ` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes.`, + ` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s; a foreground command that hits its timeout is killed.`, ) .replace( /\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./, @@ -159,16 +159,20 @@ export class BashTool implements BuiltinTool { private readonly allowBackground: boolean; + private readonly autoBackgroundOnTimeout: boolean; + constructor( private readonly kaos: Kaos, private readonly cwd: string, private readonly backgroundManager: BackgroundManager, options?: { allowBackground?: boolean | undefined; + autoBackgroundOnTimeout?: boolean; }, ) { this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows'; this.allowBackground = options?.allowBackground ?? true; + this.autoBackgroundOnTimeout = options?.autoBackgroundOnTimeout ?? true; const rendered = renderBashDescription(this.kaos.osEnv.shellName); this.description = this.allowBackground ? rendered : withoutBackgroundDescription(rendered); } @@ -278,6 +282,11 @@ export class BashTool implements BuiltinTool { // give it the background timeout so it is not still bounded by the // shorter foreground deadline. detachTimeoutMs: DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND, + // A foreground command that hits its timeout is moved to the + // background (re-armed to detachTimeoutMs) instead of being killed — + // unless disabled via config, or background tooling is unavailable + // for this agent. + autoBackgroundOnTimeout: this.allowBackground && this.autoBackgroundOnTimeout, signal: startsInBackground ? undefined : signal, }, ); @@ -304,16 +313,23 @@ export class BashTool implements BuiltinTool { try { const release = await this.backgroundManager.waitForForegroundRelease(taskId); - if (release === 'detached') { + if (release === 'detached' || release === 'timeout_detached') { collectForegroundOutput = false; + const labels = + release === 'timeout_detached' + ? { + title: 'Command timed out and moved to background', + brief: `Backgrounded ${taskId} after timeout`, + } + : { + title: 'Task moved to background', + brief: `Backgrounded ${taskId}`, + }; return this.backgroundStartedResult( taskId, proc, description, - { - title: 'Task moved to background', - brief: `Backgrounded ${taskId}`, - }, + labels, builder, 'foreground_detached', ); diff --git a/packages/agent-core/test/agent/background/manager.test.ts b/packages/agent-core/test/agent/background/manager.test.ts index 1b6afa0315..82846a4fac 100644 --- a/packages/agent-core/test/agent/background/manager.test.ts +++ b/packages/agent-core/test/agent/background/manager.test.ts @@ -735,6 +735,64 @@ describe('BackgroundManager', () => { } }); + it('auto-backgrounds a foreground task instead of killing it when its deadline fires', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const { manager } = createBackgroundManager(); + const { proc, killSpy } = pendingProcess(); + const taskId = manager.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'auto background'), + { + detached: false, + timeoutMs: 1_000, + detachTimeoutMs: 5_000, + autoBackgroundOnTimeout: true, + }, + ); + const waiting = manager.waitForForegroundRelease(taskId); + + // The 1s foreground deadline detaches the task instead of killing it. + await vi.advanceTimersByTimeAsync(1_000); + await expect(waiting).resolves.toBe('timeout_detached'); + expect(killSpy).not.toHaveBeenCalled(); + expect(manager.getTask(taskId)).toMatchObject({ status: 'running', detached: true }); + + // The task keeps running past the original deadline; the re-armed 5s + // detach deadline still applies (1000 + 5000 = 6000ms). + await vi.advanceTimersByTimeAsync(1_000); + expect(manager.getTask(taskId)?.status).toBe('running'); + await vi.advanceTimersByTimeAsync(4_000); + expect(manager.getTask(taskId)?.status).toBe('timed_out'); + expect(killSpy).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('kills a foreground task on timeout when auto-background is not enabled', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const { manager } = createBackgroundManager(); + const { proc, killSpy } = pendingProcess(); + const taskId = manager.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'plain timeout'), + { + detached: false, + timeoutMs: 1_000, + detachTimeoutMs: 5_000, + }, + ); + const waiting = manager.waitForForegroundRelease(taskId); + + await vi.advanceTimersByTimeAsync(1_000); + await expect(waiting).resolves.toBe('terminal'); + expect(killSpy).toHaveBeenCalled(); + expect(manager.getTask(taskId)?.status).toBe('timed_out'); + } finally { + vi.useRealTimers(); + } + }); + it('returns undefined or empty output for unknown task ids', async () => { const { manager } = createBackgroundManager(); diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 11b318cead..dd0fc16376 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -106,8 +106,8 @@ it('runs an agent turn through builtin tool approval and execution', async () => [wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Run a command that prints lookup-result" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "