Merge pull request #1873 from deepseek-harness/fix/code-mode-executor-collapse
fix(tools): collapse code-mode executor to run_code for model-direct calls
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md
|
||||
2026-08-07-code-mode-executor-collapse.md: 3b64675f90f830628dcde427d4388153ec7accf1
|
||||
2026-08-07-code-mode-executor-collapse.zh.md: 51b35ad44b9758805784834d1d036ba374140dd9
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Code Mode collapses the executor, not just the wire
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-07-code-mode-executor-collapse.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`mode: 'code'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing.
|
||||
|
||||
The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor.
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `code`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared.
|
||||
|
||||
Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not.
|
||||
|
||||
The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `code` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [Code Mode foundation](../feature/2026-06-15-code-mode.md) owns the transport design this note layers the execution boundary onto.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Filter `get()` / the registry view by mode
|
||||
|
||||
The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor.
|
||||
|
||||
### Filter at the agent-loop entry
|
||||
|
||||
The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns.
|
||||
|
||||
### Reject via a shipped guard
|
||||
|
||||
Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself.
|
||||
|
||||
### Keep schema omission only (status quo)
|
||||
|
||||
No provider guarantees interception of unadvertised names; the reported session proves it does not happen.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract).
|
||||
- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator).
|
||||
- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference.
|
||||
- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls.
|
||||
- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of the 100-199 guidance band. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt.
|
||||
- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Code Mode 塌缩执行器而非仅通告面
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-07-code-mode-executor-collapse.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。
|
||||
|
||||
包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。
|
||||
|
||||
## 决策
|
||||
|
||||
`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `code` 时,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现,其消息会指出改走 `run_code` 的正确路径——因为这个名字对当前模型而言是**已声明过**的(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。有效的 scope 模式包括从 agent preset 继承的声明,因此其 wire schema 与执行权限保持一致。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。
|
||||
|
||||
执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。
|
||||
|
||||
塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [Code Mode 基础](../feature/2026-06-15-code-mode.md) 之上,传输设计由后者拥有。
|
||||
|
||||
## 备选方案
|
||||
|
||||
### 按模式过滤 `get()` / 注册表视图
|
||||
|
||||
视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。
|
||||
|
||||
### 在 agent-loop 入口过滤
|
||||
|
||||
loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。
|
||||
|
||||
### 通过内置 guard 拒绝
|
||||
|
||||
guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。
|
||||
|
||||
### 只保留 schema 省略(维持现状)
|
||||
|
||||
没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。
|
||||
|
||||
## 后果
|
||||
|
||||
- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。
|
||||
- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。
|
||||
- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。
|
||||
- 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。
|
||||
- 提示词会**声明**这条塌缩,位于排在 100–199 指导段之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。
|
||||
- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: 08c1c63050aefd9e0fcc7c603ad40ec6405bf6c1
|
||||
config-catalog.zh.md: e7cfcff8d69c3a1efbe8426e25415783bc41f864
|
||||
config-catalog.md: 39433b697588ede7b36f878490f4e0b095e8d67a
|
||||
config-catalog.zh.md: d76b289f8a7ffbc434b2adeefd5f16142fdffc99
|
||||
+9
-11
@@ -2481,16 +2481,14 @@ Requires: `systemPrompt`
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Model presentation for agents that declare none of their own. `native`
|
||||
* (default) sends every visible schema; `code` sends only `run_code` plus a
|
||||
* generated SDK prompt; `both` sends both forms. Code modes require a
|
||||
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
|
||||
* (TypeScript or Python) and fail prompt assembly when it is absent or has
|
||||
* no renderer. Under `code`, native names in `toolOrder` are invalid.
|
||||
*
|
||||
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
|
||||
* which is how an agent preset composes a Code Mode agent beside native
|
||||
* ones in the same process.
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt and collapses the
|
||||
* executor to the same surface (a model-direct call may only name
|
||||
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
|
||||
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
|
||||
* has a registered SDK renderer (TypeScript or Python) and fail prompt
|
||||
* assembly when it is absent or has no renderer. Under `code`, native names
|
||||
* in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
@@ -2507,7 +2505,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:625`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-typert-loader`
|
||||
|
||||
|
||||
@@ -2482,16 +2482,14 @@ export interface Config {
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Model presentation for agents that declare none of their own. `native`
|
||||
* (default) sends every visible schema; `code` sends only `run_code` plus a
|
||||
* generated SDK prompt; `both` sends both forms. Code modes require a
|
||||
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
|
||||
* (TypeScript or Python) and fail prompt assembly when it is absent or has
|
||||
* no renderer. Under `code`, native names in `toolOrder` are invalid.
|
||||
*
|
||||
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
|
||||
* which is how an agent preset composes a Code Mode agent beside native
|
||||
* ones in the same process.
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt and collapses the
|
||||
* executor to the same surface (a model-direct call may only name
|
||||
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
|
||||
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
|
||||
* has a registered SDK renderer (TypeScript or Python) and fail prompt
|
||||
* assembly when it is absent or has no renderer. Under `code`, native names
|
||||
* in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
@@ -2508,7 +2506,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
来源:[`packages/core/tools/src/index.ts:617`](../packages/core/tools/src/index.ts)
|
||||
来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-typert-loader`
|
||||
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: c272087b03c6d27599b76ad6d6f197b023a36579
|
||||
event-producer-consumer.zh.md: b5634785e58134df453f0022a3b46ea926f3a526
|
||||
event-producer-consumer.md: 788d40e8dc110ffb5200889d9fdf17a5b17e8241
|
||||
event-producer-consumer.zh.md: 666f1f4108a3034af81d45b17c62537a4be30af6
|
||||
@@ -45,12 +45,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -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 docs/subsystems/tools.md
|
||||
tools.md: 54d20de3b4d1f2ff6d03a8e89e9356eef403382a
|
||||
tools.zh.md: f8a35ffa7121438b0623f2a0972f90df2923ac89
|
||||
tools.md: 60f982e1934a37f80236382141ebc54fdef5d7b4
|
||||
tools.zh.md: 07e5e5729bfb8329b2696c64c25b852cb8a91350
|
||||
+17
-14
@@ -156,14 +156,13 @@ Registration is a trusted same-process contract. The registry borrows the typed
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Per-scope filter over the tools a scope INHERITS — the global layer and
|
||||
* every ancestor layer on its chain. Restrictions intersect, and do not affect
|
||||
* the scope's own registrations or the reserved Code Mode transport.
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
*/
|
||||
interface ToolRestriction {
|
||||
/** Inherited tool names that stay visible; every other inherited one is removed. */
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Inherited tool names removed from visibility. */
|
||||
/** Global tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
```
|
||||
@@ -198,8 +197,12 @@ interface ToolExecutionInput {
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
* The token also marks the call as a transport sub-dispatch rather than a
|
||||
* model-direct call: under `mode: 'code'`, only calls WITH a parent may
|
||||
* execute a native tool name — a model-direct call (no parent) is denied as
|
||||
* `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
/** Required caller-owned cancellation for this invocation. */
|
||||
readonly signal: AbortSignal
|
||||
@@ -568,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ScopeKey](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:787`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="tools-events"></a>
|
||||
|
||||
@@ -593,7 +596,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:207`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolscode-dispatch-log--waterfall"></a>
|
||||
|
||||
@@ -620,7 +623,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su
|
||||
|
||||
Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolsexecute--waterfall"></a>
|
||||
|
||||
@@ -644,7 +647,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolspost-execute--waterfall"></a>
|
||||
|
||||
@@ -669,7 +672,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolspre-execute--waterfall"></a>
|
||||
|
||||
@@ -692,7 +695,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolsresult--emit"></a>
|
||||
|
||||
@@ -713,5 +716,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:197`](../../packages/core/tools/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
+17
-14
@@ -156,14 +156,13 @@ type InferArgs<S> = InferProperties<S, []>
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Per-scope filter over the tools a scope INHERITS — the global layer and
|
||||
* every ancestor layer on its chain. Restrictions intersect, and do not affect
|
||||
* the scope's own registrations or the reserved Code Mode transport.
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
*/
|
||||
interface ToolRestriction {
|
||||
/** Inherited tool names that stay visible; every other inherited one is removed. */
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Inherited tool names removed from visibility. */
|
||||
/** Global tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
```
|
||||
@@ -198,8 +197,12 @@ interface ToolExecutionInput {
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
* The token also marks the call as a transport sub-dispatch rather than a
|
||||
* model-direct call: under `mode: 'code'`, only calls WITH a parent may
|
||||
* execute a native tool name — a model-direct call (no parent) is denied as
|
||||
* `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
/** Required caller-owned cancellation for this invocation. */
|
||||
readonly signal: AbortSignal
|
||||
@@ -568,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ScopeKey](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:787`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="tools-events"></a>
|
||||
|
||||
@@ -593,7 +596,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:207`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolscode-dispatch-log--waterfall"></a>
|
||||
|
||||
@@ -620,7 +623,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su
|
||||
|
||||
Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolsexecute--waterfall"></a>
|
||||
|
||||
@@ -644,7 +647,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolspost-execute--waterfall"></a>
|
||||
|
||||
@@ -669,7 +672,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolspre-execute--waterfall"></a>
|
||||
|
||||
@@ -692,7 +695,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
<a id="toolsresult--emit"></a>
|
||||
|
||||
@@ -713,5 +716,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
|
||||
|
||||
Types: [Scoped](scope.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:197`](../../packages/core/tools/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -542,13 +542,15 @@ const SCENARIOS: Scenario[] = [
|
||||
toolSchemasSource: 'code-mode-turn',
|
||||
configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
|
||||
},
|
||||
// `both` owns its own expected prompt rather than sharing code-mode-turn's:
|
||||
// the two modes agree on every section except the run_code-only rule, which
|
||||
// `both` must NOT state because its native calls do execute.
|
||||
{
|
||||
name: 'both-mode-turn',
|
||||
hasModelTurn: true,
|
||||
recorded: true,
|
||||
pinsHeader: true,
|
||||
headerClass: 'both',
|
||||
systemPromptSource: 'code-mode-turn',
|
||||
configPath: BOTH_MODE_CONFIG,
|
||||
},
|
||||
// Machine permission scenarios use an explicit deployment policy; there is
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
|
||||
|
||||
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.
|
||||
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
|
||||
Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.
|
||||
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
|
||||
|
||||
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
|
||||
|
||||
Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
|
||||
|
||||
## Writing code for run_code
|
||||
|
||||
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
|
||||
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
```ts
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
interface ToolArgsMap {
|
||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||
bash: {
|
||||
/** The bash command to execute. */
|
||||
command: string;
|
||||
/** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */
|
||||
description: string;
|
||||
/** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */
|
||||
timeoutMs?: number;
|
||||
/** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */
|
||||
workdir?: string;
|
||||
/** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */
|
||||
run_in_background?: boolean;
|
||||
/** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||
create_goal: {
|
||||
/** The concrete completion objective inferred from the direct human request. */
|
||||
objective: string;
|
||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||
max_goal_rounds?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||
edit: {
|
||||
/** Path to edit, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** Literal text to replace. Must match exactly. */
|
||||
old_string: string;
|
||||
/** Literal replacement text. Use an empty string to delete the match. */
|
||||
new_string: string;
|
||||
/** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */
|
||||
replace_all?: boolean;
|
||||
/** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */
|
||||
interrupt_agent: {
|
||||
/** The agent id of the running agent to interrupt. */
|
||||
agent_id: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */
|
||||
list_agents: {
|
||||
/** children (default) lists direct children only; descendants walks the complete tree below you. */
|
||||
scope?: "children" | "descendants";
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
objective: string;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** 1-based first line to return. Defaults to 1. */
|
||||
offset?: number;
|
||||
/** Maximum number of lines to return. Defaults to 2000. */
|
||||
limit?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */
|
||||
send_message: {
|
||||
/** The subagent id returned when the background subagent was started. */
|
||||
subagent_id: string;
|
||||
/** The message to deliver to the subagent. */
|
||||
message: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||
skill: {
|
||||
/** The exact skill name from the available skills list. */
|
||||
name: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */
|
||||
subagent: {
|
||||
/** A short (3-5 word) description of the delegated task, for display. */
|
||||
description: string;
|
||||
/** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */
|
||||
prompt: string;
|
||||
/** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */
|
||||
run_in_background?: boolean;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */
|
||||
subagent_fork: {
|
||||
/** A short (3-5 word) description of the delegated task, for display. */
|
||||
description: string;
|
||||
/** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */
|
||||
prompt: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
||||
task_kill: {
|
||||
/** Task id returned by the tool that started the background work. */
|
||||
task_id: string;
|
||||
/** Optional short reason, recorded in the log and forwarded to the task. */
|
||||
reason?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
||||
task_list: Record<string, JsonValue>;
|
||||
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||
task_output: {
|
||||
/** Task id returned by the tool that started the background work. */
|
||||
task_id: string;
|
||||
/** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */
|
||||
wait?: boolean;
|
||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||
timeout_ms?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||
todo_write: {
|
||||
/** The COMPLETE task list, replacing any previous list. */
|
||||
todos: ({
|
||||
/** What the task is — a short imperative line. */
|
||||
content: string;
|
||||
/** pending (not started) | in_progress (now) | completed (done). */
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
})[];
|
||||
} & Record<string, JsonValue>;
|
||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||
update_goal: {
|
||||
/** Exact id returned by get_goal. */
|
||||
goal_id: string;
|
||||
/** Exact positive revision returned by get_goal. */
|
||||
revision: number;
|
||||
/** edit | pause | resume | complete | blocked */
|
||||
action: "edit" | "pause" | "resume" | "complete" | "blocked";
|
||||
/** Replacement objective; valid only with action edit. */
|
||||
objective?: string;
|
||||
/** Replacement cap; valid only with action edit. */
|
||||
max_goal_rounds?: number;
|
||||
/** Concrete blocking condition; required only with action blocked. */
|
||||
blocked_reason?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||
workflow: {
|
||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||
script: string;
|
||||
/** The workflow identity block (plain JSON — never code). */
|
||||
meta: {
|
||||
/** Short kebab-case workflow name. */
|
||||
name: string;
|
||||
/** One-line description of what the workflow does. */
|
||||
description: string;
|
||||
/** Optional guidance on when this workflow applies. */
|
||||
whenToUse?: string;
|
||||
/** Optional phase declarations matched by phase() calls. */
|
||||
phases?: ({
|
||||
/** The phase title phase() calls match by exact string. */
|
||||
title: string;
|
||||
/** Optional one-line description of the phase. */
|
||||
detail?: string;
|
||||
/** Optional provider override this phase is expected to use. */
|
||||
provider?: string;
|
||||
/** Optional model override this phase is expected to use. */
|
||||
model?: string;
|
||||
} & Record<string, JsonValue>)[];
|
||||
} & Record<string, JsonValue>;
|
||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||
args?: Record<string, JsonValue>;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Create or fully replace a UTF-8 text file. */
|
||||
write: {
|
||||
/** Path to write, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** Full UTF-8 text content to write. */
|
||||
content: string;
|
||||
/** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
interface ToolOutputMap {
|
||||
bash: {
|
||||
kind: "background";
|
||||
taskId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
aborted: boolean;
|
||||
timeoutMs: number;
|
||||
stdout: {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
spillPath?: string;
|
||||
};
|
||||
stderr: {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
spillPath?: string;
|
||||
};
|
||||
sandbox?: {
|
||||
mode: string;
|
||||
denied: boolean;
|
||||
enforcement?: string;
|
||||
runnerFailed?: boolean;
|
||||
};
|
||||
};
|
||||
create_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
edit: {
|
||||
path: string;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
interrupt_agent: {
|
||||
accepted: boolean;
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "idle" | "ready";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
result: JsonValue;
|
||||
};
|
||||
read: {
|
||||
path: string;
|
||||
offset: number;
|
||||
lines: {
|
||||
number: number;
|
||||
text: string;
|
||||
}[];
|
||||
totalLines: number;
|
||||
};
|
||||
send_message: {
|
||||
messageId: string;
|
||||
};
|
||||
skill: {
|
||||
name: string;
|
||||
provider: string;
|
||||
resourceBase?: {
|
||||
kind: "directory";
|
||||
path: string;
|
||||
} | {
|
||||
kind: "url";
|
||||
url: string;
|
||||
} | {
|
||||
kind: "opaque";
|
||||
description: string;
|
||||
};
|
||||
content: string;
|
||||
};
|
||||
subagent: {
|
||||
kind: "background";
|
||||
taskId: string;
|
||||
} | {
|
||||
kind: "continuable";
|
||||
subagentId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
runId: string;
|
||||
output: JsonValue[];
|
||||
};
|
||||
subagent_fork: {
|
||||
kind: "background";
|
||||
taskId: string;
|
||||
} | {
|
||||
kind: "continuable";
|
||||
subagentId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
runId: string;
|
||||
output: JsonValue[];
|
||||
};
|
||||
task_kill: {
|
||||
outcome: "cancellation-requested" | "already-finished";
|
||||
task: {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
};
|
||||
};
|
||||
task_list: ({
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
})[];
|
||||
task_output: {
|
||||
text: string;
|
||||
task: {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
};
|
||||
};
|
||||
todo_write: {
|
||||
todos: ({
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
})[];
|
||||
counts: {
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
};
|
||||
};
|
||||
update_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
workflow: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
result: JsonValue;
|
||||
};
|
||||
write: {
|
||||
path: string;
|
||||
operation: "create" | "update";
|
||||
before: string | null;
|
||||
after: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToolName = keyof ToolOutputMap
|
||||
|
||||
declare class ToolCallError extends Error {
|
||||
readonly name: "ToolCallError";
|
||||
readonly toolName: ToolName;
|
||||
}
|
||||
|
||||
declare const tools: {
|
||||
[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;
|
||||
}
|
||||
```
|
||||
@@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
`run_code` 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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.
|
||||
|
||||
@@ -13,6 +13,8 @@ import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, T
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
const ctx = new Context()
|
||||
@@ -687,3 +689,74 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('code-mode native-tool denial through the agent loop', () => {
|
||||
/** A minimal in-process code runtime for test purposes — never actually runs. */
|
||||
class FakeCodeRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake' as const
|
||||
async run(_request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
return { logs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function codeModeHarness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape
|
||||
await ctx.plugin(FakeCodeRuntime as any)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('denies a model-direct native-tool call under code mode: tool body never runs and session records UNKNOWN_TOOL', async () => {
|
||||
let toolInvoked = false
|
||||
const tool = defineContentToolFixture({
|
||||
name: 'write',
|
||||
description: 'Write a file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true },
|
||||
content: { type: 'string', required: true },
|
||||
},
|
||||
async execute(_args, _exec) {
|
||||
toolInvoked = true
|
||||
return [{ type: 'text', text: 'written' }]
|
||||
},
|
||||
})
|
||||
|
||||
// Scripted model emits a native tool call under code mode — the wire
|
||||
// never advertised it, but a non-compliant provider may still emit one.
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
...multiCall([{ id: 'call-1', name: 'write', args: { file_path: '/tmp/test', content: 'hello' } }]),
|
||||
...textResponse('ok'),
|
||||
],
|
||||
])
|
||||
|
||||
const ctx = await codeModeHarness(adapter)
|
||||
ctx.tools.register(tool)
|
||||
|
||||
const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'write a file' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The tool body must NOT have executed — the collapse denied the call
|
||||
// at createExecution, before the body could start.
|
||||
expect(toolInvoked).toBe(false)
|
||||
|
||||
// The session must record a tool/result with UNKNOWN_TOOL error so the
|
||||
// transcript faithfully captures that the call was denied.
|
||||
const sessionEvents = events(agent)
|
||||
const toolResult = sessionEvents.find(e => e.type === 'tool/result')
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult!.data.error).toMatchObject({
|
||||
name: 'ToolNotFoundError',
|
||||
code: 'UNKNOWN_TOOL',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7
|
||||
README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165
|
||||
README.md: 44eb25b79436a75f08406102fc1e3734e59b1001
|
||||
README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3
|
||||
@@ -13,13 +13,13 @@ 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
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ 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
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
|
||||
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
|
||||
@@ -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 说明
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -313,6 +327,10 @@ export interface ToolExecutionInput {
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
* The token also marks the call as a transport sub-dispatch rather than a
|
||||
* model-direct call: under `mode: 'code'`, only calls WITH a parent may
|
||||
* execute a native tool name — a model-direct call (no parent) is denied as
|
||||
* `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
/** Required caller-owned cancellation for this invocation. */
|
||||
@@ -474,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'
|
||||
}
|
||||
}
|
||||
@@ -624,16 +653,14 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Model presentation for agents that declare none of their own. `native`
|
||||
* (default) sends every visible schema; `code` sends only `run_code` plus a
|
||||
* generated SDK prompt; `both` sends both forms. Code modes require a
|
||||
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
|
||||
* (TypeScript or Python) and fail prompt assembly when it is absent or has
|
||||
* no renderer. Under `code`, native names in `toolOrder` are invalid.
|
||||
*
|
||||
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
|
||||
* which is how an agent preset composes a Code Mode agent beside native
|
||||
* ones in the same process.
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt and collapses the
|
||||
* executor to the same surface (a model-direct call may only name
|
||||
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
|
||||
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
|
||||
* has a registered SDK renderer (TypeScript or Python) and fail prompt
|
||||
* assembly when it is absent or has no renderer. Under `code`, native names
|
||||
* in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
@@ -647,14 +674,13 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope filter over the tools a scope INHERITS — the global layer and
|
||||
* every ancestor layer on its chain. Restrictions intersect, and do not affect
|
||||
* the scope's own registrations or the reserved Code Mode transport.
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Inherited tool names that stay visible; every other inherited one is removed. */
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Inherited tool names removed from visibility. */
|
||||
/** Global tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
|
||||
@@ -670,7 +696,7 @@ interface ToolView {
|
||||
readonly visible: ReadonlyMap<string, ToolDefinition>
|
||||
/** Pre-restriction capability names used by prompt-order validation. */
|
||||
readonly knownNames: ReadonlySet<string>
|
||||
/** Current inherited names a scoped restriction may name; its own are exempt. */
|
||||
/** Current global names that a scoped restriction may name. */
|
||||
readonly restrictableNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -708,7 +734,7 @@ class ToolLayer implements ScopeLayer {
|
||||
&& this.mode === undefined
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits an inherited tool name. */
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
@@ -805,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}.
|
||||
@@ -907,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
|
||||
@@ -1032,7 +1088,7 @@ export class ToolRegistry extends Service {
|
||||
const known = this.view(scope).restrictableNames
|
||||
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
@@ -1149,6 +1205,26 @@ export class ToolRegistry extends Service {
|
||||
return this.view(scope).visible.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the definition that MAY EXECUTE for a call, applying the mode
|
||||
* collapse at the operation boundary that owns it. The registry view
|
||||
* (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `code`
|
||||
* may only name the reserved `run_code` transport, while a nested
|
||||
* sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool
|
||||
* it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL`
|
||||
* through the executor, matching an absent definition.
|
||||
* @param name - the tool name as registered.
|
||||
* @param scope - the viewing scope (the agent); omitted = the global view.
|
||||
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
|
||||
* @returns the definition that may run, or undefined when the call must be rejected.
|
||||
*/
|
||||
private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined {
|
||||
const tool = this.get(name, scope)
|
||||
if (tool === undefined) return undefined
|
||||
if (this.collapses(name, scope, nested)) return undefined
|
||||
return tool
|
||||
}
|
||||
|
||||
/**
|
||||
* Project visible definitions onto the allowlisted model-facing schema fields,
|
||||
* excluding execution and presentation callbacks.
|
||||
@@ -1198,7 +1274,7 @@ export class ToolRegistry extends Service {
|
||||
* @returns the fail-closed scheduling mode.
|
||||
*/
|
||||
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
|
||||
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
|
||||
try {
|
||||
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
|
||||
@@ -1229,6 +1305,26 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `code` mode collapse denies a model-direct call: only the
|
||||
* reserved `run_code` transport may be named. Nested sub-dispatches (a
|
||||
* `parent` token set) bypass the collapse. One home for the
|
||||
* security-relevant predicate, shared by {@link resolveExecution} and
|
||||
* {@link createExecution} so the two can never drift apart.
|
||||
*
|
||||
* Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `code`
|
||||
* by an agent preset under a native deployment is the composition
|
||||
* `dsh-agent-tool-mode` exists for, and reading the deployment default would
|
||||
* leave exactly that agent uncollapsed — announcing one surface while
|
||||
* executing another, which is the bypass this collapse closes.
|
||||
* @param name - the tool name as registered.
|
||||
* @param scope - the viewing scope whose effective presentation mode applies.
|
||||
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
|
||||
*/
|
||||
private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean {
|
||||
return !nested && this.modeFor(scope) === 'code' && name !== RUN_CODE_NAME
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute through pre-policy, guards, around-dispatch, post-policy,
|
||||
* definition-owned content finalization, and final notification. Tool and
|
||||
@@ -1274,8 +1370,15 @@ export class ToolRegistry extends Service {
|
||||
const agent = exec.agent
|
||||
const parent = exec.parent
|
||||
const signal = exec.signal
|
||||
const definition = this.get(name, agent)
|
||||
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
||||
// Distinguish a mode-collapsed call (visible in the scope, denied only by
|
||||
// the `code` collapse) from a genuinely unknown tool. A collapsed call is
|
||||
// deterministically denied, so it terminates BEFORE the extensible policy
|
||||
// pipeline: pre-execute listeners, approval `ask`, and guards must never
|
||||
// observe — or worse, approve — a call that can only fail. An unknown tool
|
||||
// keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy
|
||||
// listeners still see every name that reaches the registry.
|
||||
const visible = this.get(name, agent)
|
||||
const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined)
|
||||
const concludingExecutions = this.concludingExecutions
|
||||
const base = {
|
||||
token,
|
||||
@@ -1292,6 +1395,19 @@ export class ToolRegistry extends Service {
|
||||
concludingExecutions.add(this as unknown as ToolExecution)
|
||||
},
|
||||
}
|
||||
// Capture the finalizer BEFORE argument materialization: the
|
||||
// `finalizeContent` contract snapshots the callback when the call starts,
|
||||
// and an arguments getter can replace or clear the registered callback
|
||||
// during `snapshotJsonValue`. The collapse only decides whether the
|
||||
// CAPTURED callback is retained: the pre-dispatch abort path keeps it
|
||||
// (the cancellation contract routes aborted results through it — a getter
|
||||
// that aborts mid-materialization before an invalid-args failure lands in
|
||||
// the same retained path), while the `UNKNOWN_TOOL` denial and the
|
||||
// invalid-args failure of a NON-ABORTED collapsed call drop it (the call
|
||||
// could never execute).
|
||||
const capturedFinalizer = visible?.finalizeContent?.bind(visible)
|
||||
const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined =>
|
||||
collapsed && !signal.aborted ? undefined : capturedFinalizer
|
||||
try {
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
if (detached === undefined) {
|
||||
@@ -1299,15 +1415,37 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
|
||||
this.deferredContexts.set(execution, deferredContexts)
|
||||
this.contentFinalizers.set(execution, finalizeContent)
|
||||
this.contentFinalizers.set(execution, finalizerFor())
|
||||
this.cancellationStates.set(execution, {
|
||||
callerSignal: signal,
|
||||
bodyInvoked: false,
|
||||
})
|
||||
if (collapsed) {
|
||||
// The collapse denies the call before the policy pipeline, but a
|
||||
// pre-dispatch abort still keeps the established cancellation
|
||||
// contract: `prepare`'s caller-cancellation check is skipped for
|
||||
// final-results, so honor the abort here instead of surfacing
|
||||
// `UNKNOWN_TOOL` on an already-cancelled call.
|
||||
if (signal.aborted) {
|
||||
return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() }
|
||||
}
|
||||
// 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) {
|
||||
const execution: MutableToolRunContext = { ...base, arguments: undefined }
|
||||
this.contentFinalizers.set(execution, finalizeContent)
|
||||
this.contentFinalizers.set(execution, finalizerFor())
|
||||
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
|
||||
}
|
||||
}
|
||||
@@ -1405,7 +1543,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
exec.signal = signal
|
||||
try {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
state.bodyInvoked = true
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
@@ -1627,7 +1765,7 @@ export class ToolRegistry extends Service {
|
||||
if (result.isError) {
|
||||
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
|
||||
}
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const replaced = this.createSuccessResult(exec, tool, decision.value)
|
||||
return this.markCanonical(exec, {
|
||||
@@ -1696,7 +1834,7 @@ export class ToolRegistry extends Service {
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const normalized = this.createSuccessResult(exec, tool, result.value)
|
||||
return this.markCanonical(exec, {
|
||||
|
||||
@@ -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' }
|
||||
@@ -1561,6 +1587,43 @@ describe('the run_code dispatch bridge', () => {
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
||||
})
|
||||
it('denies a model-direct native-tool call under code mode as UNKNOWN_TOOL', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
const registry = new ToolRegistry(ctx, { mode: 'code' })
|
||||
registerEcho(ctx, 'write')
|
||||
const result = await registry.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('call-1'),
|
||||
name: 'write',
|
||||
arguments: { text: 'hello' },
|
||||
})
|
||||
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 () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
const registry = new ToolRegistry(ctx, { mode: 'code' })
|
||||
registerEcho(ctx, 'write')
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
const result = await registry.execute({
|
||||
signal: aborted.signal,
|
||||
callId: CallId('call-1'),
|
||||
name: 'write',
|
||||
arguments: { text: 'hello' },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1572,7 +1635,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
describe('per-agent presentation', () => {
|
||||
it('gives one agent Code Mode while the deployment stays native', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'native' })
|
||||
registerEcho(ctx)
|
||||
const calls = registerEcho(ctx)
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
|
||||
scope.ctx.tools.presentAs('code')
|
||||
@@ -1581,6 +1644,17 @@ describe('per-agent presentation', () => {
|
||||
expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
expect(coded.sections.find(section => section.name === 'tools:sdk')?.text)
|
||||
.toContain('echo')
|
||||
// Announced surface and callable surface must agree for THIS agent, whose
|
||||
// mode is its own rather than the deployment's.
|
||||
const denied = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('coded-direct'),
|
||||
name: 'echo',
|
||||
arguments: { value: 'coded' },
|
||||
agent,
|
||||
})
|
||||
expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
|
||||
expect(calls).toEqual([])
|
||||
// The deployment default is untouched: an agent that declared nothing —
|
||||
// and the global view behind it — still sees the native catalog.
|
||||
const native = await systemPrompt.assemble()
|
||||
@@ -1591,7 +1665,7 @@ describe('per-agent presentation', () => {
|
||||
it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => {
|
||||
const { bindScopeParent } = await import('@deepseek-ai/dsh-scope')
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'native' })
|
||||
registerEcho(ctx)
|
||||
const calls = registerEcho(ctx)
|
||||
// The preset's standing scope declares once; the agent only PARENTS to it
|
||||
// (the per-preset standing mount configuration has no per-agent declaration).
|
||||
const standing = await mintAgentScope(ctx, 'preset:code-like')
|
||||
@@ -1603,10 +1677,40 @@ describe('per-agent presentation', () => {
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined()
|
||||
const coded = await systemPrompt.assemble({ scope: joined.agent })
|
||||
expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
// Through the EXECUTOR, not just the wire: the deployment default is
|
||||
// `native` here, so a collapse predicate reading it instead of this
|
||||
// scope's effective mode would announce [run_code] and still execute the
|
||||
// native call — the bypass, reopened for exactly the preset composition
|
||||
// `dsh-agent-tool-mode` produces.
|
||||
expect(ctx.tools.executionMode({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('preset-coded-schedule'),
|
||||
name: 'echo',
|
||||
arguments: { value: 'joined' },
|
||||
agent: joined.agent,
|
||||
})).toEqual({ kind: 'exclusive' })
|
||||
const denied = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('preset-coded-direct'),
|
||||
name: 'echo',
|
||||
arguments: { value: 'joined' },
|
||||
agent: joined.agent,
|
||||
})
|
||||
expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
|
||||
expect(calls).toEqual([])
|
||||
// A sibling that never parented stays native, as does the global view.
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined()
|
||||
const native = await systemPrompt.assemble({ scope: loner.agent })
|
||||
expect(native.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
const allowed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('native-sibling-direct'),
|
||||
name: 'echo',
|
||||
arguments: { value: 'loner' },
|
||||
agent: loner.agent,
|
||||
})
|
||||
expect(allowed).toMatchObject({ isError: false, value: 'echo:loner' })
|
||||
expect(calls).toEqual([{ value: 'loner' }])
|
||||
})
|
||||
|
||||
it('keeps run_code out of a native agent\'s dispatch table', async () => {
|
||||
|
||||
@@ -190,14 +190,14 @@ describe('restrict()', () => {
|
||||
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
|
||||
// A scope's own registration is exempt from its own filter, so naming it
|
||||
// is a caller error rather than a silent no-op.
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall".*known global tools: real/s)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/Restrictable tools: \(none\)/)
|
||||
.toThrow(/known global tools: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -907,6 +907,25 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the normalized content when the final content transform returns undefined', async () => {
|
||||
const ctx = await setup()
|
||||
let finalized = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'identity-finalizer',
|
||||
finalizeContent() {
|
||||
finalized += 1
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: '' }])
|
||||
expect(finalized).toBe(1)
|
||||
})
|
||||
|
||||
it('a block decision can ALSO attach additionalContexts', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -343,7 +343,7 @@ describe('startInProcessRun', () => {
|
||||
await expect(startInProcessRun({
|
||||
...request(parent),
|
||||
toolFilter: { deny: ['unknown-tool'] },
|
||||
}, {})).rejects.toThrow('unknown inherited tool')
|
||||
}, {})).rejects.toThrow('unknown global tool')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
@@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).rejects.toThrow(/unknown inherited tool "no_such_tool"/)
|
||||
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user