@@ -15,12 +15,8 @@ export type ToolWatchdogConfig = {
1515 waitForApproval ?: boolean ;
1616} ;
1717
18- // Outer budget for tools that have no per-call timeout. run_shell with a longer
19- // requested timeout is resolved separately (see resolveToolExecutionTimeoutMs)
20- // so this default — and MAX_TOOL_EXECUTION_TIMEOUT_MS / tools.maxTimeoutMs —
21- // cannot abort it first. Omitting run_shell timeout still defaults to 15s
22- // inside shell-guard.
23- export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 660_000 ;
18+ // Cap applied when Settings set tools.timeoutMs without tools.maxTimeoutMs.
19+ // Not an implicit default — omitted settings leave the watchdog unarmed.
2420export const MAX_TOOL_EXECUTION_TIMEOUT_MS = 1_800_000 ;
2521
2622/**
@@ -46,16 +42,26 @@ export const MAX_TOOL_APPROVAL_PAUSE_MS = 1_800_000;
4642
4743const BUDGET_EXPIRED = Symbol ( "tool-execution-budget-expired" ) ;
4844
45+ /**
46+ * Wall-clock budget for one tool `run()`, or undefined to leave the timer unarmed.
47+ * Parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run.
48+ *
49+ * Arms only when Settings pass tools.timeoutMs / tools.maxTimeoutMs, or when
50+ * run_shell passes a positive arguments.timeout (requested + slack so this
51+ * layer cannot beat shell-guard). A requested run_shell timeout is not clamped
52+ * to MAX_TOOL_EXECUTION_TIMEOUT_MS or tools.maxTimeoutMs.
53+ */
4954export function resolveToolExecutionTimeoutMs (
5055 config ?: ToolWatchdogConfig ,
5156 call ?: ToolCall ,
52- ) : number {
57+ ) : number | undefined {
5358 if ( call ?. name === "run_shell" ) {
54- return resolveRunShellWatchdogTimeoutMs ( config , call ) ;
59+ const requested = requestedRunShellTimeoutMs ( call ) ;
60+ if ( requested !== undefined ) {
61+ return requested + RUN_SHELL_WATCHDOG_SLACK_MS ;
62+ }
5563 }
56- const max = config ?. maxMs ?? MAX_TOOL_EXECUTION_TIMEOUT_MS ;
57- const raw = config ?. defaultMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS ;
58- return Math . min ( max , Math . max ( 1 , Math . floor ( raw ) ) ) ;
64+ return resolveSettingsWatchdogTimeoutMs ( config ) ;
5965}
6066
6167function requestedRunShellTimeoutMs ( call : ToolCall ) : number | undefined {
@@ -66,22 +72,15 @@ function requestedRunShellTimeoutMs(call: ToolCall): number | undefined {
6672 return Math . floor ( timeout ) ;
6773}
6874
69- /**
70- * run_shell's watchdog floor is the requested command timeout (plus slack so
71- * this outer timer cannot beat shell-guard). tools.maxTimeoutMs /
72- * MAX_TOOL_EXECUTION_TIMEOUT_MS still bound other tools only — they must not
73- * reimpose a cap when the operator/model passed a longer run_shell timeout.
74- * Omitting timeout leaves the default outer budget (shell-guard still uses 15s).
75- */
76- function resolveRunShellWatchdogTimeoutMs (
75+ function resolveSettingsWatchdogTimeoutMs (
7776 config : ToolWatchdogConfig | undefined ,
78- call : ToolCall ,
79- ) : number {
80- const raw = config ?. defaultMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS ;
81- const floor = Math . max ( 1 , Math . floor ( raw ) ) ;
82- const requested = requestedRunShellTimeoutMs ( call ) ;
83- if ( requested === undefined ) return floor ;
84- return Math . max ( floor , requested + RUN_SHELL_WATCHDOG_SLACK_MS ) ;
77+ ) : number | undefined {
78+ if ( config === undefined || ( config . defaultMs === undefined && config . maxMs === undefined ) ) {
79+ return undefined ;
80+ }
81+ const max = config . maxMs ?? MAX_TOOL_EXECUTION_TIMEOUT_MS ;
82+ const raw = config . defaultMs ?? max ;
83+ return Math . min ( max , Math . max ( 1 , Math . floor ( raw ) ) ) ;
8584}
8685
8786/** Default true: freeze tool budget while a permission prompt is open. */
@@ -122,6 +121,22 @@ export type PauseableTimeout = {
122121 resume : ( token : PauseToken ) => void ;
123122} ;
124123
124+ /** Chain parent cancel without arming a run-duration timer. */
125+ function withParentAbort ( signal : AbortSignal ) : PauseableTimeout {
126+ const controller = new AbortController ( ) ;
127+ const onParentAbort = ( ) => controller . abort ( ) ;
128+ signal . addEventListener ( "abort" , onParentAbort , { once : true } ) ;
129+ if ( signal . aborted ) controller . abort ( ) ;
130+ return {
131+ signal : controller . signal ,
132+ dispose : ( ) => {
133+ signal . removeEventListener ( "abort" , onParentAbort ) ;
134+ } ,
135+ pause : ( ) : PauseToken => 0 ,
136+ resume : ( _token : PauseToken ) => { } ,
137+ } ;
138+ }
139+
125140/**
126141 * Like withTimeout, but the remaining budget freezes while paused (e.g. while
127142 * a permission prompt is open). Pause/resume are refcounted so nested pauses
@@ -322,8 +337,10 @@ export type ToolExecutionWatchdogOptions = {
322337} ;
323338
324339/**
325- * Runs `execute` under a wall-clock race against `parentSignal`, matching the
326- * shell-guard search-tool pattern so non-abortable work still returns on time.
340+ * Runs `execute` under a race against `parentSignal` and, when `timeoutMs` is
341+ * set, a wall-clock budget. `undefined` timeout does not arm a timer — parent
342+ * cancel and the approval-budget ALS still apply. Permission pause ceiling
343+ * (`MAX_TOOL_APPROVAL_PAUSE_MS`) stays a stuck-prompt guard, not a run cap.
327344 *
328345 * When budget/parent abort wins the race, the signal is still aborted, but we
329346 * give the in-flight execute a short grace to return a usable non-error body
@@ -333,19 +350,22 @@ export type ToolExecutionWatchdogOptions = {
333350export async function runWithToolExecutionWatchdog (
334351 call : ToolCall ,
335352 parentSignal : AbortSignal ,
336- timeoutMs : number ,
353+ timeoutMs : number | undefined ,
337354 execute : ( signal : AbortSignal ) => Promise < ToolResult > ,
338355 options : ToolExecutionWatchdogOptions ,
339356) : Promise < ToolResult > {
340357 const salvageGraceMs = options . salvageGraceMs ?? TOOL_EXECUTION_SALVAGE_GRACE_MS ;
341358 const waitForApproval = options . waitForApproval ;
342- const budget = waitForApproval
343- ? withPauseableTimeout ( parentSignal , timeoutMs )
344- : {
345- ...withTimeout ( parentSignal , timeoutMs ) ,
346- pause : ( ) : PauseToken => 0 ,
347- resume : ( _token : PauseToken ) => { } ,
348- } ;
359+ const budget : PauseableTimeout =
360+ timeoutMs === undefined
361+ ? withParentAbort ( parentSignal )
362+ : waitForApproval
363+ ? withPauseableTimeout ( parentSignal , timeoutMs )
364+ : {
365+ ...withTimeout ( parentSignal , timeoutMs ) ,
366+ pause : ( ) : PauseToken => 0 ,
367+ resume : ( _token : PauseToken ) => { } ,
368+ } ;
349369 // Nested runs (task tool → child tool call) shadow the parent store: the
350370 // gate captures the innermost budget, so pause/resume must chain outward or
351371 // the parent `task` budget keeps ticking under the permission modal.
@@ -375,9 +395,10 @@ export async function runWithToolExecutionWatchdog(
375395 if ( salvaged !== undefined ) return salvaged ;
376396 // Avoid unhandled rejection if execute later fails after we move on.
377397 void executePromise . catch ( ( ) => { } ) ;
378- const content = parentSignal . aborted
379- ? `${ call . name } aborted`
380- : formatToolExecutionTimeoutMessage ( call . name , timeoutMs ) ;
398+ const content =
399+ timeoutMs !== undefined && ! parentSignal . aborted
400+ ? formatToolExecutionTimeoutMessage ( call . name , timeoutMs )
401+ : `${ call . name } aborted` ;
381402 return { callId : call . id , content, isError : true } ;
382403 }
383404
@@ -392,6 +413,7 @@ export async function runWithToolExecutionWatchdog(
392413 if (
393414 budget . signal . aborted &&
394415 ! parentSignal . aborted &&
416+ timeoutMs !== undefined &&
395417 outcome . isError === true &&
396418 typeof outcome . content === "string" &&
397419 isAbortLikeToolError ( outcome . content )
0 commit comments