DSH / Atlas
2026-07-07implementedarchitecture

Tool-call timeout policy as a plugin

工具调用超时策略作为插件

The [timeout/deadline Agent Note](2026-07-06-timeout-deadline-library.md) extracted the timing-and-classification primitive into `@deepseek-ai/dsh-timeout`, but timeout policy was still attached to individual capabilities and model-facing schemas. `bash` exposed `timeoutMs`; `web_fetch` exposed `timeout_ms`; `web_search` had no model-facing timeout even though providers already honor `exec.signal`; a future grep/glob

English

Problem

The timeout/deadline Agent Note extracted the timing-and-classification primitive into @deepseek-ai/dsh-timeout, but timeout policy was still attached to individual capabilities and model-facing schemas. bash exposed timeoutMs; web_fetch exposed timeout_ms; web_search had no model-facing timeout even though providers already honor exec.signal; a future grep/glob tool would either import the timeout library directly or invent its own timeout policy. That is the wrong authoring shape for a plugin SDK: a tool author should normally forward exec.signal to the implementation it calls, and deployment policy should decide the budget.

At the same time, not every timeout in the repo is a model-facing tool-call budget. Hooks execute command hooks by calling ctx.shell directly, not through ctx.tools.execute(), and the bash model tool multiplexes foreground execution, background start, background polling, and hook reuse through the same backend. Moving every timeout into a tool plugin in one step would conflate those paths and risk breaking hook timeout semantics.

Decision

Tool-call timeout is a policy that applies only to model-facing tool execution, in three parts:

  • @deepseek-ai/dsh-timeout remains the shared library that owns deadline() and timeoutOf().
  • @deepseek-ai/dsh-tools has an around-dispatch waterfall, tools/execute, between tools/pre-execute and tools/post-execute.
  • The repository naming contract names @deepseek-ai/dsh-tool-call-timeout-policy for the exact operation it limits. The plugin reads each tool's declared timeoutMs from the runtime and wraps a call that has one by deriving a new exec.signal.

The execution pipeline is:

ctx.tools.execute(exec)
  -> tools/pre-execute
  -> tools/execute
       -> registry dispatch (the base next())
            -> tool.execute(args, exec)
            -> thrown tool errors normalize to ToolExecutionResult
  -> tools/post-execute

The default behavior is conservative: a tool that declares no timeoutMs receives no TOOL_TIMEOUT deadline from the plugin.

The tools/execute around-dispatch extension point

@deepseek-ai/dsh-tools declares a tools/execute waterfall whose base next() is the dispatch-with-normalization thunk — the same inner try/catch that turns a thrown tool (or unknown tool) into an isError ToolExecutionResult. A listener receives (exec, next): it calls next() to delegate to dispatch (returning its result, optionally wrapped) or returns a replacement result to short-circuit dispatch. The whole pipeline still sits inside execute's outer try/catch, so a throwing listener becomes an isError result, never a turn failure.

That the catch is the base next — not something outside the waterfall — is load-bearing: when a provider sees the timeout signal and throws its own upstream-abort error, registry dispatch first converts it to a normal error result, and only then can timeout-policy replace the final result with TOOL_TIMEOUT.

The timeout-policy plugin

The plugin is @deepseek-ai/dsh-tool-call-timeout-policy, a zero-config function/namespace plugin (name / inject / apply) in the packages/guard/ group (originally its own timeout/ group). The per-tool budget is DECLARED on the tool, not on this plugin: a ToolDefinition carries an optional timeoutMs, which the owning tool plugin sets from its own config. dsh-tool-web, for example, resolves fetchTimeoutMs / searchTimeoutMs (default 30000) onto the web_fetch / web_search definitions:

- id: timeout-policy
  name: '@deepseek-ai/dsh-tool-call-timeout-policy'
- id: tool-web
  name: '@deepseek-ai/dsh-tool-web'
  config:
    fetchTimeoutMs: 30000
    searchTimeoutMs: 30000

Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. defineTool validates a positive finite budget. During dispatch the enforcer derives a deadline signal and assigns it to exec.signal; the registry fuses that deadline with the original caller signal before the body under the tool-cancellation contract. The enforcer restores the caller signal afterward and converts its own expiry into TOOL_TIMEOUT; tools without a budget pass through unchanged.

Signal replacement is by in-place mutation of exec.signal, not by passing a new object to next(). Cordis's waterfall next() ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (vendor/cordis/src/events.ts), so mutation is how the wrapper supplies its deadline to the registry. The registry re-fuses the captured caller signal immediately before the body, and the plugin restores exec.signal to the caller's original in a finally so tools/post-execute never sees the plugin's deadline signal.

timeout-policy owns both uses of the TOOL_TIMEOUT code: the internal deadline code passed to deadline()/timeoutOf() (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is:

function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
  return {
    content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
    isError: true,
    error: {
      message: `tool call timed out after ${timeoutMs}ms`,
      info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
    },
  }
}

This is a cooperative deadline. It does not kill arbitrary work by racing the tool promise; the tool or the capability it calls must honor exec.signal and reach quiescence. Declaring timeoutMs therefore MEANS "this tool is cooperative with exec.signal", which the plugin README states as its contract.

No new session event is needed for reconstructability: TOOL_TIMEOUT is the final model-facing tool/result for that call, so the existing session log already records the content and structured { name, code } error the next model request sees.

Existing tool adaptation

web_fetch and web_search are migrated. dsh-tool-web keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: web_fetch has no timeout_ms parameter, while web_search accepts a required queries array without a timeout argument. The tool bodies do not import @deepseek-ai/dsh-timeout; they forward exec.signal to ctx.web.

dsh-web-fetch-http keeps one configured provider-level timeoutMs as a large resource backstop for direct ctx.web.fetch() callers and misconfigured deployments; it owns no model-facing timeout. When a TOOL_TIMEOUT signal reaches the fetch provider first, provider-scoped classification treats it as upstream WEB_ABORTED, and the outer tools/execute wrapper replaces the final tool result with TOOL_TIMEOUT. A shipped web-tool deployment configures the provider backstop above the timeout-policy budget so the tool-call policy normally wins for model calls.

bash stays on the current backend timeout path. dsh-tool-bash continues to expose timeoutMs and run_in_background; dsh-bash-local continues to use @deepseek-ai/dsh-timeout for BASH_TIMEOUT; hook bridges continue to call runHook() and pass timeoutMs through ctx.shell. This keeps foreground/background/hook behavior stable.

read, write, edit, todo_write, job_list, and job_kill do not opt into tool-call timeout. job_output owns its bounded wait because a wait timeout is a successful live-status result, not a tool failure.

A future model-facing grep/glob tool can be implemented on top of ctx.shell without importing @deepseek-ai/dsh-timeout: it forwards exec.signal to ctx.shell, and declares its own timeoutMs (from its plugin's config) for the enforcer to apply. If bash-local's backend timeout becomes a problem for such a tool, the bash seam can later add a caller-owned-deadline mode; that is a separate decision.

Alternatives considered

Name the plugin tool-timeout. The literal Agent Note name matched the gen-tool-catalog completeness guard's packages/*/tool-* glob, which requires every match to register a model-facing tool. This plugin registers none — it is a tools/execute wrapper — so a tool-* name would either fail verify-tool-catalog or force a misleading boot entry. The package is @deepseek-ai/dsh-tool-call-timeout-policy in what was then a new timeout/ group, since folded into packages/guard/; the cordis.yml id can still be timeout-policy.

Keep per-tool timeout handling only. This was the shape for bash and web_fetch, and it matches Claude Code and Codex for shell commands. It loses for web-style tools because every new timeout-capable tool must choose validation, cap semantics, docs, snapshots, and classification. The plugin centralizes policy and classification while leaving each tool's schema focused on business input.

Move all timeout policy out of bash-local immediately. Cleaner long-term — bash-local would become a pure subprocess executor and all callers would own their deadlines. It loses as the first step because hooks call ctx.shell directly and the bash model tool has foreground/background semantics that are not the same tool-call lifetime. Keeping BASH_TIMEOUT preserves those paths while tool-call timeout proves itself on simpler tools.

Use a global default budget for every tool. Convenient, but it surprises tool authors: any tool that accidentally runs longer than the global budget would start failing once the plugin loads. A per-tool declared budget makes adoption deliberate.

Expose a model-facing timeout_ms override. Claude Code's WebFetch/WebSearch and Codex's web tools keep timeout out of the model-call shape. A model override would make timeout part of prompt semantics and force schema/argument-stripping rules into timeout-policy. Web timeout stays deployment policy only.

Let timeout-policy match tool arguments itself. A rule engine such as "disable timeout when bash.run_in_background is true" would make the policy plugin know tool-specific argument semantics. Avoided by not migrating bash to tool-call timeout.

Use tools/pre-execute plus tools/post-execute instead of a new around-dispatch extension point. A pre listener could arm a deadline and mutate exec.signal; a post listener could classify and replace. That loses because the deadline lifetime would cross two independent waterfalls: a call-id map, cleanup on every pre-deny/tool-throw/post-throw/dispose path, and ordering rules with every other listener. tools/pre-execute is also the allow/deny gate, not an execution wrapper. tools/execute gives the timeout one lexical scope: arm, delegate, classify, dispose.

Use Promise.race to enforce timeouts for non-cooperative tools. Rejected for the same reason as the timeout-library Agent Note: it returns control to the caller while the underlying process, fetch, or provider operation may still be running. The plugin only sends a signal; termination remains the implementation's responsibility.

Consequences

  • @deepseek-ai/dsh-tools gains an around-dispatch surface after the interception points deliberately split pre/post tool hooks. Its contract is narrow — wrap registry dispatch, not replace the pre-gate or post-result policy — and the base next() is dispatch-with-normalization so a wrapper never sees a raw tool throw.
  • Multiple tools/execute listeners compose by ordinary Cordis waterfall order: a listener that calls next() wraps downstream listeners plus dispatch; one that returns without next() short-circuits them. A deployment combining timeout with a future retry/sandbox/metrics wrapper chooses semantics by registration order ("timeout covers the whole retry" vs "timeout covers each attempt").
  • Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a timeoutMs without honoring exec.signal, and that tool will not stop on timeout. The registry awaits that non-quiescent body rather than racing it, while the plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal.
  • During the transition bash and the migrated web tools use different timeout paths on purpose: TOOL_TIMEOUT is the model-facing tool-call budget, while BASH_TIMEOUT remains the bash backend timeout used by bash and hooks.

中文

问题

超时/截止时间 Agent Note 将计时与分类原语提取到了 @deepseek-ai/dsh-timeout,但超时策略仍然附着在各个能力和面向模型的 schema 上。bash 暴露了 timeoutMsweb_fetch 暴露了 timeout_msweb_search 没有面向模型的超时参数,尽管提供方已经遵循 exec.signal;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 exec.signal 转发给其调用的实现,而部署策略来决定预算。

与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 ctx.shell 执行命令钩子,而非通过 ctx.tools.execute()bash 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子复用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。

决策

工具调用超时是仅适用于面向模型的工具执行的策略,由三部分组成:

  • @deepseek-ai/dsh-timeout 仍是拥有 deadline()timeoutOf() 的共享库。
  • @deepseek-ai/dsh-toolstools/pre-executetools/post-execute 之间有一个环绕分发的 waterfall(瀑布式事件)tools/execute
  • 仓库命名约定使用 @deepseek-ai/dsh-tool-call-timeout-policy,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 timeoutMs,并通过派生新的 exec.signal 来包装有此声明的调用。

执行流水线如下:

ctx.tools.execute(exec)
  -> tools/pre-execute
  -> tools/execute
       -> registry dispatch (the base next())
            -> tool.execute(args, exec)
            -> thrown tool errors normalize to ToolExecutionResult
  -> tools/post-execute

默认行为是保守的:未声明 timeoutMs 的工具不会从该插件收到 TOOL_TIMEOUT 截止信号。

tools/execute 环绕分发扩展点

@deepseek-ai/dsh-tools 声明了一个 tools/execute waterfall,其基础 next() 是带规范化的分发 thunk——即同一个内部 try/catch,将抛出的工具错误(或未知工具错误)转换为 isErrorToolExecutionResult。监听器接收 (exec, next):调用 next() 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整个流水线仍位于 execute 的外层 try/catch 内,因此抛出异常的监听器会变成 isError 结果,而非轮次失败。

catch 是基础 next(而非 waterfall 之外的东西)这一点至关重要:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为普通错误结果,然后 timeout-policy 才能将最终结果替换为 TOOL_TIMEOUT

timeout-policy 插件

该插件是 @deepseek-ai/dsh-tool-call-timeout-policy,一个零配置的函数/命名空间插件(name / inject / apply),位于 packages/guard/ 组。每个工具的预算声明在工具自身,而非本插件:ToolDefinition 携带一个可选的 timeoutMs,由拥有该工具的插件从自身配置中设置。例如 dsh-tool-webfetchTimeoutMs / searchTimeoutMs(默认 30000)解析到 web_fetch / web_search 的定义上:

- id: timeout-policy
  name: '@deepseek-ai/dsh-tool-call-timeout-policy'
- id: tool-web
  name: '@deepseek-ai/dsh-tool-web'
  config:
    fetchTimeoutMs: 30000
    searchTimeoutMs: 30000

超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。defineTool 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 exec.signal;注册表依据工具取消约定,在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 TOOL_TIMEOUT;没有预算的工具原样通过。

信号替换采用就地修改 exec.signal 的方式,而非向 next() 传递新对象。Cordis 的 waterfall next() 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(vendor/cordis/src/events.ts),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 finally 中将 exec.signal 恢复为调用方的原始值,使 tools/post-execute 永远不会看到本插件的截止信号。

timeout-policy 拥有 TOOL_TIMEOUT 代码的两种用途:传递给 deadline()/timeoutOf() 的内部截止代码(有作用域,使嵌套的外层截止被识别为普通取消)和结构化工具结果错误代码。其替换结果为:

function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
  return {
    content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
    isError: true,
    error: {
      message: `tool call timed out after ${timeoutMs}ms`,
      info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
    },
  }
}

这是一个协作式截止。它不会通过竞争工具 promise 来杀死任意工作;工具或其调用的能力必须遵循 exec.signal 并达到完全停稳。因此声明 timeoutMs 意味着「此工具与 exec.signal 协作」,插件 README 将此作为其约定。

无需新的会话事件来保证可重建性:TOOL_TIMEOUT 是该调用的最终面向模型的 tool/result,因此现有会话日志已经记录了下一次模型请求所见的内容和结构化 { name, code } 错误。

现有工具适配

web_fetchweb_search 已迁移。dsh-tool-web 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:web_fetch 没有 timeout_ms 参数,web_search 接受必填的 queries 数组,但不接受超时参数。工具体不导入 @deepseek-ai/dsh-timeout;它们将 exec.signal 转发给 ctx.web

dsh-web-fetch-http 保留一个在提供方层面配置的 timeoutMs,作为较大的资源兜底值,服务于直接调用 ctx.web.fetch() 的调用方和配置错误的部署;它不拥有面向模型的超时。当 TOOL_TIMEOUT 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 WEB_ABORTED,而外层 tools/execute 包装器将最终工具结果替换为 TOOL_TIMEOUT。一个已发布的 web 工具部署将提供方兜底配置为高于 timeout-policy 预算,使工具调用策略在模型调用中通常胜出。

bash 保持当前的后端超时路径。dsh-tool-bash 继续暴露 timeoutMsrun_in_backgrounddsh-bash-local 继续使用 @deepseek-ai/dsh-timeout 处理 BASH_TIMEOUT;钩子桥接继续调用 runHook() 并通过 ctx.shell 传递 timeoutMs。这保持了前台/后台/钩子行为的稳定。

readwriteedittodo_writejob_listjob_kill 不加入工具调用超时。job_output 自己拥有有界等待,因为等待超时是成功的实时状态结果,而非工具失败。

未来面向模型的 grep/glob 工具可以基于 ctx.shell 实现而无需导入 @deepseek-ai/dsh-timeout:它将 exec.signal 转发给 ctx.shell,并声明自己的 timeoutMs(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;那是一项独立的决策。

曾考虑的替代方案

将插件命名为 tool-timeout 字面的 Agent Note 名称匹配了 gen-tool-catalog 完整性守卫的 packages/*/tool-* glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 tools/execute 包装器——因此 tool-* 名称要么导致 verify-tool-catalog 失败,要么强制产生一个误导性的启动条目。包为 @deepseek-ai/dsh-tool-call-timeout-policy,位于新的 packages/guard/ 组;cordis.yml 的 id 仍可为 timeout-policy

仅保留逐工具的超时处理。 这是 bashweb_fetch 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。

立即将所有超时策略移出 bash-local。 长期来看更干净——bash-local 将成为纯子进程执行器,所有调用方自行管理截止时间。但作为第一步不合适,因为钩子直接调用 ctx.shell,且 bash 模型工具的前台/后台语义与工具调用生命周期不同。保留 BASH_TIMEOUT 维持了这些路径的稳定,同时让工具调用超时在更简单的工具上先行验证。

为所有工具使用全局默认预算。 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明预算使采纳成为有意的行为。

暴露面向模型的 timeout_ms 覆盖参数。 Claude Code 的 WebFetch/WebSearch 和 Codex 的 web 工具将超时排除在模型调用形状之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 timeout-policy 引入 schema/参数剥离规则。Web 超时仅作为部署策略。

timeout-policy 自行匹配工具参数。 诸如「当 bash.run_in_background 为 true 时禁用超时」之类的规则引擎会让策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来规避此问题。

使用 tools/pre-executetools/post-execute 代替新的环绕分发扩展点。 pre 监听器可以启动截止时间并修改 exec.signal;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose(资源释放)路径上清理,以及与其他监听器的排序规则。tools/pre-execute 也是允许/拒绝门禁,而非执行包装器。tools/execute 给超时一个词法作用域:启动、委托、分类、释放。

使用 Promise.race 对非协作工具强制超时。 与超时库 Agent Note 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。

后果

  • @deepseek-ai/dsh-tools 在拦截点有意拆分 pre/post 工具钩子之后,获得了一个环绕分发接口。其约定范围很窄——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 next() 是带规范化的分发,因此包装器永远不会看到未经处理的工具异常。
  • 多个 tools/execute 监听器按普通 Cordis waterfall 顺序组合:调用 next() 的监听器包装下游监听器加分发;不调用 next() 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。
  • 通过声明选择加入会带来一种有意接受的误配置风险:工具可以声明 timeoutMs 但不遵循 exec.signal,这样的工具在超时时不会停止。注册表会等待这个尚未完全停稳的工具体结束,而不是与它竞速;同时插件约定声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。
  • 过渡期间 bash 和已迁移的 web 工具有意使用不同的超时路径:TOOL_TIMEOUT 是面向模型的工具调用预算,而 BASH_TIMEOUT 仍是 bash 和钩子使用的 bash 后端超时。