深色模式
06 · 工具流水线:注册、装配与守卫式执行
工具是 Agent 的"手"。这一章讲 dsh 的 tools 服务:工具怎么注册、schema 怎么进入提示词、执行时经过哪道守卫式管线,以及"策略"为什么也是插件。
6.1 工具的最小形态
官方 cookbook 的最小工具示例就是一个完整的工具插件:
ts
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools'] // 等工具注册表就绪
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // 模型看到的说明
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // 缺省即 optional
},
output: {
schema: { type: 'string' }, // 返回值的规范 JSON 形态
render: (_args, value) => [{ type: 'text', text: value }], // 持久化渲染
},
async execute(args, exec) {
// args 由 parameters 推导出类型:{ path: string; limit?: number }
// exec 携带不可变身份(callId/name/arguments/agent/token)与 signal
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}分解这个工具的三个面:
| 面 | 字段 | 谁看 |
|---|---|---|
| 模型面 | name description parameters | 转成 JSON Schema,装配进系统提示词(ctx.systemPrompt 自动完成) |
| 执行面 | execute(args, exec) | 框架调用:args 已按 schema 校验,exec 是受保护的身份上下文 |
| 渲染面 | output.schema output.render presentCall presentResult | UI 与持久化:render 产出可回放的展示内容 |
defineTool 是类型化辅助函数;注册表也直接接受原始 ToolDefinition(JSON Schema 形式)——MCP 服务器发现的工具就是以这个形态进来的。殊途同归,这再次说明工具注册表只是一个"接缝"。
6.2 注册是 effect,作用域是 isolate
ctx.tools.register() 返回 disposer,并且附着在调用插件的 fiber 上:插件卸载 = 工具消失,HMR 安全。更妙的是作用域:
工具注册在哪个上下文,就对哪个作用域可见。全局工具注册在根 ctx;某个 agent 独享的工具注册在它的 agent.ctx(第 5 章的 setup 回调里)。这就是官方架构文档说的:"给一个会话不同的能力集 → 组合 agent preset,服务行加 isolate realm。" agent preset(预设)本质就是一份"往 agent 的 isolate 作用域里装哪些插件"的清单。
6.3 执行管线:四道闸门
工具不是"注册了就能随便跑"的。每个工具调用都要穿过一道守卫式管线——这是 dsh 里策略与机制分离的典型实现:
四道闸门的分工:
| 闸门 | 模式 | 决策 | 典型插件 |
|---|---|---|---|
tools/pre-execute | waterfall | allow / deny / ask | 审批策略(permission presets)、沙箱策略 |
tools/execute | waterfall(环绕) | 包裹执行 | 超时(tool-call-timeout)、重试、指标 |
tools/post-execute | waterfall | accept / block | 结果改写、内容过滤、compaction 剪枝 |
tools/result | emit | 无(只读) | 审计、遥测、UI 通知 |
另外还有一个单调收紧机制:ctx.tools.guard() 可以做最终拒绝(任何插件都能单向收紧,不能被后续插件放松)。安全策略用它表达"无论如何都不允许"。
并发模型
模型一次可能调用多个工具。每个工具声明自己的 executionMode:
ts
{ kind: 'parallel' } // 可以与其他并行安全调用并发
{ kind: 'exclusive' } // 独占执行(比如会改全局状态的 shell)driver 按声明分类,用"有界的滚动池"调度:并行安全的并发跑,独占的排队跑,开始前重新分类(因为中途可能有新工具被注册/注销)。
6.4 执行上下文的保护
execute(args, exec) 里的 exec 值得单独说。它携带:
- 不可变身份:
callId、name、arguments、发起调用的agent——工具不能伪造"是谁调的我"; - 不透明令牌
token:权限系统签发、工具看不懂但必须原样携带的凭证; signal:取消信号,长任务必须遵守——用户点了停止,工具就得停。
这些字段全部只读。原因很简单:工具是第三方代码(任何插件都能注册工具),身份信息如果可写,一个恶意工具就能冒充别的工具拿权限。
6.5 返回什么:规范值 + 渲染值
工具返回的两层结构值得细品:
ts
output: {
schema: { type: 'string' }, // 规范值:能 JSON 序列化,进入模型上下文
render: (_args, value) => [{ type: 'text', text: value }], // 渲染值:UI 展示与持久化
}schema声明的规范值进入模型上下文——模型只需要结构化事实;render产出的渲染值用于 UI 卡片与日志回放——人可以看懂的形式;- 两者分离,所以 UI 格式永远不会泄漏进模型上下文(官方 cookbook 的硬性规则之一)。
执行失败的语义:抛异常或返回值不符合 output.schema = 结果是 isError,模型会被告知"工具失败了"——失败也是信息。
纯函数纪律
presentCall(args) / presentResult(args, result) 负责把工具调用渲染成 UI 卡片(terminal / diff / search / web 等意图)。官方硬性要求它们是纯函数:不做 I/O、不读会话状态、不用时钟和随机数。原因是展示路径可能在回放中被反复调用,任何非确定性都会让"回放"失真。UI 卡片数据通过 presentationMeta 投影成可回放的持久数据。
6.6 工具生态一览
dsh-base 里开箱即用的工具插件(节选自 --dump-config 输出),每一个都是独立插件、独立可替换:
| 工具插件 | 能力 |
|---|---|
dsh-tool-bash / dsh-tool-pwsh | 执行 shell 命令(本地 / 沙箱) |
dsh-tool-fs / dsh-tool-fs-search | 文件读写与搜索 |
dsh-tool-str-replace-editor | 结构化文本替换 |
dsh-tool-web | 联网搜索与抓取 |
dsh-tool-subagent | 委派子代理 |
dsh-tool-todo | 任务清单 |
dsh-tool-workflow | 多代理工作流编排 |
dsh-tool-ask-user | 向用户提问 |
dsh-tool-skill | 技能加载 |
tools 服务行还有个 mode 配置(DSH_TOOLS_MODE),决定模型拿到的是"工具调用"还是"代码模式"(code mode:工具直接以编程接口暴露给模型生成的代码)——同一个注册表,两种消费方式,消费方也是可换的。
6.7 本章小结与练习
工具体系的心智模型:注册(effect + isolate 作用域)→ 装配(schema 自动进入提示词)→ 守卫式执行(四道闸门,策略全是插件)→ 双层输出(规范值给模型,渲染值给人)。 工具本身对框架一无所知,框架对工具的具体行为也一无所知——它们通过注册表和事件在运行时相遇。
小练习:设计审批策略
想象你要实现"任何写文件的工具调用都必须用户确认"。你会监听哪个事件?返回哪种决策?如果用户把策略设为"永远拒绝写文件",用哪条闸门实现?对照第 1 章的能力对照表检查你的答案,然后在 Demo 7 里做一个简化版。