第二部分 · 核心循环

第 5 章:工具系统——注册表与执行管道

一个工具从 schema 到 tool/result:五段管道、三层守卫、一次规范化的值

工具是注册进树的,不是写进核心的

在 dsh 里,「给模型加一个能力」只有一件事:ctx.tools.register(defineTool({...}))。工具注册表(packages/core/tools/src/index.tsToolRuntimectx.tools)不内置任何具体工具——连 read/bash 都来自各自接缝的 Consumer 包(第 7 章)。工具的定义同时携带三样东西:给模型看的 ToolSchema、给执行器跑的 execute、以及一个常被忽略但 dsh 当成一等公民的输出声明

/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
  /** Mandatory canonical output declaration. */
  readonly output: ToolOutputDefinition
  /**
   * Run one accepted call and return only its canonical lossless-JSON value.
   * Async work must observe or forward `exec.signal` and settle only after its
   * owned work reaches quiescence. ...
   */
  execute(args: unknown, exec: ToolRunContext): Promise<unknown>
  /**
   * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
   * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper);
   * it is NEVER sent to the model — `schemas()` whitelists only name/description/
   * parameters. ...
   */
  timeoutMs?: number
  /**
   * Pure synchronous classifier for overlap with sibling tool calls. Only
   * `true` opts in; omission, exceptions, non-`true` returns, and invalid
   * `defineTool` arguments are exclusive. ...
   */
  isConcurrencySafe?(args: unknown): boolean
  presentCall?(args: unknown): ToolCallView | undefined
  presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
}

三个约定值得先钉死:

  • execute 只返回规范化的无损 JSON 值output.schema 校验的「canonical value」),展示文本由 output.render 纯函数投影——值管数据、render 管呈现,两者分离。UI 的「渲染意图」(diff 卡片、搜索卡片、todo 列表)由 presentCall/presentResult 纯函数生成,可重放(第 17 章)。
  • timeoutMs 永远不会发给模型——schema 白名单只放行 name/description/parameters。声明它就等于承诺 execute 会转发 exec.signal、能在 abort 时收敛。
  • 并发是声明出来的:只有 isConcurrencySafe() === true 的调用才可能与其他调用并行;缺省、抛错、返回非 true 一律互斥。这是「默认保守」的并发模型。

注册、限制与守卫

register(definition) 先做一组硬校验(output 必须声明 {schema, render}、schema 必须是支持的 JSON Schema 子集、timeoutMs 必须为正、run_code 这个名字被保留给 Code Mode 传输层),然后 layers.effect(...) 把定义插入当前作用域层并返回 disposer——注册即 effect,卸载自动撤销。

另两个注册面:

  • restrict(filter):给调用方 agent 的 scope 打一个全局工具遮罩(allow/deny)。它要求 scoped context(scopeOf(this.ctx))——一个进程全局的限制会遮住所有 agent,被直接拒绝;空过滤器是 no-op,也被拒绝。子代理的 toolFilter(第 11 章)就走这里。
  • guard(guard):注册一个单调守卫(同步函数,返回字符串即拒绝)。守卫在 tools/pre-execute 瀑布之后运行——任何守卫可以拒绝,但没有任何守卫能强制放行另一个守卫已拒绝的调用。

guard 家族里有两个已发布的实例。一个是第 8 章会看到的超时策略dsh-tool-call-timeout-policy):一个挂在 tools/execute 瀑布上的 around 包装,用 using d = deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT') 把取消与超时熔成同一个信号,再用 timeoutOf 按 code 判定是「自己的定时器先到」还是「上游取消」,把超时替换成结构化的 TOOL_TIMEOUT 错误——协作式,不 abandon 工具体。另一个是重复调用提醒dsh-repeat-tool-reminder):挂在 tools/post-execute 的观察链上,按「工具名 + 规范化后的参数」作键统计同一调用的次数,阈值 [3, 5, 8] 时通过 additionalContexts 注入提醒(「你已经第 5 次调用这个工具了」)——advisory 不 veto,模型可以继续;用户的下一条消息会重置计数。

可见视图 view(scope) 的解析逻辑(index.ts:1152)解释了作用域与限制如何交互:restrict 过滤的是「继承面」(全局层 + 祖先链),永不过滤自己层注册的东西——子代理的委托运行时把子代理的报告与结构化输出工具注册进子代理自己的层,一个命名了「子代理可用能力」的过滤器绝不能把这些机制工具也剥掉。注释里甚至记录了一次历史教训:当工具从宿主组合挪到 preset 的 agent 平面后,旧实现(把豁免集当全局层读)悄悄失效——「不是我的」和「全局的」是两个概念。

五段执行管道

一次 ctx.tools.execute(exec) 调用(由第 3 章的 executeToolCalls 发起)走完五段:

图 1:工具执行五段管道。灰色段是扩展点;任何一段的失败都被归一到 ToolExecutionResult(成功值或结构化失败),最终写成日志里的 tool/result
  private async prepareExecution<T>(
    input: ToolExecutionInput,
    next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
  ): Promise<T> {
    const created = this.createExecution(input)
    if (created.kind !== 'ready') return next(created)
    const exec = created.exec
    if (this.callerCancelled(exec)) {
      return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
    }
    // ... tools/pre-execute waterfall → guards → 'ready'
  1. 准备(prepare)createExecution 铸 execution token、快照并冻结参数(snapshotJsonValue + deepFreeze)、捕获 finalizeContent 回调、检查 code-collapse(Code Mode 下直接调用非 run_code 的名字在这里确定性拒绝,先于可扩展策略管道——审批绝不能看到注定失败的调用)。
  2. tools/pre-execute 瀑布:可扩展的 allow/deny 门。监听器返回 PreToolDecision——allowdeny、或 ask(走审批,见下)。
  3. 单调守卫guardReason 从全局到 scope 链逐个检查,任何守卫返回理由即拒绝。
  4. tools/execute 瀑布(around-dispatch 包装):默认 next()dispatchToolBody——解析工具定义、调用 tool.execute。超时插件(dsh-tool-call-timeout-policy)与重试/指标插件都挂在这里。
  5. tools/post-execute 瀑布 + 物化:监听器返回 PostToolDecision——accept(可替换 content/value)、block(转成 isError 并带纠正性反馈);然后定义自有的 finalizeContent 做最后一英里变换;最后 notifyResult 把冻结的 ToolExecutionResulttools/result 事件广播(观察者只读,失败独立容错)。

成功值在 createSuccessResult 里经过三重检查:snapshotToolValue(无损 JSON)→ validateJsonSchemaValue(对照 output.schema 校验,违规抛 ToolOutputError)→ output.render 投影展示内容。整个过程里「值」与「文本」从不混为一谈:模型拿到的是渲染后的 ContentBlock[],日志里存的是规范化的 value + meta。

ask:审批接缝

tools/pre-execute 的监听者返回 {kind: 'ask'} 时,管道调用 serviceAskindex.ts:1689)——通过 ctx.get('approval') 机会性消费审批服务(第 16 章详述):

    const approval = this.ctx.get('approval')
    if (approval === undefined) {
      return { decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval (not yet supported)` } }
    }
    if (exec.agent === undefined) {
      return { decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` } }
    }
    const outcome = await approval.request({
      agent: exec.agent, toolName: exec.name, callId: exec.callId,
      ...ask.reason !== undefined ? { reason: ask.reason } : {},
      signal: exec.signal,
    })

四个出口 allowed-once / rejected / cancelled / unavailable 一一映射到 allow 或带不同理由的 deny——模型能区分「人说不」与「没有审批通道」。注意 ctx.get 而非声明注入:没装审批服务的部署退化为 deny,卸载后下次 ask 同样退化——fail-closed 是默认。

并行与串行:调度器

第 3 章的 executeToolCallsagent-loop/src/tool-calls.ts)按 executionMode 把一批工具调用分组:parallel 的调用可以重叠(isConcurrencySafe 声明过)、exclusive 单独跑并形成排序屏障。具体调度是「exclusive barrier + parallel 滚动池」:exclusive 调用是屏障,前后的 parallel 组不能跨越它;parallel 组内用滚动池并发(maxParallelToolCalls 默认 10),结果按模型输出的顺序commitReady)逐个落 tool/calltool/result 会话事件——日志里的顺序永远与模型看到的顺序一致,哪怕执行本身乱序完成。工具注册表通过 TOOL_RUNTIME_SCHEDULER 符号暴露 prepare/dispatch/finalize/finish 四个阶段的调度器接口,供 agent-loop 的并行调度器使用——执行本身仍走同一套五段管道,只是时序被编排。并发契约的核心约束是:isConcurrencySafe 的调用不得改父 agent 持有的状态(共享状态必须容忍并发),记录器竞态必须可交换或 fail-closed。

Code Mode:工具面的整体换装

工具系统的 mode 配置(native / code / both)是 dsh 的一个激进能力:部署可以声明「这个 agent 不直接调用工具,只调用一个 run_code 程序,程序内部通过 SDK 访问其余全部工具」。这等于把第 1 章说的「工具调用范式」整体折叠成「代码执行范式」——与《Prime Agent 源码解析》里的单工具世界神似,但实现路径完全不同:不是删掉工具面,而是保留全部工具,改变到达它们的方式

实现上有两个精巧的细节:

  • 提示词与执行用同一个谓词:collapse 段(tools:code-only)与执行器的拒绝逻辑共用 collapses(name, scope, nested)——「提示词不能陈述注册表不执行的规则」。Code Mode 下模型直接调用其他工具会收到带路标的错误(「只能直接调 run_code——请从程序里调用它」)而不是裸的 unknown tool。
  • 子派发的 parent token:程序内部经 SDK 调用的工具带着外层 run_code 的 execution token 作为 parent——提交式观察者可以等外层结果而不接触活的可变执行;在 code 模式下只有带 parent 的调用才能执行原生工具名。

一个具体工具:todo_write

packages/todo/tool-todotodo_write 为例看一个完整工具。它每次写入都是整表快照

// todo_write 的 output 声明(示意):
//   schema: { todos: [{ content: string, status: 'pending'|'in_progress'|'completed' }] }
//   render: 把待办渲染成模型可见的编号列表
// 执行:把 { todos } 作为规范化值返回 → 注册表写 tool/result
// 会话侧:agent-loop 把它投影成 'todo/write' 整表快照事件(log-only)
// UI 侧:todo 投影(session/projection)渲染侧栏待办,turn/start 时清空

注意 todo 条目故意没有 id——整表替换、last-write-wins,条目无需稳定身份。这是「工具即持久状态」的最小范例:状态不是藏在工具闭包里,而是通过事件写进日志、从日志投影给 UI(第 13 章展开)。

工具全景:约 40 个内置工具

「工具是插件」的实际规模有多大?docs/tool-catalog.md 列出约 40 个模型可见的工具名,按族分组:

工具来源包(示例)
代码执行run_codeCode Mode 传输层(第 5 章)
文件readwriteeditread_imagetool-fs(第 7 章)
搜索globgrepfs-search
shell / 终端bashpwshterminal_*(open/send/read/signal/close/list)tool-bashtool-terminal(第 7 章)
网络web_searchweb_fetchtool-web(第 17 章)
子代理 / 编排subagentsend_messageinterrupt_agentlist_agentsworkflowralphtool-subagenttool-workflow(第 11、12 章)
任务job_listjob_outputjob_killtodo_writeget_goalcreate_goalupdate_goaltool-jobstool-todotool-goal(第 13 章)
技能 / 交互skillask_user_questionexit_plan_modetool-skill(第 10 章)、interaction 族
语言 / 自修改lspcordis_*(inspect/define/run/stop/undefine)tool-lsptool-cordis(第 16 章)

这张表本身就是「一切皆插件」的证据:没有一个工具名属于「核心」——每个名字都来自某个 Consumer 包,随它的插件一起装卸。第 7–13、16–17 章会逐个展开这些族的实现。

实践应用

  1. 值/文本/UI 三分离:execute 只返回规范化值,render 投影文本,presentCall/presentResult 生成 UI 意图——同样的结果在模型上下文、日志、重放、实时 UI 里各取所需,互不污染。
  2. 并发默认保守:只有显式声明 isConcurrencySafe 的调用才可能并行;「默认互斥、声明并行」比「默认并行、出事加锁」安全得多。
  3. 审批是接缝不是内置:工具管道用 ctx.get('approval') 机会性消费审批服务,没装就 fail-closed 拒绝——安全默认由结构保证,不由调用方自觉。
  4. 提示词与执行共用同一谓词:Code Mode 的 collapse 规则在提示词与执行器里是同一份代码,杜绝「提示词承诺了注册表不执行的事」。

总结

这一章拆完了工具系统:ToolDefinition 的三重声明(schema/execute/output)、注册/限制/守卫三个注册面、五段执行管道(pre-execute → guards → execute → post-execute → 物化广播)、审批接缝、并行调度与 Code Mode。工具是系统的「手」,而它看到的「世界图景」——提示词、运行时上下文、压缩——由下一章负责:system-prompt 如何分节装配,compaction 如何在遗忘中保持记忆。