1
0
Эх сурвалжийг харах

fix(tools): enforce cooperative cancellation

Tianyi Cui 1 сар өмнө
parent
commit
2bc4e05a08

+ 1 - 1
docs/config-catalog.md

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

+ 13 - 9
docs/cordis-catalog/events.md

@@ -654,17 +654,19 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:120`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
-Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
+Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
 
 ```ts cordis-catalog
 /**
  * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
  * a normalized result; wrappers may change only `exec.signal`, while call
- * identity remains immutable.
+ * identity remains immutable. The registry re-fuses the original caller
+ * signal before the body, so replacement cannot detach caller cancellation;
+ * wrappers must still restore their signal and reach quiescence.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
  * @mode waterfall
@@ -674,7 +676,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
 
 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:89`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -694,16 +696,18 @@ 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:98`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
-Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
+Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
 
 ```ts cordis-catalog
 /**
  * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
- * approval support turns `ask` into denial.
+ * approval support turns `ask` into denial. Async gates must observe
+ * `exec.signal`; the registry rechecks cancellation after they settle but
+ * never abandons their promise.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  * @param exec - the pending call (name, parsed arguments, caller agent).
  * @mode waterfall
@@ -713,7 +717,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
 
 Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts)
 
 ### `tools/result` — emit
 
@@ -732,7 +736,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:106`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:110`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 

+ 5 - 2
docs/cordis-catalog/services.md

@@ -1174,7 +1174,10 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode
  * Execute through pre-policy, guards, around-dispatch, post-policy, and final
  * notification. Tool and listener failures resolve as materialized error
  * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
- * the same lossless, frozen snapshot final observers receive.
+ * the same lossless, frozen snapshot final observers receive. Cancellation
+ * arriving after entry skips a not-yet-started body or replaces a successful
+ * dispatch 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.
@@ -1184,7 +1187,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:438`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:465`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.userInteraction` — `UserInteractionService`
 

+ 14 - 3
docs/core-data-structures/tools.md

@@ -11,6 +11,15 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only
 ```ts type-equiv
 /** A registered tool: its schema plus the execution function. */
 interface ToolDefinition extends ToolSchema {
+  /**
+   * Run one accepted call. Async work must observe or forward `exec.signal` and
+   * settle only after its owned work reaches quiescence. The registry preserves
+   * caller cancellation through around-dispatch signal replacement and does
+   * not abandon this promise, but it cannot hard-kill same-process code.
+   * @param args - losslessly snapshotted, frozen model arguments.
+   * @param exec - execution identity, cancellation signal, and context deferral.
+   * @returns model-facing content plus optional private presentation metadata.
+   */
   execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
   /**
    * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
@@ -204,8 +213,10 @@ 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`. The registry
- * freezes the complete object before `tools/result` observers run.
+ * 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.
  */
 interface ToolExecution extends ToolExecutionInput {
   /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
@@ -213,7 +224,7 @@ 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. Final observers receive the frozen execution identity.
+`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.
 
 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.
 

+ 5 - 5
docs/event-producer-consumer.md

@@ -36,11 +36,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:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../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:98`](../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:80`](../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:106`](../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/change` | `emit` | [`packages/core/tools/src/index.ts:120`](../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:102`](../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:110`](../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`) | - |

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl


+ 3 - 3
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -552,7 +552,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
-        jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n *   correlation token before policy begins.\n * @returns the materialized final result.\n */',
+        jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry skips a not-yet-started body or replaces a successful\n * dispatch outcome with `ABORTED`; already-started work is still drained and\n * may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n *   correlation token before policy begins.\n * @returns the materialized final result.\n */',
       },
     ],
   },
@@ -820,7 +820,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
     name: 'tools/execute',
     mode: 'waterfall',
     signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
-    jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */',
+    jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable. The registry re-fuses the original caller\n * signal before the body, so replacement cannot detach caller cancellation;\n * wrappers must still restore their signal and reach quiescence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */',
     summary: 'Around-dispatch waterfall for timeout, retry, or metrics.',
   },
   {
@@ -834,7 +834,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
     name: 'tools/pre-execute',
     mode: 'waterfall',
     signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
-    jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */',
+    jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */',
     summary: 'Allow, deny, or ask before dispatch.',
   },
   {

+ 5 - 7
packages/core/agent-loop/tests/tool-calls.spec.ts

@@ -473,7 +473,7 @@ describe('tool-call scheduler: abort handling', () => {
     expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([])
   })
 
-  it('stops starting siblings when abort fires during ordered pre-execute', async () => {
+  it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => {
     const adapter = new MockAdapter([
       multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
       textResponse('should never be requested'),
@@ -490,16 +490,14 @@ describe('tool-call scheduler: abort handling', () => {
     })
 
     agent.send([{ type: 'text', text: 'go' }])
-    await until(() => gated.started.length === 1)
-    await new Promise(r => setTimeout(r, 5))
-    expect(gated.started).toEqual(['1'])
-    gated.release('1')
     await waitForIdle(ctx, agent)
 
+    expect(gated.started).toEqual([])
     expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
       .toEqual([CallId('c1')])
-    expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
-      .toEqual([CallId('c1')])
+    const results = events(agent).filter(e => e.type === 'tool/result')
+    expect(results.map(e => e.data.callId)).toEqual([CallId('c1')])
+    expect(results[0]?.data.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
   })
 
   it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {

+ 9 - 5
packages/core/tools/README.md

@@ -20,24 +20,28 @@ tools:
 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
 - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
-- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
+- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body.
 - `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
 
 ### Injected services
 
 `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
 
+### Cancellation
+
+Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, and around-dispatch waits, so a body cannot start late; if the body has started, the registry preserves the caller signal through wrapper replacement, awaits settlement, and replaces a successful dispatch outcome with structured `ABORTED`. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. See the [quiescent-disposal rule](../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and [timeout ownership decision](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md).
+
 ### Live events
 
 The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
 
 ### Key types
 
-- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
+- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
 - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
 - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
-- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
-- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
+- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore; the registry separately retains and re-fuses the original caller signal. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
+- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
 - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
 - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
 - `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
@@ -74,7 +78,7 @@ ctx.tools.register(defineTool({
   },
   async execute(args, exec) {
     // args is typed: { path: string; offset?: number; limit?: number }
-    const text = await readFile(args.path, 'utf8')
+    const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal })
     return [{ type: 'text', text }]
   },
 }))

+ 157 - 19
packages/core/tools/src/index.ts

@@ -72,7 +72,9 @@ declare module 'cordis' {
   interface Events {
     /**
      * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
-     * approval support turns `ask` into denial.
+     * approval support turns `ask` into denial. Async gates must observe
+     * `exec.signal`; the registry rechecks cancellation after they settle but
+     * never abandons their promise.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
      * @param exec - the pending call (name, parsed arguments, caller agent).
      * @mode waterfall
@@ -81,7 +83,9 @@ declare module 'cordis' {
     /**
      * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
      * a normalized result; wrappers may change only `exec.signal`, while call
-     * identity remains immutable.
+     * identity remains immutable. The registry re-fuses the original caller
+     * signal before the body, so replacement cannot detach caller cancellation;
+     * wrappers must still restore their signal and reach quiescence.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
      * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
      * @mode waterfall
@@ -122,6 +126,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
 
 /** A registered tool: its schema plus the execution function. */
 export interface ToolDefinition extends ToolSchema {
+  /**
+   * Run one accepted call. Async work must observe or forward `exec.signal` and
+   * settle only after its owned work reaches quiescence. The registry preserves
+   * caller cancellation through around-dispatch signal replacement and does
+   * not abandon this promise, but it cannot hard-kill same-process code.
+   * @param args - losslessly snapshotted, frozen model arguments.
+   * @param exec - execution identity, cancellation signal, and context deferral.
+   * @returns model-facing content plus optional private presentation metadata.
+   */
   execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
   /**
    * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
@@ -218,8 +231,10 @@ export 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`. The registry
- * freezes the complete object before `tools/result` observers run.
+ * 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.
  */
 export interface ToolExecution extends ToolExecutionInput {
   /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
@@ -431,6 +446,18 @@ interface ToolGuardRegistration {
   guard: ToolGuard
 }
 
+/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */
+interface ToolCancellationState {
+  readonly callerSignal: AbortSignal | undefined
+  readonly abortedAtEntry: boolean
+}
+
+/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
+interface FusedToolSignal {
+  readonly signal: AbortSignal | undefined
+  dispose(): void
+}
+
 /**
  * Tool registry and execution pipeline. Scoped registrations shadow globals;
  * one visibility resolver feeds presentation, lookup, and dispatch.
@@ -452,6 +479,8 @@ export class ToolRegistry extends Service {
 
   /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
   private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
+  /** Original caller cancellation, kept outside the wrapper-mutable execution object. */
+  private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
   private global = new Map<string, ToolDefinition>()
   private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
   /** Compiled restriction filters, per scope (see {@link restrict}). */
@@ -774,7 +803,10 @@ export class ToolRegistry extends Service {
    * Execute through pre-policy, guards, around-dispatch, post-policy, and final
    * notification. Tool and listener failures resolve as materialized error
    * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
-   * the same lossless, frozen snapshot final observers receive.
+   * the same lossless, frozen snapshot final observers receive. Cancellation
+   * arriving after entry skips a not-yet-started body or replaces a successful
+   * dispatch 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.
@@ -827,6 +859,10 @@ export class ToolRegistry extends Service {
       }
       const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
       this.deferredContexts.set(execution, deferredContexts)
+      this.cancellationStates.set(execution, {
+        callerSignal: signal,
+        abortedAtEntry: signal?.aborted === true,
+      })
       return { kind: 'ready', exec: execution }
     } catch (error: unknown) {
       const execution: ToolRunContext = { ...base, arguments: undefined }
@@ -858,6 +894,9 @@ export class ToolRegistry extends Service {
         () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
       )
       const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
+      if (this.callerCancelledAfterEntry(exec)) {
+        return await next({ kind: 'post-result', exec, result: toolAbortedResult() })
+      }
       const denialReason = decision.kind === 'allow'
         ? this.guardReason(exec)
         : decision.reason
@@ -873,7 +912,60 @@ export class ToolRegistry extends Service {
       }
       return await next({ kind: 'dispatch', exec })
     } catch (error: unknown) {
-      return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
+      return this.callerCancelledAfterEntry(exec)
+        ? await next({ kind: 'post-result', exec, result: toolAbortedResult() })
+        : next({ kind: 'final-result', exec, result: toolErrorResult(error) })
+    }
+  }
+
+  /** Whether the original live caller signal aborted after this execution entered the registry. */
+  private callerCancelledAfterEntry(exec: ToolRunContext): boolean {
+    const state = this.cancellationStates.get(exec)
+    /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
+    if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
+    return !state.abortedAtEntry && state.callerSignal?.aborted === true
+  }
+
+  /**
+   * Dispatch the registered body with the original caller signal fused back
+   * into any around-wrapper replacement. Cancellation never abandons the body:
+   * a started promise reaches quiescence before its outcome becomes `ABORTED`.
+   */
+  private async dispatchToolBody(exec: ToolRunContext): Promise<ToolExecutionResult> {
+    const state = this.cancellationStates.get(exec)
+    /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
+    if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
+    const wrapperSignal = exec.signal
+    const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
+    const signal = fused.signal
+    const abortedBeforeBody = isAborted(signal)
+
+    if (!state.abortedAtEntry && abortedBeforeBody) {
+      fused.dispose()
+      return toolAbortedResult()
+    }
+    if (signal === undefined) delete exec.signal
+    else exec.signal = signal
+    try {
+      const tool = this.get(exec.name, exec.agent)
+      if (!tool) throw new ToolNotFoundError(exec.name)
+      const returned = await tool.execute(exec.arguments, exec)
+      const content = Array.isArray(returned) ? returned : returned.content
+      const meta = Array.isArray(returned) ? undefined : returned.meta
+      const result: ToolExecutionResult = {
+        content,
+        isError: false,
+        ...meta !== undefined ? { meta } : {},
+      }
+      return !abortedBeforeBody && isAborted(signal)
+        ? toolAbortedResult(result)
+        : result
+    } catch (error: unknown) {
+      return toolErrorResult(error)
+    } finally {
+      fused.dispose()
+      if (wrapperSignal === undefined) delete exec.signal
+      else exec.signal = wrapperSignal
     }
   }
 
@@ -889,18 +981,7 @@ export class ToolRegistry extends Service {
       const carrier = scopeTarget(this, exec.agent)
       const result = await this.ctx.waterfall(
         carrier, 'tools/execute', exec,
-        async (): Promise<ToolExecutionResult> => {
-          try {
-            const tool = this.get(exec.name, exec.agent)
-            if (!tool) throw new ToolNotFoundError(exec.name)
-            const returned = await tool.execute(exec.arguments, exec)
-            const content = Array.isArray(returned) ? returned : returned.content
-            const meta = Array.isArray(returned) ? undefined : returned.meta
-            return { content, isError: false, ...meta !== undefined ? { meta } : {} }
-          } catch (error: unknown) {
-            return toolErrorResult(error)
-          }
-        },
+        () => this.dispatchToolBody(exec),
       )
       const deferredContexts = this.deferredContexts.get(exec)
       /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
@@ -914,7 +995,12 @@ export class ToolRegistry extends Service {
             ...result.additionalContexts ?? [],
           ],
         }
-      return { kind: 'post-result', result: resultWithDeferredContexts }
+      return {
+        kind: 'post-result',
+        result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError
+          ? toolAbortedResult(resultWithDeferredContexts)
+          : resultWithDeferredContexts,
+      }
     } catch (error: unknown) {
       return { kind: 'final-result', result: toolErrorResult(error) }
     }
@@ -1069,4 +1155,56 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
   }
 }
 
+/** Read live abort state across an await without treating it as synchronously immutable. */
+function isAborted(signal: AbortSignal | undefined): boolean {
+  return signal?.aborted === true
+}
+
+/**
+ * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
+ * Keeping the relay dispatch-scoped also removes listeners when work settles.
+ */
+function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal | undefined): FusedToolSignal {
+  if (caller === undefined || caller === wrapper) {
+    return { signal: wrapper ?? caller, dispose() {} }
+  }
+  if (wrapper === undefined) return { signal: caller, dispose() {} }
+
+  const controller = new AbortController()
+  let listening = false
+  const dispose = (): void => {
+    if (!listening) return
+    listening = false
+    caller.removeEventListener('abort', abortFromCaller)
+    wrapper.removeEventListener('abort', abortFromWrapper)
+  }
+  const abortFrom = (source: AbortSignal): void => {
+    const reason: unknown = source.reason
+    controller.abort(reason)
+    dispose()
+  }
+  const abortFromCaller = (): void => { abortFrom(caller) }
+  const abortFromWrapper = (): void => { abortFrom(wrapper) }
+
+  if (wrapper.aborted) abortFromWrapper()
+  else if (caller.aborted) abortFromCaller()
+  else {
+    listening = true
+    caller.addEventListener('abort', abortFromCaller, { once: true })
+    wrapper.addEventListener('abort', abortFromWrapper, { once: true })
+  }
+  return { signal: controller.signal, dispose }
+}
+
+/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */
+function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
+  const additionalContexts = prior?.additionalContexts ?? []
+  return {
+    content: [{ type: 'text', text: 'Error: tool call aborted' }],
+    isError: true,
+    error: { name: 'AbortError', code: 'ABORTED' },
+    ...additionalContexts.length > 0 ? { additionalContexts } : {},
+  }
+}
+
 export default ToolRegistry

+ 4 - 3
packages/core/tools/tests/code-mode.spec.ts

@@ -857,7 +857,7 @@ describe('the run_code dispatch bridge', () => {
     expect(calls).toEqual([])
   })
 
-  it('rejects a binding invoked after the run is over without dispatching it', async () => {
+  it('reports cancellation after rejecting a late binding without dispatching it', async () => {
     const { ctx, runtime } = await setup({ mode: 'code' })
     const calls = registerEcho(ctx)
     const controller = new AbortController()
@@ -868,8 +868,9 @@ describe('the run_code dispatch bridge', () => {
       return { logs: [], value: message }
     }
     const result = await runCode(ctx, 'program', { signal: controller.signal })
-    expect(result.isError).toBe(false)
-    expect((result.content[0] as { text: string }).text).toContain('not dispatched')
+    expect(result.isError).toBe(true)
+    expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
+    expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted')
     expect(calls).toEqual([])
   })
 

+ 300 - 2
packages/core/tools/tests/tools.spec.ts

@@ -499,6 +499,302 @@ describe('ToolRegistry', () => {
     expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
   })
 
+  it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => {
+    const ctx = await setup()
+    let dispatched = 0
+    ctx.tools.register({
+      ...echoTool,
+      name: 'must-not-run',
+      async execute() { dispatched += 1; return [] },
+    })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    ctx.on('tools/pre-execute', async (_exec, next) => {
+      entered.resolve(undefined)
+      await release.promise
+      return await next()
+    })
+
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort('cancelled in policy')
+    release.resolve(undefined)
+
+    await expect(pending).resolves.toMatchObject({
+      content: [{ type: 'text', text: 'Error: tool call aborted' }],
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+    })
+    expect(dispatched).toBe(0)
+  })
+
+  it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => {
+    const ctx = await setup()
+    let dispatched = 0
+    ctx.tools.register({
+      ...echoTool,
+      name: 'must-not-run',
+      async execute() { dispatched += 1; return [] },
+    })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    ctx.on('tools/pre-execute', async () => {
+      entered.resolve(undefined)
+      await release.promise
+      throw new Error('gate interrupted')
+    })
+
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort('cancelled in policy')
+    release.resolve(undefined)
+
+    await expect(pending).resolves.toMatchObject({
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+    })
+    expect(dispatched).toBe(0)
+  })
+
+  it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => {
+    const ctx = await setup()
+    let dispatched = 0
+    ctx.tools.register({
+      ...echoTool,
+      name: 'must-not-run',
+      async execute() { dispatched += 1; return [] },
+    })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const replacement = new AbortController()
+    ctx.on('tools/execute', async (exec, next) => {
+      const upstream = exec.signal
+      exec.signal = replacement.signal
+      try {
+        entered.resolve(undefined)
+        await release.promise
+        return await next()
+      } finally {
+        if (upstream === undefined) delete exec.signal
+        else exec.signal = upstream
+      }
+    })
+
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort('cancelled in wrapper')
+    release.resolve(undefined)
+
+    await expect(pending).resolves.toMatchObject({
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+    })
+    expect(dispatched).toBe(0)
+  })
+
+  it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => {
+    const ctx = await setup()
+    let dispatched = 0
+    ctx.tools.register({
+      ...echoTool,
+      name: 'must-not-run',
+      async execute() { dispatched += 1; return [] },
+    })
+    const replacement = AbortSignal.abort('wrapper cancelled')
+    ctx.on('tools/execute', async (exec, next) => {
+      const upstream = exec.signal
+      exec.signal = replacement
+      try {
+        return await next()
+      } finally {
+        if (upstream === undefined) delete exec.signal
+        else exec.signal = upstream
+      }
+    })
+
+    const controller = new AbortController()
+    const result = await ctx.tools.execute({
+      callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
+    })
+
+    expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
+    expect(dispatched).toBe(0)
+  })
+
+  it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => {
+    const ctx = await setup()
+    ctx.tools.register({
+      ...echoTool,
+      name: 'completed-before-wrapper',
+      async execute(_args, exec) {
+        exec.deferContext({
+          content: [{ type: 'text', text: 'completed child work' }],
+          source: { kind: 'plugin', plugin: 'child' },
+        })
+        return [{ type: 'text', text: 'body complete' }]
+      },
+    })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    ctx.on('tools/execute', async (_exec, next) => {
+      const result = await next()
+      entered.resolve(undefined)
+      await release.promise
+      return result
+    })
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort('cancelled while wrapper settled')
+    release.resolve(undefined)
+
+    await expect(pending).resolves.toMatchObject({
+      content: [{ type: 'text', text: 'Error: tool call aborted' }],
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+      additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
+    })
+  })
+
+  it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
+    const ctx = await setup()
+    const entered = Promise.withResolvers<undefined>()
+    const replacement = new AbortController()
+    let bodySignal: AbortSignal | undefined
+    ctx.tools.register({
+      ...echoTool,
+      name: 'cooperative',
+      execute(_args, exec) {
+        bodySignal = exec.signal
+        entered.resolve(undefined)
+        if (exec.signal?.aborted) return Promise.resolve([])
+        return new Promise((resolve) => {
+          exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true })
+        })
+      },
+    })
+    ctx.on('tools/execute', async (exec, next) => {
+      const upstream = exec.signal
+      exec.signal = replacement.signal
+      try {
+        return await next()
+      } finally {
+        if (upstream === undefined) delete exec.signal
+        else exec.signal = upstream
+      }
+    })
+
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    expect(bodySignal).not.toBe(controller.signal)
+    expect(bodySignal).not.toBe(replacement.signal)
+    controller.abort('cancel running body')
+
+    await expect(pending).resolves.toMatchObject({
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+    })
+    expect(bodySignal?.aborted).toBe(true)
+    expect(replacement.signal.aborted).toBe(false)
+  })
+
+  it('restores a removed caller signal for dispatch', async () => {
+    const ctx = await setup()
+    let bodySignal: AbortSignal | undefined
+    ctx.tools.register({
+      ...echoTool,
+      name: 'signal-probe',
+      async execute(_args, exec) { bodySignal = exec.signal; return [] },
+    })
+    ctx.on('tools/execute', async (exec, next) => {
+      const upstream = exec.signal
+      delete exec.signal
+      try {
+        return await next()
+      } finally {
+        if (upstream !== undefined) exec.signal = upstream
+      }
+    })
+    const controller = new AbortController()
+
+    await ctx.tools.execute({
+      callId: CallId('restored-signal'), name: 'signal-probe', arguments: {}, signal: controller.signal,
+    })
+
+    expect(bodySignal).toBe(controller.signal)
+  })
+
+  it('waits for an uncooperative started body before returning ABORTED', async () => {
+    const ctx = await setup()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<never[]>()
+    ctx.tools.register({
+      ...echoTool,
+      name: 'uncooperative',
+      execute(_args, exec) {
+        exec.deferContext({
+          content: [{ type: 'text', text: 'nested outcome' }],
+          source: { kind: 'plugin', plugin: 'nested' },
+        })
+        entered.resolve(undefined)
+        return release.promise
+      },
+    })
+    const controller = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort('must still drain')
+
+    const state = await Promise.race([
+      pending.then(() => 'settled' as const),
+      Promise.resolve('pending' as const),
+    ])
+    expect(state).toBe('pending')
+    release.resolve([])
+    await expect(pending).resolves.toMatchObject({
+      isError: true,
+      error: { name: 'AbortError', code: 'ABORTED' },
+      additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }],
+    })
+  })
+
+  it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => {
+    const ctx = await setup()
+    let dispatched = 0
+    ctx.tools.register({
+      ...echoTool,
+      name: 'domain-abort',
+      async execute(_args, exec) {
+        dispatched += 1
+        expect(exec.signal?.aborted).toBe(true)
+        throw new HarnessError('domain cleanup completed', 'DOMAIN_ABORTED')
+      },
+    })
+
+    const result = await ctx.tools.execute({
+      callId: CallId('pre-aborted'), name: 'domain-abort', arguments: {}, signal: AbortSignal.abort(),
+    })
+
+    expect(dispatched).toBe(1)
+    expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' })
+  })
+
   it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
@@ -560,7 +856,7 @@ describe('ToolRegistry', () => {
     expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
   })
 
-  it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
+  it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => {
     const ctx = await setup()
     let seenSignal: AbortSignal | undefined
     ctx.tools.register({
@@ -583,7 +879,9 @@ describe('ToolRegistry', () => {
     })
 
     await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
-    expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
+    expect(seenSignal).toBeDefined()
+    expect(seenSignal).not.toBe(upstream)
+    expect(seenSignal).not.toBe(replacement)
   })
 
   it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {

+ 37 - 3
packages/timeout/timeout-policy/tests/timeout-policy.spec.ts

@@ -126,7 +126,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
     expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
   })
 
-  it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => {
+  it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
     const ctx = await setup()
     ctx.tools.register(cooperativeTool)
     const upstream = new AbortController()
@@ -134,8 +134,42 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
     upstream.abort('user cancelled')
     await vi.advanceTimersByTimeAsync(0)
     const result = await pending
-    expect(result.isError).toBe(false)
-    expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' })
+    expect(result.isError).toBe(true)
+    expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
+    expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
+  })
+
+  it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => {
+    const ctx = await setup()
+    const sawAbort = Promise.withResolvers<undefined>()
+    const releaseCleanup = Promise.withResolvers<undefined>()
+    ctx.tools.register(defineTool({
+      name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
+      async execute(_args, exec) {
+        if (!exec.signal?.aborted) {
+          await new Promise<undefined>((resolve) => {
+            exec.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true })
+          })
+        }
+        sawAbort.resolve(undefined)
+        await releaseCleanup.promise
+        return [{ type: 'text' as const, text: 'cleanup complete' }]
+      },
+    }))
+    const upstream = new AbortController()
+    const pending = ctx.tools.execute({
+      callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal,
+    })
+
+    await vi.advanceTimersByTimeAsync(100)
+    await sawAbort.promise
+    upstream.abort('too late to replace timeout')
+    releaseCleanup.resolve(undefined)
+
+    await expect(pending).resolves.toMatchObject({
+      isError: true,
+      error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
+    })
   })
 })
 

+ 13 - 9
website/zh-CN/api/harness/events.md

@@ -741,7 +741,7 @@ Emitted when any prompt provider changes. This registry notification is unfilter
 
 A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L116)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L120)
 
 ### tools/execute
 
@@ -751,7 +751,9 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 /**
  * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
  * a normalized result; wrappers may change only `exec.signal`, while call
- * identity remains immutable.
+ * identity remains immutable. The registry re-fuses the original caller
+ * signal before the body, so replacement cannot detach caller cancellation;
+ * wrappers must still restore their signal and reach quiescence.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
  * @mode waterfall
@@ -759,11 +761,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
 ```
 
-Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
+Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
 
 - `exec` — the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L89)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L93)
 
 ### tools/post-execute
 
@@ -786,7 +788,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 - `exec` — the call that just ran (name, parsed arguments, caller agent).
 - `result` — the dispatch outcome a listener may accept, replace, or block.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L98)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L102)
 
 ### tools/pre-execute
 
@@ -795,7 +797,9 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 ```ts website-api
 /**
  * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
- * approval support turns `ask` into denial.
+ * approval support turns `ask` into denial. Async gates must observe
+ * `exec.signal`; the registry rechecks cancellation after they settle but
+ * never abandons their promise.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  * @param exec - the pending call (name, parsed arguments, caller agent).
  * @mode waterfall
@@ -803,11 +807,11 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
 ```
 
-Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
+Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
 
 - `exec` — the pending call (name, parsed arguments, caller agent).
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L80)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L82)
 
 ### tools/result
 
@@ -829,7 +833,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
 - `exec` — the execution object that traversed the pipeline.
 - `result` — a deep-frozen snapshot of the final returned result.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L106)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L110)
 
 ## workflow/*
 

+ 13 - 10
website/zh-CN/api/harness/tools.md

@@ -6,7 +6,7 @@
 
 Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L438)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L465)
 
 ### ctx.tools.register(definition)
 
@@ -26,7 +26,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du
 
 **Returns** the exact disposer that unregisters the tool.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L538)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L567)
 
 ### ctx.tools.restrict(filter)
 
@@ -47,7 +47,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names,
 
 **Returns** the exact disposer that lifts this restriction.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L578)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L607)
 
 ### ctx.tools.guard(guard)
 
@@ -71,7 +71,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A
 
 **Returns** the exact disposer that unregisters the guard.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L629)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L658)
 
 ### ctx.tools.get(name, scope?)
 
@@ -95,7 +95,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl
 
 **Returns** the definition the scope resolves, or undefined when none is visible.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L731)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L760)
 
 ### ctx.tools.schemas(scope?)
 
@@ -115,7 +115,7 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc
 
 **Returns** one deep-cloned schema per visible tool.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L741)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L770)
 
 ### ctx.tools.executionMode(exec)
 
@@ -136,7 +136,7 @@ Classify a pending call through the caller's visible tool definition. Only an ex
 
 **Returns** the fail-closed scheduling mode.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L762)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L791)
 
 ### ctx.tools.execute(exec)
 
@@ -145,7 +145,10 @@ Classify a pending call through the caller's visible tool definition. Only an ex
  * Execute through pre-policy, guards, around-dispatch, post-policy, and final
  * notification. Tool and listener failures resolve as materialized error
  * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
- * the same lossless, frozen snapshot final observers receive.
+ * the same lossless, frozen snapshot final observers receive. Cancellation
+ * arriving after entry skips a not-yet-started body or replaces a successful
+ * dispatch 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.
@@ -153,10 +156,10 @@ Classify a pending call through the caller's visible tool definition. Only an ex
 async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
 ```
 
-Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive.
+Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry skips a not-yet-started body or replaces a successful dispatch outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.
 
 - `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins.
 
 **Returns** the materialized final result.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L782)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L814)

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно