DeepSeek Harness 工具执行流水线:一次工具调用的守卫、执行与定格
本文是
dsh官方参考「工具执行流水线」的导读。上一篇文章《Agent 生命周期》里,工具调用只是时序伪代码里的一行tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*;这一篇把它完整展开——一次工具调用如何被三组 waterfall(瀑布式事件)层层改写,最终定格为一条tool/result事件。
〇、先记住一句话
一次工具调用 = 三次 waterfall(pre-execute/execute/post-execute)+ 一次结果定格(快照 →finalizeContent→tools/result)。
-
前置瀑布( tools/pre-execute)管"能不能跑":钩子、权限、沙箱、审批; -
执行瀑布( tools/execute)管"怎么跑":超时、重试、指标包裹工具体; -
后置瀑布( tools/post-execute)管"结果怎么用":接受、阻止、替换、添加上下文; -
结果定格:注册表对结果做无损快照, finalizeContent执行最后的仅内容不变式,tools/result同步通知,最终冻结为唯一一份模型可见的权威事实。
理解这条主线,后面所有细节都是它的展开。
一、全景:一次工具调用的完整旅程
先看整体流程图,再逐段拆解。
graph TD A[Assistant message 含 tool-call 块] --> B[Session event: tool/call执行前记录] B --> C[UI pending cardpresentCall args] C --> D[”tools/pre-execute waterfallhooks · permission · sandbox”] D --> E{单调守卫 deny 或 abstain+ ctx.approval 一次性审批} E -- ”denied 或 审批被拒” --> F[工具体被跳过denied · rejected · cancelled] E -- allow --> G[”tools/execute waterfalltimeout · retry · metrics 环绕 dispatch”] G --> H[注册的工具 execute body] H --> I[Tool-owned 事件todo/write · fs/observed · hook/* · tool/code-dispatch] I --> J[”tools/post-execute waterfallaccept · block · replace · add context”] J --> K[Registry 外层规范化无损快照 pipeline/result] K --> L[ToolDefinition.finalizeContent最后仅内容不变式] L --> M[tools/result 同步通知冻结的权威结果] M --> N[Active-batch additionalContexts FIFO结果之后注入 user/message] N --> O[Session event: tool/result单一 model-facing 结果] O --> P[Tool batch settled 批次结算] P --> Q[UI completed cardpresentResult args result]
注意图里最关键的一句话:tools/pre-execute→ 单调守卫 →tools/execute→tools/post-execute这三个 waterfall 可以改写一次调用;而 finalizeContent 与 tools/result 在它们之后运行,由工具定义自身控制,不再参与改写。
二、起点:模型发出 tool-call
流水线从模型输出一个工具调用块开始:
-
Assistant message 包含 tool-call 块——模型决定调用某个工具; -
Session event: tool/call在执行之前就被记录下来(持久化事实,可回放); -
UI pending card:界面立刻展示一张"进行中"卡片,调用 presentCall(args)把参数呈现给用户。
从这一刻起,调用进入流水线。注意顺序:先落tool/call事件、再进守卫——即使后面被拒绝,这次"试图调用"的事实也已经留档。
三、第一道闸门:tools/pre-execute waterfall
这是调用能否放行的第一道(也是最主要的一道)闸门。它承载三类关注点:钩子(hooks)、权限(permission)、沙箱(sandbox)。各种插件挂在这里,对调用做出裁决。
3.1 可能的裁决
瀑布式事件允许每个监听者对调用做出自己的决定:
|
|
|
|---|---|
allow |
|
deny |
|
throw |
|
ask |
|
allowed-once |
|
3.2 单调守卫(monotonic guards)
在瀑布之外,注册表还维护着一组单调守卫:
-
每个守卫只能选择 deny或abstain(弃权)——守卫不能放行,只能拦或不管; -
身份受保护:守卫的裁决不会被其他环节绕过或重排; -
所有"不得重新排序的所有者策略"也以已注册守卫的形式存在——即使有 ctx.approval之类的交互流程,它们仍会被执行。
3.3 ctx.approval:一次性审批
ctx.approval 提供一次性(one-shot)审批提示:需要用户拍板时,它发起一次询问。
-
它在单调守卫之前处理"询问"环节; -
如果审批缺席或无法回答,结果一律按 deny处理——拿不到明确许可,就不放行。
3.4 被拒的后果
一旦出现denied 或 approval refused,工具体(tool body)被完全跳过:不执行、也不产生副作用,调用直接以拒绝/取消收场(rejected、cancelled、unavailable 等状态)。
小结:
tools/pre-execute决定了"这次调用有没有资格跑"。
四、执行:tools/execute waterfall
通过守卫之后,进入执行阶段。这个 waterfall 把环绕分发(dispatch)的关注点包在真正工具体的外面:
4.1 环绕关注点
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
这些都由其他插件在 tools/execute 上包装实现,工具体本身不需要关心。
4.2 注册的工具 execute body
最内层是已注册工具的execute()主体——真正干活的代码。它执行过程中会产生两类事件:
① 文件系统意图事件(仅 tool-fs 的变更)
fs/write-intent
(写入意图) fs/edit-intent
(编辑意图)
这些是"先读后写"策略的门禁点:文件系统的先读后编辑检查位于tool-fs之下,通过fs/*事件实现。它由专门的策略插件(如 dsh-fs-observation-policy)挂接,不改变工具 schema。
② Tool-owned 会话事件(工具自己发出的)
todo/write
(任务清单更新) fs/observed
(文件已被观察) hook/invoked
、 hook/result(钩子被调用及其结果)tool/code-dispatch
(code 模式的代码分派)
若 wrapper 在执行中抛错(
wrapper throws),异常沿tools/execute向上冒泡,按throw处理。
五、收尾:tools/post-execute waterfall
执行完成后,结果先经过后置瀑布——这是"结果级"的最后改写机会:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
到这里,一次调用可以被改写的环节全部结束。接下来进入"定格"阶段——结果不再被 waterfall 改写。
六、结果定格:从快照到权威结果
6.1 Registry 外层规范化:无损快照
注册表对候选结果做外层规范化(outer normalization):
-
对 pipeline/result做无损快照(snapshot); -
如果快照本身失败,会先把失败规范化( throws变成isError之类的结构),再继续走后面的不变式。
快照的意义:让后续回调看到的是同一份固定的结果,而不是可能被并发改动的活对象。
6.2 ToolDefinition.finalizeContent:最后的仅内容不变式
finalizeContent由工具定义自身声明,是最后一个仅内容(content-only)的不变式:
-
它同步执行,只允许调整内容; -
它使用的是已经随快照固定的结果; -
它不参与 waterfall 改写——这是定义自己收尾的最后一道关。
6.3 tools/result:同步通知冻结结果
tools/result 是一个同步通知,把冻结的、权威的结果分发给监听者。此刻结果已经定型,监听者只能观察,不能再改。
6.4 Active-batch additionalContexts FIFO
如果本次调用属于一个"活动批次",批次的additionalContexts会以FIFO顺序,在已记录的工具结果之后注入 user/message。这样保证:注入的上下文总是排在本批结果后面,不会打乱时序。
6.5 Session event:tool/result
最后,流水线产出一条tool/result会话事件——这是唯一一份面向模型(model-facing)的结果。无论中间经过多少次改写,模型最终看到的只有这一份定格结果。
七、批次结算与 UI 呈现
-
Tool batch settled:当一批调用(例如模型一次输出中的多个工具调用)的所有 tool/result事件都记录完成后,批次结算; -
UI completed card:界面把"进行中"卡片翻转为"已完成"卡片,调用 presentResult(args, result)同时呈现原始参数与最终结果。
至此,一次工具调用的完整旅程结束:从 tool/call 到 tool/result,全程有据可查、可回放。
八、三个 waterfall 能力速查表
|
|
|
|
|
|---|---|---|---|
tools/pre-execute |
|
ctx.approval) |
|
tools/execute |
|
fs/* 意图与 tool-owned 事件 |
|
tools/post-execute |
|
|
|
finalizeContent
tools/result |
|
|
|
九、几个值得记住的设计点
-
三处 waterfall 是"一次调用可被改写的全部":所有钩子、策略、审批都挂在这三个点上,过了 tools/post-execute就再没人能动它; -
守卫只能拦、不能放:单调守卫 deny or abstain,且身份受保护——这保证了"安全策略不可被绕过"; -
拿不到许可 = 拒绝: ctx.approval一次性询问,缺席或无法回答一律deny,工具体被跳过; -
文件系统先读后写不碰 schema:通过 fs/*意图事件实现,是tool-fs之下的门禁,而不是工具定义的改动; -
结果只有一份:中间再多的 replace / add context,最终都以单一 tool/result事件呈现给模型——回放时只有一个"权威答案"。
一句话收尾:dsh 用"三道瀑布 + 一次定格"把一次工具调用变成了可审计、可拦截、可改写、且只留一份权威结果的过程。理解了这条流水线,就理解了为什么在 dsh 里加一个审批策略、换一个沙箱、或改造某个工具的行为,都是在"往流水线上挂插件",而不是改工具本身。