From fc6afa97fe7e7166efbe990d407e3a21d4efdbbc Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:09:05 +0800 Subject: [PATCH 01/13] 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 } From 7e180cfd7074bb83cbe9c2c383a7f8d048595016 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:22:15 +0800 Subject: [PATCH 02/13] feat(tools): attach ApprovalError codes to approval denials An ask that approval does not grant now denies with ApprovalError and APPROVAL_REJECTED, APPROVAL_CANCELLED, or APPROVAL_UNAVAILABLE, so a user rejection is routable on tool/result like other structured failures. Model-facing reasons are unchanged. Refs #7869 --- .agents/skills/dsh-error-handling/SKILL.md | 4 ++-- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- docs/subsystems/tools.i18n.yaml | 4 ++-- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- packages/core/tools/src/contracts.ts | 12 +++++++++++ packages/core/tools/src/pipeline.ts | 20 ++++++++++++------- packages/core/tools/tests/tools.spec.ts | 7 ++++++- .../hook-cc-pretool-ask/session.v4.jsonl | 2 +- 12 files changed, 43 insertions(+), 20 deletions(-) diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index 35f51cb935..410f830dab 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -17,14 +17,14 @@ 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`, 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: +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` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), 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, 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 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. +A policy that denies a call in `tools/pre-execute` supplies `info` with its own `name` and `code`. 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. Existing `name` and `code` values persist in Session logs and feed analytics. Renaming or reclassifying one is an externally perceptible change that needs an [upgrade guide](../dsh-create-upgrade-guide/SKILL.md). diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 310e193d05..d06e221f7e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1308,8 +1308,8 @@ en: 0080c345c319a4b5 zh: de1a8b3ed621ce21 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: d70be5d46cb1ef7d - zh: d3d5a05d62e443fe + en: 201964862981c234 + zh: 57dd19efdbf93594 /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index d3a8d5bb2e..bb400b173a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5269,7 +5269,7 @@ Sources: [`packages/llm/llm/src/types.ts:134`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/contracts.ts:376`](../packages/core/tools/src/contracts.ts) +Sources: [`packages/core/tools/src/contracts.ts:388`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 73ebef9642..c6c7d0f7d2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5271,7 +5271,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/contracts.ts:376`](../packages/core/tools/src/contracts.ts) +来源:[`packages/core/tools/src/contracts.ts:388`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 57c674e416..a51c94d7fe 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -223,7 +223,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:497"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:376"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:388"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 6aecc60c2c..48c99ccfa0 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: fe34c933c35399e9 - zh: 58142bf2f0d5877b + en: fa1bbf79ee7026d6 + zh: cf37174936293852 /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 47c333df63..c61435cf51 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`); 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`. +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`. An `ask` that approval does not grant becomes a denial with `ApprovalError` and `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. Any other thrown value, guard denial, 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 0a643f25bd..808d7907d7 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`);已开始的工具主体在取消后抛出 signal 的 reason 或 `AbortError` 时,结果保留其 message,并带上 `AbortError` 与 `ABORTED`;`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`。审批未授予的 `ask` 会变成带 `ApprovalError` 的拒绝,code 为 `APPROVAL_REJECTED`、`APPROVAL_CANCELLED` 或 `APPROVAL_UNAVAILABLE`。其他抛出值、guard 拒绝和 post-execute block 产生的 `error` 都不含 `info`。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器,并为原生与嵌套调用执行可选的展示元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts index 089dc09417..52d06c96eb 100644 --- a/packages/core/tools/src/contracts.ts +++ b/packages/core/tools/src/contracts.ts @@ -266,6 +266,18 @@ export const TOOL_ABORTED = 'ABORTED' /** Canonical error code for cancellation before a tool body was invoked. */ export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' +/** Error name the registry records when approval does not grant a call. */ +export const APPROVAL_ERROR_NAME = 'ApprovalError' + +/** Approval code for a call the user rejected. */ +export const APPROVAL_REJECTED = 'APPROVAL_REJECTED' + +/** Approval code for an approval request cancelled without a caller cancellation. */ +export const APPROVAL_CANCELLED = 'APPROVAL_CANCELLED' + +/** Approval code for a call that needs approval when no approval service, Agent, or channel can answer it. */ +export const APPROVAL_UNAVAILABLE = 'APPROVAL_UNAVAILABLE' + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index b4128ba29e..db9a82eba7 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -7,7 +7,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-user-approval' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' +import { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' import { validateJsonSchemaValue } from './json-schema.ts' import type { ToolsService } from './index.ts' import type { ResolvedToolSet, ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' @@ -432,7 +432,8 @@ export class ToolExecutionPipeline { * session to audit to and no UI to route to. Otherwise the outcome maps * one-to-one — `allowed-once` proceeds; the three non-grants deny with * distinct reasons so the model can tell a human "no" from an absent - * approval channel. + * approval channel. Every denial carries `ApprovalError` with + * `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. */ private async serviceAsk( exec: ToolExecution, @@ -441,13 +442,13 @@ export class ToolExecutionPipeline { const approval = this.ctx.get('approval') if (approval === undefined) { return { - decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, + decision: approvalDenial(ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)`, APPROVAL_UNAVAILABLE), approvalCancelled: false, } } if (exec.agent === undefined) { return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, + decision: approvalDenial(`tool "${exec.name}" requires approval, but the call has no agent to route it through`, APPROVAL_UNAVAILABLE), approvalCancelled: false, } } @@ -462,15 +463,15 @@ export class ToolExecutionPipeline { switch (outcome) { case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } case 'rejected': return { - decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, + decision: approvalDenial(`the user rejected tool "${exec.name}"`, APPROVAL_REJECTED), approvalCancelled: false, } case 'cancelled': return { - decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, + decision: approvalDenial(`approval for tool "${exec.name}" was cancelled`, APPROVAL_CANCELLED), approvalCancelled: true, } case 'unavailable': return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, + decision: approvalDenial(`tool "${exec.name}" requires approval, but no approval channel is available`, APPROVAL_UNAVAILABLE), approvalCancelled: false, } default: return assertNever(outcome, 'ApprovalOutcome') @@ -692,6 +693,11 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { } } +/** Materialize a non-granting approval outcome as a denial with routable `ApprovalError` identity. */ +function approvalDenial(reason: string, code: string): Extract { + return { kind: 'deny', reason, info: { name: APPROVAL_ERROR_NAME, code } } +} + /** 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 diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index fd3ca61a39..2210774820 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' -import ToolRuntime, { defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -837,6 +837,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) + expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) }) describe('ask routing through ctx.approval', () => { @@ -893,6 +894,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) + expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_REJECTED }) }) it('denies with the cancellation reason on cancelled', async () => { @@ -903,6 +905,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) + expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_CANCELLED }) }) it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => { @@ -947,6 +950,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) + expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) }) it('denies an agent-less execution without asking — nothing to route or audit through', async () => { @@ -962,6 +966,7 @@ describe('ToolRuntime', () => { expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) + expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) }) it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => { diff --git a/snapshots/session/hook-cc-pretool-ask/session.v4.jsonl b/snapshots/session/hook-cc-pretool-ask/session.v4.jsonl index a7e9e8d802..b6442d4b68 100644 --- a/snapshots/session/hook-cc-pretool-ask/session.v4.jsonl +++ b/snapshots/session/hook-cc-pretool-ask/session.v4.jsonl @@ -18,7 +18,7 @@ {"type":"hook/result","data":{"turn":1,"point":"PreToolUse","handlerId":"claude-code:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":4.532041999999819}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"rejected"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311"},"toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311"},"toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true,"id":"{{message:5}}"},"error":{"name":"ApprovalError","code":"APPROVAL_REJECTED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22},"stream":[{"type":"chunk","time":1788699375166,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699375166,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," bash"," tool"," returned"," an"," error"," saying"," it"," requires"," manual"," approval"," in"," this"," session","."," I","'ll"," report"," this"," verb","atim","."]},{"type":"chunk","time":1788699375166,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699375166,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," tool"," result"," I"," got"," back"," verb","atim"," is",":\n\n","```\n","Error",":"," bash"," requires"," manual"," approval"," in"," this"," session","\n","```"]},{"type":"chunk","time":1788699375167,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}},{"type":"chunk","time":1788699375167,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}},{"type":"chunk","time":1788699375167,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}},{"type":"chunk","time":1788699375167,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 3d8d0ba95c6157ffe605c0e61486e8c3bc263a84 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:32:45 +0800 Subject: [PATCH 03/13] feat(tools): let guard denials and post-execute blocks carry error info A ToolGuard may return a ToolGuardDenial with optional ToolErrorInfo, and a post-execute block may supply info; the registry records it on the failed result. checkGuards now returns the normalized denial. String guards and blocks without info keep their existing results. Refs #7869 --- .agents/skills/dsh-error-handling/SKILL.md | 4 +-- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- docs/subsystems/tools.i18n.yaml | 4 +-- docs/subsystems/tools.md | 27 +++++++++++++------ docs/subsystems/tools.zh.md | 27 +++++++++++++------ packages/core/tools/src/contracts.ts | 17 +++++++++--- packages/core/tools/src/index.ts | 10 +++---- packages/core/tools/src/pipeline.ts | 8 +++--- packages/core/tools/tests/scoped.spec.ts | 14 ++++++++++ packages/core/tools/tests/tools.spec.ts | 17 ++++++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 14 ++++++---- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++++ .../cordis-inspect-jsdoc/session.v4.jsonl | 2 +- 17 files changed, 116 insertions(+), 44 deletions(-) diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index 410f830dab..2e63b5425b 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -17,14 +17,14 @@ 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` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), 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, 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`, guard denial, or post-execute block with `info` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), 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, and a guard denial or block without `info`, 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 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`. 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. +A policy that denies a call in `tools/pre-execute`, through `tools.guard()`, or by a post-execute block supplies `info` with its own `name` and `code`. 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. Existing `name` and `code` values persist in Session logs and feed analytics. Renaming or reclassifying one is an externally perceptible change that needs an [upgrade guide](../dsh-create-upgrade-guide/SKILL.md). diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d06e221f7e..25178e3c12 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1308,8 +1308,8 @@ en: 0080c345c319a4b5 zh: de1a8b3ed621ce21 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: 201964862981c234 - zh: 57dd19efdbf93594 + en: d44882acca0f367c + zh: e938d48f5a38df60 /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bb400b173a..ba5189c78f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5269,7 +5269,7 @@ Sources: [`packages/llm/llm/src/types.ts:134`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/contracts.ts:388`](../packages/core/tools/src/contracts.ts) +Sources: [`packages/core/tools/src/contracts.ts:389`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c6c7d0f7d2..e591adcf30 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5271,7 +5271,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/contracts.ts:388`](../packages/core/tools/src/contracts.ts) +来源:[`packages/core/tools/src/contracts.ts:389`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index a51c94d7fe..cd80ce419a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -223,7 +223,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:497"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:388"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:389"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 48c99ccfa0..0ee42585f7 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: fa1bbf79ee7026d6 - zh: cf37174936293852 + en: 3c59bceacac56c4f + zh: aa1352ce8418ec7a /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index c61435cf51..8f80900b86 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -455,7 +455,7 @@ interface ToolDispatchExecution extends Omit { `ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. -A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type 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. +A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason or `ToolGuardDenial` can only reduce permission, so a later listener cannot undo it. A denial's optional `info` becomes the failed result's structured error identity. ```ts type-equiv /** @@ -464,9 +464,19 @@ A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type delibera * returning `undefined` leaves it unchanged. Because guards have no allow * result, listener ordering cannot turn a denial back into permission. * @param execution - the identity-protected call after extensible pre-execute policy completed. - * @returns a final denial reason, or `undefined` to leave the call allowed. + * @returns a final denial reason, a denial with structured error identity, or `undefined` to leave the call allowed. */ -type ToolGuard = (execution: Readonly) => string | undefined +type ToolGuard = (execution: Readonly) => string | ToolGuardDenial | undefined +``` + +```ts type-equiv +/** A guard denial: the model-facing reason and optional structured error identity recorded on the result. */ +interface ToolGuardDenial { + /** Model-facing reason rendered as the failed call's content. */ + readonly reason: string + /** Structured error identity persisted beside the reason. */ + readonly info?: ToolErrorInfo +} ``` ```ts type-equiv @@ -522,7 +532,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`); 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`. An `ask` that approval does not grant becomes a denial with `ApprovalError` and `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. Any other thrown value, guard denial, 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`. An `ask` that approval does not grant becomes a denial with `ApprovalError` and `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. A guard denial or post-execute block carries `info` only when it supplies one; any other thrown value 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. @@ -550,12 +560,13 @@ type PreToolDecision = ```ts type-equiv /** * Post-dispatch decision: accept, replace one projection, attach context for the - * next request, or block by turning corrective feedback into an error result. + * next request, or block by turning corrective feedback into an error result + * whose optional `info` is its structured error identity. */ type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } + | { kind: 'block'; feedback: ContentBlock[]; info?: ToolErrorInfo; additionalContexts?: UserMessage[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. A deny may attach structured identity and user-facing detail without changing its model-facing reason. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. @@ -733,9 +744,9 @@ guard(guard: ToolGuard): () => void /** * Check global and inherited guards after pre-execute policy and approval resolve. * @param exec - invocation whose guards are checked before dispatch. - * @returns the first denial reason, or undefined when no guard denies. + * @returns the first denial with its optional structured identity, or undefined when no guard denies. */ -checkGuards(exec: ToolExecution): string | undefined +checkGuards(exec: ToolExecution): ToolGuardDenial | undefined ``` Types: [ScopeKey](scope.md) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 808d7907d7..c03d4fb029 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -455,7 +455,7 @@ interface ToolDispatchExecution extends Omit { `ToolExecutionToken` 是不透明的运行时 `Symbol`,仅用于身份比较。策略执行前,`execute()` 会物化并冻结参数、拒绝非 JSON 输入并分配 token。身份字段、调用方必需的 signal 和可选的 parent token 均保持 readonly。`ToolDispatchExecution` 包装层可以替换 signal 但不能移除;注册表会在调用工具函数体前重新融合调用方的 signal。最终观察者接收冻结的执行身份。 -`ToolGuard` 是感知作用域的最终预分派策略。其返回类型有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 +`ToolGuard` 是感知作用域的最终预分派策略。其返回类型有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 或 `ToolGuardDenial` 只能缩减权限,因此后续监听器无法撤销它。拒绝中可选的 `info` 会成为失败结果的结构化错误身份。 ```ts type-equiv /** @@ -464,9 +464,19 @@ interface ToolDispatchExecution extends Omit { * returning `undefined` leaves it unchanged. Because guards have no allow * result, listener ordering cannot turn a denial back into permission. * @param execution - the identity-protected call after extensible pre-execute policy completed. - * @returns a final denial reason, or `undefined` to leave the call allowed. + * @returns a final denial reason, a denial with structured error identity, or `undefined` to leave the call allowed. */ -type ToolGuard = (execution: Readonly) => string | undefined +type ToolGuard = (execution: Readonly) => string | ToolGuardDenial | undefined +``` + +```ts type-equiv +/** A guard denial: the model-facing reason and optional structured error identity recorded on the result. */ +interface ToolGuardDenial { + /** Model-facing reason rendered as the failed call's content. */ + readonly reason: string + /** Structured error identity persisted beside the reason. */ + readonly info?: ToolErrorInfo +} ``` ```ts type-equiv @@ -522,7 +532,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`);已开始的工具主体在取消后抛出 signal 的 reason 或 `AbortError` 时,结果保留其 message,并带上 `AbortError` 与 `ABORTED`;`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。审批未授予的 `ask` 会变成带 `ApprovalError` 的拒绝,code 为 `APPROVAL_REJECTED`、`APPROVAL_CANCELLED` 或 `APPROVAL_UNAVAILABLE`。其他抛出值、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`。审批未授予的 `ask` 会变成带 `ApprovalError` 的拒绝,code 为 `APPROVAL_REJECTED`、`APPROVAL_CANCELLED` 或 `APPROVAL_UNAVAILABLE`。guard 拒绝或 post-execute block 只有自带 `info` 时才会携带它;其他抛出值产生的 `error` 不含 `info`。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器,并为原生与嵌套调用执行可选的展示元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 @@ -550,12 +560,13 @@ type PreToolDecision = ```ts type-equiv /** * Post-dispatch decision: accept, replace one projection, attach context for the - * next request, or block by turning corrective feedback into an error result. + * next request, or block by turning corrective feedback into an error result + * whose optional `info` is its structured error identity. */ type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } + | { kind: 'block'; feedback: ContentBlock[]; info?: ToolErrorInfo; additionalContexts?: UserMessage[] } ``` 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。拒绝可以附带结构化身份与用户可见详情,而不改变其模型可见原因。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 @@ -733,9 +744,9 @@ guard(guard: ToolGuard): () => void /** * Check global and inherited guards after pre-execute policy and approval resolve. * @param exec - invocation whose guards are checked before dispatch. - * @returns the first denial reason, or undefined when no guard denies. + * @returns the first denial with its optional structured identity, or undefined when no guard denies. */ -checkGuards(exec: ToolExecution): string | undefined +checkGuards(exec: ToolExecution): ToolGuardDenial | undefined ``` Types: [ScopeKey](scope.zh.md) diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts index 52d06c96eb..d20507b65a 100644 --- a/packages/core/tools/src/contracts.ts +++ b/packages/core/tools/src/contracts.ts @@ -374,12 +374,13 @@ export type PreToolDecision = /** * Post-dispatch decision: accept, replace one projection, attach context for the - * next request, or block by turning corrective feedback into an error result. + * next request, or block by turning corrective feedback into an error result + * whose optional `info` is its structured error identity. */ export type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } + | { kind: 'block'; feedback: ContentBlock[]; info?: ToolErrorInfo; additionalContexts?: UserMessage[] } /** * Per-scope filter over global tools. Restrictions intersect and do not affect @@ -398,6 +399,14 @@ export interface ToolRestriction { * returning `undefined` leaves it unchanged. Because guards have no allow * result, listener ordering cannot turn a denial back into permission. * @param execution - the identity-protected call after extensible pre-execute policy completed. - * @returns a final denial reason, or `undefined` to leave the call allowed. + * @returns a final denial reason, a denial with structured error identity, or `undefined` to leave the call allowed. */ -export type ToolGuard = (execution: Readonly) => string | undefined +export type ToolGuard = (execution: Readonly) => string | ToolGuardDenial | undefined + +/** A guard denial: the model-facing reason and optional structured error identity recorded on the result. */ +export interface ToolGuardDenial { + /** Model-facing reason rendered as the failed call's content. */ + readonly reason: string + /** Structured error identity persisted beside the reason. */ + readonly info?: ToolErrorInfo +} diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index d2d10d6b5b..0cb307d1cb 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -11,7 +11,7 @@ import { projectToolSchema } from './schema.ts' import { assertSupportedJsonSchema } from './json-schema.ts' import { RUN_CODE_NAME, ToolActivationError } from './contracts.ts' import type { - ToolDefinition, ToolRestriction, ToolExecution, ToolGuard, + ToolDefinition, ToolRestriction, ToolExecution, ToolGuard, ToolGuardDenial, ResolvedToolSet, ToolActivationContext, ToolRegistrationOptions, } from './contracts.ts' @@ -388,13 +388,13 @@ export class ToolsService extends Service { /** * Check global and inherited guards after pre-execute policy and approval resolve. * @param exec - invocation whose guards are checked before dispatch. - * @returns the first denial reason, or undefined when no guard denies. + * @returns the first denial with its optional structured identity, or undefined when no guard denies. */ - checkGuards(exec: ToolExecution): string | undefined { + checkGuards(exec: ToolExecution): ToolGuardDenial | undefined { for (const layer of [this.guards.global, ...this.guards.chainLayers(exec.agent)]) { for (const guard of layer.guards.values()) { - const reason = guard(exec) - if (reason !== undefined) return reason + const denial = guard(exec) + if (denial !== undefined) return typeof denial === 'string' ? { reason: denial } : denial } } return undefined diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index db9a82eba7..ee694849e2 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -210,9 +210,9 @@ export class ToolExecutionPipeline { if (decision.kind === 'cancel') { return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } } - const denialReason = decision.kind === 'allow' ? this.tools.checkGuards(exec) : decision.reason - const denialInfo = decision.kind === 'deny' ? decision.info : undefined - if (denialReason !== undefined) { + const denial = decision.kind === 'allow' ? this.tools.checkGuards(exec) : decision + if (denial !== undefined) { + const { reason: denialReason, info: denialInfo } = denial return { kind: 'post-result', exec, @@ -500,7 +500,7 @@ export class ToolExecutionPipeline { return this.markCanonical(exec, { content: decision.feedback, isError: true, - error: { message }, + error: { message, ...decision.info === undefined ? {} : { info: decision.info } }, ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, }) } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 2a45fccfb0..e17c2ec1ec 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -335,6 +335,20 @@ describe('scoped execution dispatch', () => { expect(bodyCalls).toBe(0) }) + it('records the structured identity of a guard denial', async () => { + const ctx = await mount() + ctx.tools.register(tool('t')) + ctx.tools.guard(() => ({ reason: 'outside the shared workspace', info: { name: 'ForkDeniedError', code: 'FORK_PATH_OUTSIDE_WORKSPACE' } })) + + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 't', arguments: {} }) + + expect(result).toMatchObject({ + content: [{ type: 'text', text: 'Error: outside the shared workspace' }], + isError: true, + error: { message: 'outside the shared workspace', info: { name: 'ForkDeniedError', code: 'FORK_PATH_OUTSIDE_WORKSPACE' } }, + }) + }) + it('live-iterates a guard registered by an earlier guard', async () => { const ctx = await mount() const calls: string[] = [] diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 2210774820..e296614bee 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -569,6 +569,23 @@ describe('ToolRuntime', () => { expect(result.error?.message).toBe(message) }) + it('records the structured identity of a post-execute block', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by hook' }], + info: { name: 'HookBlockedError', code: 'HOOK_BLOCKED' }, + })) + + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('block-info'), name: 'echo', arguments: {} }) + expect(result).toMatchObject({ + content: [{ type: 'text', text: 'blocked by hook' }], + isError: true, + error: { message: 'blocked by hook', info: { name: 'HookBlockedError', code: 'HOOK_BLOCKED' } }, + }) + }) + it('contains a non-JSON post-execute failure projection as a safe final error', async () => { const ctx = await setup() ctx.tools.register(echoTool) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a0af7ea1d0..7558dcf8cb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4170,10 +4170,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the effect disposer that removes this guard.', }, { - signature: 'checkGuards(exec: ToolExecution): string | undefined', + signature: 'checkGuards(exec: ToolExecution): ToolGuardDenial | undefined', description: 'Check global and inherited guards after pre-execute policy and approval resolve.', parameters: [{ name: 'exec', description: 'invocation whose guards are checked before dispatch.' }], - returns: 'the first denial reason, or undefined when no guard denies.', + returns: 'the first denial with its optional structured identity, or undefined when no guard denies.', }, ], }, @@ -7722,7 +7722,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PostToolDecision', - declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', + declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n info?: ToolErrorInfo;\n additionalContexts?: UserMessage[];\n};', }, { name: 'PreparedAdapterCall', @@ -9742,7 +9742,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolGuard', - declaration: 'export type ToolGuard = (execution: Readonly) => string | undefined;', + declaration: 'export type ToolGuard = (execution: Readonly) => string | ToolGuardDenial | undefined;', + }, + { + name: 'ToolGuardDenial', + declaration: 'export interface ToolGuardDenial {\n readonly reason: string;\n readonly info?: ToolErrorInfo;\n}', }, { name: 'ToolHistory', @@ -9810,7 +9814,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolsService', - declaration: 'export class ToolsService extends Service {\n static inject;\n static Config;\n constructor(ctx: Context);\n register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void;\n restrict(filter: ToolRestriction): () => void;\n get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined;\n executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined;\n async resolve(context: ToolActivationContext): Promise;\n definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[];\n schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[];\n setExecutor(executor: ToolExecutor): () => void;\n async execute(input: ToolBatchInput): Promise;\n guard(guard: ToolGuard): () => void;\n checkGuards(exec: ToolExecution): string | undefined;\n}', + declaration: 'export class ToolsService extends Service {\n static inject;\n static Config;\n constructor(ctx: Context);\n register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void;\n restrict(filter: ToolRestriction): () => void;\n get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined;\n executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined;\n async resolve(context: ToolActivationContext): Promise;\n definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[];\n schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[];\n setExecutor(executor: ToolExecutor): () => void;\n async execute(input: ToolBatchInput): Promise;\n guard(guard: ToolGuard): () => void;\n checkGuards(exec: ToolExecution): ToolGuardDenial | undefined;\n}', }, { name: 'ToolUpdate', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f4ac94ef20..6a3d8de35b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -834,6 +834,7 @@ export const LINK_MAP: Readonly> = { ToolExecutionResult: 'tools.md', ToolExecutionToken: 'tools.md', ToolGuard: 'tools.md', + ToolGuardDenial: 'tools.md', ToolRuntime: 'tools.md', ToolsService: 'tools.md', ToolExecutor: 'tools.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index bbb8d1cf0d..7bb8a6c585 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -851,6 +851,11 @@ "symbol": "ToolGuard", "source": "packages/core/tools/src/contracts.ts" }, + { + "doc": "docs/subsystems/tools.md", + "symbol": "ToolGuardDenial", + "source": "packages/core/tools/src/contracts.ts" + }, { "doc": "docs/subsystems/tools.md", "symbol": "ToolErrorInfo", diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl index b13f0b0143..65303a2719 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Concrete tool registry and scoped executor dispatcher.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n },\n {\n \"name\": \"options\",\n \"description\": \"optional step-time activation condition.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"description\": \"Resolve the definition a call may execute. With a step's admitted set, the set decides; without one, conditional registrations are unavailable.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the calling agent; omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for a call outside a model step.\"\n }\n ],\n \"returns\": \"the executable definition, or undefined when the call must fail as `UNKNOWN_TOOL`.\"\n },\n {\n \"signature\": \"async resolve(context: ToolActivationContext): Promise\",\n \"description\": \"Resolve conditions once for this step without changing shared registrations. Cancellation or a rejected condition publishes no partial selection. Removed registrations cannot be restored by a late condition result.\",\n \"parameters\": [\n {\n \"name\": \"context\",\n \"description\": \"preparing agent, captured model selection, and cancellation.\"\n }\n ],\n \"returns\": \"the complete selection; the caller disposes it after its tool batch settles.\"\n },\n {\n \"signature\": \"definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[]\",\n \"description\": \"Read the visible capability definitions for descriptions and program bindings.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"viewing Agent or preset; omitted selects the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"definitions after scoped shadowing and restrictions, limited to the step's admission when given.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for registered-definition inspection.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"setExecutor(executor: ToolExecutor): () => void\",\n \"description\": \"Provide the executor for the calling scope; an unscoped contribution is the deployment default. Rendering any executor-owned section requires the viewing scope's selection to remain available.\",\n \"parameters\": [\n {\n \"name\": \"executor\",\n \"description\": \"initialized provider owned by the contributing plugin.\"\n }\n ],\n \"returns\": \"the effect disposer. Unloading leaves the selection unavailable until it is replaced.\"\n },\n {\n \"signature\": \"async execute(input: ToolBatchInput): Promise\",\n \"description\": \"Delegate a raw call batch to its Agent's provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.\",\n \"parameters\": [\n {\n \"name\": \"input\",\n \"description\": \"original calls, optional owning Agent, and optional logging step.\"\n }\n ],\n \"returns\": \"the selected executor's settled batch; unavailable selections reject.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic denial in the calling scope.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"evaluated after extensible pre-execute policy by the shared pipeline.\"\n }\n ],\n \"returns\": \"the effect disposer that removes this guard.\"\n },\n {\n \"signature\": \"checkGuards(exec: ToolExecution): string | undefined\",\n \"description\": \"Check global and inherited guards after pre-execute policy and approval resolve.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"invocation whose guards are checked before dispatch.\"\n }\n ],\n \"returns\": \"the first denial reason, or undefined when no guard denies.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssembleContext\",\n \"declaration\": \"export interface AssembleContext {\\n scope?: ScopeKey;\\n signal?: AbortSignal;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"PromptSection\",\n \"declaration\": \"export interface PromptSection {\\n readonly name: string;\\n readonly order: number;\\n readonly text: string | ((context: AssembleContext) => string);\\n readonly interpolate?: boolean;\\n readonly complete?: boolean;\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ResolvedToolSet\",\n \"declaration\": \"export interface ResolvedToolSet {\\n dispose(): void;\\n view(scope: ScopeKey | undefined): ReadonlyMap;\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolActivationContext\",\n \"declaration\": \"export interface ToolActivationContext {\\n readonly agent: Agent;\\n readonly modelSelection: Readonly;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolBatchInput\",\n \"declaration\": \"export interface ToolBatchInput {\\n readonly calls: readonly ToolCallBlock[];\\n readonly signal: AbortSignal;\\n readonly maxParallelToolCalls?: number;\\n readonly agent?: Agent;\\n readonly toolSet?: ResolvedToolSet;\\n readonly log?: {\\n readonly turn: number;\\n readonly step: number;\\n };\\n}\"\n },\n {\n \"name\": \"ToolBatchResult\",\n \"declaration\": \"export interface ToolBatchResult {\\n readonly results: readonly ToolCallResult[];\\n readonly additionalContexts: readonly UserMessage[];\\n readonly concluded: boolean;\\n}\"\n },\n {\n \"name\": \"ToolCallBlock\",\n \"declaration\": \"export interface ToolCallBlock {\\n type: 'tool-call';\\n id: ToolCallId;\\n name: string;\\n arguments: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly toolSet?: ResolvedToolSet;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolExecutor\",\n \"declaration\": \"export interface ToolExecutor {\\n readonly sections?: readonly ToolPromptSection[];\\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\\n execute(input: ToolBatchInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolModelInput\",\n \"declaration\": \"export type ToolModelInput = Pick;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPromptSection\",\n \"declaration\": \"export interface ToolPromptSection extends Pick {\\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\\n}\"\n },\n {\n \"name\": \"ToolProviderResult\",\n \"declaration\": \"export interface ToolProviderResult {\\n readonly schemas: readonly ToolSchema[];\\n readonly knownNames?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolRegistrationOptions\",\n \"declaration\": \"export interface ToolRegistrationOptions {\\n readonly when?: (context: ToolActivationContext) => boolean | Promise;\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Concrete tool registry and scoped executor dispatcher.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n },\n {\n \"name\": \"options\",\n \"description\": \"optional step-time activation condition.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined\",\n \"description\": \"Resolve the definition a call may execute. With a step's admitted set, the set decides; without one, conditional registrations are unavailable.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the calling agent; omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for a call outside a model step.\"\n }\n ],\n \"returns\": \"the executable definition, or undefined when the call must fail as `UNKNOWN_TOOL`.\"\n },\n {\n \"signature\": \"async resolve(context: ToolActivationContext): Promise\",\n \"description\": \"Resolve conditions once for this step without changing shared registrations. Cancellation or a rejected condition publishes no partial selection. Removed registrations cannot be restored by a late condition result.\",\n \"parameters\": [\n {\n \"name\": \"context\",\n \"description\": \"preparing agent, captured model selection, and cancellation.\"\n }\n ],\n \"returns\": \"the complete selection; the caller disposes it after its tool batch settles.\"\n },\n {\n \"signature\": \"definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[]\",\n \"description\": \"Read the visible capability definitions for descriptions and program bindings.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"viewing Agent or preset; omitted selects the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission; omit only to inspect registered definitions.\"\n }\n ],\n \"returns\": \"definitions after scoped shadowing and restrictions, limited to the step's admission when given.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n },\n {\n \"name\": \"toolSet\",\n \"description\": \"step admission, or omitted for registered-definition inspection.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"setExecutor(executor: ToolExecutor): () => void\",\n \"description\": \"Provide the executor for the calling scope; an unscoped contribution is the deployment default. Rendering any executor-owned section requires the viewing scope's selection to remain available.\",\n \"parameters\": [\n {\n \"name\": \"executor\",\n \"description\": \"initialized provider owned by the contributing plugin.\"\n }\n ],\n \"returns\": \"the effect disposer. Unloading leaves the selection unavailable until it is replaced.\"\n },\n {\n \"signature\": \"async execute(input: ToolBatchInput): Promise\",\n \"description\": \"Delegate a raw call batch to its Agent's provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.\",\n \"parameters\": [\n {\n \"name\": \"input\",\n \"description\": \"original calls, optional owning Agent, and optional logging step.\"\n }\n ],\n \"returns\": \"the selected executor's settled batch; unavailable selections reject.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic denial in the calling scope.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"evaluated after extensible pre-execute policy by the shared pipeline.\"\n }\n ],\n \"returns\": \"the effect disposer that removes this guard.\"\n },\n {\n \"signature\": \"checkGuards(exec: ToolExecution): ToolGuardDenial | undefined\",\n \"description\": \"Check global and inherited guards after pre-execute policy and approval resolve.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"invocation whose guards are checked before dispatch.\"\n }\n ],\n \"returns\": \"the first denial with its optional structured identity, or undefined when no guard denies.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssembleContext\",\n \"declaration\": \"export interface AssembleContext {\\n scope?: ScopeKey;\\n signal?: AbortSignal;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"PromptSection\",\n \"declaration\": \"export interface PromptSection {\\n readonly name: string;\\n readonly order: number;\\n readonly text: string | ((context: AssembleContext) => string);\\n readonly interpolate?: boolean;\\n readonly complete?: boolean;\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ResolvedToolSet\",\n \"declaration\": \"export interface ResolvedToolSet {\\n dispose(): void;\\n view(scope: ScopeKey | undefined): ReadonlyMap;\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolActivationContext\",\n \"declaration\": \"export interface ToolActivationContext {\\n readonly agent: Agent;\\n readonly modelSelection: Readonly;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolBatchInput\",\n \"declaration\": \"export interface ToolBatchInput {\\n readonly calls: readonly ToolCallBlock[];\\n readonly signal: AbortSignal;\\n readonly maxParallelToolCalls?: number;\\n readonly agent?: Agent;\\n readonly toolSet?: ResolvedToolSet;\\n readonly log?: {\\n readonly turn: number;\\n readonly step: number;\\n };\\n}\"\n },\n {\n \"name\": \"ToolBatchResult\",\n \"declaration\": \"export interface ToolBatchResult {\\n readonly results: readonly ToolCallResult[];\\n readonly additionalContexts: readonly UserMessage[];\\n readonly concluded: boolean;\\n}\"\n },\n {\n \"name\": \"ToolCallBlock\",\n \"declaration\": \"export interface ToolCallBlock {\\n type: 'tool-call';\\n id: ToolCallId;\\n name: string;\\n arguments: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly toolSet?: ResolvedToolSet;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolExecutor\",\n \"declaration\": \"export interface ToolExecutor {\\n readonly sections?: readonly ToolPromptSection[];\\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\\n execute(input: ToolBatchInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | ToolGuardDenial | undefined;\"\n },\n {\n \"name\": \"ToolGuardDenial\",\n \"declaration\": \"export interface ToolGuardDenial {\\n readonly reason: string;\\n readonly info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolModelInput\",\n \"declaration\": \"export type ToolModelInput = Pick;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPromptSection\",\n \"declaration\": \"export interface ToolPromptSection extends Pick {\\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\\n}\"\n },\n {\n \"name\": \"ToolProviderResult\",\n \"declaration\": \"export interface ToolProviderResult {\\n readonly schemas: readonly ToolSchema[];\\n readonly knownNames?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolRegistrationOptions\",\n \"declaration\": \"export interface ToolRegistrationOptions {\\n readonly when?: (context: ToolActivationContext) => boolean | Promise;\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} From 3b9f88dd2e4706faef6c0b9e7e26e020adafb2e6 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:40:42 +0800 Subject: [PATCH 04/13] refactor(terminal): make TerminalError a HarnessError TerminalError already carried a stable code but extended Error, so terminal tool failures persisted without error.name and error.code. It now extends HarnessError with the same name, codes, and constructor. Refs #7869 --- packages/terminal/terminal/package.json | 2 ++ packages/terminal/terminal/src/index.ts | 12 ++++++++---- packages/terminal/terminal/tests/service.spec.ts | 9 +++++++++ packages/terminal/terminal/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ .../pty-tools-sandbox-backend/session.v4.jsonl | 2 +- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index afccf4fbac..99c6ef3ca8 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -34,12 +34,14 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index d9e5444465..07eff48199 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -6,6 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { TerminalBackendCleanupError } from './types.ts' import type { TerminalBackend, @@ -62,11 +63,14 @@ export type TerminalErrorCode = | 'SEND_ACTIVE' | 'SERVICE_DISPOSING' -/** Error carrying a stable {@link TerminalErrorCode}. */ -export class TerminalError extends Error { - constructor(message: string, readonly code: TerminalErrorCode) { - super(message) +/** Harness error carrying a stable {@link TerminalErrorCode}. */ +export class TerminalError extends HarnessError { + override readonly code: TerminalErrorCode + + constructor(message: string, code: TerminalErrorCode) { + super(message, code) this.name = 'TerminalError' + this.code = code } } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 0acd31028f..6baf9350b2 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -5,6 +5,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { TerminalBackend, TerminalBackendSession, @@ -194,6 +195,14 @@ describe('TerminalSessionService ownership and lifecycle', () => { const operation = ctx.terminals.startSend(owner, created.sessionId, { text: 'echo hi', submit: true }) expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(TerminalError) + let concurrent: unknown + try { + ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true }) + } catch (error: unknown) { + concurrent = error + } + expect(concurrent).toBeInstanceOf(HarnessError) + expect(concurrent).toMatchObject({ name: 'TerminalError', code: 'SEND_ACTIVE' }) expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false }) expect(operation.cancel()).toBe(true) await operation.done diff --git a/packages/terminal/terminal/tsconfig.json b/packages/terminal/terminal/tsconfig.json index 46a562bf63..7d85cedee2 100644 --- a/packages/terminal/terminal/tsconfig.json +++ b/packages/terminal/terminal/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../llm/llm" + }, { "path": "../../util/brand" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ac595dde..c1a8808bba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17090,6 +17090,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session diff --git a/snapshots/session/pty-tools-sandbox-backend/session.v4.jsonl b/snapshots/session/pty-tools-sandbox-backend/session.v4.jsonl index cd75f61650..53ba65e448 100644 --- a/snapshots/session/pty-tools-sandbox-backend/session.v4.jsonl +++ b/snapshots/session/pty-tools-sandbox-backend/session.v4.jsonl @@ -24,7 +24,7 @@ {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699410720,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699410720,"index":0,"dt":[],"id":"pty-signal","name":"terminal_signal","args":["{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"]},{"type":"chunk","time":1788699410720,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}},{"type":"chunk","time":1788699410720,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699410720,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"pty-signal"},"toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"pty-signal"},"toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true,"id":"{{message:9}}"},"error":{"name":"TerminalError","code":"NO_SESSION"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699410738,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699410738,"index":0,"dt":[],"id":"pty-kill","name":"terminal_close","args":["{\"sessionId\":\"pty-1\"}"]},{"type":"chunk","time":1788699410738,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}},{"type":"chunk","time":1788699410738,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699410738,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} From 3f3e9caeae9fca73b00f9d86cd554873ed67aa47 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:43:55 +0800 Subject: [PATCH 05/13] feat(tools): add ToolContextUnavailableError for missing call context Tool bodies that lack a calling Agent, an injected service, or an open turn get one shared HarnessError with the TOOL_CONTEXT_UNAVAILABLE code, instead of each tool throwing a plain Error. The stacked per-area refactors adopt it at their call sites. Refs #7869 --- .agents/skills/dsh-error-handling/SKILL.md | 2 +- .../references/error-codes.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- docs/subsystems/tools.i18n.yaml | 4 ++-- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- packages/core/tools/src/contracts.ts | 20 ++++++++++++++++++ packages/core/tools/tests/tools.spec.ts | 21 ++++++++++++++++++- 11 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index 2e63b5425b..8756f91030 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -20,7 +20,7 @@ Convert foreign failures where the dependency is owned. Node errno, SDK, protoco 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`, guard denial, or post-execute block with `info` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), 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, and a guard denial or block without `info`, 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. +- Missing composition or caller context, such as an absent Agent, service, or open turn, throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`) from `dsh-tools`. - 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`. diff --git a/.agents/skills/dsh-error-handling/references/error-codes.md b/.agents/skills/dsh-error-handling/references/error-codes.md index 8398513965..fb9cc36312 100644 --- a/.agents/skills/dsh-error-handling/references/error-codes.md +++ b/.agents/skills/dsh-error-handling/references/error-codes.md @@ -17,7 +17,7 @@ Use this guide when adding or reviewing an error class, an error code, or a fail ## Choose the class 1. Reuse the owning domain's existing class when the failure belongs to that domain: `FsError` for filesystem targets, `SearchError` for `glob`/`grep`, `LspError`, `WebError`, `SubagentError`, `TeamError`, `GoalError`, `WorkflowError`, `UserQuestionError`, `SessionQueryError`, the `Sandbox*Error` classes, and `CodeRunFailedError`. -2. Use `ToolArgsError` (`INVALID_ARGS`) for argument constraints the parameter schema cannot express, such as an empty string, a range, or a cross-field rule. +2. Use `ToolArgsError` (`INVALID_ARGS`) for argument constraints the parameter schema cannot express, such as an empty string, a range, or a cross-field rule, and `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`) for a tool call that lacks a required Agent, service, or open turn. 3. Declare a new class only for a new owning domain. Prefer one class with a code union over one class per failure; a separate subclass is justified when consumers must catch it by type or it carries its own structured fields, such as `ToolArgsError.violations`, and it then extends the domain class when one exists so the domain still matches with `instanceof`. 4. Never declare a coded class that extends bare `Error`. An existing one is migrated by changing its base class and keeping every `name` and `code`. diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 25178e3c12..fa26fcdbef 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1308,8 +1308,8 @@ en: 0080c345c319a4b5 zh: de1a8b3ed621ce21 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: d44882acca0f367c - zh: e938d48f5a38df60 + en: a8e8f9ff2de055f5 + zh: ed3f709744159461 /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index ba5189c78f..b578b82e77 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5269,7 +5269,7 @@ Sources: [`packages/llm/llm/src/types.ts:134`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/contracts.ts:389`](../packages/core/tools/src/contracts.ts) +Sources: [`packages/core/tools/src/contracts.ts:409`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index e591adcf30..fe9bacb708 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5271,7 +5271,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/contracts.ts:389`](../packages/core/tools/src/contracts.ts) +来源:[`packages/core/tools/src/contracts.ts:409`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index cd80ce419a..5889fc2f0a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -223,7 +223,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:497"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:389"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:409"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 0ee42585f7..70666f46ac 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: 3c59bceacac56c4f - zh: aa1352ce8418ec7a + en: be510bb2027f6d2b + zh: 1282ab990937f81d /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 8f80900b86..493bea368d 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -571,7 +571,7 @@ type PostToolDecision = Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. A deny may attach structured identity and user-facing detail without changing its model-facing reason. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. -Post-policy may replace either content or value, never both. Content replacement preserves the canonical value and existing metadata; value replacement is revalidated and recomputes content/metadata; a block removes the value and becomes an `isError` containing corrective feedback. Content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. +Post-policy may replace either content or value, never both. Content replacement preserves the canonical value and existing metadata; value replacement is revalidated and recomputes content/metadata; a block removes the value and becomes an `isError` containing corrective feedback. Content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. A tool body whose composition or call lacks a required Agent, service, or open turn throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`). ## The enforced raw JSON Schema subset diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index c03d4fb029..515fcd6142 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -571,7 +571,7 @@ type PostToolDecision = 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。拒绝可以附带结构化身份与用户可见详情,而不改变其模型可见原因。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 -后置策略可以替换内容或值,但不能同时替换两者。替换内容会保留规范值和现有元数据;替换值会重新校验并重新计算内容/元数据;阻止会移除值,并转为包含纠正反馈的 `isError`。内容替换是展示策略,而非保密策略;需要隐藏程序化值的监听器必须阻止或替换该值。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 +后置策略可以替换内容或值,但不能同时替换两者。替换内容会保留规范值和现有元数据;替换值会重新校验并重新计算内容/元数据;阻止会移除值,并转为包含纠正反馈的 `isError`。内容替换是展示策略,而非保密策略;需要隐藏程序化值的监听器必须阻止或替换该值。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。工具主体所需的 Agent、服务或进行中的 turn 不存在时,抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)。 ## 已强制执行的原始 JSON Schema 子集 diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts index d20507b65a..c774682666 100644 --- a/packages/core/tools/src/contracts.ts +++ b/packages/core/tools/src/contracts.ts @@ -318,6 +318,26 @@ export class ToolNotFoundError extends HarnessError { } } +/** Code of {@link ToolContextUnavailableError}. */ +export const TOOL_CONTEXT_UNAVAILABLE = 'TOOL_CONTEXT_UNAVAILABLE' + +/** + * Thrown by a tool body when its composition or call lacks context the tool + * requires, such as a calling Agent, an injected service, or an open turn. + * The failure is not an argument the model can correct; `message` names the + * missing context. + */ +export class ToolContextUnavailableError extends HarnessError { + /** + * @param message - names the tool and the missing context. + * @param options - standard `cause` chaining. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, TOOL_CONTEXT_UNAVAILABLE, options) + this.name = 'ToolContextUnavailableError' + } +} + /** Thrown when a tool body or post-policy value violates its declared output. */ export class ToolOutputError extends HarnessError { /** Schema/value violations in validation order. */ diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index e296614bee..e683bee631 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' -import ToolRuntime, { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_CONTEXT_UNAVAILABLE, ToolContextUnavailableError, APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -793,6 +793,25 @@ describe('ToolRuntime', () => { }) }) + it('records ToolContextUnavailableError thrown by a tool body', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'needs-agent', + execute() { return Promise.reject(new ToolContextUnavailableError('needs-agent requires a calling agent')) }, + }) + + const result = await executeToolFixture(ctx, { + signal: testToolSignal, callId: ToolCallId('needs-agent'), name: 'needs-agent', arguments: {}, + }) + + expect(result).toMatchObject({ + content: [{ type: 'text', text: 'Error: needs-agent requires a calling agent' }], + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: TOOL_CONTEXT_UNAVAILABLE } }, + }) + }) + it('ToolNotFoundError carries a stable message and code', async () => { const { HarnessError } = await import('@deepseek-ai/dsh-llm') const err = new ToolNotFoundError('ghost') From 1b2cac01e4d6dcb4667f6875e568f9a45da79c61 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 16:47:29 +0800 Subject: [PATCH 06/13] feat(jobs): add JobError with closed job failure codes The jobs Service Definition now exports JobError, a HarnessError whose code is one of JOB_ERROR_CODES, so job registries and job-backed tools can report background, ownership, and limit failures with routable codes. The stacked per-area refactors adopt it. Refs #7869 --- packages/jobs/jobs/README.i18n.yaml | 8 +++--- packages/jobs/jobs/README.md | 3 +- packages/jobs/jobs/README.zh.md | 3 +- packages/jobs/jobs/package.json | 2 ++ packages/jobs/jobs/src/error.ts | 40 ++++++++++++++++++++++++++ packages/jobs/jobs/src/index.ts | 1 + packages/jobs/jobs/tests/error.spec.ts | 13 +++++++++ packages/jobs/jobs/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 9 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 packages/jobs/jobs/src/error.ts create mode 100644 packages/jobs/jobs/tests/error.spec.ts diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 390bb53bdd..e80181dfbe 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -30,8 +30,8 @@ en: cce846c7e3b0c33e zh: 17f353d970b4216d /deepseek-ai-dsh-jobs/use-this-package/what-can-go-wrong: - en: fcfee3f72ea703ac - zh: 80299901bd44783b + en: b7b06ef24f092a55 + zh: 52bcd65939ec8b18 /deepseek-ai-dsh-jobs/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 @@ -39,8 +39,8 @@ en: 2a42e1e06dfc469e zh: 5c2c77eca52846f0 /deepseek-ai-dsh-jobs/understand-the-implementation/source-map: - en: c9a58b4340999ae4 - zh: ef68a0b71a06d183 + en: 474dd94bf936e0e1 + zh: bec0e9768a2d5d50 /deepseek-ai-dsh-jobs/understand-the-implementation/service-operations: en: b2e4ef34ccf1e749 zh: 128b57b57ed09316 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 034bf232fe..868accf406 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -54,7 +54,7 @@ Loading these two plugins on a harness base that already provides the agent, too ### What can go wrong -Any preflight rejection leaves no job id or registered work. Jobs managed by the shipped in-process registry die with the harness process; durable execution across restarts needs a different backend implementing this contract. +Any preflight rejection leaves no job id or registered work. Implementations and job tools report expected job failures as `JobError`, a `HarnessError` whose code is one of `JOB_ERROR_CODES` (`JOB_NOT_FOUND`, `JOB_FOREIGN_SESSION`, `JOB_LIMIT_REACHED`, and others), so a tool result keeps `error.name` and `error.code`. Jobs managed by the shipped in-process registry die with the harness process; durable execution across restarts needs a different backend implementing this contract. ----- @@ -82,6 +82,7 @@ This section explains the design decisions behind the contract and points at the | [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `JobEvent`, and the read results | | [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | the `JobId` branded identifier, importable without the agent dependency | +| [`src/error.ts`](src/error.ts) | `JobError` and its closed `JOB_ERROR_CODES` list | | [`src/archive-admission.ts`](src/archive-admission.ts) | The `job` family of the Workspace registry's archive admission, installed by the seam's constructor for every implementation | ### Service operations diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index c05628b4c5..9c04d0704c 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 可能出什么问题 -任何预检拒绝都不会留下 job id 或已注册的工作。由随附的进程内注册表管理的任务会随 harness 进程终止而消失;跨重启的持久执行需要一个实现本约定的不同后端。 +任何预检拒绝都不会留下 job id 或已注册的工作。实现和任务工具用 `JobError` 报告预期的任务失败;它是 `HarnessError`,code 取自 `JOB_ERROR_CODES`(如 `JOB_NOT_FOUND`、`JOB_FOREIGN_SESSION`、`JOB_LIMIT_REACHED`),因此工具结果会保留 `error.name` 和 `error.code`。由随附的进程内注册表管理的任务会随 harness 进程终止而消失;跨重启的持久执行需要一个实现本约定的不同后端。 ----- @@ -82,6 +82,7 @@ kind: "package-reference" | [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`JobEvent` 与读取结果 | | [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | 带类型标记的 `JobId` 标识符,无需 agent 依赖即可导入 | +| [`src/error.ts`](src/error.ts) | `JobError` 及其封闭的 `JOB_ERROR_CODES` 列表 | | [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `job` 族,由接缝构造函数为每个实现安装 | ### 服务操作 diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 06c461e53f..01ea00ef93 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -41,6 +41,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" @@ -53,6 +54,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/jobs/jobs/src/error.ts b/packages/jobs/jobs/src/error.ts new file mode 100644 index 0000000000..366d16dd98 --- /dev/null +++ b/packages/jobs/jobs/src/error.ts @@ -0,0 +1,40 @@ +/** Machine-routable background-job failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every {@link JobError} code: + * - `JOB_BACKGROUND_DISABLED` — the calling tool's configuration turns background execution off. + * - `JOB_CONTROLLER_UNAVAILABLE` — no attached job controller serves the job's owner. + * - `JOB_NOT_FOUND` — the job id names no listed job. + * - `JOB_FOREIGN_SESSION` — the job belongs to another Session. + * - `JOB_OWNER_NOT_LIVE` — the owning Agent is no longer live. + * - `JOB_LIMIT_REACHED` — the registry's configured job limit is reached. + */ +export const JOB_ERROR_CODES = [ + 'JOB_BACKGROUND_DISABLED', + 'JOB_CONTROLLER_UNAVAILABLE', + 'JOB_NOT_FOUND', + 'JOB_FOREIGN_SESSION', + 'JOB_OWNER_NOT_LIVE', + 'JOB_LIMIT_REACHED', +] as const + +/** One code from {@link JOB_ERROR_CODES}. */ +export type JobErrorCode = typeof JOB_ERROR_CODES[number] + +/** Background-job failure carrying a stable {@link JobErrorCode}. */ +export class JobError extends HarnessError { + override readonly code: JobErrorCode + + /** + * @param message - names the job operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: JobErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'JobError' + this.code = code + } +} diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 7f31c3795b..f023f039b0 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -15,6 +15,7 @@ import { installJobArchiveAdmission } from './archive-admission.ts' import type { JobEvents, JobId, JobOutputRead, JobRead, JobSpec, JobView } from './types.ts' export { JobId } from './types.ts' +export { JOB_ERROR_CODES, JobError, type JobErrorCode } from './error.ts' export type { JobAppendOptions, JobChannel, diff --git a/packages/jobs/jobs/tests/error.spec.ts b/packages/jobs/jobs/tests/error.spec.ts new file mode 100644 index 0000000000..afab2195bf --- /dev/null +++ b/packages/jobs/jobs/tests/error.spec.ts @@ -0,0 +1,13 @@ +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { JOB_ERROR_CODES, JobError } from '@deepseek-ai/dsh-jobs' +import { describe, expect, it } from 'vitest' + +describe('JobError', () => { + it.each(JOB_ERROR_CODES)('is a HarnessError named JobError with code %s', (code) => { + const cause = new Error('registry detail') + const error = new JobError('job operation failed', code, { cause }) + + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'JobError', code, message: 'job operation failed', cause }) + }) +}) diff --git a/packages/jobs/jobs/tsconfig.json b/packages/jobs/jobs/tsconfig.json index 88933b81d1..b0f16aab20 100644 --- a/packages/jobs/jobs/tsconfig.json +++ b/packages/jobs/jobs/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent" }, + { + "path": "../../llm/llm" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1a8808bba..82c3643f33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12997,6 +12997,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session From 43408f9d1ab946be988c3ed11aa2c6d692729aea Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:52:28 +0800 Subject: [PATCH 07/13] docs(module-graph): record jobs and terminal dependencies on dsh-llm JobError and TerminalError extend HarnessError, so dsh-jobs and dsh-terminal now depend on dsh-llm. Refs #7869 --- docs/module-graph.md | 6 ++++-- docs/module-graph.zh.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index e5f11c68b4..32c55bbb1a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -716,6 +716,7 @@ flowchart TD pkg_user_questions --> pkg_typert_protocol pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand + pkg_jobs --> pkg_llm pkg_jobs --> pkg_session pkg_jobs --> pkg_workspace pkg_sandbox_policy --> pkg_agent @@ -738,6 +739,7 @@ flowchart TD pkg_working_directory --> pkg_system_prompt pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand + pkg_terminal --> pkg_llm pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm @@ -1760,12 +1762,12 @@ flowchart TD | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 62a0ff3872..dc35678e4e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -718,6 +718,7 @@ flowchart TD pkg_user_questions --> pkg_typert_protocol pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand + pkg_jobs --> pkg_llm pkg_jobs --> pkg_session pkg_jobs --> pkg_workspace pkg_sandbox_policy --> pkg_agent @@ -740,6 +741,7 @@ flowchart TD pkg_working_directory --> pkg_system_prompt pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand + pkg_terminal --> pkg_llm pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm @@ -1762,12 +1764,12 @@ flowchart TD | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | From 3368b86e2c0a025ec1c6d532836e321320e70913 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:12:48 +0800 Subject: [PATCH 08/13] refactor(tools): keep approval and context codes off the plugin entry The plugin entry may export only Loader fields and Service or Error classes, so the ApprovalError and ToolContextUnavailableError code constants move to an internal module; the recorded strings are unchanged. Refs #7869 --- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- packages/core/tools/src/contracts.ts | 16 +--------------- packages/core/tools/src/error-codes.ts | 21 +++++++++++++++++++++ packages/core/tools/src/pipeline.ts | 3 ++- packages/core/tools/tests/tools.spec.ts | 14 +++++++------- 8 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 packages/core/tools/src/error-codes.ts diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8a54502d88..a5fb682b48 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1308,8 +1308,8 @@ en: 0080c345c319a4b5 zh: de1a8b3ed621ce21 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: a8e8f9ff2de055f5 - zh: ed3f709744159461 + en: b2488366535b5fdc + zh: b64d50345521eb99 /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f4a35a4e48..6b80fbbc99 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5269,7 +5269,7 @@ Sources: [`packages/llm/llm/src/types.ts:134`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/contracts.ts:409`](../packages/core/tools/src/contracts.ts) +Sources: [`packages/core/tools/src/contracts.ts:395`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index bb5d5956db..c73b594c28 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5271,7 +5271,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/contracts.ts:409`](../packages/core/tools/src/contracts.ts) +来源:[`packages/core/tools/src/contracts.ts:395`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 7e9226a706..3a1b058f36 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -223,7 +223,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:497"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:409"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:395"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts index c774682666..51597b4107 100644 --- a/packages/core/tools/src/contracts.ts +++ b/packages/core/tools/src/contracts.ts @@ -1,5 +1,6 @@ /** Tool definitions and invocation data shared by registries and executors. @module */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import { TOOL_CONTEXT_UNAVAILABLE } from './error-codes.ts' import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent' import type { ScopeKey } from '@deepseek-ai/dsh-scope' @@ -266,18 +267,6 @@ export const TOOL_ABORTED = 'ABORTED' /** Canonical error code for cancellation before a tool body was invoked. */ export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' -/** Error name the registry records when approval does not grant a call. */ -export const APPROVAL_ERROR_NAME = 'ApprovalError' - -/** Approval code for a call the user rejected. */ -export const APPROVAL_REJECTED = 'APPROVAL_REJECTED' - -/** Approval code for an approval request cancelled without a caller cancellation. */ -export const APPROVAL_CANCELLED = 'APPROVAL_CANCELLED' - -/** Approval code for a call that needs approval when no approval service, Agent, or channel can answer it. */ -export const APPROVAL_UNAVAILABLE = 'APPROVAL_UNAVAILABLE' - /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -318,9 +307,6 @@ export class ToolNotFoundError extends HarnessError { } } -/** Code of {@link ToolContextUnavailableError}. */ -export const TOOL_CONTEXT_UNAVAILABLE = 'TOOL_CONTEXT_UNAVAILABLE' - /** * Thrown by a tool body when its composition or call lacks context the tool * requires, such as a calling Agent, an injected service, or an open turn. diff --git a/packages/core/tools/src/error-codes.ts b/packages/core/tools/src/error-codes.ts new file mode 100644 index 0000000000..5b4ebe0d61 --- /dev/null +++ b/packages/core/tools/src/error-codes.ts @@ -0,0 +1,21 @@ +/** + * Error identities the registry records on failed tool results. The values are + * persisted contract; the entry does not re-export them, and consumers compare + * the recorded strings. + * @module + */ + +/** Error name the registry records when approval does not grant a call. */ +export const APPROVAL_ERROR_NAME = 'ApprovalError' + +/** Approval code for a call the user rejected. */ +export const APPROVAL_REJECTED = 'APPROVAL_REJECTED' + +/** Approval code for an approval request cancelled without a caller cancellation. */ +export const APPROVAL_CANCELLED = 'APPROVAL_CANCELLED' + +/** Approval code for a call that needs approval when no approval service, Agent, or channel can answer it. */ +export const APPROVAL_UNAVAILABLE = 'APPROVAL_UNAVAILABLE' + +/** Code of `ToolContextUnavailableError`. */ +export const TOOL_CONTEXT_UNAVAILABLE = 'TOOL_CONTEXT_UNAVAILABLE' diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index ee694849e2..d640b88f53 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -7,7 +7,8 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-user-approval' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' +import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' +import { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE } from './error-codes.ts' import { validateJsonSchemaValue } from './json-schema.ts' import type { ToolsService } from './index.ts' import type { ResolvedToolSet, ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index e683bee631..8f4455c621 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' -import ToolRuntime, { TOOL_CONTEXT_UNAVAILABLE, ToolContextUnavailableError, APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { ToolContextUnavailableError, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -808,7 +808,7 @@ describe('ToolRuntime', () => { expect(result).toMatchObject({ content: [{ type: 'text', text: 'Error: needs-agent requires a calling agent' }], isError: true, - error: { info: { name: 'ToolContextUnavailableError', code: TOOL_CONTEXT_UNAVAILABLE } }, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, }) }) @@ -873,7 +873,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) - expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) + expect(result.error?.info).toEqual({ name: 'ApprovalError', code: 'APPROVAL_UNAVAILABLE' }) }) describe('ask routing through ctx.approval', () => { @@ -930,7 +930,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) - expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_REJECTED }) + expect(result.error?.info).toEqual({ name: 'ApprovalError', code: 'APPROVAL_REJECTED' }) }) it('denies with the cancellation reason on cancelled', async () => { @@ -941,7 +941,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) - expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_CANCELLED }) + expect(result.error?.info).toEqual({ name: 'ApprovalError', code: 'APPROVAL_CANCELLED' }) }) it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => { @@ -986,7 +986,7 @@ describe('ToolRuntime', () => { const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) - expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) + expect(result.error?.info).toEqual({ name: 'ApprovalError', code: 'APPROVAL_UNAVAILABLE' }) }) it('denies an agent-less execution without asking — nothing to route or audit through', async () => { @@ -1002,7 +1002,7 @@ describe('ToolRuntime', () => { expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) - expect(result.error?.info).toEqual({ name: APPROVAL_ERROR_NAME, code: APPROVAL_UNAVAILABLE }) + expect(result.error?.info).toEqual({ name: 'ApprovalError', code: 'APPROVAL_UNAVAILABLE' }) }) it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => { From ecae589b38a40dfda3b759597342a4922936e236 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:16:44 +0800 Subject: [PATCH 09/13] fix(repeat-tool-reminder): keep a downstream block's error info The reminder rebuilt a downstream post-execute block from its feedback and contexts, which would drop the block's new optional info. It now spreads the downstream decision, so a blocked call keeps its structured error identity when the reminder fires. Refs #7869 --- packages/guard/repeat-tool-reminder/README.i18n.yaml | 4 ++-- packages/guard/repeat-tool-reminder/README.md | 2 +- packages/guard/repeat-tool-reminder/README.zh.md | 2 +- packages/guard/repeat-tool-reminder/src/index.ts | 8 +------- .../tests/repeat-tool-reminder.spec.ts | 3 +++ 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/guard/repeat-tool-reminder/README.i18n.yaml b/packages/guard/repeat-tool-reminder/README.i18n.yaml index 862f1f4ddb..a2ab8510b3 100644 --- a/packages/guard/repeat-tool-reminder/README.i18n.yaml +++ b/packages/guard/repeat-tool-reminder/README.i18n.yaml @@ -36,8 +36,8 @@ en: b76a432aad14aa90 zh: b0cd916f7897fb42 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/reminder-delivery: - en: a3edc5c0f98b0d1e - zh: 173f6166ce3af779 + en: e11bb31ae82954cd + zh: 14f9c32ca93e7096 /deepseek-ai-dsh-repeat-tool-reminder/understand-the-implementation/source-map: en: ba2eb423e165510d zh: db5155503a8504cb diff --git a/packages/guard/repeat-tool-reminder/README.md b/packages/guard/repeat-tool-reminder/README.md index e50e47a8e8..2ae59c1dc7 100644 --- a/packages/guard/repeat-tool-reminder/README.md +++ b/packages/guard/repeat-tool-reminder/README.md @@ -88,7 +88,7 @@ Each agent's chain is keyed by `(tool name, canonical arguments)` — two calls ### Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array, so both decision variants (a blocked call included) still get the nudge while every entry retains its own source and metadata and a downstream block keeps its feedback and structured error identity. ### Source map diff --git a/packages/guard/repeat-tool-reminder/README.zh.md b/packages/guard/repeat-tool-reminder/README.zh.md index 75c2928967..da59e511e9 100644 --- a/packages/guard/repeat-tool-reminder/README.zh.md +++ b/packages/guard/repeat-tool-reminder/README.zh.md @@ -88,7 +88,7 @@ guard 建立在四项承诺之上: ### 提醒传递 -提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据。 +提醒随 post-execute 决策的 `additionalContexts`(来源为 `{kind: 'repeat-tool-reminder', form: 'notice', summary: ' × '}`)传递,绝不替换 `content`:用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后作为注入的 `user/message` 追加,会话将其渲染为普通的合成用户消息——模型可见、带有来源归属,且无需新会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并把提醒放在下游决策的上下文数组之前,因此两种决策变体(包括被阻止的调用)都会收到提醒,同时每个条目保留自己的来源与元数据,下游阻止也保留其反馈与结构化错误标识。 ### 源码地图 diff --git a/packages/guard/repeat-tool-reminder/src/index.ts b/packages/guard/repeat-tool-reminder/src/index.ts index a79722aad0..97a6766888 100644 --- a/packages/guard/repeat-tool-reminder/src/index.ts +++ b/packages/guard/repeat-tool-reminder/src/index.ts @@ -221,13 +221,7 @@ export function apply(ctx: Context, config: Config): void { const reminder = observe(exec) const downstream = await next() if (!reminder) return downstream - if (downstream.kind === 'block') { - return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) } - } - return { - ...downstream, - additionalContexts: prependContext(reminder, downstream.additionalContexts), - } + return { ...downstream, additionalContexts: prependContext(reminder, downstream.additionalContexts) } }) // A user interjection changes the context; repetition across it is not a diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index 251932d4b2..b21e0bf7d0 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -321,6 +321,7 @@ describe('fold onto the downstream decision', () => { ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'nope' }], + info: { name: 'DownstreamPolicyError', code: 'DOWNSTREAM_BLOCKED' }, additionalContexts: [createUserMessage({ content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'test' as const }, })], @@ -347,6 +348,8 @@ describe('fold onto the downstream decision', () => { const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.isError)).toBe(true) expect(results[1]!.data.message.content).toEqual([{ type: 'text', text: 'nope' }]) + // The block's structured error identity survives the reminder fold. + expect(results.map(r => r.data.error)).toEqual([0, 1].map(() => ({ name: 'DownstreamPolicyError', code: 'DOWNSTREAM_BLOCKED' }))) }) it('preserves a downstream canonical value replacement while folding', async () => { From 09604ac04d4f0a84f6add2c6e5397b049b6290cb Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:21:45 +0800 Subject: [PATCH 10/13] refactor(tools): tighten cancellation, approval, and job error contracts Type the approval denial helper with a closed ApprovalErrorCode, state that a cancellation is recognized by the signal reason or an Error named AbortError, note that ApprovalError names a denial rather than a thrown class, and separate JOB_CONTROLLER_UNAVAILABLE from a missing jobs service. The jobs README describes JobError as the seam's export rather than as current caller behavior, and the terminal test asserts one concurrent send failure. Refs #7869 --- ...2026-07-19-cooperative-tool-cancellation.i18n.yaml | 4 ++-- .../2026-07-19-cooperative-tool-cancellation.md | 2 +- .../2026-07-19-cooperative-tool-cancellation.zh.md | 2 +- .agents/skills/dsh-error-handling/SKILL.md | 4 ++-- .../dsh-error-handling/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/error-codes.ts | 3 +++ packages/core/tools/src/pipeline.ts | 11 ++++++----- packages/jobs/jobs/README.i18n.yaml | 4 ++-- packages/jobs/jobs/README.md | 2 +- packages/jobs/jobs/README.zh.md | 2 +- packages/jobs/jobs/src/error.ts | 3 ++- packages/terminal/terminal/tests/service.spec.ts | 2 +- 21 files changed, 35 insertions(+), 30 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 1cb3a2ce6c..cccd955846 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: 8dd2ee8bcc3bbadd - zh: 0792ddab00d72512 + en: 86c5566581c1d3d6 + zh: 3cb71fbb97bdb291 /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 9369c18c6b..3f4fbcc597 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 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. +`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 `Error` named `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 99cf662d77..15856a1cce 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`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。已开始的工具主体抛出 signal 的 reason 或不具备 `HarnessError` 身份的 `AbortError` 时,结果保留该 message,并携带 `{ name: 'AbortError', code: 'ABORTED' }`。拒绝、包装层失败、其他工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 +`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。已开始的工具主体抛出 signal 的 reason,或不具备 `HarnessError` 身份、name 为 `AbortError` 的 `Error` 时,结果保留该 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 8756f91030..d9adf1db0b 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`, guard denial, or post-execute block with `info` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), 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, and a guard denial or block without `info`, 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`, guard denial, or post-execute block with `info` (including the registry's approval denials, `ApprovalError` with `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`), the registry's own cancellation result (`AbortError` with `ABORTED` or `ABORTED_BEFORE_DISPATCH`, also applied to a signal reason or an `Error` named `AbortError` that the body throws after cancellation), or a `tools/execute` wrapper result such as the timeout policy's `TOOL_TIMEOUT`. Any other thrown value, and a guard denial or block without `info`, 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 `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`) from `dsh-tools`. -- 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. +- Caller cancellation either propagates the signal's reason or an `Error` named `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`, through `tools.guard()`, or by a post-execute block supplies `info` with its own `name` and `code`. 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 fb9cc36312..853dadd6ad 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: 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. +- 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 an `Error` named `AbortError` that a tool throws after cancellation, whichever operation inside the tool aborted; 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 69e442121d..fefaa9bfd1 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: 291be8e1926ba5f2 - zh: f5ff9c6648452161 + en: b81b03778cfdecde + zh: 83f0b7863a5217c6 /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 6917b1e612..f13c9f169f 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 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`. +- **Honor `exec.signal`.** Cancel in-flight work when it fires and let the signal's reason or a dependency's error named `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 9cdf5541dd..68ca1f0b61 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`。** 信号触发时取消进行中的工作,让 signal 的 reason 或依赖抛出的 `AbortError` 直接传播;注册表会把它记录为 `AbortError` 与 `TOOL_ABORTED` 常量(值为 `ABORTED`)。不要把它替换成另一个普通 `Error`。 +- **遵守 `exec.signal`。** 信号触发时取消进行中的工作,让 signal 的 reason 或依赖抛出的、name 为 `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 70666f46ac..7259adff93 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: be510bb2027f6d2b - zh: 1282ab990937f81d + en: 0900eea6efe5a500 + zh: 13fa6eb7dbd8c9dd /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 493bea368d..bc2aadbeca 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -532,7 +532,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`); 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`. An `ask` that approval does not grant becomes a denial with `ApprovalError` and `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. A guard denial or post-execute block carries `info` only when it supplies one; any other thrown value 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 `Error` named `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`. An `ask` that approval does not grant becomes a denial with the name `ApprovalError`, which identifies the registry's denial and is not a thrown class, and `APPROVAL_REJECTED`, `APPROVAL_CANCELLED`, or `APPROVAL_UNAVAILABLE`. A guard denial or post-execute block carries `info` only when it supplies one; any other thrown value 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 515fcd6142..0edd8063a6 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -532,7 +532,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`);已开始的工具主体在取消后抛出 signal 的 reason 或 `AbortError` 时,结果保留其 message,并带上 `AbortError` 与 `ABORTED`;`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。审批未授予的 `ask` 会变成带 `ApprovalError` 的拒绝,code 为 `APPROVAL_REJECTED`、`APPROVAL_CANCELLED` 或 `APPROVAL_UNAVAILABLE`。guard 拒绝或 post-execute block 只有自带 `info` 时才会携带它;其他抛出值产生的 `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 或 name 为 `AbortError` 的 `Error` 时,结果保留其 message,并带上 `AbortError` 与 `ABORTED`;`tools/execute` 包装器也可能返回带 `info` 的结果,例如超时策略的 `TOOL_TIMEOUT`。审批未授予的 `ask` 会变成 name 为 `ApprovalError` 的拒绝(该 name 标识注册表的拒绝,并不对应可抛出的类),code 为 `APPROVAL_REJECTED`、`APPROVAL_CANCELLED` 或 `APPROVAL_UNAVAILABLE`。guard 拒绝或 post-execute block 只有自带 `info` 时才会携带它;其他抛出值产生的 `error` 不含 `info`。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器,并为原生与嵌套调用执行可选的展示元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 996df18d4f..e5f5ba4c3e 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: 41f1c678aba0056e - zh: 7976fdd8aced1cc5 + en: c6db8efb31370459 + zh: 07aec8a6230d943a /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 178cd0e402..9d40ba281c 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`, 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. +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 `Error` named `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 a1c351b7c8..9de1e6d70b 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`,抛出的 signal reason 或不具备 `HarnessError` 身份的 `AbortError` 保留其 message 并携带 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败与超时产生的 `TOOL_TIMEOUT` 仍保留更具体的结果。未知工具与抛出异常的工具都会变成结构化错误(`UNKNOWN_TOOL`),因此调用会失败而不会结束轮次。 +每次类型化调用都会实体化并冻结解析后的参数、分配不透明关联 token,再运行策略与分发。pre-execute 拒绝可以在模型可见原因旁附带 `ToolErrorInfo`;原生与 PTC 持久投影会保留结构化名称、代码与可选用户可见原因,但不会把该详情加入模型内容。取消采用协作式并等待完全停稳:每个工具主体都收到调用方拥有的 `exec.signal` 且必须观测它;调用主体前的取消为 `ABORTED_BEFORE_DISPATCH`,调用主体后的取消只能把成功结果替换为 `ABORTED`,抛出的 signal reason,或不具备 `HarnessError` 身份、name 为 `AbortError` 的 `Error`,保留其 message 并携带 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败与超时产生的 `TOOL_TIMEOUT` 仍保留更具体的结果。未知工具与抛出异常的工具都会变成结构化错误(`UNKNOWN_TOOL`),因此调用会失败而不会结束轮次。 ### PTC mode diff --git a/packages/core/tools/src/error-codes.ts b/packages/core/tools/src/error-codes.ts index 5b4ebe0d61..1038854a96 100644 --- a/packages/core/tools/src/error-codes.ts +++ b/packages/core/tools/src/error-codes.ts @@ -17,5 +17,8 @@ export const APPROVAL_CANCELLED = 'APPROVAL_CANCELLED' /** Approval code for a call that needs approval when no approval service, Agent, or channel can answer it. */ export const APPROVAL_UNAVAILABLE = 'APPROVAL_UNAVAILABLE' +/** One code an approval denial records under {@link APPROVAL_ERROR_NAME}. */ +export type ApprovalErrorCode = typeof APPROVAL_REJECTED | typeof APPROVAL_CANCELLED | typeof APPROVAL_UNAVAILABLE + /** Code of `ToolContextUnavailableError`. */ export const TOOL_CONTEXT_UNAVAILABLE = 'TOOL_CONTEXT_UNAVAILABLE' diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index d640b88f53..1a13c08748 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -8,7 +8,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-user-approval' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' -import { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE } from './error-codes.ts' +import { APPROVAL_CANCELLED, APPROVAL_ERROR_NAME, APPROVAL_REJECTED, APPROVAL_UNAVAILABLE, type ApprovalErrorCode } from './error-codes.ts' import { validateJsonSchemaValue } from './json-schema.ts' import type { ToolsService } from './index.ts' import type { ResolvedToolSet, ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' @@ -255,9 +255,10 @@ 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. + * After cancellation, a thrown signal reason or an `Error` named + * `AbortError` without `HarnessError` identity keeps its message and gains + * the `ABORTED` identity, whichever operation inside the body aborted; a + * thrown `HarnessError` and any other failure stay unchanged. */ private async dispatchToolBody(exec: MutableToolRunContext): Promise { const state = this.cancellationStates.get(exec) @@ -695,7 +696,7 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { } /** Materialize a non-granting approval outcome as a denial with routable `ApprovalError` identity. */ -function approvalDenial(reason: string, code: string): Extract { +function approvalDenial(reason: string, code: ApprovalErrorCode): Extract { return { kind: 'deny', reason, info: { name: APPROVAL_ERROR_NAME, code } } } diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index e80181dfbe..1d2665d284 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -30,8 +30,8 @@ en: cce846c7e3b0c33e zh: 17f353d970b4216d /deepseek-ai-dsh-jobs/use-this-package/what-can-go-wrong: - en: b7b06ef24f092a55 - zh: 52bcd65939ec8b18 + en: 11a642faa1856789 + zh: ffdc16cd7214cff7 /deepseek-ai-dsh-jobs/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 868accf406..ae873d7937 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -54,7 +54,7 @@ Loading these two plugins on a harness base that already provides the agent, too ### What can go wrong -Any preflight rejection leaves no job id or registered work. Implementations and job tools report expected job failures as `JobError`, a `HarnessError` whose code is one of `JOB_ERROR_CODES` (`JOB_NOT_FOUND`, `JOB_FOREIGN_SESSION`, `JOB_LIMIT_REACHED`, and others), so a tool result keeps `error.name` and `error.code`. Jobs managed by the shipped in-process registry die with the harness process; durable execution across restarts needs a different backend implementing this contract. +Any preflight rejection leaves no job id or registered work. The seam exports `JobError`, a `HarnessError` whose code is one of `JOB_ERROR_CODES` (`JOB_NOT_FOUND`, `JOB_FOREIGN_SESSION`, `JOB_LIMIT_REACHED`, and others), for implementations and job tools to report expected job failures with a routable `error.name` and `error.code`. Jobs managed by the shipped in-process registry die with the harness process; durable execution across restarts needs a different backend implementing this contract. ----- diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 9c04d0704c..49a2e1e2e8 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 可能出什么问题 -任何预检拒绝都不会留下 job id 或已注册的工作。实现和任务工具用 `JobError` 报告预期的任务失败;它是 `HarnessError`,code 取自 `JOB_ERROR_CODES`(如 `JOB_NOT_FOUND`、`JOB_FOREIGN_SESSION`、`JOB_LIMIT_REACHED`),因此工具结果会保留 `error.name` 和 `error.code`。由随附的进程内注册表管理的任务会随 harness 进程终止而消失;跨重启的持久执行需要一个实现本约定的不同后端。 +任何预检拒绝都不会留下 job id 或已注册的工作。接缝导出 `JobError`:它是 `HarnessError`,code 取自 `JOB_ERROR_CODES`(如 `JOB_NOT_FOUND`、`JOB_FOREIGN_SESSION`、`JOB_LIMIT_REACHED`),供实现和任务工具报告预期的任务失败,使结果带有可路由的 `error.name` 和 `error.code`。由随附的进程内注册表管理的任务会随 harness 进程终止而消失;跨重启的持久执行需要一个实现本约定的不同后端。 ----- diff --git a/packages/jobs/jobs/src/error.ts b/packages/jobs/jobs/src/error.ts index 366d16dd98..78a210d907 100644 --- a/packages/jobs/jobs/src/error.ts +++ b/packages/jobs/jobs/src/error.ts @@ -5,7 +5,8 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** * Every {@link JobError} code: * - `JOB_BACKGROUND_DISABLED` — the calling tool's configuration turns background execution off. - * - `JOB_CONTROLLER_UNAVAILABLE` — no attached job controller serves the job's owner. + * - `JOB_CONTROLLER_UNAVAILABLE` — the jobs service is composed but no attached job controller serves the + * job's owner. A call whose composition lacks the jobs service itself throws `ToolContextUnavailableError`. * - `JOB_NOT_FOUND` — the job id names no listed job. * - `JOB_FOREIGN_SESSION` — the job belongs to another Session. * - `JOB_OWNER_NOT_LIVE` — the owning Agent is no longer live. diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 6baf9350b2..935c699738 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -194,13 +194,13 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(ctx.terminals.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' }) const operation = ctx.terminals.startSend(owner, created.sessionId, { text: 'echo hi', submit: true }) - expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(TerminalError) let concurrent: unknown try { ctx.terminals.startSend(owner, created.sessionId, { text: 'pwd', submit: true }) } catch (error: unknown) { concurrent = error } + expect(concurrent).toBeInstanceOf(TerminalError) expect(concurrent).toBeInstanceOf(HarnessError) expect(concurrent).toMatchObject({ name: 'TerminalError', code: 'SEND_ACTIVE' }) expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false }) From 71cb6c739a8fd0c93a53a2f875fee0846c037e6e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 21:26:38 +0800 Subject: [PATCH 11/13] refactor(error): move HarnessError to the dependency-free dsh-error package dsh-llm depends on dsh-attachment and dsh-typert-protocol, so classes in those packages could not extend a base defined in dsh-llm without a project-reference cycle. HarnessError now lives in @deepseek-ai/dsh-error, which has no dependencies; dsh-llm declares it as a peer and re-exports the same class, so existing imports and instanceof checks keep working. The Agent Note records why the original leaf-package placement no longer holds. Refs #7869 --- ...rness-error-dependency-free-base.i18n.yaml | 19 +++++ ...1-05-harness-error-dependency-free-base.md | 31 ++++++++ ...5-harness-error-dependency-free-base.zh.md | 31 ++++++++ .agents/skills/dsh-error-handling/SKILL.md | 2 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + packages/llm/llm/package.json | 2 + packages/llm/llm/src/error.ts | 22 ++---- packages/llm/llm/tsconfig.json | 3 + packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/error/README.i18n.yaml | 34 +++++++++ packages/util/error/README.md | 76 +++++++++++++++++++ packages/util/error/README.zh.md | 76 +++++++++++++++++++ packages/util/error/package.json | 38 ++++++++++ packages/util/error/src/index.ts | 22 ++++++ packages/util/error/tests/error.spec.ts | 19 +++++ packages/util/error/tsconfig.json | 10 +++ .../workflow/workflow-ptc/src/guest-source.ts | 2 +- pnpm-lock.yaml | 9 +++ scripts/doc-standard.spec.ts | 1 + scripts/package-dependency-policy.ts | 2 + scripts/verify-package-readme-limitations.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 29 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md create mode 100644 .agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.zh.md create mode 100644 packages/util/error/README.i18n.yaml create mode 100644 packages/util/error/README.md create mode 100644 packages/util/error/README.zh.md create mode 100644 packages/util/error/package.json create mode 100644 packages/util/error/src/index.ts create mode 100644 packages/util/error/tests/error.spec.ts create mode 100644 packages/util/error/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.i18n.yaml new file mode 100644 index 0000000000..25c6eefe08 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-05-harness-error-dependency-free-base.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md +/agent-note-keep-the-harnesserror-base-in-a-dependency-free-package: + en: 6ff178be521c3318 + zh: 18a9a8cc261fe975 +/agent-note-keep-the-harnesserror-base-in-a-dependency-free-package/problem: + en: 29880462facde90c + zh: d07805b2000c85b1 +/agent-note-keep-the-harnesserror-base-in-a-dependency-free-package/decision: + en: caec42054c040d37 + zh: d726f0da57dc1e68 +/agent-note-keep-the-harnesserror-base-in-a-dependency-free-package/alternatives-considered: + en: 8245e473d4e90250 + zh: fe7883dfe13070a9 +/agent-note-keep-the-harnesserror-base-in-a-dependency-free-package/consequences: + en: 9a577f5c3bb9fa15 + zh: 4c18ec885768b9d3 diff --git a/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md new file mode 100644 index 0000000000..9c79c374f2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md @@ -0,0 +1,31 @@ +# Agent Note: Keep the HarnessError base in a dependency-free package + +Status: implemented + +English | [中文](2026-11-05-harness-error-dependency-free-base.zh.md) + +## Problem + +The tool registry records a failed result's `error.name` and `error.code` only when the thrown value is `instanceof HarnessError`, so every expected failure must extend the one shared class; copying its `code` field is not enough. The [archived structured-error-taxonomy note](../../archived/architecture/2026-06-11-structured-error-taxonomy.md) placed the base in `dsh-llm` because `dsh-llm` was then a leaf that every package already imported, so the base added no dependency edge. + +`dsh-llm` stopped being a leaf. `ImageBlock` references `ImageAttachmentRef` from `dsh-attachment`, and `dsh-llm` depends on `dsh-typert-protocol`. A class in either lower package cannot extend a base defined in `dsh-llm` without a TypeScript project-reference cycle, so `AttachmentError` and `RemoteError` re-implement the `code` field on plain `Error`, and tool failures that carry them persist without structured identity. + +## Decision + +`HarnessError` lives in `@deepseek-ai/dsh-error` (`packages/util/error`), a package with no dependencies that contains only the base class. `dsh-llm` depends on it and re-exports the same class from its entry, so existing `import { HarnessError } from '@deepseek-ai/dsh-llm'` statements keep working and both import paths name one constructor, which keeps `instanceof` checks consistent. LLM-specific failure codes and helpers such as `isHarnessError` stay in `dsh-llm`. + +New code imports `HarnessError` from `dsh-error` when the importing package does not otherwise need `dsh-llm`, so a package that needs only the base takes no dependency on the LLM runtime. Packages below `dsh-llm`, such as `dsh-attachment`, can extend the base without a cycle. + +## Alternatives considered + +**Keep the base in `dsh-llm`.** Lower packages would keep re-implementing the field shape, and the registry would keep dropping their codes; recognizing those shapes structurally in the registry would make it trust any object with a string `code`, including Node errno values. + +**Recognize specific classes in the registry.** Matching `RemoteError` or `AttachmentError` by marker would add a dependency from `dsh-tools` to each package and repeat for every new low-level error class. + +**Move the base to an existing utility package.** `dsh-util-values` and `dsh-brand` have unrelated roles; a dedicated package names the role and keeps the base free of their dependencies and change history. + +**Drop the `dsh-llm` re-export.** Every existing import would have to change in one step without a behavior benefit. + +## Consequences + +Any package can extend `HarnessError`, including packages that `dsh-llm` depends on, and the base no longer changes with the LLM runtime. The cost is one more published package and one more dependency edge for `dsh-llm`. Two import paths exist for one class; both are supported, and the error-handling rules name `dsh-error` as the home. diff --git a/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.zh.md b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.zh.md new file mode 100644 index 0000000000..8ff0c999bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 把 HarnessError 基类放在无依赖的包中 + +Status: implemented + +[English](2026-11-05-harness-error-dependency-free-base.md) | 中文 + +## 问题 + +工具注册表只有在抛出值 `instanceof HarnessError` 时,才会在失败结果上记录 `error.name` 与 `error.code`,因此每种预期失败都必须继承这一个共享类;只复制它的 `code` 字段不够。[已归档的结构化错误分类决策](../../archived/architecture/2026-06-11-structured-error-taxonomy.md)把基类放在 `dsh-llm` 中,因为当时 `dsh-llm` 是所有包都已导入的叶子包,放在那里不会增加依赖边。 + +`dsh-llm` 后来不再是叶子包。`ImageBlock` 引用了 `dsh-attachment` 的 `ImageAttachmentRef`,`dsh-llm` 还依赖 `dsh-typert-protocol`。这两个更底层的包中的类无法继承定义在 `dsh-llm` 里的基类,否则 TypeScript 项目引用会成环,因此 `AttachmentError` 与 `RemoteError` 只能在普通 `Error` 上重复实现 `code` 字段,携带它们的工具失败在持久化时没有结构化身份。 + +## 决策 + +`HarnessError` 位于 `@deepseek-ai/dsh-error`(`packages/util/error`)。这个包没有任何依赖,只包含该基类。`dsh-llm` 依赖它,并从自身入口重新导出同一个类,因此现有的 `import { HarnessError } from '@deepseek-ai/dsh-llm'` 继续可用,两种导入路径指向同一个构造函数,`instanceof` 判断保持一致。LLM 专属的失败码以及 `isHarnessError` 等辅助函数仍留在 `dsh-llm`。 + +当导入方本身不需要 `dsh-llm` 时,新代码从 `dsh-error` 导入 `HarnessError`,这样只需要基类的包不必依赖 LLM 运行时。`dsh-attachment` 等位于 `dsh-llm` 之下的包可以继承该基类而不形成依赖环。 + +## 考虑过的替代方案 + +**把基类留在 `dsh-llm`。** 底层包会继续重复实现字段结构,注册表也会继续丢弃它们的 code;如果让注册表按结构识别这类对象,它就会信任任何带字符串 `code` 的对象,包括 Node errno 值。 + +**在注册表中识别特定类。** 按标记匹配 `RemoteError` 或 `AttachmentError` 会让 `dsh-tools` 依赖每个这样的包,而且每出现一个新的底层错误类都要重复一次。 + +**把基类移到现有的工具包。** `dsh-util-values` 与 `dsh-brand` 的职责与此无关;独立的包能准确命名职责,也让基类不受它们的依赖与变更历史影响。 + +**去掉 `dsh-llm` 的重新导出。** 所有现有导入都得一次性修改,却不带来任何行为收益。 + +## 后果 + +任何包都可以继承 `HarnessError`,包括 `dsh-llm` 所依赖的包,基类也不再随 LLM 运行时一起变更。代价是多发布一个包,`dsh-llm` 多一条依赖边。同一个类有两种导入路径,两者都受支持,错误处理规则把 `dsh-error` 作为它的归属位置。 diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md index d9adf1db0b..930c19adfd 100644 --- a/.agents/skills/dsh-error-handling/SKILL.md +++ b/.agents/skills/dsh-error-handling/SKILL.md @@ -11,7 +11,7 @@ Define failures, their owners, and what remains safe after failure. Expected failures need declared types, stable discriminants, and handling-relevant details. Reuse package error conventions, preserve causes, and separate machine identity from display text. Never route handling by parsing messages. Unexpected defects such as `assertNever` may remain plain `Error`; containment must never turn them into success. -An error class that carries a machine code extends `HarnessError` from `dsh-llm`, directly or through its package's error class, never bare `Error`. Prefer one error class per owning package or domain over a class per failure; a separate class is justified when callers catch it by type or it carries its own structured fields, such as `ToolArgsError.violations`. `name` is the class name, except for cancellation (below), and `code` values are stable `UPPER_SNAKE_CASE` strings, usually prefixed by the domain and declared as a union or constants beside the class. A code names the failure class its consumers act on, not the call site; add a code rather than reuse one whose meaning differs. The [error class and code guide](references/error-codes.md) defines class choice, naming, the standard conditions, declaration, foreign-error conversion, and compatibility. +An error class that carries a machine code extends `HarnessError` from `dsh-error` (also re-exported by `dsh-llm`), directly or through its package's error class, never bare `Error`. Prefer one error class per owning package or domain over a class per failure; a separate class is justified when callers catch it by type or it carries its own structured fields, such as `ToolArgsError.violations`. `name` is the class name, except for cancellation (below), and `code` values are stable `UPPER_SNAKE_CASE` strings, usually prefixed by the domain and declared as a union or constants beside the class. A code names the failure class its consumers act on, not the call site; add a code rather than reuse one whose meaning differs. The [error class and code guide](references/error-codes.md) defines class choice, naming, the standard conditions, declaration, foreign-error conversion, and compatibility. Convert foreign failures where the dependency is owned. Node errno, SDK, protocol, native, worker, and cross-realm errors become the owning package's error with the original as `cause`; a foreign numeric or vendor code belongs in the message, not in `code`. Preserve structure across layers: never re-wrap a `HarnessError` in a plain `Error`, store only its message for a later rethrow, or drop its `name` and `code` while redacting text. A process or worker boundary transports `name` and `code` and reconstructs the error. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 373e60cfb8..7edf100e3d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -5157,6 +5157,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | +| `@deepseek-ai/dsh-error` | — | [`packages/util/error/src/index.ts`](../packages/util/error/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5cc92c982c..c9ed22e1d4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -5159,6 +5159,7 @@ export interface Config { | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | +| `@deepseek-ai/dsh-error` | — | [`packages/util/error/src/index.ts`](../packages/util/error/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 8de69f193c..aaf8663092 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -12,6 +12,7 @@ flowchart TD pkg_brand["brand"] pkg_chunked_list["chunked-list"] pkg_deque["deque"] + pkg_error["error"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] pkg_launch_environment["launch-environment"] @@ -1542,6 +1543,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | — | | [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | +| [`error`](../packages/util/error) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | | [`launch-environment`](../packages/util/launch-environment) | `util` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a0f670e64a..086ceb919f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -14,6 +14,7 @@ flowchart TD pkg_brand["brand"] pkg_chunked_list["chunked-list"] pkg_deque["deque"] + pkg_error["error"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] pkg_launch_environment["launch-environment"] @@ -1544,6 +1545,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | — | | [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | +| [`error`](../packages/util/error) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | | [`launch-environment`](../packages/util/launch-environment) | `util` | — | diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 2a15031cb9..34334d64df 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -62,6 +62,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { @@ -75,6 +76,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index dd49f5f1be..ecf877e061 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -1,25 +1,13 @@ /** - * Harness error base with a stable machine-routable code and chained cause. - * Package errors extend it so tool results and replay can retain failure class. + * LLM failure codes and helpers over the shared Harness error base. The base + * class lives in `@deepseek-ai/dsh-error` and is re-exported here, so both + * import paths name the same class. * @module @deepseek-ai/dsh-llm/error */ -/** - * Base class for all harness errors. Carries a `code` (stable, programmatic — - * e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the - * human-readable `message`, and supports `cause` chaining via the standard - * `ErrorOptions`. `name` defaults to the subclass constructor name. - */ -export class HarnessError extends Error { - /** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */ - readonly code: string +import { HarnessError } from '@deepseek-ai/dsh-error' - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, options) - this.code = code - this.name = new.target.name - } -} +export { HarnessError } /** * Every failure code the built-in providers and the LLM runtime raise. An entry diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 64a44424e0..44a433caca 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/error" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index e60eafcfac..f726477c4b 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: 5d7eb32e5a9e81dc - zh: 5e419d4b44ffbd10 + en: e345b399695a4e93 + zh: 179fee3e2c4d90cc /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index 70d238ab01..ac3e59f3a1 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -27,6 +27,7 @@ Each package provides one primitive or one data set; open a package page for how | Package | Role | |---|---| | [`brand/`](brand/README.md) | Nominal string types and their stateless constructor | +| [`error/`](error/README.md) | The dependency-free `HarnessError` base class with a stable machine-routable code | | [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests | | [`plugin-detail/`](plugin-detail/README.md) | Selects plugin detail documents and reads them from local packages and npm file CDNs | | [`plugin-market-index/`](plugin-market-index/README.md) | Validates plugin market index format 1 and builds pinned install arguments | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index dbd0021390..e59f702191 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -27,6 +27,7 @@ kind: "package-group" | 包 | 职责 | |---|---| | [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 | +| [`error/`](error/README.zh.md) | 无依赖、带稳定可路由 code 的 `HarnessError` 基类 | | [`package-manifest/`](package-manifest/README.zh.md) | 包 manifest(元数据清单)的共享 TypeScript 声明 | | [`plugin-detail/`](plugin-detail/README.zh.md) | 选择插件详细介绍文档,并从本地包和 npm 文件 CDN 读取它们 | | [`plugin-market-index/`](plugin-market-index/README.zh.md) | 校验插件市场索引格式 1,并生成锁定版本的安装参数 | diff --git a/packages/util/error/README.i18n.yaml b/packages/util/error/README.i18n.yaml new file mode 100644 index 0000000000..4e75502d84 --- /dev/null +++ b/packages/util/error/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/error/README.md +/: + en: 765d60b0cc7f8cf1 + zh: 66b731f7143ccc5c +/deepseek-ai-dsh-error: + en: 78fc89983568c13f + zh: 12bb37d45b49562b +/deepseek-ai-dsh-error/summary: + en: eefb4278f326243a + zh: 681e25fc7d65865e +/deepseek-ai-dsh-error/table-of-contents: + en: c992e0cd2e7042d6 + zh: 2e8cd826664632d1 +/deepseek-ai-dsh-error/use-this-package: + en: 1b8c9b68ec9b48fc + zh: 4b0b54d3330b288b +/deepseek-ai-dsh-error/understand-the-implementation: + en: 42b15ac6321706aa + zh: 4ed0861911abaf5d +/deepseek-ai-dsh-error/understand-the-implementation/source-map: + en: a2e80abfe169f97f + zh: eb37e22c32f66add +/deepseek-ai-dsh-error/understand-the-implementation/why-it-stays-dependency-free: + en: 79564181c4c59db6 + zh: 845856fa121b3ff7 +/deepseek-ai-dsh-error/further-exploration: + en: 530bc3a0690a4012 + zh: 192682c5a8571dfe +/deepseek-ai-dsh-error/dev-note: + en: 5de1d6304e00c58f + zh: 0087361e6f535488 diff --git a/packages/util/error/README.md b/packages/util/error/README.md new file mode 100644 index 0000000000..f630e4173a --- /dev/null +++ b/packages/util/error/README.md @@ -0,0 +1,76 @@ +--- +description: "Dependency-free HarnessError base class whose stable code lets tool results, retries, and replay route failures." +kind: "package-library" +--- + +# @deepseek-ai/dsh-error + +English | [中文](README.zh.md) + +## Summary + +`dsh-error` holds `HarnessError`, the base class for every expected Harness failure. A `HarnessError` carries a stable machine-routable `code` beside its human-readable `message` and optional `cause`, and its `name` is the constructing class name. The tool registry records `name` and `code` of a thrown `HarnessError` on `tool/result`, so retry policy, UI, replay, and analytics route on the code instead of parsing text. The package has no dependencies, so packages below `dsh-llm` in the dependency graph can extend the base too; `dsh-llm` re-exports the same class. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Extend `HarnessError` with one class per owning package or domain and give each failure a stable `UPPER_SNAKE_CASE` code: + +```ts +import { HarnessError } from '@deepseek-ai/dsh-error' + +export class ArchiveError extends HarnessError {} + +throw new ArchiveError('archive entry is missing', 'ARCHIVE_ENTRY_NOT_FOUND') +``` + +Assign `this.name` a string literal in a subclass constructor when a bundler could rename the class, because `name` is persisted. Import the class from `@deepseek-ai/dsh-error` or from its `@deepseek-ai/dsh-llm` re-export; both name the same class, so `instanceof` checks agree. The [error class and code guide](../../../.agents/skills/dsh-error-handling/references/error-codes.md) defines class choice, naming, and code conventions. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | The `HarnessError` base class | + +### Why it stays dependency-free + +`dsh-llm` depends on lower packages such as `dsh-attachment` and `dsh-typert-protocol`, so a base class defined there could not be extended below it without a dependency cycle. Keeping the class in a package without dependencies lets every package extend it; the [decision note](../../../.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.md) records the relocation. + +
+ +----- + + +## Further Exploration + +- [Tools subsystem](../../../docs/subsystems/tools.md) — how the registry records `ToolErrorInfo` from a thrown `HarnessError`. +- [Error-handling rules](../../../.agents/skills/dsh-error-handling/SKILL.md) — when code must throw a `HarnessError`. + +----- + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/error/README.zh.md b/packages/util/error/README.zh.md new file mode 100644 index 0000000000..2703d929e7 --- /dev/null +++ b/packages/util/error/README.zh.md @@ -0,0 +1,76 @@ +--- +description: "无依赖的 HarnessError 基类,其稳定 code 让工具结果、重试与回放能够按失败类别路由。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-error + +[English](README.md) | 中文 + +## 概述 + +`dsh-error` 存放 `HarnessError`,即所有预期 Harness 失败的基类。`HarnessError` 在人类可读的 `message` 与可选的 `cause` 之外,携带稳定、可供机器路由的 `code`,其 `name` 为构造它的类名。工具注册表会把抛出的 `HarnessError` 的 `name` 与 `code` 记录到 `tool/result` 上,使重试策略、UI、回放与数据分析按 code 路由,而不必解析文本。本包没有任何依赖,因此依赖图中位于 `dsh-llm` 之下的包也能继承该基类;`dsh-llm` 会重新导出同一个类。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +每个所属包或领域继承出一个 `HarnessError` 子类,并为每种失败指定稳定的 `UPPER_SNAKE_CASE` code: + +```ts +import { HarnessError } from '@deepseek-ai/dsh-error' + +export class ArchiveError extends HarnessError {} + +throw new ArchiveError('archive entry is missing', 'ARCHIVE_ENTRY_NOT_FOUND') +``` + +打包工具可能重命名类时,在子类构造函数中把 `this.name` 赋为字符串字面量,因为 `name` 会被持久化。可以从 `@deepseek-ai/dsh-error` 导入该类,也可以使用 `@deepseek-ai/dsh-llm` 的重新导出;两者是同一个类,因此 `instanceof` 判断结果一致。[错误类与 code 指南](../../../.agents/skills/dsh-error-handling/references/error-codes.md)规定了类的选择、命名与 code 约定。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `HarnessError` 基类 | + +### 为什么保持无依赖 + +`dsh-llm` 依赖 `dsh-attachment`、`dsh-typert-protocol` 等更底层的包,定义在其中的基类无法被这些包继承,否则会形成依赖环。把该类放在没有依赖的包中,所有包都可以继承它;[决策记录](../../../.agents/notes/implemented/architecture/2026-11-05-harness-error-dependency-free-base.zh.md)记录了这次迁移。 + +
+ +----- + + +## 进一步探索 + +- [工具子系统](../../../docs/subsystems/tools.zh.md) — 注册表如何根据抛出的 `HarnessError` 记录 `ToolErrorInfo`。 +- [错误处理规则](../../../.agents/skills/dsh-error-handling/SKILL.md) — 何时必须抛出 `HarnessError`。 + +----- + + +## 开发备注 + +
+维护者的工作背景——点击展开 + +无。 + +
diff --git a/packages/util/error/package.json b/packages/util/error/package.json new file mode 100644 index 0000000000..01ed7ece93 --- /dev/null +++ b/packages/util/error/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-error", + "description": "Dependency-free HarnessError base class with a stable machine-routable code for the DeepSeek Harness", + "version": "0.2.2-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/error" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "lib/**/*.js.map", + "src" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/util/error/src/index.ts b/packages/util/error/src/index.ts new file mode 100644 index 0000000000..dac2a1b412 --- /dev/null +++ b/packages/util/error/src/index.ts @@ -0,0 +1,22 @@ +/** + * Harness error base with a stable machine-routable code and chained cause. + * Package errors extend it so tool results and replay can retain failure class. + * @module @deepseek-ai/dsh-error + */ + +/** + * Base class for all harness errors. Carries a `code` (stable, programmatic — + * e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the + * human-readable `message`, and supports `cause` chaining via the standard + * `ErrorOptions`. `name` defaults to the subclass constructor name. + */ +export class HarnessError extends Error { + /** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */ + readonly code: string + + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.code = code + this.name = new.target.name + } +} diff --git a/packages/util/error/tests/error.spec.ts b/packages/util/error/tests/error.spec.ts new file mode 100644 index 0000000000..2012fbc596 --- /dev/null +++ b/packages/util/error/tests/error.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { HarnessError } from '../src/index.ts' + +class SampleError extends HarnessError {} + +describe('HarnessError', () => { + it('carries a code, a cause, and the constructing class name', () => { + const cause = new Error('underlying failure') + const error = new SampleError('operation failed', 'SAMPLE_FAILED', { cause }) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'SampleError', code: 'SAMPLE_FAILED', message: 'operation failed', cause }) + }) + + it('names a direct instance HarnessError', () => { + expect(new HarnessError('failed', 'FAILED').name).toBe('HarnessError') + }) +}) diff --git a/packages/util/error/tsconfig.json b/packages/util/error/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/error/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/packages/workflow/workflow-ptc/src/guest-source.ts b/packages/workflow/workflow-ptc/src/guest-source.ts index 64f04db286..52313de66a 100644 --- a/packages/workflow/workflow-ptc/src/guest-source.ts +++ b/packages/workflow/workflow-ptc/src/guest-source.ts @@ -1,4 +1,4 @@ /** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */ /** Self-contained ESM guest transported into the mounted PTC runtime. */ -export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\n/**\n* Every failure code the built-in providers and the LLM runtime raise. An entry\n* marked `retryChoice` is offered as a choice for a provider retry policy's\n* `retryableCodes`; being a choice does not make a code retried by default.\n*/\nconst LLM_FAILURE_CODES = {\n\tABORTED: {},\n\tACCOUNT_QUOTA: {},\n\tACCOUNT_SIGN_IN_REQUIRED: {},\n\tACCOUNT_TOKEN_INVALID: {},\n\tAUTH: {},\n\tCLIENT_UPGRADE_REQUIRED: {},\n\tCONTEXT_WINDOW_EXCEEDED: {},\n\tDISCOVERY_FAILED: {},\n\tDISCOVERY_UNSUPPORTED: {},\n\tDUPLICATE_ADAPTER: {},\n\tDUPLICATE_DIRECTORY: {},\n\tDUPLICATE_DISCOVERY: {},\n\tEMPTY_RESPONSE: { retryChoice: true },\n\tFILES_API: {},\n\tIMAGE_OFFLOAD_REQUIRED: {},\n\tINVALID_ADAPTER: {},\n\tINVALID_CATALOG: {},\n\tINVALID_CONFIG: {},\n\tINVALID_CREDENTIAL: {},\n\tINVALID_DIRECTORY: {},\n\tINVALID_DISCOVERY: {},\n\tINVALID_MODEL_CONTEXT: {},\n\tINVALID_MODEL_INFO: {},\n\tINVALID_MODEL_MAX_TOKENS: {},\n\tINVALID_MODEL_REASONING: {},\n\tINVALID_PREPARED_CALL: {},\n\tINVALID_REPLAY_STATE: {},\n\tINVALID_REQUEST: {},\n\tINVALID_RESPONSE: {},\n\tMALFORMED_RESPONSE: { retryChoice: true },\n\tMISSING_CREDENTIAL: {},\n\tMODEL_SWITCH_COMPACTION_FAILED: {},\n\tMODEL_UNSUPPORTED: {},\n\tNO_ADAPTER: {},\n\tNO_CREDENTIAL_STORE: {},\n\tNO_DISCOVERY: {},\n\tPI_AI_ERROR: {},\n\tQUOTA: { retryChoice: true },\n\tRATE_LIMIT: { retryChoice: true },\n\tREGISTRATION_DISPOSED: {},\n\tREQUEST_EXTENSION: {},\n\tREQUEST_HEADERS: {},\n\tSERVER: { retryChoice: true },\n\tSTREAM_CLOSED: { retryChoice: true },\n\tTIMEOUT: { retryChoice: true },\n\tTRANSPORT: { retryChoice: true },\n\tUNKNOWN: {},\n\tUNKNOWN_MODEL: {},\n\tUNSTORABLE_PROVIDER_ID: {},\n\tUNSUPPORTED_CONTENT: {},\n\tUNSUPPORTED_OPTION: {},\n\tUNSUPPORTED_REASONING_EFFORT: {}\n};\nObject.freeze(Object.keys(LLM_FAILURE_CODES).filter((code) => \"retryChoice\" in LLM_FAILURE_CODES[code]));\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tlet parent;\n\tconst assign = (item) => {\n\t\tif (parent === void 0) {\n\t\t\tif (detach) root = item;\n\t\t} else if (parent.kind === \"array\") {\n\t\t\tif (parent.target !== void 0) parent.target[parent.key] = item;\n\t\t} else if (parent.target !== void 0) Object.defineProperty(parent.target, parent.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst frames = [];\n\tlet current = value;\n\tfor (;;) {\n\t\tif (current === null) assign(null);\n\t\telse if (typeof current === \"boolean\" || typeof current === \"string\") assign(current);\n\t\telse if (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(current);\n\t\t} else {\n\t\t\tif (typeof current !== \"object\") return void 0;\n\t\t\tif (ancestors.has(current)) return void 0;\n\t\t\tif (Array.isArray(current)) {\n\t\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\t\tconst length = current.length;\n\t\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\t\tconst target = detach ? [] : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tlet indices;\n\t\t\t\tif (!Number.isInteger(length)) {\n\t\t\t\t\tindices = [];\n\t\t\t\t\tfor (let index = length - 1; index >= 0; index--) indices.push(index);\n\t\t\t\t}\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tlength: indices?.length ?? length,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: 0,\n\t\t\t\t\tindices,\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\t\t\tconst keys = enumerableStringKeys(current);\n\t\t\t\tif (keys === void 0) return void 0;\n\t\t\t\tconst target = detach ? {} : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tkeys,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: \"\",\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t}\n\t\t\tancestors.add(current);\n\t\t}\n\t\tfor (;;) {\n\t\t\tconst frame = frames.at(-1);\n\t\t\tif (frame === void 0) return detach ? root : true;\n\t\t\tconst length = frame.kind === \"array\" ? frame.length : frame.keys.length;\n\t\t\tif (frame.index >= length) {\n\t\t\t\tancestors.delete(frame.source);\n\t\t\t\tframes.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst index = frame.index++;\n\t\t\tparent = frame;\n\t\t\tif (frame.kind === \"array\") {\n\t\t\t\tconst key = frame.indices === void 0 ? index : frame.indices[frame.indices.length - index - 1];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured index count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tif (!Object.prototype.hasOwnProperty.call(frame.source, key)) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t} else {\n\t\t\t\tconst key = frame.keys[index];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" +export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/util/error/src/index.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tlet parent;\n\tconst assign = (item) => {\n\t\tif (parent === void 0) {\n\t\t\tif (detach) root = item;\n\t\t} else if (parent.kind === \"array\") {\n\t\t\tif (parent.target !== void 0) parent.target[parent.key] = item;\n\t\t} else if (parent.target !== void 0) Object.defineProperty(parent.target, parent.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst frames = [];\n\tlet current = value;\n\tfor (;;) {\n\t\tif (current === null) assign(null);\n\t\telse if (typeof current === \"boolean\" || typeof current === \"string\") assign(current);\n\t\telse if (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(current);\n\t\t} else {\n\t\t\tif (typeof current !== \"object\") return void 0;\n\t\t\tif (ancestors.has(current)) return void 0;\n\t\t\tif (Array.isArray(current)) {\n\t\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\t\tconst length = current.length;\n\t\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\t\tconst target = detach ? [] : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tlet indices;\n\t\t\t\tif (!Number.isInteger(length)) {\n\t\t\t\t\tindices = [];\n\t\t\t\t\tfor (let index = length - 1; index >= 0; index--) indices.push(index);\n\t\t\t\t}\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tlength: indices?.length ?? length,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: 0,\n\t\t\t\t\tindices,\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\t\t\tconst keys = enumerableStringKeys(current);\n\t\t\t\tif (keys === void 0) return void 0;\n\t\t\t\tconst target = detach ? {} : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tkeys,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: \"\",\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t}\n\t\t\tancestors.add(current);\n\t\t}\n\t\tfor (;;) {\n\t\t\tconst frame = frames.at(-1);\n\t\t\tif (frame === void 0) return detach ? root : true;\n\t\t\tconst length = frame.kind === \"array\" ? frame.length : frame.keys.length;\n\t\t\tif (frame.index >= length) {\n\t\t\t\tancestors.delete(frame.source);\n\t\t\t\tframes.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst index = frame.index++;\n\t\t\tparent = frame;\n\t\t\tif (frame.kind === \"array\") {\n\t\t\t\tconst key = frame.indices === void 0 ? index : frame.indices[frame.indices.length - index - 1];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured index count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tif (!Object.prototype.hasOwnProperty.call(frame.source, key)) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t} else {\n\t\t\t\tconst key = frame.keys[index];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29c8e3abc7..b033f4ad64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13214,6 +13214,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-package-manifest': specifier: workspace:* version: link:../../util/package-manifest @@ -17750,6 +17753,12 @@ importers: specifier: workspace:~ version: link:../../../vendor/cordis + packages/util/error: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + packages/util/file-drag: dependencies: mime: diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 55551497c8..4ae1b2fc56 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -94,6 +94,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/atomic-write': 'Zero-dependency filesystem write utility.', 'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.', 'packages/util/crypto': 'Zero-dependency identifier minting utility.', + 'packages/util/error': 'Zero-dependency HarnessError base class.', 'packages/util/deque': 'Zero-dependency circular deque utility.', 'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.', 'packages/util/code-language': 'Zero-dependency file-extension to syntax-highlighting language table.', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index c093799134..700ba10ca6 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -80,6 +80,8 @@ const PEER_REQUIRED_HOST_EXPORTS = { // CUA checks UserQuestionError and preserves AttachmentError from image admission. '@deepseek-ai/dsh-attachment': ['admitEncodedImages'], '@deepseek-ai/dsh-user-questions': ['UserQuestionError'], + // The tool registry and every subclass rely on `instanceof` against one HarnessError class. + '@deepseek-ai/dsh-error': ['HarnessError'], // `instanceof` checks need the translator's own class instance. '@deepseek-ai/dsh-experimental-translator': ['TranslationError'], // Stateless; a safe classification would turn the forge panel's forge and worktree peers into dependencies. diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index 5330165b52..153557d625 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -18,6 +18,7 @@ const CANONICAL = '## Known Limitations and Deferred Work' /** Packages audited as having no limitations section, keyed by repo-relative directory. */ const NO_LIMITATIONS: Readonly> = { 'packages/util/brand': 'Stateless nominal-string and canonical-key helpers have no deferred work.', + 'packages/util/error': 'A single base class with no behavior beyond its constructor has no deferred work.', } /** A heading that reads as a limitations section — canonical or drifted. */ diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6e44649447..d1be3c86de 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -35,6 +35,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/experimental/liveshare-protocol': 'The package only defines wire frames and identity values that its consumers exchange; it registers nothing model-facing.', 'packages/util/brand': 'The package only constructs plain string values and registers nothing model-facing.', + 'packages/util/error': 'The package only defines the HarnessError base class; packages that throw it own any model-facing text.', 'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', 'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', 'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.', diff --git a/tsconfig.base.json b/tsconfig.base.json index 6d38f4dac3..927f5a99d1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -453,6 +453,7 @@ "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deliverable-file-references": ["./packages/deliverables/deliverable-file-references/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], + "@deepseek-ai/dsh-error": ["./packages/util/error/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 7b4499138e..ea675b53ba 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -343,6 +343,7 @@ { "path": "./packages/boot/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/error" }, { "path": "./packages/util/package-manifest" }, { "path": "./packages/util/plugin-detail" }, { "path": "./packages/util/plugin-market-index" }, From ffde355ab76d684b53b0964cbcfb663bd3330dfc Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 21:40:22 +0800 Subject: [PATCH 12/13] refactor(jobs,terminal): import HarnessError from dsh-error JobError and TerminalError only need the error base, so dsh-jobs and dsh-terminal depend on dsh-error instead of the LLM runtime. The Python SDK runtime closure includes the new package. Refs #7869 --- docs/module-graph.md | 75 ++++++++++--------- docs/module-graph.zh.md | 75 ++++++++++--------- packages/jobs/jobs/package.json | 4 +- packages/jobs/jobs/src/error.ts | 2 +- packages/jobs/jobs/tests/error.spec.ts | 2 +- packages/jobs/jobs/tsconfig.json | 2 +- packages/terminal/terminal/package.json | 4 +- packages/terminal/terminal/src/index.ts | 2 +- .../terminal/terminal/tests/service.spec.ts | 2 +- packages/terminal/terminal/tsconfig.json | 2 +- pnpm-lock.yaml | 11 ++- python/sdk-runtime/package.json | 1 + 12 files changed, 94 insertions(+), 88 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index aaf8663092..1f8dfaee91 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -486,12 +486,8 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end + pkg_llm --> pkg_error pkg_session --> pkg_scope - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_llm - pkg_skill --> pkg_scope - pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand pkg_app_boot --> pkg_home_paths @@ -499,8 +495,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand - pkg_authorization --> pkg_credentials - pkg_authorization --> pkg_llm pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths @@ -508,17 +502,17 @@ flowchart TD pkg_experimental_liveshare --> pkg_scope pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_llm pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry - pkg_skill_office --> pkg_skill - pkg_web_fetch_http --> pkg_timeout - pkg_web_fetch_http --> pkg_web + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_llm + pkg_skill --> pkg_scope + pkg_web --> pkg_llm pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -527,17 +521,14 @@ flowchart TD pkg_attachment_local --> pkg_home_paths pkg_hmr --> pkg_app_boot pkg_hmr --> pkg_cmdline - pkg_deliverable_file_references --> pkg_system_prompt + pkg_authorization --> pkg_credentials + pkg_authorization --> pkg_llm pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_forge_github --> pkg_experimental_forge pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_plugin_recommendations --> pkg_llm - pkg_plugin_recommendations --> pkg_skill - pkg_plugin_recommendations --> pkg_typert_protocol - pkg_skill_badge --> pkg_skill pkg_host_directory_picker_auto --> pkg_client_connection pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native @@ -546,11 +537,10 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver - pkg_persona --> pkg_system_prompt + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session - pkg_sandbox_windows_acl --> pkg_skill - pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_format_catalog --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_session @@ -568,11 +558,15 @@ flowchart TD pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_skill_office --> pkg_skill + pkg_web_fetch_http --> pkg_timeout + pkg_web_fetch_http --> pkg_web pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence pkg_config_editor --> pkg_app_boot pkg_config_editor --> pkg_hmr + pkg_deliverable_file_references --> pkg_system_prompt pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -581,11 +575,18 @@ flowchart TD pkg_experimental_translator --> pkg_session_persistence pkg_experimental_translator --> pkg_timeout pkg_experimental_translator --> pkg_util_values + pkg_plugin_recommendations --> pkg_llm + pkg_plugin_recommendations --> pkg_skill + pkg_plugin_recommendations --> pkg_typert_protocol + pkg_skill_badge --> pkg_skill pkg_host_product_telemetry_otel --> pkg_otel + pkg_persona --> pkg_system_prompt pkg_ptc_runtime --> pkg_sandbox pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session + pkg_sandbox_windows_acl --> pkg_skill + pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_projection_cache --> pkg_session @@ -718,7 +719,7 @@ flowchart TD pkg_user_questions --> pkg_typert_protocol pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand - pkg_jobs --> pkg_llm + pkg_jobs --> pkg_error pkg_jobs --> pkg_session pkg_jobs --> pkg_workspace pkg_sandbox_policy --> pkg_agent @@ -741,7 +742,7 @@ flowchart TD pkg_working_directory --> pkg_system_prompt pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand - pkg_terminal --> pkg_llm + pkg_terminal --> pkg_error pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm @@ -1562,7 +1563,6 @@ flowchart TD | [`util-values`](../packages/util/values) | `util` | — | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | -| [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | | [`fs-patch`](../packages/fs/fs-patch) | `fs` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | @@ -1688,44 +1688,38 @@ flowchart TD | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | +| [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | -| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-liveshare`](../packages/experimental/liveshare) | `experimental` | [`scope`](../packages/core/scope) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | -| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | -| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | +| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | -| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | -| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1735,14 +1729,21 @@ flowchart TD | [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | +| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | +| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | +| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | +| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | @@ -1775,12 +1776,12 @@ flowchart TD | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 086ceb919f..a6070b4f5e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -488,12 +488,8 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end + pkg_llm --> pkg_error pkg_session --> pkg_scope - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_llm - pkg_skill --> pkg_scope - pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand pkg_app_boot --> pkg_home_paths @@ -501,8 +497,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand - pkg_authorization --> pkg_credentials - pkg_authorization --> pkg_llm pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths @@ -510,17 +504,17 @@ flowchart TD pkg_experimental_liveshare --> pkg_scope pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_llm pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry - pkg_skill_office --> pkg_skill - pkg_web_fetch_http --> pkg_timeout - pkg_web_fetch_http --> pkg_web + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_llm + pkg_skill --> pkg_scope + pkg_web --> pkg_llm pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -529,17 +523,14 @@ flowchart TD pkg_attachment_local --> pkg_home_paths pkg_hmr --> pkg_app_boot pkg_hmr --> pkg_cmdline - pkg_deliverable_file_references --> pkg_system_prompt + pkg_authorization --> pkg_credentials + pkg_authorization --> pkg_llm pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_forge_github --> pkg_experimental_forge pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_plugin_recommendations --> pkg_llm - pkg_plugin_recommendations --> pkg_skill - pkg_plugin_recommendations --> pkg_typert_protocol - pkg_skill_badge --> pkg_skill pkg_host_directory_picker_auto --> pkg_client_connection pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native @@ -548,11 +539,10 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver - pkg_persona --> pkg_system_prompt + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session - pkg_sandbox_windows_acl --> pkg_skill - pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_format_catalog --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_session @@ -570,11 +560,15 @@ flowchart TD pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_skill_office --> pkg_skill + pkg_web_fetch_http --> pkg_timeout + pkg_web_fetch_http --> pkg_web pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence pkg_config_editor --> pkg_app_boot pkg_config_editor --> pkg_hmr + pkg_deliverable_file_references --> pkg_system_prompt pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -583,11 +577,18 @@ flowchart TD pkg_experimental_translator --> pkg_session_persistence pkg_experimental_translator --> pkg_timeout pkg_experimental_translator --> pkg_util_values + pkg_plugin_recommendations --> pkg_llm + pkg_plugin_recommendations --> pkg_skill + pkg_plugin_recommendations --> pkg_typert_protocol + pkg_skill_badge --> pkg_skill pkg_host_product_telemetry_otel --> pkg_otel + pkg_persona --> pkg_system_prompt pkg_ptc_runtime --> pkg_sandbox pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session + pkg_sandbox_windows_acl --> pkg_skill + pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_projection_cache --> pkg_session @@ -720,7 +721,7 @@ flowchart TD pkg_user_questions --> pkg_typert_protocol pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand - pkg_jobs --> pkg_llm + pkg_jobs --> pkg_error pkg_jobs --> pkg_session pkg_jobs --> pkg_workspace pkg_sandbox_policy --> pkg_agent @@ -743,7 +744,7 @@ flowchart TD pkg_working_directory --> pkg_system_prompt pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand - pkg_terminal --> pkg_llm + pkg_terminal --> pkg_error pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm @@ -1564,7 +1565,6 @@ flowchart TD | [`util-values`](../packages/util/values) | `util` | — | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | -| [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | | [`fs-patch`](../packages/fs/fs-patch) | `fs` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | @@ -1690,44 +1690,38 @@ flowchart TD | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | +| [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | -| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-liveshare`](../packages/experimental/liveshare) | `experimental` | [`scope`](../packages/core/scope) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | -| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | -| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | +| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | -| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | -| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1737,14 +1731,21 @@ flowchart TD | [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | +| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | +| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | +| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | +| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | @@ -1777,12 +1778,12 @@ flowchart TD | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 01ea00ef93..ea45155488 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -41,7 +41,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" @@ -54,7 +54,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/jobs/jobs/src/error.ts b/packages/jobs/jobs/src/error.ts index 78a210d907..e0f0af64d9 100644 --- a/packages/jobs/jobs/src/error.ts +++ b/packages/jobs/jobs/src/error.ts @@ -1,6 +1,6 @@ /** Machine-routable background-job failures. @module */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every {@link JobError} code: diff --git a/packages/jobs/jobs/tests/error.spec.ts b/packages/jobs/jobs/tests/error.spec.ts index afab2195bf..c8c268945e 100644 --- a/packages/jobs/jobs/tests/error.spec.ts +++ b/packages/jobs/jobs/tests/error.spec.ts @@ -1,4 +1,4 @@ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { JOB_ERROR_CODES, JobError } from '@deepseek-ai/dsh-jobs' import { describe, expect, it } from 'vitest' diff --git a/packages/jobs/jobs/tsconfig.json b/packages/jobs/jobs/tsconfig.json index b0f16aab20..77732e676a 100644 --- a/packages/jobs/jobs/tsconfig.json +++ b/packages/jobs/jobs/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../core/agent" }, { - "path": "../../llm/llm" + "path": "../../util/error" }, { "path": "../../core/session" diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 99c6ef3ca8..eacfab7bba 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -34,14 +34,14 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index 07eff48199..f97ab51cae 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -6,7 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { TerminalBackendCleanupError } from './types.ts' import type { TerminalBackend, diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 935c699738..3d8304275b 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -5,7 +5,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import type { TerminalBackend, TerminalBackendSession, diff --git a/packages/terminal/terminal/tsconfig.json b/packages/terminal/terminal/tsconfig.json index 7d85cedee2..7deb3fd6da 100644 --- a/packages/terminal/terminal/tsconfig.json +++ b/packages/terminal/terminal/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../core/agent" }, { - "path": "../../llm/llm" + "path": "../../util/error" }, { "path": "../../util/brand" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b033f4ad64..cacf7aa38f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13091,9 +13091,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand - '@deepseek-ai/dsh-llm': + '@deepseek-ai/dsh-error': specifier: workspace:* - version: link:../../llm/llm + version: link:../../util/error '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -17193,9 +17193,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand - '@deepseek-ai/dsh-llm': + '@deepseek-ai/dsh-error': specifier: workspace:* - version: link:../../llm/llm + version: link:../../util/error '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -18401,6 +18401,9 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:* version: link:../../packages/llm/deepseek-llm-api-extensions + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../packages/util/error '@deepseek-ai/dsh-file-reference': specifier: workspace:* version: link:../../packages/context/file-reference diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index e9929dc670..bf94634746 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", From 3da5b3a7495686655f4b233fe4959333c9cb519e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 11:55:51 +0800 Subject: [PATCH 13/13] test(web): expect ApprovalError on a rejected approval A user-rejected approval now records ApprovalError with APPROVAL_REJECTED on tool/result, so the Auto review e2e asserts that identity instead of an absent error. Refs #7869 --- apps/web/tests/shipped-composition.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index ba6bcc0cca..36b74d0e88 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -823,7 +823,7 @@ it('routes one browser-authored Auto request through the same model and asks the event.type === 'tool/result' && event.data.message.toolCallId === AUTO_CALL_ID )) - expect(result?.data.error).toBeUndefined() + expect(result?.data.error).toEqual({ name: 'ApprovalError', code: 'APPROVAL_REJECTED' }) const durableModelResult = JSON.stringify(result?.data.message) expect(durableModelResult).toContain('the user rejected tool \\"bash\\"') expect(durableModelResult).not.toContain('direct user authorized inspection only')