Files
deepseek-harness/packages/timeout/timeout-policy
Tianyi Cui 2489402610 Merge origin/master: scope-aware fusion of the tools/execute seam, session-prefix, and tool-cordis
Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker,
the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard,
agent/session-prefix, the ui reorganization). Beyond the ten textual
conflicts, the merge reconciles master's new seams with this branch's
scoped-registration world:

- tools/execute (new waterfall around core dispatch): dispatched with the
  SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper
  times/retries only its own agent's calls — and its base thunk resolves the
  tool through the caller's visible view (get(exec.name, exec.agent)), so a
  scoped/shadowed tool dispatches and a restricted-away global stays
  UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered
  doc sentence; invariants table + verify-scoped-dispatch pin it (21 events).
- agent/session-prefix (new waterfall, once per loop instance): composed via
  the fused agentEvents dispatcher (scope-filtered like every agent-subject
  event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps
  master's new sessionPrefix parameter with this branch's Scoped this.
- timeout-policy reads the budget through the caller's visible view
  (get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its
  calls; a global name-twin's budget is never misapplied to a shadowing
  per-agent variant.
- tool-cordis: cordis_inspect's tools section lists the CALLING agent's view
  (its description promises "what you can call"); the sandbox tool façade's
  reads resolve through the mount's own scope, mirroring where its register
  lands writes; sandboxRegisterTool's return type carries the exact-disposer
  union honestly. dsh-scope declared as peer+dev with the project reference.
- doc-sync chain unions master's verify-cordis-api with this branch's
  verify-scoped-dispatch; the generated catalogs, event matrix (the
  zero-dispatcher guard passes over master's new events), module graph, and
  the cordis api-catalog are regenerated on the merged surface.

Full gate sequence green on the merged tree: typecheck, lint, per-file 100%
coverage (2668 tests), snapshots (38), doc-sync, module graph, build,
hygiene, demo smoke.
2026-07-09 23:24:42 +08:00
..

dsh-timeout-policy

Tool-call timeout enforcer: a single tools/execute around-dispatch listener that arms a per-call cooperative deadline on exec.signal for a tool declaring timeoutMs on its ToolDefinition and returns a structured TOOL_TIMEOUT result when that deadline wins. The budget is read from the tool's own declaration (ToolDefinition.timeoutMs, set by the owning tool plugin), so this plugin is zero-config. It is the reference tools/execute wrapper and the enforcement home for model-facing tool-call budgets (the timeout-library RFC's foreseen middleware).

Plugin (namespace: timeout-policy)

A function/namespace plugin (name / inject / apply), not a service. It registers no tool and takes no config — it consumes ctx.tools's tools/execute waterfall (which the dsh-tools registry always provides) and reads each dispatched tool's declared timeoutMs from the registry (ctx.tools.get(exec.name)).

- id: timeout-policy
  name: '@deepseek-ai/dsh-timeout-policy'

The per-tool budget is declared by the tool plugin (e.g. dsh-tool-web's fetchTimeoutMs/searchTimeoutMs config, attached as ToolDefinition.timeoutMs); this plugin only enforces it, so a mistyped tool name is not possible.

Behavior

For a tool that declares a timeoutMs the listener:

  1. Reads the budget from the tool's own declaration in the registry (ctx.tools.get(exec.name)?.timeoutMs) and arms deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT') — one signal fusing the caller's abort with this plugin's timer (@deepseek-ai/dsh-timeout).
  2. Swaps that derived signal onto exec for the downstream dispatch, then restores the caller's own signal afterward (cordis next() ignores passed arguments, so the wrapper mutates the shared exec in place; restoring keeps tools/post-execute seeing the caller's signal).
  3. After dispatch, if timeoutOf(d.signal, 'TOOL_TIMEOUT') matches — this plugin's own timer fired — replaces the result with a structured TOOL_TIMEOUT tool result: { isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }.

A tool that declares no budget 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). Declaring timeoutMs therefore means "cooperative with exec.signal": a tool that ignores the signal will not stop on timeout. Only signal-forwarding tools should declare it — 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).