From fc6afa97fe7e7166efbe990d407e3a21d4efdbbc Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:09:05 +0800 Subject: [PATCH] fix(tools): classify cancellation thrown by a started tool body A started tool body that throws the signal's reason or an AbortError without HarnessError identity now keeps its message and carries AbortError / ABORTED, so the cancellation is routable like the registry's own cancellation results. HarnessErrors and distinct failures that settle during cancellation stay unchanged. Refs #7869 --- ...19-cooperative-tool-cancellation.i18n.yaml | 4 +- ...026-07-19-cooperative-tool-cancellation.md | 2 +- ...-07-19-cooperative-tool-cancellation.zh.md | 2 +- .agents/skills/dsh-error-handling/SKILL.md | 4 +- .../references/error-codes.md | 2 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/pipeline.ts | 21 +++- packages/core/tools/tests/tools.spec.ts | 113 ++++++++++++++++++ 16 files changed, 150 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index e4b4714af1..1cb3a2ce6c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -15,8 +15,8 @@ en: 0e1399cad4151f87 zh: 960fa0ccbe0f8fea /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/decision/cancellation-codes-record-whether-dispatch-occurred: - en: c787fba8731ac147 - zh: 21b5968ae6f8cd59 + en: 8dd2ee8bcc3bbadd + zh: 0792ddab00d72512 /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/decision/pre-aborted-entry-short-circuits-after-materialization: en: 53362db6a0c71698 zh: 311de45ca26d3fdc diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 8ad0af1d21..9369c18c6b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -32,7 +32,7 @@ An around-dispatch wrapper may replace `exec.signal` for its delegated lifetime `ABORTED_BEFORE_DISPATCH` carries `{ name: 'AbortError' }` and model text `Error: tool call aborted before dispatch`. It applies whenever cancellation prevents body invocation, including pre-aborted entry, cancellation during pre-policy or approval, an aborted wrapper signal, a wrapper success overtaken by caller cancellation before delegation, and agent-loop siblings skipped after turn cancellation. -`ABORTED` carries model text `Error: tool call aborted` and applies only after the body was invoked, including cancellation while an around wrapper or post-policy listener waits after body completion. A denial, wrapper failure, tool failure, or post-policy failure remains more specific than generic cancellation. A timeout owned by timeout-policy remains `TOOL_TIMEOUT`, and contexts deferred before a successful outcome is replaced remain attached. +`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 started body that throws the signal's reason or an `AbortError` without `HarnessError` identity keeps that message and carries `{ name: 'AbortError', code: 'ABORTED' }`. A denial, wrapper failure, other tool failure, or post-policy failure remains more specific than generic cancellation. A timeout owned by timeout-policy remains `TOOL_TIMEOUT`, and contexts deferred before a successful outcome is replaced remain attached. ### Pre-aborted entry short-circuits after materialization diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 613db6dacd..99cf662d77 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -32,7 +32,7 @@ Status: implemented `ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop(智能体循环)跳过的同批调用。 -`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 +`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。已开始的工具主体抛出 signal 的 reason 或不具备 `HarnessError` 身份的 `AbortError` 时,结果保留该 message,并携带 `{ name: 'AbortError', code: 'ABORTED' }`。拒绝、包装层失败、其他工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 ### 进入时已中止会在物化后短路 diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index 126c9d0ecc..35f51cb935 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -17,11 +17,11 @@ Convert foreign failures where the dependency is owned. Node errno, SDK, protoco ## Tool failures -The tool registry keeps `error.name` and `error.code` on `tool/result` when the producer supplies them: a `HarnessError` thrown by the tool body, a pre-execute `deny` with `info`, the registry's own cancellation result (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`), or a `tools/execute` wrapper result such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, a guard denial, an approval rejection, and a post-execute block persist only text that retry policy, UI, and analytics cannot route on. Every expected failure reachable from a tool body therefore throws a `HarnessError`, normally the owning domain's subclass: +The tool registry keeps `error.name` and `error.code` on `tool/result` when the producer supplies them: a `HarnessError` thrown by the tool body, a pre-execute `deny` with `info`, the registry's own cancellation result (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`, also applied to a signal reason or `AbortError` the body throws after cancellation), or a `tools/execute` wrapper result such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, a guard denial, an approval rejection, and a post-execute block persist only text that retry policy, UI, and analytics cannot route on. Every expected failure reachable from a tool body therefore throws a `HarnessError`, normally the owning domain's subclass: - Hand-checked argument constraints throw `ToolArgsError` (`INVALID_ARGS`) from `dsh-tools`. - Missing composition or caller context, such as an absent Agent, service, or open turn, throws the owning package's error with its own code. -- Caller cancellation throws a `HarnessError` whose `name` is `AbortError` and whose code is the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`), matching the registry's cancellation result; never a bare `AbortError` or `signal.reason`. +- Caller cancellation either propagates the signal's reason or an `AbortError`, which the registry classifies as `AbortError` with the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`), or throws a `HarnessError` with that `name` and code. Never replace it with a different plain `Error`; a failure distinct from the cancellation, such as a cleanup failure, keeps its own class and code. - Dependency failures follow the conversion rule above; filesystem errno maps to `FsError` codes such as `FS_NOT_FOUND`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR`. A policy that denies a call in `tools/pre-execute` supplies `info` with its own `name` and `code`. The registry's built-in approval rejections and guard denials currently carry only a reason, so they remain unroutable until the registry gives them codes. A failure the model must not treat as success is an `isError` result; a successful domain outcome in a non-ideal state, such as a non-zero exit or no matches, stays a success value, and a success-shaped failure value is allowed only when the output schema documents it. Tests assert `error.info.name` and `error.info.code` for each expected failure path, not only the message. diff --git a/.agents/skills/dsh-error-handling/references/error-codes.md b/.agents/skills/dsh-error-handling/references/error-codes.md index 26b2cada50..8398513965 100644 --- a/.agents/skills/dsh-error-handling/references/error-codes.md +++ b/.agents/skills/dsh-error-handling/references/error-codes.md @@ -26,7 +26,7 @@ Use this guide when adding or reviewing an error class, an error code, or a fail - `name` is the PascalCase class name ending in `Error`, such as `JobError` or `SandboxEscalationError`. - Assign `this.name` a string literal in the constructor. `HarnessError` defaults `name` to `new.target.name`, but a bundler may rename a colliding class binding, and a renamed class would change persisted data. - Do not reuse another domain's class name, and do not encode a code in the name. -- Cancellation is the one exception: a tool throws a `HarnessError` whose `name` is `AbortError` and whose code is the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`), so tool-thrown and registry-generated cancellations share one `name` and `code`. +- Cancellation is the one exception: its `name` is `AbortError` and its code is the `dsh-tools` `TOOL_ABORTED` constant (`ABORTED`). The registry applies both to a signal reason or `AbortError` a tool throws after cancellation; a tool that throws its own cancellation error uses a `HarnessError` with the same `name` and code. ## Format the code diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 5f78bb6983..69e442121d 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -9,8 +9,8 @@ en: 2cc80a2a79e23005 zh: 87e43383055890ce /tool-authoring-reference/rules-of-the-execute-contract: - en: f12260d67c4c4d4d - zh: 3955d44622432fd9 + en: 291be8e1926ba5f2 + zh: f5ff9c6648452161 /tool-authoring-reference/long-running-work: en: 5f6b0c15cb91e21b zh: 633a2192df186ac3 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index b1eaed1098..6917b1e612 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -45,7 +45,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S - **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. - **Declare and return one canonical JSON value.** `output.schema` uses `ValueSchemaSpec` and may have an object, array, scalar, or null root. `execute` returns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it to `output.render(args, value)`. Do not return content blocks from the body or make callers parse prose for ids and fields. - **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw a `HarnessError` subclass with a stable `code` for every expected failure; a thrown `HarnessError` is the only tool-body outcome that keeps `error.name` and `error.code` on the result, and the [error-handling rules](../../.agents/skills/dsh-error-handling/SKILL.md#tool-failures) define the classes and codes. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. -- **Honor `exec.signal`.** Cancel in-flight work when it fires and throw a `HarnessError` named `AbortError` whose code is the `TOOL_ABORTED` constant (`ABORTED`), matching the registry's own cancellation result. +- **Honor `exec.signal`.** Cancel in-flight work when it fires and let the signal's reason or a dependency's `AbortError` propagate; the registry records it as `AbortError` with the `TOOL_ABORTED` constant (`ABORTED`). Do not replace it with a different plain `Error`. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. - **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'my-plugin' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Declare that `kind` through [`MessageSourceMap`](../subsystems/llm-streaming.md#content-blocks-and-messages) in your plugin: session format V4 rejects the retired `{ kind: 'plugin', plugin: '' }` wrapper at message admission. Guard against disposed agents (try/catch). diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 0149575daf..9cdf5541dd 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -45,7 +45,7 @@ export function apply(ctx: Context) { - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token`、必填且由调用方持有的 `signal`,以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 `exec.signal` 以施加截止时间,但不能移除该信号。 - **声明并返回一个规范 JSON 值。** `output.schema` 使用 `ValueSchemaSpec`,根可以是对象、数组、标量或 null。`execute` 只返回推导出的值;注册表将其快照为无损 JSON,完成校验和冻结后,再传给 `output.render(args, value)`。工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。 - **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。每种预期失败都应抛出带稳定 `code` 的 `HarnessError` 子类;在工具主体的各种产出中,只有抛出的 `HarnessError` 会让结果保留 `error.name` 和 `error.code`,具体的错误类和 code 由[错误处理规则](../../.agents/skills/dsh-error-handling/SKILL.md#tool-failures)规定。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 -- **遵守 `exec.signal`。** 信号触发时取消进行中的工作,并抛出 `name` 为 `AbortError`、code 为 `TOOL_ABORTED` 常量(值为 `ABORTED`)的 `HarnessError`,与注册表自己生成的取消结果一致。 +- **遵守 `exec.signal`。** 信号触发时取消进行中的工作,让 signal 的 reason 或依赖抛出的 `AbortError` 直接传播;注册表会把它记录为 `AbortError` 与 `TOOL_ABORTED` 常量(值为 `ABORTED`)。不要把它替换成另一个普通 `Error`。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 - **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'my-plugin' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。在插件里通过 [`MessageSourceMap`](../subsystems/llm-streaming.zh.md#content-blocks-and-messages) 声明该 `kind`:session format V4 在消息准入处拒绝退役的 `{ kind: 'plugin', plugin: '' }` 包装。请防范已 dispose 的 agent(try/catch)。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index c155ec4cb6..6aecc60c2c 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -21,8 +21,8 @@ en: 14522295539ea2fb zh: 572c462f2c6b70ad /tools/execution-extensible-waterfalls-plus-monotonic-policy: - en: 0baa3a0d6fb8d3be - zh: 3583d70d2507cac8 + en: fe34c933c35399e9 + zh: 58142bf2f0d5877b /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 02b213abdb..47c333df63 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -522,7 +522,7 @@ interface ToolErrorInfo { } ``` -The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/ptc-dispatch` stores the sub-call's rendered `content`, `isError`, optional structured `error`, and presentation `meta`. Replay reproduces presentation but cannot reconstruct canonical intermediate values. Optional `ToolErrorInfo.reason` retains raw user-facing detail without adding it to model-facing content. The registry sets `info` from a thrown `HarnessError` (its `name` and `code`), from a pre-execute `deny` that supplies it, and on its own cancellation results (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`); a `tools/execute` wrapper may also return a result with `info`, such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, guard denial, approval rejection, or post-execute block produces an `error` without `info`. +The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/ptc-dispatch` stores the sub-call's rendered `content`, `isError`, optional structured `error`, and presentation `meta`. Replay reproduces presentation but cannot reconstruct canonical intermediate values. Optional `ToolErrorInfo.reason` retains raw user-facing detail without adding it to model-facing content. The registry sets `info` from a thrown `HarnessError` (its `name` and `code`), from a pre-execute `deny` that supplies it, and on its own cancellation results (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`); when a started body throws the signal's reason or an `AbortError` after cancellation, the result keeps that message and carries `AbortError` with `ABORTED`; a `tools/execute` wrapper may also return a result with `info`, such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, guard denial, approval rejection, or post-execute block produces an `error` without `info`. On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional presentation metadata projector for native and nested calls. It separately materializes the durable presentation fields immediately before `tools/result`; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe `isError`. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append. diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 32e333c298..0a643f25bd 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -522,7 +522,7 @@ interface ToolErrorInfo { } ``` -结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/ptc-dispatch` 则存储子调用渲染后的 `content`、`isError`、可选结构化 `error` 和展示 `meta`。回放可以重现展示,却无法重建规范的中间值。可选的 `ToolErrorInfo.reason` 保留面向用户的原始详情,不将其加入模型可见内容。注册表会在以下情况设置 `info`:抛出的是 `HarnessError`(取其 `name` 和 `code`)、pre-execute 的 `deny` 自带 `info`,以及注册表自己生成的取消结果(`AbortError`,code 为 `ABORTED` 或 `ABORTED_BEFORE_DISPATCH`);`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。其他抛出值、guard 拒绝、审批拒绝和 post-execute block 产生的 `error` 都不含 `info`。 +结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/ptc-dispatch` 则存储子调用渲染后的 `content`、`isError`、可选结构化 `error` 和展示 `meta`。回放可以重现展示,却无法重建规范的中间值。可选的 `ToolErrorInfo.reason` 保留面向用户的原始详情,不将其加入模型可见内容。注册表会在以下情况设置 `info`:抛出的是 `HarnessError`(取其 `name` 和 `code`)、pre-execute 的 `deny` 自带 `info`,以及注册表自己生成的取消结果(`AbortError`,code 为 `ABORTED` 或 `ABORTED_BEFORE_DISPATCH`);已开始的工具主体在取消后抛出 signal 的 reason 或 `AbortError` 时,结果保留其 message,并带上 `AbortError` 与 `ABORTED`;`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。其他抛出值、guard 拒绝、审批拒绝和 post-execute block 产生的 `error` 都不含 `info`。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器,并为原生与嵌套调用执行可选的展示元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 68b03222e1..996df18d4f 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -45,8 +45,8 @@ en: 2dee0a37e0c1fa38 zh: 0963cba32685612e /deepseek-ai-dsh-tools/understand-the-implementation/execution-and-cancellation: - en: dc44a88997777988 - zh: 56454aeace1c8b77 + en: 41f1c678aba0056e + zh: 7976fdd8aced1cc5 /deepseek-ai-dsh-tools/understand-the-implementation/ptc-mode: en: 83cf9a5077158879 zh: 80508a70a3674226 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a0ce675371..178cd0e402 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -125,7 +125,7 @@ The registry holds typed `ToolDefinition`s in scoped layers and projects them on ### Execution and cancellation -Each typed invocation materializes and freezes parsed arguments, assigns an opaque correlation token, and runs policy and dispatch. A pre-execute denial may attach `ToolErrorInfo` beside its model-facing reason; the native and PTC durable projections preserve the structured name, code, and optional user-facing reason without adding that detail to model content. Cancellation is cooperative and quiescent: every tool body receives the caller-owned `exec.signal` and must observe it; cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`, after invocation it replaces only a successful outcome with `ABORTED`. Denials, wrapper failures, tool failures, post-policy failures, and timeout-owned `TOOL_TIMEOUT` remain more specific. Unknown and throwing tools become structured errors (`UNKNOWN_TOOL`), so a call fails without ending the turn. +Each typed invocation materializes and freezes parsed arguments, assigns an opaque correlation token, and runs policy and dispatch. A pre-execute denial may attach `ToolErrorInfo` beside its model-facing reason; the native and PTC durable projections preserve the structured name, code, and optional user-facing reason without adding that detail to model content. Cancellation is cooperative and quiescent: every tool body receives the caller-owned `exec.signal` and must observe it; cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`, after invocation it replaces only a successful outcome with `ABORTED`, and a thrown signal reason or `AbortError` without `HarnessError` identity keeps its message but carries `ABORTED`. Denials, wrapper failures, tool failures, post-policy failures, and timeout-owned `TOOL_TIMEOUT` remain more specific. Unknown and throwing tools become structured errors (`UNKNOWN_TOOL`), so a call fails without ending the turn. ### PTC mode diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 55e9c50020..a1c351b7c8 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -125,7 +125,7 @@ Agent 步骤的并发上限使用 [agent-loop 配置](../agent-loop/README.zh.md ### 执行与取消 -每次类型化调用都会实体化并冻结解析后的参数、分配不透明关联 token,再运行策略与分发。pre-execute 拒绝可以在模型可见原因旁附带 `ToolErrorInfo`;原生与 PTC 持久投影会保留结构化名称、代码与可选用户可见原因,但不会把该详情加入模型内容。取消采用协作式并等待完全停稳:每个工具主体都收到调用方拥有的 `exec.signal` 且必须观测它;调用主体前的取消为 `ABORTED_BEFORE_DISPATCH`,调用主体后的取消只能把成功结果替换为 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败与超时产生的 `TOOL_TIMEOUT` 仍保留更具体的结果。未知工具与抛出异常的工具都会变成结构化错误(`UNKNOWN_TOOL`),因此调用会失败而不会结束轮次。 +每次类型化调用都会实体化并冻结解析后的参数、分配不透明关联 token,再运行策略与分发。pre-execute 拒绝可以在模型可见原因旁附带 `ToolErrorInfo`;原生与 PTC 持久投影会保留结构化名称、代码与可选用户可见原因,但不会把该详情加入模型内容。取消采用协作式并等待完全停稳:每个工具主体都收到调用方拥有的 `exec.signal` 且必须观测它;调用主体前的取消为 `ABORTED_BEFORE_DISPATCH`,调用主体后的取消只能把成功结果替换为 `ABORTED`,抛出的 signal reason 或不具备 `HarnessError` 身份的 `AbortError` 保留其 message 并携带 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败与超时产生的 `TOOL_TIMEOUT` 仍保留更具体的结果。未知工具与抛出异常的工具都会变成结构化错误(`UNKNOWN_TOOL`),因此调用会失败而不会结束轮次。 ### PTC mode diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index 56c68c6c59..b4128ba29e 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -254,6 +254,9 @@ export class ToolExecutionPipeline { * 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`. + * After cancellation, a thrown signal reason or `AbortError` without + * `HarnessError` identity keeps its message and gains the `ABORTED` + * identity; a thrown `HarnessError` and any other failure stay unchanged. */ private async dispatchToolBody(exec: MutableToolRunContext): Promise { const state = this.cancellationStates.get(exec) @@ -278,7 +281,9 @@ export class ToolExecutionPipeline { ? toolAbortedResult(result) : result } catch (error: unknown) { - return toolErrorResult(error) + return isAborted(signal) && isUnclassifiedCancellation(error, signal) + ? toolErrorResult(error, { name: 'AbortError', code: TOOL_ABORTED }) + : toolErrorResult(error) } finally { fused.dispose() exec.signal = wrapperSignal @@ -687,13 +692,23 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { } } +/** Whether a value thrown after cancellation is that cancellation and carries no identity of its own. */ +function isUnclassifiedCancellation(error: unknown, signal: AbortSignal): boolean { + if (errorInfo(error) !== undefined) return false + try { + return error === signal.reason || (error instanceof Error && error.name === 'AbortError') + } catch { + // A value whose prototype or `name` cannot be read is not a recognizable cancellation. + return false + } +} + /** Mint a same-process correlation token whose identity is its value. */ function createExecutionToken(): ToolExecutionToken { return Symbol('dsh.tool.execution') as ToolExecutionToken } -function toolErrorResult(error: unknown): ToolExecutionResult { - const info = errorInfo(error) +function toolErrorResult(error: unknown, info = errorInfo(error)): ToolExecutionResult { const message = toolErrorMessage(error) return { content: [{ type: 'text', text: `Error: ${message}` }], diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 785927d780..fd3ca61a39 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1729,6 +1729,119 @@ describe('ToolRuntime', () => { }) }) + it.each([ + ['the signal reason', (signal: AbortSignal): void => { signal.throwIfAborted() }, 'caller stopped'], + ['a dependency AbortError', (): void => { throw new DOMException('This operation was aborted', 'AbortError') }, 'This operation was aborted'], + ])('classifies %s thrown after cancellation as ABORTED', async (_label, fail, message) => { + const ctx = await setup() + const entered = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'throws-on-abort', + async execute(_args, exec) { + entered.resolve(undefined) + await new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + fail(exec.signal) + return 'not reached' + }, + }) + const controller = new AbortController() + const pending = executeToolFixture(ctx, { + callId: ToolCallId('throws-on-abort'), name: 'throws-on-abort', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('caller stopped') + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + error: { message, info: { name: 'AbortError', code: TOOL_ABORTED } }, + }) + }) + + it('keeps a HarnessError thrown after cancellation', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'domain-abort', + execute(_args, exec) { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + exec.signal.addEventListener('abort', () => { + reject(new HarnessError('search cancelled', 'SEARCH_ABORTED')) + }, { once: true }) + }) + }, + }) + const controller = new AbortController() + const pending = executeToolFixture(ctx, { + callId: ToolCallId('domain-abort'), name: 'domain-abort', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('caller stopped') + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: search cancelled' }], + isError: true, + error: { info: { name: 'HarnessError', code: 'SEARCH_ABORTED' } }, + }) + }) + + it.each([ + ['a distinct failure', (): void => { throw new Error('cleanup failed') }, 'Error: cleanup failed'], + ['an uninspectable value', (): void => { + throw new Proxy({}, { + getPrototypeOf: () => { throw new Error('prototype trap') }, + has: () => { throw new Error('has trap') }, + get: () => { throw new Error('get trap') }, + }) + }, 'Error: '], + ])('keeps %s thrown during cancellation without info', async (_label, fail, text) => { + const ctx = await setup() + const entered = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'fails-during-abort', + async execute(_args, exec) { + entered.resolve(undefined) + await new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + fail() + return 'not reached' + }, + }) + const controller = new AbortController() + const pending = executeToolFixture(ctx, { + callId: ToolCallId('fails-during-abort'), name: 'fails-during-abort', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('caller stopped') + + const result = await pending + expect(result).toMatchObject({ content: [{ type: 'text', text }], isError: true }) + expect(result.error?.info).toBeUndefined() + }) + + it('keeps a plain failure without info when the call was not cancelled', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'plain-failure', + execute() { return Promise.reject(new Error('disk full')) }, + }) + + const result = await executeToolFixture(ctx, { + callId: ToolCallId('plain-failure'), name: 'plain-failure', arguments: {}, signal: new AbortController().signal, + }) + + expect(result).toMatchObject({ isError: true, error: { message: 'disk full' } }) + expect(result.error?.info).toBeUndefined() + }) + it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => { const ctx = await setup() const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 }