Model-facing tool-call budgets were tangled into each capability's schema (bash timeoutMs, web_fetch timeout_ms) with no shared home. Add a tools/execute around-dispatch waterfall to dsh-tools whose base next() is the dispatch-with-normalization thunk, and a new @deepseek-ai/dsh-timeout-policy plugin (packages/timeout/) that arms a per-tool deadline on exec.signal and returns a structured TOOL_TIMEOUT when it wins. Migrate web_fetch (drop the model-facing timeout_ms) and web_search onto it; the fetch provider keeps its timeout only as a resource backstop for direct callers. bash and hook command execution keep BASH_TIMEOUT unchanged. Named the plugin timeout-policy (not the RFC's tool-timeout) so it does not trip the gen-tool-catalog packages/*/tool-* completeness guard, and replace exec.signal by in-place mutation before next() since cordis waterfall next() ignores passed arguments. RFC moved to implemented/ recording both deviations.
3.6 KiB
dsh-timeout-policy
Tool-call timeout policy: a single tools/execute around-dispatch listener that arms a per-call cooperative deadline on exec.signal for each configured tool and returns a structured TOOL_TIMEOUT result when that deadline wins. It is the reference tools/execute wrapper and the deployment-owned home for model-facing tool-call budgets (the timeout-library RFC's foreseen middleware).
Plugin (namespace: timeout-policy)
A function/namespace plugin (name / Config / apply), not a service. It registers no tool and injects nothing — it consumes ctx.tools's tools/execute waterfall, which the dsh-tools registry always provides.
Config
Per-tool policy, keyed by the model-facing tool name. There is deliberately no global default (a global budget would silently start failing any tool that runs long once the plugin loads) and no model-facing override (timeout is deployment policy, not prompt semantics) in this version.
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
config:
tools:
web_fetch:
timeoutMs: 30000
web_search:
timeoutMs: 30000
| Key | Type | Meaning |
|---|---|---|
tools |
Record<string, { timeoutMs }> |
Per-tool timeout policy; an unlisted tool gets no deadline. timeoutMs is required per configured tool and must be positive finite. |
Behavior
For a configured tool the listener:
- Arms
deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')— one signal fusing the caller's abort with this plugin's timer (@deepseek-ai/dsh-timeout). - Swaps that derived signal onto
execfor the downstream dispatch, then restores the caller's own signal afterward (cordisnext()ignores passed arguments, so the wrapper mutates the sharedexecin place; restoring keepstools/post-executeseeing the caller's signal). - After dispatch, if
timeoutOf(d.signal, 'TOOL_TIMEOUT')matches — this plugin's own timer fired — replaces the result with a structuredTOOL_TIMEOUTtool result:{ isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }.
An unconfigured tool delegates untouched (no deadline).
The base next() of tools/execute is the registry's dispatch-with-normalization thunk, so when the timeout signal reaches a provider that throws its own upstream-abort error, dispatch first turns it into a normal error result, and this wrapper then replaces that with TOOL_TIMEOUT. That ordering is why the replacement is keyed off the signal (timeoutOf), not off the dispatched result's shape.
Cooperative, not a hard kill
The derived signal only notifies; termination stays with the tool and the capability it forwards exec.signal to (the dsh-timeout library owns no kill). "Configured" therefore means "cooperative with exec.signal": a tool that ignores the signal will not stop on timeout. A deployment must only configure tools that forward the signal to their implementation — the shipped web_fetch/web_search (which forward through ctx.web to providers) are the reference. TOOL_TIMEOUT needs no session event for reconstructability: it is the final model-facing tool/result, already logged by the loop.
Composing with other tools/execute wrappers
Multiple tools/execute listeners compose by cordis registration order. Combined with a future retry/sandbox/metrics wrapper, registration order chooses the semantics — "timeout covers the whole retry operation" (timeout registered outer) versus "timeout covers each attempt" (timeout registered inner).