feat(tools): require cancellation signal on every invocation

This commit is contained in:
Tianyi Cui
2026-07-19 23:38:54 +08:00
parent a99750f341
commit e8b95c8754
77 changed files with 1129 additions and 446 deletions

View File

@@ -116,7 +116,7 @@ Tool-time context—including async `agent.inject()` notices and post-tool `addi
The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success.
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering.
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering.
Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.

View File

@@ -1230,7 +1230,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:399`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:419`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-tui`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
adding-a-tool.md: da214702939e01fedf3d0d69be7560bbafe0696a
adding-a-tool.zh.md: b216d18b1593cd7e6074685bd39684f1b9694eac
adding-a-tool.md: 2057009e3a656a51011c73b4d4b94b97ed4c3930
adding-a-tool.zh.md: 42b56c54c9fe511cef943a765d8de6e6eb7a58a9

View File

@@ -37,7 +37,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
- **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state.
- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, the required caller-owned `signal`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required `exec.signal` to impose a deadline but cannot remove it.
- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them.
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]``meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`.
@@ -45,7 +45,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
## Long-running work
Gate `run_in_background` with producer config, reject a pre-aborted call, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup.
Gate `run_in_background` with producer config, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The registry skips a pre-aborted invocation before the producer body; the runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup.
The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.

View File

@@ -37,7 +37,7 @@ export function apply(ctx: Context) {
- **参数已为你校验。** `defineTool``execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具MCP不由 harness 校验,它们自行校验输入。
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON在策略开始前冻结该值并分配一个不透明的 `exec.token``callId``name``arguments``agent``token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`以施加取消或截止时间。
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON在策略开始前冻结该值并分配一个不透明的 `exec.token``callId``name``arguments``agent``token`、必填且由调用方持有的 `signal`以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 `exec.signal` 以施加截止时间,但不能移除该信号
- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。
- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。
- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]``meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活。仅在此处放 UI 数据,绝不放入模型可见的 `content`
@@ -45,7 +45,7 @@ export function apply(ctx: Context) {
## 长时间运行的工作
通过 producer 配置控制 `run_in_background`拒绝已预先中止的调用,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。
通过 producer 配置控制 `run_in_background`,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前跳过已预先中止的调用;运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。
producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`

View File

@@ -697,7 +697,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:122`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:123`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
@@ -714,23 +714,24 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
```
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with `ABORTED`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with the code selected by whether the tool body was invoked. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
```ts cordis-catalog
/**
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this seam as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with `ABORTED`.
* cancellation replaces only a successful accepted outcome with the code
* selected by whether the tool body was invoked.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
@@ -741,7 +742,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:105`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
@@ -781,7 +782,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:112`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
## `workflow/*`

View File

@@ -1176,9 +1176,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body or replaces a successful pipeline outcome with
* `ABORTED`; already-started work is still drained and may retain a
* tool-owned structured error.
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
* drained and may retain a tool-owned structured error.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result.
@@ -1188,7 +1188,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:467`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`

View File

@@ -147,7 +147,7 @@ interface ToolRestriction {
## Execution: extensible waterfalls plus monotonic policy
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`.
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`.
```ts type-equiv
/** Opaque call identity that permits correlation without exposing mutable execution state. */
@@ -170,10 +170,11 @@ interface ToolExecutionInput {
/**
* Opaque token of the enclosing transport execution, when one exists. Code
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
* the outer `run_code` outcome without receiving its live mutable execution.
*/
* the outer `run_code` outcome without receiving its live mutable execution.
*/
readonly parent?: ToolExecutionToken
signal?: AbortSignal
/** Required caller-owned cancellation for this invocation. */
readonly signal: AbortSignal
}
```
@@ -212,11 +213,9 @@ type ToolExecutionMode =
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `signal`; immediately
* before the body, the registry re-fuses the original caller signal so a
* wrapper cannot detach caller cancellation. The registry freezes the complete
* object before `tools/result` observers run.
* call identity, the caller signal, and the registry-assigned {@link token} are
* readonly. The registry freezes the complete object before `tools/result`
* observers run.
*/
interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
@@ -224,7 +223,19 @@ interface ToolExecution extends ToolExecutionInput {
}
```
`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch, and the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity.
```ts type-equiv
/**
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
* may replace the signal for its delegated lifetime, but it cannot remove it.
* The registry fuses every replacement with the captured caller signal.
*/
interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
/** Cancellation signal visible to the next wrapper or tool body. */
signal: AbortSignal
}
```
`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity.
A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.

View File

@@ -38,11 +38,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:122`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-cooperative-tool-cancellation.md: d810b8d31aceffd10c10014c3f53173ca81f283f
2026-07-19-cooperative-tool-cancellation.zh.md: b0e0d6107b385ca19917bc8379a04814504d0283
2026-07-19-cooperative-tool-cancellation.md: 24bd16a26f2f1a82810ce0fd47697c6eaf62bcc4
2026-07-19-cooperative-tool-cancellation.zh.md: 037c540e7ce912264355793e445f34af6e222f45

View File

@@ -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.

View File

@@ -1,4 +1,4 @@
# RFC: 注册表边界上的协作式工具取消
# RFC注册表边界上的协作式工具取消
Status: implemented
@@ -6,60 +6,68 @@ Status: implemented
## 问题
个已注册工具都会收到可选的 `AbortSignal`,但仅提供信号不足以构成可靠的取消边界。取消可能发生在执行前策略或审批等待期间、环绕调度包装层委托前后的等待期间,或工具主体启动后。若各工具和包装层各自处理这些竞态,调用方取消后工具主体仍可能启动,延迟完成的成功结果也可能在取消后生效
次类型化工具调用都需要一个由调用方持有的取消信号。可选的 `ToolExecutionInput.signal` 允许直接调用方不承担所有权,使每个工具主体中的 `exec.signal` 都成为可选值,也会诱使注册表提供无法表达真实调用方生命周期的后备信号
环绕调度插件还需要替换 `exec.signal`,以加入截止时间或其他运行时取消来源。若把这个可变槽位视为唯一的调用方信号,包装层就可能意外切断调用方的取消。禁止替换又会移除[工具调用超时策略](2026-07-07-tool-call-timeout-policy.md)所采用的词法作用域组合方式
流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合
通过工具 promise 与取消竞速来返回 `ABORTED` 也不安全。同进程 JavaScript 即使在竞速中落败、其 promise 被丢弃,仍会继续运行,因此子进程、网络活动、嵌套调度和延后产生的上下文都可能超过已报告结果的生命周期。注册表无法用通用方式强制终止这些工作,因为终止机制属于工作所属的能力,正如[超时与截止时间决策](2026-07-06-timeout-deadline-library.md)所规定
取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的使用方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行
## 决策
`ToolRegistry` 为每次通过 `ctx.tools.execute()` 发起的调用提供协作式、保证完全停稳的取消边界。它独立于环绕调度对执行对象的修改来保留调用方取消,阻止工具主体在取消后才启动,等待所有已经启动的工具主体完成,并让最终结果物化前先发生的取消覆盖所有成功的流水线结果
`ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal``ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径
这项保证只覆盖控制平面,不等同于通用的强制终止。所有异步 `ToolDefinition.execute()` 都必须观察或转发 `exec.signal`,并且仅在自己负责的工作停止后完成。同进程代码若违反这项契约,注册表不保证其能在有界时间内完成
`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号
### 调用方取消不会在流水线中丢失
注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、队列、模型与工具 JSON、持久化与文件、worker、进程和线协议边界违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。
注册表在物化执行对象时捕获调用方信号,并记录该信号在进入时是否已经中止。这份状态存放在包装层可修改的 `ToolRunContext` 之外。
### 可变性由流水线阶段决定
对于进入时仍有效的信号,注册表会在 `tools/pre-execute`、审批以及工具主体启动前再次检查。若取消发生在这些等待期间,注册表会返回结构化 `ABORTED`,且不会启动工具主体。调度前一刻,注册表把原始调用方信号与包装层当前提供的 `exec.signal` 融合,因此无论包装层新增、替换还是移除公开槽位,都无法让运行中的工具主体脱离调用方取消。仅属于本次调度的监听器会在工具主体完成时移除
`ToolDispatchExecution``ToolExecution` 相同,唯一差异是其必填 `signal` 可修改。只有 `tools/execute` waterfall瀑布式事件接收这个类型。前置策略、后置策略、结果观察者、守卫和工具实现接收注册表私有可变运行对象的只读视图
环绕调度 waterfall瀑布式事件和结果后置策略完成后注册表还会再次检查原始调用方信号。即使工具主体更早完成,包装层或后置策略监听器也不能在调用方取消后返回延迟成功结果。包装层或策略自身产生的失败仍按失败处理,因此 timeout-policy 包装层可以把自身先到达的截止时间归类为 `TOOL_TIMEOUT`,而不会被通用取消覆盖
环绕调度包装层可以在委托期间替换 `exec.signal`,但无法通过类型系统删除它或赋值为 `undefined`。注册表在可变对象之外捕获必填的调用方信号,在工具主体调用前把每次包装层替换与调用方信号融合,在完成后移除仅属于本次调度的监听器,并无条件恢复必填的上游信号
### 已启动的工作必须完全停稳
### 取消代码记录是否发生过调度
`ToolDefinition.execute()` 一旦启动,注册表就会等待它完成。工具主体启动后发生的取消会通过融合信号通知它,但注册表不会与其 promise 竞速,也不会丢弃该 promise。若工具主体在这次取消后仍以成功结果完成注册表会用 `{ name: 'AbortError', code: 'ABORTED' }` 替换成功结果;工具自身的结构化失败仍是信息更具体的结果。通用取消替换成功结果时,会保留组合工具延后附加的上下文
`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'``TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已经开始
即使工具主体不协作这项规则仍然适用注册表调用会保持未完成直到工具主体完成。这项代价是刻意保留的因为提前返回会让调用看似已经结束但其副作用仍在运行。进程、worker、网络和提供方实现各自提供终止机制并使用信号使工作完全停稳注册表只负责调度与结果完整性
`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop 跳过的同批调用
`tools/post-execute` 之前产生的取消结果会继续经过该策略;若取消发生在异步后置监听器等待期间,注册表只替换其成功结果。冻结的 `tools/result` 通知是完成边界agent loop智能体循环会记录最终的模型可见 `tool/result`,从而保持可重建性
`ABORTED` 携带模型可见文本 `Error: tool call aborted`并且只在工具主体已经调用后使用包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留
### 进入时已中止属于独立的直接调用契约
### 进入时已中止会在物化后短路
若信号在进入注册表时已经中止工具主体仍会收到它。直接调用服务的代码可利用该状态执行能力特定的清理或错误转换信息更具体的结果也会保持可见。agent loop 调度器不会在轮次信号已经中止时启动新的模型驱动工具主体,因此这项例外不会重新允许模型工具延迟调度
注册表先创建调用 token并对参数进行无损快照和冻结。即使调用方信号已经中止参数物化失败仍优先返回。物化成功后进入时已中止的信号会跳过 `tools/pre-execute`、审批、`tools/execute``tools/post-execute` 和工具主体,然后发布且只发布一次冻结的权威 `tools/result`,其代码为 `ABORTED_BEFORE_DISPATCH`
### 已启动工作仍必须完全停稳
工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消并在所持有的工作完全停稳后完成不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。
这项决策只要求工具调用接缝携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力接缝中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。
## 验证
[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 固定了执行前策略、环绕调度和后置策略等待期间的取消行为,以及信号替换与移除、禁止延迟成功、上下文保留、已启动工具主体排空和进入前已中止的直接调用行为。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 固定了禁止延迟启动的规则,以及未调度同批调用在会话日志中仍具有配对结果。[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 固定了调用方先取消和超时归属方分类行为
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖未调度同批调用的持久化配对结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属
任何注册表测试都无法证明任意第三方同进程代码会在有界时间内停止。各能力的测试仍需证明其子进程、worker、套接字或提供方取消能够使工作完全停稳。
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
## 考虑过的替代方案
**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在落败的 promise 及其副作用仍在运行时报告完成。这违反了[资源释放必须完全停稳的规则](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it),并可能让会话记录 `ABORTED` 后仍有工作修改状态
**保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况
**由注册表强制终止每个工具。** 不予采纳,因为同进程 JavaScript 没有安全、通用的抢占机制而各能力的实际终止方式不同进程组需要信号和升级处理worker 需要终止,网络客户端则需要按协议中止。把这些机制移入 `ToolRegistry` 会让核心注册表耦合到每种实现
**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程接缝,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号
**相信各工具和环绕包装层自行保留调用方取消。** 不予采纳,因为可变信号槽位与异步执行前、环绕调度等待共同构成一处共享调度边界。集中捕获并重复检查可以让所有已注册工具遵守相同的禁止延迟启动和禁止延迟成功规则,无需重复实现竞态处理
**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责
**禁止环绕包装层替换 `exec.signal`。** 不予采纳,因为截止时间和嵌套运行时作用域需要为一次词法调度派生信号。在工具主体启动前重新融合调用方信号,可以同时保留组合能力与取消语义
**向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置
**跳过进入时信号已经中止的所有调用。** 不予采纳,因为直接调用方可能需要工具主体执行清理,或把取消转换为能力特定的结果。注册表会区分这种显式进入状态与调度期间由有效变为中止的信号,而 agent loop 会独立阻止轮次取消后产生新的模型驱动调度
**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套运行时作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消
**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。
## 后果
- 每次注册表调用都遵循同一份服务层取消契约,包括插件或 MCP 桥接提供的工具;但只有协作式实现才能保证及时停止
- 调用方取消在执行前策略、环绕调度和后置策略的成功路径上保持单调:只要进入时有效的调用方信号在最终结果物化前发生中止,工具主体就不会延迟启动,普通成功也不会成为权威结果
- 若实现忽略信号,已启动的工作可以无限期推迟取消。注册表会刻意把这一缺陷暴露为无法完全停稳的调用,而不是用提前返回的结果掩盖它
- 能力特定失败与超时归属保持不变。通用 `ABORTED` 只替换成功结果,不替换信息更具体的错误结果
- 环绕包装层继续通过替换信号来完成组合,而原始调用方信号在调度时无法被切断
- TypeScript 会拒绝所有缺少 `signal``ToolExecutionInput`、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试
- 持久化结果的使用方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`
- 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为
- 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用
- 下游能力接口保持不变,直到关联的提议 RFC 被接受并实现

View File

@@ -24,7 +24,7 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat
- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers.
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation.
- **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.