diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d3ace774..96efe0f81 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,12 +5,14 @@ ### Added - Implemented TTSR resume gate to ensure `prompt()` blocks until TTSR interrupt continuations complete, preventing race conditions between TTSR injections and subsequent prompts +- Added `tools.maxTimeout` setting to enforce a global timeout ceiling across all tool calls ### Changed - Replaced `#waitForRetry()` with `#waitForPostPromptRecovery()` to handle both retry and TTSR resume gates, ensuring prompt completion waits for all post-prompt recovery operations - Updated intent field parameter name from `agent__intent` to `_i` for cleaner tool call contracts - Refined intent parameter guidance to require concise 2-6 word sentences in present participle form +- Centralized per-tool timeout constants and clamping into `tool-timeouts.ts` ## [13.3.7] - 2026-02-27 ### Breaking Changes diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 660e577a7..25a60efee 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -511,6 +511,16 @@ export const SETTINGS_SCHEMA = { description: "Ask the agent to describe the intent of each tool call before executing it", }, }, + "tools.maxTimeout": { + type: "number", + default: 0, + ui: { + tab: "tools", + label: "Max tool timeout", + description: "Maximum timeout in seconds the agent can set for any tool (0 = no limit)", + submenu: true, + }, + }, "async.enabled": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index f8a1ac2aa..faefa667c 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -149,6 +149,15 @@ const OPTION_PROVIDERS: Partial> = { { value: "60", label: "60 seconds" }, { value: "120", label: "120 seconds" }, ], + // Global tool timeout ceiling + "tools.maxTimeout": [ + { value: "0", label: "No limit" }, + { value: "30", label: "30 seconds" }, + { value: "60", label: "60 seconds" }, + { value: "120", label: "120 seconds" }, + { value: "300", label: "5 minutes" }, + { value: "600", label: "10 minutes" }, + ], // Edit fuzzy threshold "edit.fuzzyThreshold": [ { value: "0.85", label: "0.85", description: "Lenient" }, diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index cc67e8a2b..46d05db61 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1295,7 +1295,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return key; }, cursorExecHandlers, - transformToolCallArguments: obfuscator?.hasSecrets() ? args => obfuscator!.deobfuscateObject(args) : undefined, + transformToolCallArguments: (args, _toolName) => { + let result = args; + const maxTimeout = settings.get("tools.maxTimeout"); + if (maxTimeout > 0 && typeof result.timeout === "number") { + result = { ...result, timeout: Math.min(result.timeout, maxTimeout) }; + } + if (obfuscator?.hasSecrets()) { + result = obfuscator.deobfuscateObject(result); + } + return result; + }, intentTracing: !!intentField, }); cursorEventEmitter = event => agent.emitExternalEvent(event); diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 1a77de31e..e40984aba 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -25,6 +25,7 @@ import { resolveToCwd } from "./path-utils"; import { replaceTabs } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; +import { clampTimeout } from "./tool-timeouts"; export const BASH_DEFAULT_PREVIEW_LINES = 10; @@ -200,7 +201,7 @@ export class BashTool implements AgentTool { } // Clamp to reasonable range: 1s - 3600s (1 hour) - const timeoutSec = Math.max(1, Math.min(3600, rawTimeout)); + const timeoutSec = clampTimeout("bash", rawTimeout); const timeoutMs = timeoutSec * 1000; if (asyncRequested) { diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 5d5b94cfd..50c105ebd 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -37,6 +37,7 @@ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type: import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" }; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; +import { clampTimeout } from "./tool-timeouts"; /** * Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke @@ -57,8 +58,6 @@ async function loadPuppeteer(): Promise { } } -const DEFAULT_TIMEOUT_SECONDS = 30; -const MAX_TIMEOUT_SECONDS = 120; const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 }; const STEALTH_IGNORE_DEFAULT_ARGS = [ "--disable-extensions", @@ -452,11 +451,6 @@ export interface ReadableResult { markdown?: string; } -function clampTimeout(timeoutSeconds?: number): number { - if (timeoutSeconds === undefined) return DEFAULT_TIMEOUT_SECONDS; - return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS); -} - function ensureParam(value: T | undefined, name: string, action: string): T { if (value === undefined || value === null || value === "") { throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`); @@ -956,7 +950,7 @@ export class BrowserTool implements AgentTool> { try { throwIfAborted(signal); - const timeoutSeconds = clampTimeout(params.timeout); + const timeoutSeconds = clampTimeout("browser", params.timeout); const timeoutMs = timeoutSeconds * 1000; const details: BrowserToolDetails = { action: params.action }; diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index 417722d9d..c3fca4d44 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -24,6 +24,7 @@ import { formatStyledArtifactReference, type OutputMeta } from "./output-meta"; import { formatExpandHint, getDomain } from "./render-utils"; import { ToolAbortError } from "./tool-errors"; import { toolResult } from "./tool-result"; +import { clampTimeout } from "./tool-timeouts"; // ============================================================================= // Types and Constants @@ -872,7 +873,7 @@ export class FetchTool implements AgentTool { const { cells, timeout: rawTimeout = 30, cwd, reset } = params; // Clamp to reasonable range: 1s - 600s (10 min) - const timeoutSec = Math.max(1, Math.min(600, rawTimeout)); + const timeoutSec = clampTimeout("python", rawTimeout); const timeoutMs = timeoutSec * 1000; const timeoutSignal = AbortSignal.timeout(timeoutMs); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 1f3e2c7c6..bd78b583a 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -19,6 +19,7 @@ import type { ToolSession } from "."; import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; +import { clampTimeout } from "./tool-timeouts"; const sshSchema = Type.Object({ host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }), @@ -155,7 +156,7 @@ export class SshTool implements AgentTool { const remoteCommand = buildRemoteCommand(command, cwd, hostInfo); // Clamp to reasonable range: 1s - 3600s (1 hour) - const timeoutSec = Math.max(1, Math.min(3600, rawTimeout)); + const timeoutSec = clampTimeout("ssh", rawTimeout); const timeoutMs = timeoutSec * 1000; const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES); diff --git a/packages/coding-agent/src/tools/tool-timeouts.ts b/packages/coding-agent/src/tools/tool-timeouts.ts new file mode 100644 index 000000000..273b72ebe --- /dev/null +++ b/packages/coding-agent/src/tools/tool-timeouts.ts @@ -0,0 +1,28 @@ +export interface ToolTimeoutConfig { + /** Default timeout in seconds when agent omits the field */ + default: number; + /** Minimum allowed timeout in seconds */ + min: number; + /** Maximum allowed timeout in seconds (per-tool ceiling) */ + max: number; +} + +export const TOOL_TIMEOUTS = { + bash: { default: 300, min: 1, max: 3600 }, + python: { default: 30, min: 1, max: 600 }, + browser: { default: 30, min: 1, max: 120 }, + ssh: { default: 60, min: 1, max: 3600 }, + fetch: { default: 20, min: 1, max: 45 }, +} as const satisfies Record; + +export type ToolWithTimeout = keyof typeof TOOL_TIMEOUTS; + +/** + * Clamp a raw timeout to the allowed range for a tool. + * If rawTimeout is undefined, returns the tool's default. + */ +export function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number): number { + const config = TOOL_TIMEOUTS[tool]; + const timeout = rawTimeout ?? config.default; + return Math.max(config.min, Math.min(config.max, timeout)); +}