|
|
|
|
@@ -6,60 +6,68 @@ English | [中文](2026-07-19-cooperative-tool-cancellation.zh.md)
|
|
|
|
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
|
|
|
|
Every registered tool receives an optional `AbortSignal`, but a signal alone does not define a reliable cancellation boundary. Cancellation can arrive while pre-execution policy or approval is waiting, while an around-dispatch wrapper is waiting before or after delegation, or after the tool body has started. If each tool and wrapper interprets those races independently, a body can start after its caller has cancelled or a late success can escape after cancellation.
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
Around-dispatch plugins also need to replace `exec.signal` to add deadlines or other operational cancellation. Treating that mutable slot as the only caller signal lets a wrapper accidentally detach caller cancellation. Forbidding replacement would remove the lexical composition used by the [tool-call timeout policy](2026-07-07-tool-call-timeout-policy.md).
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
Returning `ABORTED` by racing the tool promise is not a safe fallback. Same-process JavaScript keeps running after the losing promise is abandoned, so subprocesses, network activity, nested dispatches, and deferred context production can outlive the reported result. The registry cannot generically hard-kill that work because termination belongs to the capability that owns it, as established by the [timeout/deadline decision](2026-07-06-timeout-deadline-library.md).
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
`ToolRegistry` owns a cooperative, quiescent cancellation boundary for every call through `ctx.tools.execute()`. It preserves caller cancellation independently of around-dispatch mutation, prevents a body from starting after live cancellation, awaits every body that did start, and lets cancellation that wins before final result materialization supersede every successful pipeline outcome.
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
This is a control-plane guarantee, not universal hard termination. Every asynchronous `ToolDefinition.execute()` observes or forwards `exec.signal` and settles only after its owned work stops. The registry does not claim bounded-time settlement for same-process code that violates that contract.
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
### Caller cancellation survives the pipeline
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
The registry captures the caller's signal and whether it was already aborted when it materializes the execution. That state is kept outside the wrapper-mutable `ToolRunContext`.
|
|
|
|
|
### Mutability follows the pipeline stage
|
|
|
|
|
|
|
|
|
|
A signal that was live on entry is rechecked after `tools/pre-execute`, approval, and immediately before the tool body. Cancellation during any of those waits yields structured `ABORTED` without starting the body. Immediately before dispatch, the registry fuses the original caller signal with the current wrapper-supplied `exec.signal`, so adding, replacing, or removing the public slot cannot detach the caller from a running body. Dispatch-scoped listeners are removed when the body settles.
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
The registry also rechecks the original caller after the around-dispatch waterfall and post-result policy settle. A wrapper or post-policy listener cannot return a late successful result after caller cancellation merely because the body completed earlier. A wrapper- or policy-owned failure remains a failure; the timeout-policy wrapper may therefore classify its own winning deadline as `TOOL_TIMEOUT` instead of losing that information to generic cancellation.
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
### Started work reaches quiescence
|
|
|
|
|
### Cancellation codes record whether dispatch occurred
|
|
|
|
|
|
|
|
|
|
Once `ToolDefinition.execute()` starts, the registry awaits it. Cancellation that arrives after the body starts notifies it through the fused signal but does not race or abandon its promise. If the body settles successfully after that cancellation, the registry replaces success with `{ name: 'AbortError', code: 'ABORTED' }`; a structured tool failure remains the more specific result. Context deferred by a composite tool is retained when generic cancellation replaces success.
|
|
|
|
|
`dsh-tools` exports `TOOL_ABORTED = 'ABORTED'` and `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`. The registry records body invocation immediately before calling `ToolDefinition.execute()`.
|
|
|
|
|
|
|
|
|
|
This applies even to an uncooperative body: the registry remains pending until the body settles. That cost is deliberate because returning early would make the call appear complete while its side effects remain live. Process, worker, network, and provider implementations supply their own termination mechanism and use the signal to reach quiescence; the registry only owns dispatch and result integrity.
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
A cancellation result produced before `tools/post-execute` continues through that policy; cancellation while an asynchronous post listener is waiting replaces only its successful outcome. The frozen `tools/result` notification is the completion boundary, and the agent loop records the resulting model-visible `tool/result`, preserving reconstructability.
|
|
|
|
|
`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 is a distinct direct-call contract
|
|
|
|
|
### Pre-aborted entry short-circuits after materialization
|
|
|
|
|
|
|
|
|
|
A signal already aborted when registry entry begins still reaches the tool body. Direct service callers use that state for capability-specific cleanup or error translation, and the more specific result remains observable. The agent-loop scheduler does not start a new model-driven body under an already-aborted turn signal, so this exception does not reopen late model dispatch.
|
|
|
|
|
The registry first creates the call token and losslessly snapshots and freezes the arguments. A materialization failure wins even when the caller signal is already aborted. After successful materialization, a pre-aborted signal skips `tools/pre-execute`, approval, `tools/execute`, `tools/post-execute`, and the tool body, then publishes exactly one frozen authoritative `tools/result` with `ABORTED_BEFORE_DISPATCH`.
|
|
|
|
|
|
|
|
|
|
### 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 seam only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in [Required cancellation through tool-reachable capability seams](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md).
|
|
|
|
|
|
|
|
|
|
## Verification
|
|
|
|
|
|
|
|
|
|
[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) pins cancellation during pre-policy and around/post waits, signal replacement and removal, no-late-success behavior, context retention, started-body drainage, and pre-aborted direct entry. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) pin the no-late-start rule and balanced session-log results for undispatched sibling calls. [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) pins caller-cancel-first and timeout-owned classification.
|
|
|
|
|
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/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`](../../../../packages/core/tools/tests/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`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
|
|
|
|
|
|
|
|
|
|
No registry test can prove that arbitrary third-party same-process code stops in bounded time. Capability tests remain responsible for proving their subprocess, worker, socket, or provider cancellation reaches quiescence.
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
**Race the tool promise against cancellation.** Rejected because it reports completion while the losing promise and its side effects remain live. This violates the [quiescent-disposal rule](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and can let work mutate state after the session records `ABORTED`.
|
|
|
|
|
**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.
|
|
|
|
|
|
|
|
|
|
**Make the registry hard-kill every tool.** Rejected because same-process JavaScript has no safe generic preemption mechanism, while real termination differs by capability: process groups need signals and escalation, workers need termination, and network clients need protocol-aware abort. Moving those mechanisms into `ToolRegistry` would couple the core registry to every implementation.
|
|
|
|
|
**Validate `AbortSignal` at runtime.** Rejected because this is a typed same-process seam, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable.
|
|
|
|
|
|
|
|
|
|
**Trust each tool and around wrapper to preserve caller cancellation.** Rejected because the mutable signal slot and asynchronous pre/around waits form one shared scheduling boundary. Central capture and rechecks give every registered tool the same no-late-start and no-late-success rules without duplicating race handling.
|
|
|
|
|
**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.
|
|
|
|
|
|
|
|
|
|
**Forbid around wrappers from replacing `exec.signal`.** Rejected because deadlines and nested operational scopes need to derive a signal for one lexical dispatch. Re-fusing the caller immediately before the body preserves both composition and cancellation.
|
|
|
|
|
**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.
|
|
|
|
|
|
|
|
|
|
**Skip every call whose signal is aborted at entry.** Rejected because direct callers may need the tool body to perform cleanup or translate cancellation into a capability-specific result. The registry distinguishes that explicit entry state from a live signal that aborts during scheduling, while the agent loop independently prevents new model-driven dispatch after turn cancellation.
|
|
|
|
|
**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](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it).
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
- Every registry invocation has one service-layer cancellation contract, including tools supplied by plugins or MCP bridges, but only cooperative implementations are guaranteed to stop promptly.
|
|
|
|
|
- Caller cancellation is monotonic across pre-policy, around-dispatch, and post-policy success: once a live caller signal aborts before final materialization, a body does not start late and a normal success does not become authoritative.
|
|
|
|
|
- Started work can delay cancellation indefinitely when an implementation ignores its signal. The registry deliberately exposes that defect as a non-quiescent call instead of hiding it behind an early result.
|
|
|
|
|
- Capability-specific failures and timeout ownership remain intact. Generic `ABORTED` replaces success, not a more informative error result.
|
|
|
|
|
- Around wrappers retain signal replacement as their composition mechanism, while the original caller signal remains non-detachable at dispatch.
|
|
|
|
|
- TypeScript rejects every `ToolExecutionInput` that omits `signal`, 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 RFC is accepted and implemented.
|
|
|
|
|
|