第 06 章 能力接缝与工具执行管线
代码基线:commit
8f86b979a1(2026-10-01)|0.2.0-rc.1| 本系列讲次:M5
本章导读
这一章讲"模型与外部世界之间那道关"。
第 5 章讲那道关怎么设防(策略、审批、单调守护、结果改写),这一章讲关后面那些能力各自长什么样:bash 执行、持久终端、进程沙箱、语言服务器。
先给一张体检表。五个执行类接缝、19 个包,每个接缝的 Provider 数量决定它是否真的可替换。ctx.shell 有多个 Provider,ctx.lsp 只有一个——前者是真正可替换的接缝,后者不是。
然后是本章最值得学的三段设计。第一,ShellExecRequest → ShellExecSpec 两段式:默认值不藏在 run() 里藏成 ?? default,而是在一个可单独测试的 resolve(request): Spec 步骤里完成。做成类型上的两段式,就没法违反。
第二,env 与 dshEnv 的三重保险:执行器先丢弃环境里全部 DSH_*、dshEnv 最后合并、键名类型化。设计意图写在注释里——"an unavailable current fact cannot inherit a stale value",取不到当前事实时宁可没有,也不能是错的。
第三,ToolGuard 那个没有 allow 返回值的签名:单调性是类型级事实而不是约定。
学习要点
- 数 Provider 的个数,比读接缝的定义更有价值
resolve(request): Spec是"显式优于隐式"的标准模板- 模型能设什么、只有代码能设什么,在类型和注释里分层
- 沙箱按平台先定候选链、探测只在候选 > 1 时仲裁
SandboxEnforcement有两类来源:静态档案事实 vs 运行时报备
本章目标
- 拿到
tools/*的权威清单:6 个事件,4 个 waterfall + 2 个 emit - 逐字读懂
prepareExecution这个方法——它是整条管线策略最密集的 45 行 - 理解一条设计得极漂亮的约束:单调守护没有 allow 结果,所以顺序无法把拒绝变回允许
- 修正书稿 §5.4 两处不精确
一、tools/*:6 个事件
实测 packages/core/tools/src/index.ts:
waterfall tools/pre-execute ← 准入:allow / deny / cancel / ask
waterfall tools/execute ← 环绕:超时、重试、指标
waterfall tools/post-execute ← 改写:accept / block
waterfall tools/ptc-dispatch-log ← PTC 派发日志
emit tools/result ← 权威结果通知
emit tools/change ← 注册表变了(工具增删)
和 M4 的 agent/* 同构:一个 emit 通知事实,waterfall 才给改变行为的把手。
三个 waterfall 的文档注释把边界划得很清楚:
pre-execute:"Allow, deny, cancel, or ask before dispatch.next()delegates to allow;cancelselects the canonical pre-dispatch cancellation result, and missing approval support turnsaskinto denial."execute:"Around-dispatch waterfall for timeout, retry, or metrics.next()returns a normalized result; wrappers may change onlyexec.signal, while call identity remains immutable."post-execute:"Post-dispatch decision: accept, replace one projection, attach context for the next request, or block by turning corrective feedback into an error result."
三个都是 scope-filtered(
this: Scoped<ToolRuntime>),这是 M1 §4.3 / M4 §1.1 的第三次出现。
二、prepareExecution:整条管线策略最密集的 45 行
index.ts:1493-1539,逐段读:
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() })
}
try {
const carrier = scopeTarget(this, exec.agent) // ← M1 §4.3 的载体
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }), // 默认放行
)
const askResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate) // ② 审批
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) { ... } // ③ 审批中取消
if (decision.kind === 'cancel') { ... } // cancel 决策
const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason
...
if (this.callerCancelled(exec)) { ... } // ④ 派发前再查
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
2.1 三个观察
① 取消检查实测 3 次(脚本第 5 项输出),分别落在:派发前、审批后、真正派发前。每道关卡都可能等异步(审批、超时),所以每道关卡之后都要重查。
② scopeTarget(this, exec.agent) —— 和 M4 的 agent/* 用的是同一个载体构造。这条工具调用因此只投递给发起它那个 agent 的监听器。
③ 整个 prepare 包在一个 try 里 —— 任何策略监听器抛错都不会让管线崩,而是归一化成 toolErrorResult(error)。
2.2 ★ 信号重熔:换不掉的取消传播
dispatchToolBody(:1564-1576):
const wrapperSignal = exec.signal
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
const signal = fused.signal
if (isAborted(signal)) { fused.dispose(); return toolAbortedBeforeDispatchResult() }
exec.signal = signal
tools/execute 的注释解释了为什么:
wrappers may change only
exec.signal... The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence.
around wrapper 允许换超时信号(这是它存在的意义),但 registry 在进 body 前把原始调用者信号重新熔上去——所以"顺手把取消传播也换掉"这条路被堵死了。
这是本章最值得学的一条工程手法:允许扩展点做它该做的事,同时在进入核心路径前重新合成必须保证的约束,而不是靠文档说"请不要改这个字段"。
三、★ 单调守护:书稿这条写对了,而且代码把理由写得更狠
书稿 §5.4 第 4 站说:
它只能"拒绝"或"弃权",绝不能在通过后再反悔……单调整保证了一个"已经放行的调用"不会在执行前被一次次反复搏杀。
实测 index.ts:723-731:
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
单调性不是靠约定,是靠返回类型保证的:没有 allow 这个返回值,顺序就无从把拒绝变回允许。
而 guardReason(:1145-1154)的实现印证了书稿说的"审查更严格、不可被延后":
private guardReason(exec: ToolExecution): string | undefined {
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
if (exec.agent === undefined) return undefined
for (const layer of this.layers.chainLayers(exec.agent)) {
const reason = layer.guardReason(exec)
if (reason !== undefined) return reason
}
return undefined
}
全局层先查,再沿 agent 的作用域链逐层查,任一层拒绝就返回。全程没有"通过"的返回值。
注意它在 prepareExecution 里的位置(:1519):
const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason
即使 waterfall 放行了,guard 仍然可以拒绝。 guard 在策略层之下、body 之上,是最后一道。
四、★ 勘误一:PreToolDecision 是 4 种,书稿讲了 3 种
实测:
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
| { kind: 'cancel' }
| { kind: 'ask'; reason?: string; displayReason?: {...} }
书稿 §5.4 第 3 站:"每个监听者都可以返回 allow(放行)、deny(拒绝)、或 ask(需要问人)。"
漏了 cancel。 而 cancel 的语义与 deny 有本质区别:
deny |
cancel |
|
|---|---|---|
| 结果 | 物化成一条 isError 的工具结果(Error: <reason>) |
toolAbortedBeforeDispatchResult() |
| 模型看到 | "你被拒绝了,因为……" | "这次调用被取消了" |
tools/pre-execute 的注释也点明:"missing approval support turns ask into denial"——没有审批能力时 ask 降级为 deny,但 cancel 不降级。
五、★ 勘误二:PostToolDecision 只有 2 种,书稿列了 4 种行为
实测:
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
书稿 §5.4 第 9 站:"监听者可以 accept(接受)、block(阻塞)、replace(替换结果)、或 add context(往上下文里补充注入)。"
实测只有 accept / block 两个 kind。书稿说的另两种是 accept 的载荷形态,不是独立决策:
- "replace 替换结果" =
accept带content或带value(二选一,类型上互斥) - "add context" = 两个变体都有的可选字段
additionalContexts?: UserMessage[]
书稿 §5.6 讲的"无损物化与深度冻结"在这里有了准确的落点:
value与content在类型上互斥(value?: never/content?: never),所以"结果"始终只有一种规范形态。
六、tool/call 落账不在 core/tools 里
书稿 §5.4 第 1 站说"一条 tool/call 会话事件在工具真正执行之前就被记录进日志"。成立,但落账方是:
packages/core/agent-loop/src/tool-calls.ts:264
const event = session.append('tool/call', { turn, step, callId, name, arguments })
不是 core/tools。 这是一条干净的架构分界:
| 负责 | |
|---|---|
agent-loop/src/tool-calls.ts |
何时记(模型点名后、body 之前) |
core/tools |
怎么执行(策略、审批、执行、改写) |
core/tools 完全不知道自己属于哪个 turn、哪个 step——它拿到的是一个 ToolExecution。
七、书稿 §5.4.1"三个瀑布流"——需要加一处限定
书稿 §5.4.1 论证"为什么是三个瀑布流而不是一个大执行函数",核心是策略与本体彻底分离:
工具本体只需要做好一件事:接收规范化的参数、返回规范化的 JSON 值。 它根本不需要知道自己外围还有多少道策略在等它。
这条论述成立且是本章最有价值的一段。 需要补的只有一处:实测是 4 个 waterfall(还有 tools/ptc-dispatch-log),不过它服务的是 PTC 模式而非普通调用路径,书稿讲"一次普通工具调用的旅行"时不算错。
另外 tools/execute 有一条书稿没提的硬约束:wrapper 只能改 exec.signal,调用身份不可变("wrappers may change only exec.signal, while call identity remains immutable")。这条与 §2.2 的信号重熔是配套的。
本章实验(附录)
八、动手验证
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ygsdoc/学习笔记/labs/M5-pipeline-lab.ts"
六组输出:① 6 个 tools/* 事件与派发模式;② PreToolDecision 的 4 种取值;③ PostToolDecision 的 2 种 kind 与 accept 的两种载荷;④ ToolGuard 签名 + 单调性注释原文;⑤ prepareExecution 的 3 次取消检查 + 信号重熔那一行;⑥ 十四站走带的符号核对。
M5 通过标准:能解释"为什么 ToolGuard 的返回类型只有 string | undefined 而不是布尔"——因为一旦有 allow 结果,监听器顺序就能把别人的拒绝变回允许;单调性必须是类型级保证,不是约定。
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "dsh-源码精读/labs/M5-pipeline-lab.ts"
预期输出(本机实测,任何一行对不上就说明基线变了):
1) tools/* 事件共 6 个:
waterfall tools/pre-execute
waterfall tools/execute
waterfall tools/post-execute
waterfall tools/ptc-dispatch-log
emit tools/result
emit tools/change
2) PreToolDecision 取值 4 种: allow, deny, cancel, ask
→ 书稿 §5.4 第 3 站只讲了 allow / deny / ask,漏了 cancel
3) PostToolDecision 取值 2 种: accept, block
accept 有两种载荷形态:content: ContentBlock[] 或 value: JsonValue
→ 书稿说的"replace"其实是 accept 的其中一种,不是独立的第三种决策
4) ToolGuard 签名: (execution: Readonly<ToolExecution>) => string | undefined
代码注释: /** A monotonic execution guard evaluated after every `tools/pre-execute` listener and before the tool body. Returning a reason denies the call; returning `undefined` leaves it unchanged. Because guards have no allow result, listener ordering cannot turn a denial back into permission. @param execution - the identity-protected call after extensible pre-execute policy completed. @returns a final denial reason, or `undefined` to leave the call allowed. /
→ 返回 string(拒绝) 或 undefined(弃权),**没有 allow 结果**
→ 所以监听器顺序无法把拒绝变回允许。这是书稿 §5.4 第 4 站说对了的一条
5) prepareExecution 里 callerCancelled() 检查 3 次:
if (this.callerCancelled(exec)) {
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
if (this.callerCancelled(exec)) {
return await next({ kind: 'dispatch', exec })
private callerCancelled(exec: ToolRunContext): boolean {
信号重熔: fuseToolSignals(state.callerSignal, wrapperSignal)
→ tools/execute 的 around wrapper 可以换 exec.signal,
但 registry 在进 body 前把原始 caller signal 重新熔上,换不掉取消传播
6) 书稿 §5.4 十四站走带的符号核对:
✓ 3 tools/pre-execute
✓ 4 单调守护 guard
✓ 5 一次性审批 ask
✓ 6 tools/execute
✓ 10 归一化
✓ 11 finalizeContent
✓ 12 tools/result
✓ 1 tool/call 落账 ← 不在 core/tools,由 agent-loop/src/tool-calls.ts:264 写入
本章的勘误条目见 附录 A · 勘误总表。