Cooperative tool cancellation at the registry boundary
注册表边界上的协作式工具取消
Every typed tool invocation needs a caller-owned cancellation signal. An optional `ToolExecutionInput.signal` lets direct callers omit ownership, makes `exec.signal` optional in every tool body, and encourages registry fallbacks that cannot represent the caller's actual lifetime. The pipeline also has different mutability needs at different stages. Tool implementations, pre-policy, post-policy, and result observers o
English
Problem
Every typed tool invocation needs a caller-owned cancellation signal. An optional ToolExecutionInput.signal lets direct callers omit ownership, makes exec.signal optional in every tool body, and encourages registry fallbacks that cannot represent the caller's actual lifetime.
The pipeline also has different mutability needs at different stages. Tool implementations, pre-policy, post-policy, and result observers only borrow cancellation state, while an around-dispatch wrapper must temporarily replace the signal to add a deadline or another lexical cancellation scope. One mutable public type either grants mutation too broadly or prevents that composition.
Cancellation can arrive before policy, during approval, inside an around-dispatch wait, after a tool body starts, or while post-policy waits. One undifferentiated ABORTED result cannot tell durable consumers whether body side effects were possible. Racing a tool promise against cancellation is not a safe fallback because abandoned same-process work continues after the registry reports completion.
Decision
ToolExecutionInput.signal is a required readonly AbortSignal. ToolExecution.signal and ToolRunContext.signal are therefore required and readonly as well. Every typed caller supplies the signal it owns; the registry provides no overload, default controller, never-abort sentinel, or convenience execution path.
ToolDefinition.execute(args, exec) keeps its existing signature. defineTool() contextually types exec.signal as a required AbortSignal, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested Code Mode dispatches pass their current operation signal explicitly.
The registry trusts this typed same-process contract. It does not perform runtime AbortSignal validation or add hostile-input tests for an omitted or malformed signal. Validation remains at parser/config, model/tool JSON, durable/file, worker, process, and wire boundaries; untyped JavaScript that violates the TypeScript interface has no compatibility contract.
Mutability follows the pipeline stage
ToolDispatchExecution is identical to ToolExecution except that its required signal is mutable. Only the tools/execute waterfall receives this type. Pre-policy, post-policy, result observers, guards, and tool implementations receive readonly views of a private registry-owned mutable run object.
An around-dispatch wrapper may replace exec.signal for its delegated lifetime but cannot typefully delete it or assign undefined. The registry captures the required caller signal outside that mutable object, fuses every wrapper replacement with the caller signal immediately before body invocation, removes dispatch-scoped listeners after settlement, and restores the required upstream signal unconditionally.
Cancellation codes record whether dispatch occurred
dsh-tools exports TOOL_ABORTED = 'ABORTED' and TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'. The registry records body invocation immediately before calling ToolDefinition.execute().
ABORTED_BEFORE_DISPATCH carries { name: 'AbortError' } and model text Error: tool call aborted before dispatch. It applies whenever cancellation prevents body invocation, including pre-aborted entry, cancellation during pre-policy or approval, an aborted wrapper signal, a wrapper success overtaken by caller cancellation before delegation, and agent-loop siblings skipped after turn cancellation.
ABORTED carries model text Error: tool call aborted and applies only after the body was invoked, including cancellation while an around wrapper or post-policy listener waits after body completion. A denial, wrapper failure, tool failure, or post-policy failure remains more specific than generic cancellation. A timeout owned by timeout-policy remains TOOL_TIMEOUT, and contexts deferred before a successful outcome is replaced remain attached.
Pre-aborted entry short-circuits after materialization
The registry first creates the call token, snapshots the visible definition's optional final-content callback, and losslessly snapshots and freezes the arguments. An argument-materialization failure wins even when the caller signal is already aborted. Before final content, the registry also losslessly snapshots the candidate result and converts a result-snapshot failure into an ordinary error, so the callback can still enforce its content invariant. After successful argument materialization, a pre-aborted signal skips tools/pre-execute, approval, tools/execute, tools/post-execute, and the tool body, then passes ABORTED_BEFORE_DISPATCH through that content-only callback before publishing exactly one frozen authoritative tools/result.
Started work still reaches quiescence
Once a tool body starts, the registry awaits it. Cancellation reaches the body through the fused signal but never races or abandons its promise. A cooperative implementation stops or forwards cancellation and settles after its owned work reaches quiescence; an uncooperative same-process implementation can keep the registry pending indefinitely. Process, worker, network, and provider layers retain responsibility for their own termination mechanisms.
This decision requires cancellation at the tool invocation boundary only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in Required cancellation through tool-reachable capability seams.
Verification
execution-signal-types.spec.ts proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and defineTool() inference. tools.spec.ts covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. tool-calls.spec.ts and contract-regressions.spec.ts cover balanced durable results for undispatched siblings. code-mode.spec.ts and first-party integration suites cover explicit forwarding, while timeout-policy.spec.ts preserves timeout ownership.
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
Alternatives considered
Keep the signal optional and synthesize a fallback. Rejected because a registry-owned fallback has no caller lifetime to represent and preserves the exact omission the type should prevent.
Validate AbortSignal at runtime. Rejected because this is a typed same-process boundary, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable.
Add supportsCancellation metadata, callback-arity checks, or signal-use linting. Rejected because none proves that asynchronous work observes or correctly forwards cancellation. Availability is a type contract; behavior remains a tool and capability responsibility.
Expose one mutable execution type to every stage. Rejected because observers and tool implementations only borrow the signal. Stage-specific types make replacement possible only where the pipeline owns that operation.
Forbid around wrappers from replacing the signal. Rejected because deadlines and nested operational scopes need lexical derivation. Capturing and fusing the caller signal preserves composition without allowing detachment.
Race the tool promise against cancellation. Rejected because it reports completion while side effects may remain live, violating the quiescent-disposal rule.
Consequences
- TypeScript rejects every
ToolExecutionInputthat omitssignal, every tool or observer mutation of a readonly signal, and every around-dispatch attempt to remove the signal. - Durable consumers can distinguish calls whose body may have produced side effects (
ABORTED) from calls that never entered the body (ABORTED_BEFORE_DISPATCH). - The change is intentionally breaking under the repository's pre-release stance; no compatibility overload or runtime fallback remains.
- Cooperative tools stop promptly and reach quiescence; an implementation that ignores its signal remains observable as a pending call.
- Downstream capability interfaces remain unchanged until the linked proposed Agent Note is accepted and implemented.
中文
问题
每次类型化工具调用都需要一个由调用方持有的取消信号。可选的 ToolExecutionInput.signal 允许直接调用方不承担所有权,使每个工具主体中的 exec.signal 都成为可选值,也会诱使注册表提供无法表达真实调用方生命周期的后备信号。
流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合。
取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 ABORTED 结果无法让持久化结果的消费方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。
决策
ToolExecutionInput.signal 是必填且只读的 AbortSignal,因此 ToolExecution.signal 和 ToolRunContext.signal 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。
ToolDefinition.execute(args, exec) 保持现有签名。defineTool() 会把 exec.signal 上下文推断为必填的 AbortSignal,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。
注册表信任这份类型化同进程约定。它不在运行时校验 AbortSignal,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性约定。
可变性由流水线阶段决定
ToolDispatchExecution 与 ToolExecution 相同,唯一差异是其必填 signal 可修改。只有 tools/execute waterfall(瀑布式事件)接收这个类型。前置策略、后置策略、结果观察者、守卫和工具实现接收注册表私有可变运行对象的只读视图。
环绕调度包装层可以在委托期间替换 exec.signal,但无法通过类型系统删除它或赋值为 undefined。注册表在可变对象之外捕获必填的调用方信号,在工具主体调用前把每次包装层替换与调用方信号融合,在完成后移除仅属于本次调度的监听器,并无条件恢复必填的上游信号。
取消代码记录是否发生过调度
dsh-tools 导出 TOOL_ABORTED = 'ABORTED' 和 TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'。注册表在调用 ToolDefinition.execute() 的前一刻记录工具主体已调用。
ABORTED_BEFORE_DISPATCH 携带 { name: 'AbortError' } 和模型可见文本 Error: tool call aborted before dispatch。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop(智能体循环)跳过的同批调用。
ABORTED 携带模型可见文本 Error: tool call aborted,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 TOOL_TIMEOUT,成功结果被取消替换前延后附加的上下文仍会保留。
进入时已中止会在物化后短路
注册表先创建调用 token,对可见工具定义的可选 finalizeContent callback 做快照,并对参数进行无损快照和冻结。即使调用方信号已经中止,参数物化失败仍优先返回。在最终内容处理之前,注册表还会对候选结果进行无损快照,并把结果快照失败转换为普通错误,从而使该 callback 仍能保证其内容不变量成立。参数物化成功后,进入时已中止的信号会跳过 tools/pre-execute、审批、tools/execute、tools/post-execute 和工具主体,然后先由该仅处理内容的 callback 处理 ABORTED_BEFORE_DISPATCH,再发布且只发布一次冻结的权威 tools/result。
已启动工作仍必须完全停稳
工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。
这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的工具可达能力 seam 中的必填取消。
验证
execution-signal-types.spec.ts 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 defineTool() 推断。tools.spec.ts 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。tool-calls.spec.ts 与 contract-regressions.spec.ts 覆盖为未调度的同批调用补齐持久化结果。code-mode.spec.ts 和第一方集成测试覆盖显式转发,timeout-policy.spec.ts 保持超时归属。
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
考虑过的替代方案
保留可选信号并生成后备值。 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。
在运行时校验 AbortSignal。 不予采纳,因为这是类型化同进程边界,不是序列化边界。运行时检查只会重复静态约定,仍无法强制实现协作式使用信号。
添加 supportsCancellation 元数据、回调参数数量检查或信号使用 lint。 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型约定;具体行为仍由工具和能力负责。
向所有阶段公开同一个可变执行类型。 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。
禁止环绕包装层替换信号。 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。
让工具 promise 与取消竞速。 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反dispose(资源释放)必须完全停稳的规则。
后果
- TypeScript 会拒绝所有缺少
signal的ToolExecutionInput、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试。 - 持久化结果的消费方可以区分工具主体可能产生过副作用的调用(
ABORTED)和从未进入工具主体的调用(ABORTED_BEFORE_DISPATCH)。 - 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为。
- 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用。
- 下游能力接口保持不变,直到关联的提议 Agent Note 被接受并实现。