diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 66f1321..11e463b 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -28,6 +28,8 @@ export { disposeShellSession, toolTimeoutMs, withToolTimeout, + setToolTimeoutOverrides, + DEFAULT_TOOL_TIMEOUTS_SEC, type TodoItem, } from "./shared"; diff --git a/src/agent/tools/shared.ts b/src/agent/tools/shared.ts index ce22855..c601b51 100644 --- a/src/agent/tools/shared.ts +++ b/src/agent/tools/shared.ts @@ -52,6 +52,26 @@ export const DEFAULT_TOOL_TIMEOUT_MS = 60_000; /** Tools that manage their own lifetime (user wait / nested agent). */ export const NO_TOOL_TIMEOUT = new Set(["Task", "AskQuestion"]); +/** Built-in defaults in seconds (for settings UI). */ +export const DEFAULT_TOOL_TIMEOUTS_SEC: Record = Object.fromEntries( + Object.entries(TOOL_TIMEOUT_MS).map(([k, v]) => [k, Math.round(v / 1000)]), +); + +/** User overrides from settings (tool name → seconds). Empty/missing = built-in default. */ +let toolTimeoutOverridesSec: Record = {}; + +/** Apply settings overrides (seconds). Call whenever feature config loads/changes. */ +export function setToolTimeoutOverrides(sec: Record | undefined): void { + const next: Record = {}; + if (sec) { + for (const [k, v] of Object.entries(sec)) { + const n = Number(v); + if (Number.isFinite(n) && n > 0) next[k] = Math.floor(n); + } + } + toolTimeoutOverridesSec = next; +} + /** * Race a tool promise against a hard timeout. On timeout rejects with an Error * whose message starts with "timeout:" so the loop can surface it cleanly. @@ -84,9 +104,11 @@ export function withToolTimeout(p: Promise, ms: number, label: string): Pr }); } -/** Resolve the hard timeout for a tool name (0 = none). */ +/** Resolve the hard timeout for a tool name (0 = none). Honors settings overrides. */ export function toolTimeoutMs(name: string): number { if (NO_TOOL_TIMEOUT.has(name)) return 0; + const overrideSec = toolTimeoutOverridesSec[name]; + if (overrideSec != null && overrideSec > 0) return overrideSec * 1000; return TOOL_TIMEOUT_MS[name] ?? DEFAULT_TOOL_TIMEOUT_MS; } diff --git a/src/extension.ts b/src/extension.ts index 22c8584..0099ded 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { SidebarProvider } from './ui/sidebarProvider'; import { registerInlineReview } from './ui/inlineReview'; import { SettingsPanel } from './ui/settingsPanel'; import { FeatureStore } from './stores/featureStore'; +import { setToolTimeoutOverrides } from './agent/tools/shared'; import { mcpManager } from './integrations/mcpClient'; import { setIndexStorageDir } from './agent/semanticIndex'; import { setDocsStorageDir, setDocSourcesProvider } from './agent/docsIndex'; @@ -32,6 +33,9 @@ export function activate(context: vscode.ExtensionContext) { const settingsManager = new SettingsManager(context); const featureStore = new FeatureStore(context); + const syncToolTimeouts = () => setToolTimeoutOverrides(featureStore.get().toolTimeoutsSec); + syncToolTimeouts(); + context.subscriptions.push(featureStore.onDidChange(syncToolTimeouts)); initOAuth(context); initUsage(context); // Prefetch the provider-grouped model list so every UI (settings, pickers) diff --git a/src/stores/featureStore.ts b/src/stores/featureStore.ts index c1d93a2..9b47444 100644 --- a/src/stores/featureStore.ts +++ b/src/stores/featureStore.ts @@ -232,6 +232,8 @@ export interface FeatureConfig { maxTabCount: number; /** Max agent steps per run before pausing (0 = default 50). */ maxAgentSteps: number; + /** Per-tool hard timeout overrides in seconds (empty = built-in defaults). */ + toolTimeoutsSec: Record; /** Automatically continue when the step limit is reached. */ autoContinue: boolean; /** Play a sound when the agent finishes responding. */ @@ -281,6 +283,7 @@ const DEFAULTS: FeatureConfig = { submitWithCtrlEnter: false, maxTabCount: 0, maxAgentSteps: 50, + toolTimeoutsSec: {}, autoContinue: false, completionSound: false, webSearchEnabled: true, diff --git a/webview-ui/settings/App.tsx b/webview-ui/settings/App.tsx index 892fe57..7c1bb0b 100644 --- a/webview-ui/settings/App.tsx +++ b/webview-ui/settings/App.tsx @@ -67,11 +67,38 @@ const NAV: { id: Section; label: string; icon: IconName; sep?: boolean }[] = [ { id: "about", label: "About", icon: "book" }, ]; +/** Built-in tool hard timeouts (seconds). Keep in sync with src/agent/tools/shared.ts. */ +const TOOL_TIMEOUT_DEFAULTS: { name: string; sec: number }[] = [ + { name: "Shell", sec: 120 }, + { name: "AwaitShell", sec: 150 }, + { name: "Grep", sec: 30 }, + { name: "Glob", sec: 30 }, + { name: "FileSearch", sec: 20 }, + { name: "SemanticSearch", sec: 45 }, + { name: "SearchDocs", sec: 30 }, + { name: "ListDir", sec: 15 }, + { name: "Read", sec: 30 }, + { name: "ReadLints", sec: 20 }, + { name: "WebSearch", sec: 25 }, + { name: "WebFetch", sec: 30 }, + { name: "StrReplace", sec: 30 }, + { name: "Write", sec: 30 }, + { name: "Delete", sec: 15 }, + { name: "EditNotebook", sec: 30 }, + { name: "CallMcpTool", sec: 60 }, + { name: "FetchMcpResource", sec: 45 }, + { name: "ListMcpResources", sec: 20 }, + { name: "TodoWrite", sec: 5 }, + { name: "TodoRead", sec: 5 }, + { name: "WritePlan", sec: 15 }, + { name: "SwitchMode", sec: 5 }, +]; + /** Search terms per section so the nav filter finds settings inside pages too. */ const SECTION_KEYWORDS: Partial> = { general: "editor settings keyboard shortcuts notifications privacy chat titles auto judge model completion sound reset", usage: "tokens quota limits plan usage oauth account rate limit", - agents: "text size submit ctrl enter max tab count web search fetch context conversation", + agents: "text size submit ctrl enter max tab count web search fetch context conversation tool timeout shell grep", models: "enable disable model catalog reasoning effort thinking context", providers: "api key openai anthropic google openrouter oauth custom base url connect", behavior: "workspace context file reading terminal tools auto edits approval allow deny ask review policy allowlist denylist commands mcp web", @@ -824,6 +851,31 @@ export function App() { +
Tool Timeouts
+

Hard timeout per tool (seconds). Empty = built-in default. Prevents a hung tool from blocking the agent forever.

+ + {TOOL_TIMEOUT_DEFAULTS.map(({ name, sec }) => { + const overrides = features.toolTimeoutsSec || {}; + const val = overrides[name]; + return ( + + 0 ? val : null} + step="1" + min="1" + placeholder={String(sec)} + onChange={(v) => { + const next = { ...(features.toolTimeoutsSec || {}) }; + if (v == null || v <= 0) delete next[name]; + else next[name] = Math.floor(v); + setFeatures({ toolTimeoutsSec: next }); + }} + /> + + ); + })} + +
Subagents
diff --git a/webview-ui/settings/features.ts b/webview-ui/settings/features.ts index f9887ee..29f1ce9 100644 --- a/webview-ui/settings/features.ts +++ b/webview-ui/settings/features.ts @@ -260,6 +260,8 @@ export interface FeatureConfig { submitWithCtrlEnter: boolean; maxTabCount: number; maxAgentSteps: number; + /** Per-tool hard timeout overrides in seconds (empty = built-in defaults). */ + toolTimeoutsSec: Record; autoContinue: boolean; completionSound: boolean; webSearchEnabled: boolean; @@ -346,6 +348,7 @@ export const EMPTY_FEATURES: FeatureConfig = { submitWithCtrlEnter: false, maxTabCount: 0, maxAgentSteps: 50, + toolTimeoutsSec: {}, autoContinue: false, completionSound: false, webSearchEnabled: true,