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/.changeset/taskoutput-discourage-blocking.md b/.changeset/taskoutput-discourage-blocking.md new file mode 100644 index 0000000000..bbca9de422 --- /dev/null +++ b/.changeset/taskoutput-discourage-blocking.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Optimize the TaskOutput tool prompts to discourage blocking waits on background tasks. 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-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index bc6ddf5d2a..510ec3ea51 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; +import type { IConfigService } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const TASK_SECTION = 'task'; @@ -18,11 +19,27 @@ export const LEGACY_BACKGROUND_SECTION = 'background'; export const AgentTaskConfigSchema = 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(), }); export type AgentTaskConfig = z.infer; +/** + * Read the effective task config, falling back to the legacy `[background]` + * section when `[task]` is unset. + */ +export function resolveAgentTaskConfig(config: IConfigService): AgentTaskConfig | undefined { + return ( + config.get(TASK_SECTION) ?? + config.get(LEGACY_BACKGROUND_SECTION) + ); +} + registerConfigSection(TASK_SECTION, AgentTaskConfigSchema); registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema); diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index ee4f012b64..a0c5feb824 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -49,11 +49,17 @@ export interface RegisterAgentTaskOptions { readonly timeoutMs?: number; /** Deadline to apply if a foreground task is detached. `0` and `undefined` do not arm a timer. */ 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'; /** * Options for tracking a TaskHandle with the Agent task service. diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index a467107ee9..e44e87cbdd 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -64,7 +64,7 @@ import { type IAgentTaskEntry, type RegisterAgentTaskOptions, } from './task'; -import { LEGACY_BACKGROUND_SECTION, TASK_SECTION, type AgentTaskConfig } from './configSection'; +import { resolveAgentTaskConfig } from './configSection'; import { AgentTaskPersistence } from './persist'; import { TaskModel, taskStarted, taskTerminated } from './taskOps'; import { formatTaskList } from '#/agent/task/tools/task-list'; @@ -304,6 +304,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, + autoBackgroundOnTimeout: options.autoBackgroundOnTimeout, signal: detached ? undefined : options.signal, }; this.assertCanRegister(detached); @@ -335,13 +336,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.ghosts.delete(entry.taskId); if (timeoutMs !== undefined && timeoutMs > 0) { - entry.timeoutHandle = setTimeout(() => { - void this.terminateWithGrace(entry, { - abortReason: 'Timed out', - finalStatus: 'timed_out', - }); - }, timeoutMs); - entry.timeoutHandle.unref?.(); + this.armManagerTimeout(entry, timeoutMs); } entry.lifecyclePromise = Promise.resolve() @@ -417,13 +412,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.ghosts.delete(taskId); if (timeoutMs !== undefined && timeoutMs > 0) { - entry.timeoutHandle = setTimeout(() => { - void this.terminateWithGrace(entry, { - abortReason: 'Timed out', - finalStatus: 'timed_out', - }); - }, timeoutMs); - entry.timeoutHandle.unref?.(); + this.armManagerTimeout(entry, timeoutMs); } const outputSub = handle.onDidOutput((chunk) => { @@ -578,6 +567,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { detach(taskId: string): AgentTaskInfo | undefined { const entry = this.tasks.get(taskId); if (entry === undefined) return this.ghosts.get(taskId); + return this.detachEntry(entry, false); + } + + /** + * 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. + */ + private detachEntry(entry: ManagedTask, viaTimeout: boolean): AgentTaskInfo | undefined { if (TERMINAL_STATUSES.has(entry.status)) return this.toInfo(entry); const foregroundRelease = entry.foregroundRelease; @@ -598,7 +596,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.startOutputPersist(entry); void this.persistLive(entry); this.recordTaskStarted(this.toInfo(entry)); - foregroundRelease.resolve('detached'); + foregroundRelease.resolve(viaTimeout ? 'timeout_detached' : 'detached'); return this.toInfo(entry); } @@ -611,16 +609,39 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.timeoutHandle = undefined; } if (timeoutMs > 0) { - entry.timeoutHandle = setTimeout(() => { - void this.terminateWithGrace(entry, { - abortReason: 'Timed out', - finalStatus: 'timed_out', - }); - }, timeoutMs); - entry.timeoutHandle.unref?.(); + this.armManagerTimeout(entry, timeoutMs); } } + /** + * Arm a manager-owned deadline (`timeoutMs` / `detachTimeoutMs`). A + * foreground task opted into auto-background survives its first deadline by + * detaching to the background — `detachEntry` re-arms `detachTimeoutMs` — + * instead of being killed; every other deadline terminates with grace. + */ + private armManagerTimeout(entry: ManagedTask, timeoutMs: number): void { + entry.timeoutHandle = setTimeout(() => { + entry.timeoutHandle = undefined; + if (this.canAutoBackgroundOnTimeout(entry)) { + this.detachEntry(entry, true); + return; + } + void this.terminateWithGrace(entry, { + abortReason: 'Timed out', + finalStatus: 'timed_out', + }); + }, timeoutMs); + entry.timeoutHandle.unref?.(); + } + + /** + * 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); + } + async stop(taskId: string, reason?: string): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return undefined; @@ -791,20 +812,13 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private assertCanRegister(detached: boolean): void { - const maxRunningTasks = this.taskConfig()?.maxRunningTasks; + const maxRunningTasks = resolveAgentTaskConfig(this.config)?.maxRunningTasks; if (maxRunningTasks === undefined) return; if (!detached) return; if (this.activeTaskCount() < maxRunningTasks) return; throw new Error('Too many background tasks are already running.'); } - private taskConfig(): AgentTaskConfig | undefined { - return ( - this.config.get(TASK_SECTION) ?? - this.config.get(LEGACY_BACKGROUND_SECTION) - ); - } - private activeTaskCount(): number { let count = 0; for (const entry of this.tasks.values()) { diff --git a/packages/agent-core-v2/src/agent/task/tools/task-output.md b/packages/agent-core-v2/src/agent/task/tools/task-output.md index 1720938bbf..7b902943d1 100644 --- a/packages/agent-core-v2/src/agent/task/tools/task-output.md +++ b/packages/agent-core-v2/src/agent/task/tools/task-output.md @@ -1,12 +1,13 @@ -Retrieve output from a running or completed background task. +Retrieve a snapshot of a running or completed background task. -Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. +Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` to check progress, or to read the output of a task that has already completed. Guidelines: - Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. +- By default this tool is non-blocking and returns a current status/output snapshot — that is the normal way to use it. - Do not use TaskOutput to wait for a result you need before continuing — if your next step depends on the task's result, run that task in the foreground instead. TaskOutput is for a deliberate progress check you will act on without blocking, not a way to sit and wait for a background task you just launched. -- By default this tool is non-blocking and returns a current status/output snapshot. -- Use block=true only when you intentionally want to wait for completion or timeout. +- Use block=true only when the user explicitly asked you to wait for the task. Never block on a task you launched in the current turn — if you need its result right away, it should have been a foreground call. +- If a block=true call returns `retrieval_status: timeout` (the task is still running), do not block on the same task again. Continue with other work or hand back to the user — the completion notification arrives on its own. - This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. - For a terminal task, the metadata also explains why it ended. A shell command that runs to completion reports `status: completed` on a zero exit, or `status: failed` with its non-zero `exit_code` — judge that failure from the `exit_code`, because a plain command failure carries no `stop_reason` and no `terminal_reason`. `terminal_reason` is a categorical label emitted only when the end is not an ordinary exit: `timed_out` when the deadline aborted it, `stopped` when it was explicitly stopped, or `failed` when it errored without producing an exit code; the `stopped` and `failed` cases also carry a human-readable `stop_reason`. A task that finished on its own with a clean exit carries neither `stop_reason` nor `terminal_reason`. - The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. diff --git a/packages/agent-core-v2/src/agent/task/tools/task-output.ts b/packages/agent-core-v2/src/agent/task/tools/task-output.ts index aba0d97978..80dc123efe 100644 --- a/packages/agent-core-v2/src/agent/task/tools/task-output.ts +++ b/packages/agent-core-v2/src/agent/task/tools/task-output.ts @@ -45,7 +45,9 @@ export const TaskOutputInputSchema = z.object({ block: z .boolean() .default(false) - .describe('Whether to wait for the task to finish before returning.') + .describe( + 'Whether to wait for the task to finish before returning. Discouraged — background tasks notify automatically on completion; use only when the user explicitly asked you to wait.', + ) .optional(), timeout: z .number() @@ -147,6 +149,11 @@ export class TaskOutputTool implements BuiltinTool { fullOutputTool: output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, fullOutputHint: fullOutputHint(output), + // Nudge at the exact point of misuse: a blocking wait that timed out. + nextStep: + args.block === true && !TERMINAL_STATUSES.has(current.status) + ? 'The task is still running after waiting. Do not block on it again — continue with other work or hand back to the user; you will be notified automatically when it completes.' + : undefined, }), '', ]; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md index cb237f9174..e6d57c76f0 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/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-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index 0f8cc8a40f..a0ae3d9d7d 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 @@ -16,8 +16,10 @@ * `node:child_process`. * * Hardening: - * - `args.timeout` (seconds) and the ambient `signal` both stop the - * manager-owned process task on either edge. + * - `args.timeout` (seconds) arms the manager-owned deadline; a foreground + * command whose deadline fires is moved to the background instead of + * being killed (unless disabled via config), while the ambient `signal` + * always stops the task. * - stdin is closed immediately so interactive commands (`cat`, `read`, * `python -c 'input()'`) receive EOF instead of hanging. * - Two-phase kill is owned by `IAgentTaskService`: SIGTERM → grace → SIGKILL. @@ -33,6 +35,8 @@ import { z } from 'zod'; import { IAgentTaskService } from '#/agent/task/task'; +import { resolveAgentTaskConfig } from '#/agent/task/configSection'; +import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; @@ -153,8 +157,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\./, @@ -162,6 +166,18 @@ function withoutBackgroundDescription(description: string): string { ); } +/** + * Strip the auto-background-on-timeout promise when the config disabled it + * (`task.bash_auto_background_on_timeout = false`): timed-out foreground + * commands are killed in that configuration, and the description must say so. + */ +function withoutAutoBackgroundOnTimeout(description: string): string { + return description.replace( + ' 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.', + ' A foreground command that hits its timeout is killed.', + ); +} + export class BashTool implements BuiltinTool { readonly name = 'Bash' as const; readonly parameters: Record = toInputJsonSchema(BashInputSchema); @@ -176,6 +192,7 @@ export class BashTool implements BuiltinTool { @ISessionContext private readonly ctx: ISessionContext, @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IConfigService private readonly config: IConfigService, ) { this.isWindowsBash = this.env.osKind === 'Windows'; this.renderedDescription = renderBashDescription(this.env.shellName); @@ -189,10 +206,16 @@ export class BashTool implements BuiltinTool { ); } + private autoBackgroundOnTimeout(): boolean { + return resolveAgentTaskConfig(this.config)?.bashAutoBackgroundOnTimeout ?? true; + } + get description(): string { - return this.allowBackground() - ? this.renderedDescription - : withoutBackgroundDescription(this.renderedDescription); + if (!this.allowBackground()) return withoutBackgroundDescription(this.renderedDescription); + if (!this.autoBackgroundOnTimeout()) { + return withoutAutoBackgroundOnTimeout(this.renderedDescription); + } + return this.renderedDescription; } resolveExecution(args: BashInput): ToolExecution { @@ -297,6 +320,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, }, ); @@ -323,16 +351,23 @@ export class BashTool implements BuiltinTool { try { const release = await this.tasks.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-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 2ae3a211f1..60b2e3d2f5 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -96,8 +96,8 @@ describe('Agent loop', () => { [emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "" } ] } [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "