Skip to content

源码拆解 06 · dsh-tools:工具注册表与执行管线

本篇拆解 packages/core/tools(npm 包 @deepseek-ai/dsh-tools)。重点看三处:注册表的三种约束能力(register / restrict / guard)、执行管线的四事件实现、以及 schema 如何从 TypeScript 类型世界流向模型世界。

6.1 文件地图

text
packages/core/tools/src/
├── index.ts          # ToolRuntime 服务:注册表 + 执行管线入口(~1950 行,主体)
├── types.ts          # ToolDefinition / 执行输入输出类型
├── schema.ts         # defineTool 工厂 + 参数校验
├── json-schema.ts    # JSON Schema 构造(模型面)
├── ts-types.ts       # 参数类型推导(InferArgs)
├── code-mode.ts      # Code Mode:工具以编程接口暴露
└── presentation.ts   # UI 卡片渲染意图

6.2 一个工具定义的三个世界

types.tsToolDefinition 是三个世界的交汇点(原理篇 6.1的表):

ts
interface ToolDefinition extends ToolSchema {   // ToolSchema = { name; description?; parameters? }
  readonly output: ToolOutputDefinition         // 输出契约(强制)
  execute(args: unknown, exec: ToolRunContext): Promise<unknown>
  timeoutMs?: number                            // 协作式超时预算
  isConcurrencySafe?(args: unknown): boolean    // true 才可并行;缺省/异常 = 互斥(失败关闭!)
  presentCall?(args): ToolCallView | undefined        // UI 待执行态(纯函数)
  presentResult?(args, result): ToolResultView | undefined
}

注意 isConcurrencySafe 的失败关闭语义:这个函数抛异常或缺省时,工具按互斥处理。并发安全的判定宁可保守——一个被误判为"可并行"的有状态工具会造成真正的数据竞争,而误判为"互斥"最多慢一点。安全策略默认 deny,这里默认 serial,同一套工程直觉。

defineTool 工厂(schema.ts)做参数规约的编译:从 ParameterSchemaSpec 推导出 TypeScript 的 args 类型(InferArgs),运行时预校验模型传来的参数。模型给参数不可信,执行前先过 schema——参数错误变成结构化的 ToolArgsError,而不是在工具内部深处炸出一个 TypeError。

6.3 ToolRuntime:注册表的三层约束

index.tsToolRuntime 公开面:

ts
class ToolRuntime extends Service {
  register(definition: ToolDefinition): () => void   // 注册(effect,可精确注销)
  restrict(filter: ToolRestriction): () => void      // 作用域内 allow/deny 过滤全局工具
  guard(guard: ToolGuard): () => void                // 单调守卫:返回字符串 = 拒绝原因
  schemas(scope?): ToolSchema[]                      // 模型可见的 schema 列表
  execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>  // 完整管线入口
}

三层约束各解决一个问题:

能力语义使用场景
register增:把工具放进某个作用域工具插件、agent preset
restrict减:按 allow/deny 过滤其他插件注册的工具给某个 agent 裁剪能力集
guard闸:单调收紧,任何插件都能最终拒绝安全策略的硬底线

guard 的"单调"很关键:多个 guard 叠加只会越来越严,不存在"后来的插件放松前面的限制"——这正是审批系统需要的语义(原理篇 6.3)。

6.4 execute():管线入口与作用域解析

execute() 是完整管线入口,内部流程对应原理篇 6.3 的时序图

ts
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
  // ① 解析工具定义(作用域链上查找;找不到 → ToolNotFoundError, code 'UNKNOWN_TOOL')
  // ② tools/pre-execute 瀑布 → allow / deny / ask
  //    deny → 直接产出 isError 结果;ask → 交给审批插件(挂起等待用户)
  // ③ tools/execute 瀑布:监听器可替换 signal(加超时),环绕调用 definition.execute
  //    执行结果:抛错 → { isError: true, error: { name, code } }
  //             正常 → 校验 output.schema,产出 value + render 出的 content
  // ④ tools/post-execute 瀑布 → accept / 替换 value / block
  // ⑤ tools/result emit(只读观察;监听器失败被隔离,不反噬管线)
}

执行结果的两个重要字段:

ts
type ToolExecutionResult =
  | { isError: false; value: JsonValue; content: ContentBlock[]; concludesTurn?: true; … }
  | { isError: true; error: ToolFailure; content: ContentBlock[]; … }
  • value(规范值)与 content(渲染块)成对出现——原理篇 6.5的双层输出在类型上就是双字段;
  • concludesTurn:工具结果可声明"本回合到此为止"(官方注释:"The inverse control (stop a tool loop early) is data too")——连"提前结束循环"这种控制流都表达为数据,所以 turn 的停止条件无需特殊代码路径。

6.5 作用域:一个工具如何只属于一个 agent

register 的注册位置由调用时的当前 fiber 上下文决定:在根 ctx 上调用 → 全局工具;在 agent.ctx(isolate 作用域)上调用 → 该 agent 专属。schemas(scope)execute 的查找都沿作用域链:agent 作用域优先,根作用域兜底。

这套机制在 dsh-base 与 web bundle 的配合里有个精妙的用法:web 层把一批工具行(bash、fs、subagent……)disabled: true,然后由 agent presets 在 agent 作用域里按预设重新挂载——工具不再是进程级配置,而是会话级配置。--dump-config 里看到的那些 disabled: true 行背后就是这套分工。

6.6 Code Mode:同一个注册表的第二种消费方式

code-mode.ts 实现了消费工具注册表的另一种方式:模型不再通过 tool-call 调工具,而是生成代码、由运行时以编程接口执行(await tools.<name>(args))。同一个 ToolDefinition,两种消费协议。这印证了原理篇 6.6的话:注册表是接缝,消费方可替换。Code Mode 还要求模型侧的循环改造(循环内调用工具的代码如何被沙箱执行、如何把嵌套调用日志回写会话),这就是 tools/code-dispatch-log 事件与 tool/code-dispatch-* 会话事件的由来。

6.7 本章小结

dsh-tools 的三层结构:注册表(register/restrict/guard 三种约束)、管线(pre/execute/post/result 四事件)、schema 通道(TS 类型 ↔ JSON Schema 双向推导)。工具作者只需要关心"我的工具干什么",而"能不能干、怎么展示、怎么记账"全部由管线上的其他插件决定——工具与策略彻底解耦。

小练习:类型推导走一遍

defineTool 声明一个参数为 { query: string; limit?: number } 的工具,在 execute(args) 里体会 args 的类型推导。然后回答:1)模型传来 limit: "abc" 时会发生什么、发生在哪一步?2)isConcurrencySafe 为什么失败关闭?3)如果你想在某个 agent 上禁用这个工具但保留全局注册,用 restrict 还是 guard?为什么?

(答案分别在 6.2、6.2、6.3 节。)

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