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:
Yichen Jiang
2026-08-11 23:22:54 +08:00
committed by GitHub
28 changed files with 1007 additions and 124 deletions
@@ -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
+1 -1
View File
@@ -20,7 +20,7 @@ One agent declares one presentation. A second declaration in the same compositio
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section and the rule that only `run_code` may be called directly, `native` presents every tool schema. The selection also decides what may EXECUTE: under `code` the registry resolves a model-direct call naming any other tool to `UNKNOWN_TOOL`, so this row is what keeps the announced surface and the callable surface the same for every agent it covers ([executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)).
#### KV Cache effect
+1 -1
View File
@@ -20,7 +20,7 @@ preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs(
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
间接生效,取决于它在 `dsh-tools` 中选择的投影:`code` 呈现 `run_code`、一份生成的 SDK 段,以及「只有 `run_code` 可被直接调用」这条规则,`native` 呈现每个工具的 schema。该选择同时决定了**什么可以执行**:在 `code` 下,注册表会把模型直呼其他任何工具名解析为 `UNKNOWN_TOOL`,因此这一行正是让「通告面」与「可调用面」对每个被它覆盖的 agent 保持一致的东西([执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md))。
#### KV Cache effect
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7
README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165
README.md: 44eb25b79436a75f08406102fc1e3734e59b1001
README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3
+6 -4
View File
@@ -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
+6 -4
View File
@@ -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 说明
+171 -33
View File
@@ -43,6 +43,20 @@ import { renderToolsSdkPy } from './py-types.ts'
* with its zh pair, plus this package's own README pair and the
* {@link Config.mode} JSDoc.
*/
/**
* Prompt order of the `code` collapse statement: after the persona and before
* the 100-199 per-tool guidance band, so the model reads which tools it may
* call before it reads what each one is for.
*/
const COLLAPSE_SECTION_ORDER = 99
/**
* The model-facing statement of the `code` collapse. Names the consequence
* (the call fails) and the route (inside the program), because a rule the
* model can only discover by being denied is one it corrects too late.
*/
const CODE_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.`
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
typescript: renderToolsSdk,
python: renderToolsSdkPy,
@@ -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, {
+106 -2
View File
@@ -135,6 +135,32 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code:')
})
it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
// Stand in for a real tool's guidance section, which sits in the 100-199
// band and names its tool without saying how it is reached.
ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' })
const assembly = await systemPrompt.assemble()
const names = assembly.sections.map(section => section.name)
const rule = assembly.sections.find(section => section.name === 'tools:code-only')
expect(rule?.text).toContain(`\`${RUN_CODE_NAME}\` is the only tool you can call directly`)
// The rule is worthless after the guidance it qualifies.
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tool:echo'))
expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tools:sdk'))
})
it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
// Registered (the deployment is non-native) but empty, so the renderer
// drops it: `both` executes the native call the rule would forbid.
expect(assembly.sections.find(section => section.name === 'tools:code-only')?.text).toBe('')
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
})
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
let output: JsonSchemaNode = { type: 'string' }
@@ -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 () => {
+4 -4
View File
@@ -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\)/)
})
})
+19
View File
@@ -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)