feat(tools): let one agent choose its tool presentation, and ship code

Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.

The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.

Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.

`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.

The shipped `code` preset is `standard` plus that row, ordered second.
This commit is contained in:
Yichen Jiang
2026-08-05 20:31:52 +08:00
parent d247c50c6f
commit 9eaa9d22a5
31 changed files with 1108 additions and 60 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad
README.md: 99c529880be631663084236a3d56faf96d8055c0
README.zh.md: 4a4e0258ef315f81af37c8d2c435e2596d802dab

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`.
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -13,11 +13,12 @@ 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. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, 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 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 TypeScript `ctx.codeRuntime`, 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 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-explicit-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)).
@@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **Code Mode is TypeScript-only, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only.
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。
工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者;单个 agent 可用 `presentAs` 为自己遮蔽该默认值。
## 服务:`ToolRegistry`(ctx 键:`tools`)
@@ -13,11 +13,12 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `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 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-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))。
@@ -190,6 +191,6 @@ The available tools:
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。
- **Code Mode 只支持 TypeScript,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽与 `presentAs` 会选择每个 agent 的可见绑定及其形态,但在同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。

View File

@@ -591,10 +591,15 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
* Code modes require a TypeScript runtime and fail prompt assembly when it is
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
* 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
* TypeScript runtime and fail prompt assembly when it is absent or
* mismatched. 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.
*/
mode?: ToolPresentationMode
/**
@@ -649,6 +654,12 @@ class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
/**
* Presentation this scope's agent declared for itself, shadowing the
* deployment default. One cell rather than an entry table: two answers to
* "which form does the model see" is a contradiction, not a merge.
*/
mode: ToolPresentationMode | undefined
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
@@ -659,6 +670,7 @@ class ToolLayer implements ScopeLayer {
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
&& this.mode === undefined
}
/** Whether every compiled restriction in this layer admits a global tool name. */
@@ -739,41 +751,117 @@ export class ToolRegistry extends Service {
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
/** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */
private readonly defaultMode: ToolPresentationMode
private readonly maxParallelSubCalls: number
/**
* Reserved presentation transport, kept outside the filterable registration
* layers. Built on first need rather than at construction: which agents run
* a code mode is no longer known when the service is constructed, and the
* transport is stateless beyond its closures over `this`.
*/
private codeTransport: ToolDefinition | undefined
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'tools')
// The schema already defaulted an omitted mode; the ?? narrows the
// optional-input type for direct (non-Loader) construction in tests.
this.mode = config.mode ?? 'native'
// `run_code` is presentation infrastructure, not an end capability. It
// therefore does not enter the global layer: per-agent restrictions must
// not remove it, and a scoped registration must not shadow it. The
// visibility resolver appends this reserved definition after resolving
// the filterable global/scoped capability layers.
this.codeTransport = this.mode === 'native'
? undefined
: createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(),
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
this.defaultMode = config.mode ?? 'native'
this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order.
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.sdkSchemas(context.scope))
},
})
if (this.defaultMode !== 'native') {
ctx.systemPrompt.section(this.sdkSection())
}
}
/**
* The generated-SDK prompt section, registered globally by a code-mode
* deployment and per agent by {@link presentAs}.
*
* The body regenerates from the CALLING scope, and renders empty for an
* agent presenting natively — an agent that opted out under a code-mode
* deployment still sees the global registration, and an empty section is
* dropped from the rendered prompt.
* @returns the section registration.
*/
private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
return {
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order.
text: (context) => {
const mode = this.modeFor(context.scope)
if (mode === 'native') return ''
this.requireCodeRuntime(mode)
return renderToolsSdk(this.sdkSchemas(context.scope))
},
}
}
/**
* The presentation one scope's agent sees: its own declaration, else the
* deployment default.
* @param scope - the calling agent, or undefined for the global view.
* @returns the resolved presentation mode.
*/
private modeFor(scope?: ScopeKey): ToolPresentationMode {
return this.layers.peek(scope)?.mode ?? this.defaultMode
}
/**
* The reserved `run_code` transport, built on first need.
*
* It never enters the global layer: per-agent restrictions must not remove
* it, and a scoped registration must not shadow it. The visibility resolver
* appends it after resolving the filterable global/scoped capability layers,
* and only for scopes whose mode actually presents it.
* @returns the shared transport definition.
*/
private requireCodeTransport(): ToolDefinition {
this.codeTransport ??= createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(this.defaultMode),
maxParallel: this.maxParallelSubCalls,
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
return this.codeTransport
}
/**
* Present this agent's tools in `mode` instead of the deployment default.
*
* Scoped only, and one declaration per agent: this is how an agent preset
* composes a Code Mode agent beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation this agent's model sees.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void {
const ctx = this.ctx
if (scopeOf(ctx) === undefined) {
throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row')
}
const dispose = ctx.effect(function* (this: ToolRegistry) {
yield this.layers.effect(
ctx,
(layer) => {
if (layer.mode !== undefined) {
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`)
}
layer.mode = mode
return () => { layer.mode = undefined }
},
{ label: 'tools.presentAs()' },
)
// The SDK section is per agent 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())
}.bind(this), 'tools.presentAs()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
return dispose
}
/**
* Build one scope's wire schemas and names for prompt-order validation.
* Restrictions do not make known tools invalid, but a mode collapse does.
@@ -781,11 +869,12 @@ export class ToolRegistry extends Service {
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
const view = this.view(scope)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'native') {
const mode = this.modeFor(scope)
if (mode === 'native') {
return { schemas, knownNames: [...view.knownNames] }
}
this.requireCodeRuntime()
if (this.mode === 'code') {
this.requireCodeRuntime(mode)
if (mode === 'code') {
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
knownNames: [RUN_CODE_NAME],
@@ -802,13 +891,13 @@ export class ToolRegistry extends Service {
* 'native'` (the loop's optional-backend idiom, same as
* `sessionPersistence`).
*/
private requireCodeRuntime(): CodeRuntime {
private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (runtime.language !== 'typescript') {
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
throw new Error(`dsh-tools: mode "${mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
}
return runtime
}
@@ -833,7 +922,10 @@ export class ToolRegistry extends Service {
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
// Reserved unconditionally: any agent may select a code mode for itself,
// so a name free to take under the deployment default would become a
// collision the moment a preset mounted.
if (name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
return this.layers.effect(
@@ -864,8 +956,7 @@ export class ToolRegistry extends Service {
...allow !== undefined ? { allow: new Set(allow) } : {},
...deny !== undefined ? { deny: new Set(deny) } : {},
}
if (this.codeTransport !== undefined
&& [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
}
const known = this.view(scope).restrictableNames
@@ -931,9 +1022,11 @@ export class ToolRegistry extends Service {
}
// Presentation infrastructure is resolved last and outside capability
// filtering. Registration rejects this reserved name, so the insertion is
// an invariant assertion as well as protection against future layer changes.
if (this.codeTransport !== undefined) {
visible.set(RUN_CODE_NAME, this.codeTransport)
// an invariant assertion as well as protection against future layer
// changes. Per scope: a native agent must not find `run_code` in its
// dispatch table because some other agent in the process presents it.
if (this.modeFor(scope) !== 'native') {
visible.set(RUN_CODE_NAME, this.requireCodeTransport())
}
return { visible, knownNames, restrictableNames }
}

View File

@@ -1481,3 +1481,106 @@ describe('the run_code dispatch bridge', () => {
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
})
/**
* Presentation is per agent, because an agent preset composes it: one
* deployment runs a Code Mode agent beside native ones, and neither may see
* the other's catalog. The deployment `mode` is the default those agents
* shadow, not a process-wide fact.
*/
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 { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('code')
const coded = await systemPrompt.assemble({ scope: agent })
expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
expect(coded.sections.find(section => section.name === 'tools:sdk')?.text)
.toContain('echo')
// 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()
expect(native.tools.map(tool => tool.name)).toEqual(['echo'])
expect(native.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it('keeps run_code out of a native agent\'s dispatch table', async () => {
const { ctx } = await setup({ mode: 'native' })
registerEcho(ctx)
const coded = await mintAgentScope(ctx, 'coded')
const plain = await mintAgentScope(ctx, 'plain')
coded.scope.ctx.tools.presentAs('code')
// Not merely hidden from the prompt: the transport one agent presents must
// not be dispatchable by another that never presented it.
expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined()
expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined()
expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined()
})
it('lets an agent opt out of a code-mode deployment', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('native')
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
// The deployment's global section still reaches this scope; rendering it
// empty is what keeps the opted-out agent's prompt free of an SDK.
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('')
})
it('restores the deployment default when the agent unloads', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native' })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
const dispose = scope.ctx.tools.presentAs('code')
dispose()
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it('refuses a second declaration for the same agent', async () => {
const { ctx } = await setup({ mode: 'native' })
const { scope } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('code')
// Two answers to "which form does the model see" is a contradiction, and
// silently keeping either one would make the composition unreadable.
expect(() => scope.ctx.tools.presentAs('both'))
.toThrow('conflicts with "code" already declared')
})
it('refuses an unscoped declaration', async () => {
const { ctx } = await setup({ mode: 'native' })
expect(() => ctx.tools.presentAs('code'))
.toThrow('requires a scoped context')
})
it('reserves run_code even where no agent presents it', async () => {
const { ctx } = await setup({ mode: 'native' })
// The name must stay free under a native deployment too: an agent preset
// mounting later would otherwise collide with whatever took it.
expect(() => registerEcho(ctx, RUN_CODE_NAME)).toThrow('is reserved')
})
it('reports the missing runtime against the agent\'s own mode', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
registerEcho(ctx)
const { scope, agent } = await mintAgentScope(ctx)
scope.ctx.tools.presentAs('both')
await expect(systemPrompt.assemble({ scope: agent }))
.rejects.toThrow('mode "both" requires a code runtime')
})
})