深色模式
源码拆解 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.ts 里 ToolDefinition 是三个世界的交汇点(原理篇 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.ts 的 ToolRuntime 公开面:
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 节。)