fix(tools): state the code-mode collapse in the prompt and the denial

The executor collapse landed without telling the model it exists. Every
tool contributes its own guidance section naming its tool, none of them
qualify how that tool is reached, and they all render before the SDK
(orders 100-199 against SDK_SECTION_ORDER 150), so the prompt said "Use
the read tool" eleven times and never said only run_code is callable.

A real session shows the consequence: the model emitted a native call,
read `unknown tool "read"` for a tool the same prompt declares, and
concluded the deployment was inconsistent rather than routing through
run_code.

The registry now contributes `tools:code-only` at order 99 -- ahead of
the guidance band -- stating the rule, registered wherever `tools:sdk`
is and rendering empty outside an effective `code`. `both` renders it
empty because its native calls do execute, which is also why
both-mode-turn no longer shares code-mode-turn's expected prompt. The
denial itself now names the route back, since a bare UNKNOWN_TOOL for a
declared tool is what misled the model.
This commit is contained in:
Yichen Jiang
2026-08-11 22:44:34 +08:00
parent 428aec44e6
commit 5d2c943d38
13 changed files with 571 additions and 25 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-tool-mode/README.md
README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2
README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce
README.md: f5f2df21285411dbb3b8c5cac18cc3ab0dc8a22b
README.zh.md: 84c2ff009540c2637e01875b1171c39afa6ae2f3

View File

@@ -20,7 +20,7 @@ One agent declares one presentation. A second declaration in the same compositio
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section and the rule that only `run_code` may be called directly, `native` presents every tool schema. The selection also decides what may EXECUTE: under `code` the registry resolves a model-direct call naming any other tool to `UNKNOWN_TOOL`, so this row is what keeps the announced surface and the callable surface the same for every agent it covers ([executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)).
#### KV Cache effect

View File

@@ -20,7 +20,7 @@ preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs(
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
间接生效,取决于它在 `dsh-tools` 中选择的投影:`code` 呈现 `run_code`、一份生成的 SDK 段,以及「只有 `run_code` 可被直接调用」这条规则,`native` 呈现每个工具的 schema。该选择同时决定了**什么可以执行**:在 `code` 下,注册表会把模型直呼其他任何工具名解析为 `UNKNOWN_TOOL`,因此这一行正是让「通告面」与「可调用面」对每个被它覆盖的 agent 保持一致的东西([执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md))。
#### KV Cache effect

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: 653720b9a577df630c3b4c23a4615ff640e95292
README.zh.md: 8c4671bf8d4f10f806fcce1a21044c82ff2d6d7d
README.md: 44eb25b79436a75f08406102fc1e3734e59b1001
README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport, the generated `tools:sdk` section, and the `tools:code-only` rule stating that only `run_code` may be called directly — which the executor then enforces, resolving a model-direct call naming any other tool to `UNKNOWN_TOOL` before policy runs; `both` contributes both forms and states no such rule, because its native calls do execute. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
### Public API
@@ -115,7 +115,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
### Code Mode
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`.
Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`<name>\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs).
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
@@ -146,7 +148,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
#### What the model sees
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. Under `code` the prompt also carries the `tools:code-only` rule, ordered ahead of the per-tool guidance band so the model reads which tools it may call before it reads what each one is for; `both` renders it empty. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
##### Code Mode SDK instructions

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输、生成的 `tools:sdk` 段,以及声明「只有 `run_code` 可被直接调用」的 `tools:code-only` 规则——执行器随后强制该规则,模型直呼其他任何工具名都会在策略运行之前解析为 `UNKNOWN_TOOL`;`both` 同时贡献两种形式,且不声明该规则,因为它的原生调用确实会执行。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
### 公开 API
@@ -115,7 +115,9 @@ ctx.tools.register(defineTool({
### Code Mode
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。
在 `code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`<name>\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token,不受此限制,因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。
- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
@@ -146,7 +148,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
#### 模型看到的内容
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。在 `code` 下,提示词还会带上 `tools:code-only` 规则,其顺序排在逐工具指导段之前,让模型先读到「可以调用哪些工具」再读「每个工具做什么」;`both` 下它渲染为空。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
##### Code Mode SDK 说明

View File

@@ -43,6 +43,20 @@ import { renderToolsSdkPy } from './py-types.ts'
* with its zh pair, plus this package's own README pair and the
* {@link Config.mode} JSDoc.
*/
/**
* Prompt order of the `code` collapse statement: after the persona and before
* the 100-199 per-tool guidance band, so the model reads which tools it may
* call before it reads what each one is for.
*/
const COLLAPSE_SECTION_ORDER = 99
/**
* The model-facing statement of the `code` collapse. Names the consequence
* (the call fails) and the route (inside the program), because a rule the
* model can only discover by being denied is one it corrects too late.
*/
const CODE_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.`
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
typescript: renderToolsSdk,
python: renderToolsSdkPy,
@@ -478,8 +492,19 @@ export interface ToolFailure {
* distinguish it from a tool body's own error.
*/
export class ToolNotFoundError extends HarnessError {
constructor(toolName: string) {
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
/**
* @param toolName - the name the caller asked for.
* @param reachableFrom - how the model reaches this tool instead, when the
* name IS visible and only the presentation denies calling it directly.
* Omitted for a name that is registered nowhere.
*/
constructor(toolName: string, reachableFrom?: string) {
super(
reachableFrom === undefined
? `unknown tool "${toolName}"`
: `unknown tool "${toolName}": ${reachableFrom}`,
'UNKNOWN_TOOL',
)
this.name = 'ToolNotFoundError'
}
}
@@ -806,10 +831,37 @@ export class ToolRegistry extends Service {
this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.defaultMode !== 'native') {
ctx.systemPrompt.section(this.collapseSection())
ctx.systemPrompt.section(this.sdkSection())
}
}
/**
* The prompt statement of the `code` executor collapse, registered wherever
* {@link sdkSection} is and rendering empty outside an effective `code`.
*
* Every tool contributes its own guidance section naming its tool, none of
* them qualify how that tool is reached, and they all render before the SDK
* (orders 100-199 against {@link SDK_SECTION_ORDER}). Without this the model
* reads a catalog of tools it is told to use and no statement that only
* `run_code` may be called, so it emits a native call, receives
* `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes the
* deployment is inconsistent. {@link COLLAPSE_SECTION_ORDER} places the rule
* before that guidance rather than after it.
*
* `both` renders empty: native calls do execute there, so the rule is false.
* @returns the section registration.
*/
private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
return {
name: 'tools:code-only',
order: COLLAPSE_SECTION_ORDER,
// The SAME predicate the executor denies by, so the prompt cannot state
// a rule the registry does not enforce (see `collapses`).
text: context => this.modeFor(context.scope) === 'code' ? CODE_ONLY_INSTRUCTION : '',
}
}
/**
* The generated-SDK prompt section, registered globally by a code-mode
* deployment and per scope by {@link presentAs}.
@@ -908,11 +960,14 @@ export class ToolRegistry extends Service {
},
{ label: 'tools.presentAs()' },
)
// The SDK section is per scope for the same reason the mode is. Under a
// deployment that already defaults to a code mode this shadows the
// global registration with an identical body, which costs nothing and
// keeps one rule instead of a case analysis.
if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection())
// The SDK and collapse sections are per scope for the same reason the
// mode is. Under a deployment that already defaults to a code mode this
// shadows the global registration with an identical body, which costs
// nothing and keeps one rule instead of a case analysis.
if (mode !== 'native') {
yield ctx.systemPrompt.section(this.collapseSection())
yield ctx.systemPrompt.section(this.sdkSection())
}
}.bind(this), 'tools.presentAs()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
return dispose
@@ -1367,7 +1422,18 @@ export class ToolRegistry extends Service {
if (signal.aborted) {
return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() }
}
return { kind: 'final-result', exec: execution, result: toolErrorResult(new ToolNotFoundError(name)) }
// The name IS visible here, so the denial carries the route the model
// must take instead. Without it the model reads a bare `unknown tool`
// for a tool the prompt just declared and concludes the deployment is
// broken rather than correcting itself.
return {
kind: 'final-result',
exec: execution,
result: toolErrorResult(new ToolNotFoundError(
name,
`only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`,
)),
}
}
return { kind: 'ready', exec: execution }
} catch (error: unknown) {

View File

@@ -135,6 +135,32 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code:')
})
it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
// Stand in for a real tool's guidance section, which sits in the 100-199
// band and names its tool without saying how it is reached.
ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' })
const assembly = await systemPrompt.assemble()
const names = assembly.sections.map(section => section.name)
const rule = assembly.sections.find(section => section.name === 'tools:code-only')
expect(rule?.text).toContain(`\`${RUN_CODE_NAME}\` is the only tool you can call directly`)
// The rule is worthless after the guidance it qualifies.
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tool:echo'))
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tools:sdk'))
})
it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
// Registered (the deployment is non-native) but empty, so the renderer
// drops it: `both` executes the native call the rule would forbid.
expect(assembly.sections.find(section => section.name === 'tools:code-only')?.text).toBe('')
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
})
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
let output: JsonSchemaNode = { type: 'string' }
@@ -1574,6 +1600,11 @@ describe('the run_code dispatch bridge', () => {
})
expect(result.isError).toBe(true)
expect(result.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
// The name IS declared to this model, so a bare `unknown tool` reads as a
// broken deployment. The denial carries the route instead.
expect(result.error?.message).toBe(
`unknown tool "write": only \`${RUN_CODE_NAME}\` is callable directly — call \`write\` from inside a \`${RUN_CODE_NAME}\` program instead`,
)
})
it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => {