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:
@@ -1124,6 +1124,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'tools',
|
||||
summary: 'Tool registry and execution pipeline.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'presentAs(mode: ToolPresentationMode): () => void',
|
||||
jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'register(definition: ToolDefinition): () => void',
|
||||
jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */',
|
||||
@@ -3043,6 +3047,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ToolOutputDefinition',
|
||||
declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolPresentationMode',
|
||||
declaration: 'export type ToolPresentationMode = \'native\' | \'code\' | \'both\';',
|
||||
},
|
||||
{
|
||||
name: 'ToolProviderResult',
|
||||
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
|
||||
|
||||
6
packages/core/agent-tool-mode/README.i18n.yaml
Normal file
6
packages/core/agent-tool-mode/README.i18n.yaml
Normal file
@@ -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 packages/core/agent-tool-mode/README.md
|
||||
README.md: 59b2d7fa817b349ff0bf04d3871f1617c95d9125
|
||||
README.zh.md: 6fae0fb20be473bb3f0ffe3a4c72c1986d4f272e
|
||||
31
packages/core/agent-tool-mode/README.md
Normal file
31
packages/core/agent-tool-mode/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# dsh-agent-tool-mode
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`.
|
||||
|
||||
## Why a row rather than a registry
|
||||
|
||||
The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it.
|
||||
|
||||
What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get.
|
||||
|
||||
## What it does
|
||||
|
||||
`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition.
|
||||
|
||||
`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing.
|
||||
|
||||
One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override.
|
||||
|
||||
## 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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset.
|
||||
31
packages/core/agent-tool-mode/README.zh.md
Normal file
31
packages/core/agent-tool-mode/README.zh.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# dsh-agent-tool-mode
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。
|
||||
|
||||
## 为什么是一行插件,而不是把注册表搬下来
|
||||
|
||||
工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。
|
||||
|
||||
preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。
|
||||
|
||||
## 它做什么
|
||||
|
||||
`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。
|
||||
|
||||
`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。
|
||||
|
||||
一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。
|
||||
|
||||
## 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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。
|
||||
45
packages/core/agent-tool-mode/package.json
Normal file
45
packages/core/agent-tool-mode/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-tool-mode",
|
||||
"description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
70
packages/core/agent-tool-mode/src/index.ts
Normal file
70
packages/core/agent-tool-mode/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Agent-plane presentation selector: the row an agent preset carries to say
|
||||
* which form of its tools the model sees.
|
||||
*
|
||||
* The tool registry itself stays on the host plane — the agent loop's
|
||||
* scheduler, the API proxy's presenters, and every tool plugin are all its
|
||||
* consumers, so it cannot move into a preset. What a preset CAN own is the
|
||||
* presentation: `ctx.tools.presentAs()` declares it for the mounting agent
|
||||
* alone, so a Code Mode agent runs beside native ones in one process.
|
||||
*
|
||||
* A code mode needs a TypeScript code runtime, which is a host-plane service
|
||||
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
|
||||
* This row therefore waits for it rather than assuming it: a preset selecting
|
||||
* Code Mode against a deployment that composes no runtime fails at mount, named
|
||||
* in the preset's own activation audit, instead of at the first prompt.
|
||||
* @module @deepseek-ai/dsh-agent-tool-mode
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'tool-mode'
|
||||
|
||||
/**
|
||||
* Required services. `codeRuntime` is NOT listed: a `native` row must mount in
|
||||
* a deployment that composes no runtime, and the mode-dependent wait is
|
||||
* declared inside {@link apply} instead.
|
||||
*/
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Plugin config. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The form this agent's model sees. `native` sends every visible schema,
|
||||
* `code` sends only `run_code` plus a generated SDK, `both` sends both.
|
||||
* Required rather than defaulted: the deployment default is what a preset
|
||||
* without this row already gets, so an omitted value would mean the row was
|
||||
* composed for nothing.
|
||||
*/
|
||||
mode: ToolPresentationMode
|
||||
}
|
||||
|
||||
/** Runtime schema. */
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union(['native', 'code', 'both'] as const).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Declare this agent's tool presentation.
|
||||
* @param ctx - the mounting agent's scope context.
|
||||
* @param config - the selected presentation.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// `presentAs` is itself the effect — it registers through the calling
|
||||
// context and hands back that exact disposer — so the declaration unwinds
|
||||
// with this row without a second wrapper owning it.
|
||||
if (config.mode === 'native') {
|
||||
ctx.tools.presentAs('native')
|
||||
return
|
||||
}
|
||||
// The wait is the loud failure: an entry still pending on `codeRuntime` is
|
||||
// what `dsh-agent-presets` reports as an unusable row, naming this id.
|
||||
ctx.inject(['codeRuntime'], (runtimeCtx: Context) => {
|
||||
runtimeCtx.tools.presentAs(config.mode)
|
||||
})
|
||||
}
|
||||
32
packages/core/agent-tool-mode/src/invariant.ts
Normal file
32
packages/core/agent-tool-mode/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`.
|
||||
* @module @deepseek-ai/dsh-agent-tool-mode/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-mode-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package makes exactly one scoped call into
|
||||
* `ctx.tools` and owns no event or snapshot of its own; the relation it
|
||||
* establishes — which presentation one agent's assembly uses — is the tool
|
||||
* registry's to hold, and `dsh-tools` observes it there.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
129
packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts
Normal file
129
packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The row an agent preset carries to pick its tool presentation. What it owes
|
||||
* its caller: the choice reaches THIS agent and no other, it unwinds with the
|
||||
* agent, and a code mode composed against a deployment with no code runtime
|
||||
* stops at mount — where a preset's activation audit can name it — rather
|
||||
* than at the first prompt assembly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode'
|
||||
|
||||
/** A runtime that never runs anything: presentation never dispatches. */
|
||||
class StubRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'stub'
|
||||
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
return Promise.resolve({ logs: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/** A host plane with one tool, optionally carrying a code runtime. */
|
||||
async function host(options: { runtime?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
await ctx.plugin(ToolRegistry, {})
|
||||
if (options.runtime !== false) await ctx.plugin(StubRuntime)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'Echo tool.',
|
||||
parameters: { value: { type: 'string', required: true } },
|
||||
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
||||
execute: args => Promise.resolve(args.value),
|
||||
}))
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mount the row under one agent's scope, as a preset subtree does. */
|
||||
async function mount(ctx: Context, config: Config, id = 'agent') {
|
||||
const agent = { id: SessionId(id) } as Agent
|
||||
let inner!: Context
|
||||
const fiber = ctx.plugin(Object.assign((host: Context) => {
|
||||
inner = createScope(host, agent).ctx
|
||||
}, { inject: ['tools', 'systemPrompt'] }))
|
||||
await fiber.await()
|
||||
const row = inner.plugin({ name, inject: [...inject], Config, apply }, config)
|
||||
await row.await()
|
||||
return { agent, fiber, row }
|
||||
}
|
||||
|
||||
describe('the tool-mode row', () => {
|
||||
it('declares the services it uses without holding a code runtime hostage', () => {
|
||||
// A `native` row must mount where no runtime is composed, so the wait is
|
||||
// conditional inside apply rather than static metadata.
|
||||
expect(inject).toEqual(['tools'])
|
||||
})
|
||||
|
||||
it('gives its own agent Code Mode and leaves the rest native', async () => {
|
||||
const ctx = await host()
|
||||
const coded = await mount(ctx, { mode: 'code' }, 'coded')
|
||||
const plain = await mount(ctx, { mode: 'native' }, 'plain')
|
||||
|
||||
const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent })
|
||||
|
||||
expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo')
|
||||
expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('presents both forms when asked for both', async () => {
|
||||
const ctx = await host()
|
||||
const { agent } = await mount(ctx, { mode: 'both' })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME])
|
||||
})
|
||||
|
||||
it('restores the deployment default when the agent unloads', async () => {
|
||||
const ctx = await host()
|
||||
const { agent, row } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
await row.dispose()
|
||||
|
||||
// HMR safety: the preset subtree is torn down with its agent, and the
|
||||
// presentation must go with it rather than outliving the composition.
|
||||
const assembly = await ctx.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('waits for a code runtime the deployment does not compose', async () => {
|
||||
const ctx = await host({ runtime: false })
|
||||
|
||||
const { agent, row } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
// Pending, not applied: `dsh-agent-presets` rejects a mount holding a row
|
||||
// that never reached a usable state, naming this id — so the preset fails
|
||||
// where the operator can act, instead of at the first request.
|
||||
expect(row.ctx.get('codeRuntime')).toBeUndefined()
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('applies once the runtime arrives', async () => {
|
||||
const ctx = await host({ runtime: false })
|
||||
const { agent } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
await ctx.plugin(StubRuntime)
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
})
|
||||
|
||||
it('requires a mode rather than defaulting one', () => {
|
||||
// An omitted value would mean the row was composed for nothing: a preset
|
||||
// without this row already gets the deployment default.
|
||||
expect(() => Config({} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
27
packages/core/agent-tool-mode/tsconfig.json
Normal file
27
packages/core/agent-tool-mode/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user