Skip to content

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 presentResultUI 与持久化: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-executewaterfallallow / deny / ask审批策略(permission presets)、沙箱策略
tools/executewaterfall(环绕)包裹执行超时(tool-call-timeout)、重试、指标
tools/post-executewaterfallaccept / block结果改写、内容过滤、compaction 剪枝
tools/resultemit无(只读)审计、遥测、UI 通知

另外还有一个单调收紧机制:ctx.tools.guard() 可以做最终拒绝(任何插件都能单向收紧,不能被后续插件放松)。安全策略用它表达"无论如何都不允许"。

并发模型

模型一次可能调用多个工具。每个工具声明自己的 executionMode

ts
{ kind: 'parallel' }    // 可以与其他并行安全调用并发
{ kind: 'exclusive' }   // 独占执行(比如会改全局状态的 shell)

driver 按声明分类,用"有界的滚动池"调度:并行安全的并发跑,独占的排队跑,开始前重新分类(因为中途可能有新工具被注册/注销)。

6.4 执行上下文的保护

execute(args, exec) 里的 exec 值得单独说。它携带:

  • 不可变身份callIdnamearguments、发起调用的 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 里做一个简化版。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写