From ca7368e5f15d59359edb8493f66be8fa54dc7aab Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 11 May 2026 19:12:33 +0200 Subject: [PATCH] fix(bash): add 120s default timeout to prevent autonomous mode hangs - Add BUILT_IN_DEFAULT_TIMEOUT_SECS = 120 constant to bash tool - Compute effectiveTimeout = timeout ?? resolvedDefaultTimeout so LLM calls without a timeout get the 120s guard automatically - Add defaultTimeoutSeconds? to BashToolOptions for override at creation - Dynamic bashSchemaWithDefault describes the actual default in the LLM tool description, improving model awareness - Add BashSettings interface + getBashDefaultTimeoutSeconds() to SettingsManager so users can override or disable via settings.json - Wire defaultTimeoutSeconds into agent-session.ts _buildRuntime() Root cause: npx sf --help triggered npm package download, hanging for 4+ minutes without timeout, consuming entire autonomous run budget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coding-agent/src/core/agent-session.ts | 2 + .../coding-agent/src/core/settings-manager.ts | 19 ++++++++ packages/coding-agent/src/core/tools/bash.ts | 45 +++++++++++++++++-- packages/coding-agent/src/index.ts | 1 + 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 78099e793..94dffcd19 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2530,6 +2530,8 @@ export class AgentSession { read: { autoResizeImages }, bash: { commandPrefix: shellCommandPrefix, + defaultTimeoutSeconds: + this.settingsManager.getBashDefaultTimeoutSeconds(), interceptor: { enabled: this.settingsManager.getBashInterceptorEnabled(), rules: this.settingsManager.getBashInterceptorRules(), diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 9500e0eb1..ca891f6b2 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -51,6 +51,14 @@ export interface BashInterceptorSettings { rules?: BashInterceptorRule[]; // override default rules } +export interface BashSettings { + /** + * Default timeout in seconds for bash commands when the LLM does not provide one. + * Set to 0 to disable (commands run without a timeout). Defaults to 120. + */ + defaultTimeoutSeconds?: number; +} + export interface MarkdownSettings { codeBlockIndent?: string; // default: " " } @@ -173,6 +181,7 @@ export interface Settings { memory?: MemorySettings; async?: AsyncSettings; bashInterceptor?: BashInterceptorSettings; + bash?: BashSettings; taskIsolation?: TaskIsolationSettings; fallback?: FallbackSettings; modelDiscovery?: ModelDiscoverySettings; @@ -505,6 +514,16 @@ export class SettingsManager { return this.settings.bashInterceptor?.rules; } + /** + * Default bash command timeout in seconds. Returns undefined when unset, + * which lets the bash tool use its built-in default (120s). + * Returns 0 explicitly when the user has disabled the default. + */ + getBashDefaultTimeoutSeconds(): number | undefined { + const val = this.settings.bash?.defaultTimeoutSeconds; + return typeof val === "number" ? val : undefined; + } + reload(): void { const globalLoad = SettingsManager.tryLoadFromStorage( this.storage, diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 8e5decc81..bae2d738a 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -28,6 +28,15 @@ import { truncateTail, } from "./truncate.js"; +/** + * Built-in default timeout (seconds) applied when the caller does not provide + * one and `BashToolOptions.defaultTimeoutSeconds` is not set. + * Prevents stuck commands (e.g. `npx pkg --help` triggering a download) from + * consuming the entire autonomous-mode run budget. + * Set `defaultTimeoutSeconds: 0` in BashToolOptions to opt out. + */ +const BUILT_IN_DEFAULT_TIMEOUT_SECS = 120; + // Cached Win32 FFI handles for restoring VT input after child processes let _vtHandles: { GetConsoleMode: any; @@ -145,7 +154,8 @@ const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), timeout: Type.Optional( Type.Number({ - description: "Timeout in seconds (optional, no default timeout)", + description: + "Timeout in seconds. Omitting uses the built-in default (see createBashTool). Pass 0 to run without timeout.", }), ), }); @@ -316,6 +326,12 @@ export interface BashToolOptions { }; /** Tool names available in the session, used by the interceptor to check if replacement tools exist */ availableToolNames?: string[] | (() => string[]); + /** + * Default timeout in seconds applied when the LLM does not provide one. + * Set to 0 to disable the built-in default (allows commands to run indefinitely). + * Defaults to BUILT_IN_DEFAULT_TIMEOUT_SECS (120s) when not set. + */ + defaultTimeoutSeconds?: number; } export function createBashTool( @@ -326,6 +342,12 @@ export function createBashTool( const commandPrefix = options?.commandPrefix; const spawnHook = options?.spawnHook; const artifactManager = options?.artifactManager; + // Resolve the default timeout once at construction time. + // Explicit 0 in options disables the built-in default; undefined falls through to BUILT_IN_DEFAULT_TIMEOUT_SECS. + const resolvedDefaultTimeout = + options?.defaultTimeoutSeconds !== undefined + ? options.defaultTimeoutSeconds + : BUILT_IN_DEFAULT_TIMEOUT_SECS; // Pre-compile interceptor rules once at construction time const interceptorInstance = options?.interceptor?.enabled @@ -334,17 +356,32 @@ export function createBashTool( ) : null; + const timeoutDescription = + resolvedDefaultTimeout > 0 + ? `Default: ${resolvedDefaultTimeout}s. Pass 0 to run without a timeout.` + : "No default timeout — commands run until completion."; + const bashSchemaWithDefault = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional( + Type.Number({ + description: `Timeout in seconds. ${timeoutDescription}`, + }), + ), + }); + return { name: "bash", label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - parameters: bashSchema, + description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Default timeout: ${resolvedDefaultTimeout > 0 ? `${resolvedDefaultTimeout}s` : "none"} — pass a higher value for long-running commands (builds, installs, tests).`, + parameters: bashSchemaWithDefault, execute: async ( _toolCallId: string, { command, timeout }: { command: string; timeout?: number }, signal?: AbortSignal, onUpdate?, ) => { + // Apply the resolved default when the LLM does not provide a timeout. + const effectiveTimeout = timeout ?? resolvedDefaultTimeout; // Check bash interceptor — block commands that duplicate dedicated tools if (interceptorInstance) { const toolNames = @@ -463,7 +500,7 @@ export function createBashTool( .exec(spawnContext.command, spawnContext.cwd, { onData: handleData, signal, - timeout, + timeout: effectiveTimeout, env: spawnContext.env, }) .then(({ exitCode }) => { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8a640cdc6..af2be99a3 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -280,6 +280,7 @@ export { } from "./core/session-manager.js"; export { type AsyncSettings, + type BashSettings, type CompactionSettings, type ImageSettings, type MemorySettings,