第四篇追到 executeToolCalls() 时,我原本以为后面就是一层普通封装:按名字找到工具,调用 execute(),再把返回值交给模型。
真正读进去,最费脑子的反而不是"怎样调用工具",而是顺序。
同一个 Assistant 消息可以带多个 tool call。读文件一类的调用可以并发,写文件或未知工具要独占;某个调用可能后启动却先完成;用户也可能在中途取消。可最终写入 Session 的 tool/result 仍要和模型给出的调用顺序一一对应,下一次模型请求还必须看到一段可以重放的完整历史。
DeepSeek Harness 没有靠"所有工具串行执行"换取简单,而是把整条链拆成了有序的外层和可并发的中段:
assistant/message → tool/call → prepare → dispatch → finalize → tools/result → tool/result
项目地址:deepseek-ai/deepseek-harness
源码基线:0.1.0-rc.5
模型消息写完,工具调度才开始
入口仍在 packages/core/agent-loop/src/agent.ts。
模型流结束后,Agent Loop 先把完整的 assistant/message 写进 Session,再从消息内容中筛出 tool call:
ts
this.session.append('assistant/message', {
turn, step, message, ...usage,
}, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
const toolCalls = message.content.filter(
block => block.type === 'tool-call',
)
if (toolCalls.length === 0) return { kind: 'completed' }
const { concluded } = await executeToolCalls(
this.loopCtx,
turn, step, toolCalls, signal,
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)
这个顺序很重要。工具有没有成功、是否被拒绝、是否来得及启动,都不会改变模型确实发出过这批调用的事实。回放 Session 时,先看到 Assistant 提出调用,再看到每个调用的处理结果。
executeToolCalls() 收到的数组也保留模型原始顺序。它先为每一项准备运行输入:
ts
const planned: PlannedCall[] = toolCalls.map(block => ({
block,
exec: {
callId: block.id, name: block.name,
arguments: parseArguments(block.arguments),
agent, signal,
},
}))
function parseArguments(raw: string): unknown {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
这里没有急着用 schema 校验。合法 JSON 先变成对象;解析失败则保留原始字符串,后面由工具运行时产出结构化错误。这样一次坏参数仍然会走完 tool/call → tool/result,而不是在 Agent Loop 里突然抛断整段会话。
tool/call 先入账,prepare 再决定能不能跑
调度器真正启动某一项时,第一步不是权限判断,而是追加 tool/call:
ts
const startCall = async (index: number): Promise<void> => {
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await scheduler.prepare(call.exec)
// 根据 prepared.kind 决定 dispatch、post-result 或 final-result
}
tool/call 保存的是模型给出的原始参数字符串。随后进入 ToolRuntime,解析后的参数会再做一次无损 JSON 快照并深度冻结:
所以同一个调用有两种参数视图:Session 里保留模型原文,运行时拿到的是脱离调用方引用、不可再修改的 JSON 值。策略插件、工具本体和结果观察者看到的是同一份稳定参数,而不是一个可能被前一个监听器改掉的对象。
prepare 接着处理三层判断:
ts
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 denialReason = askResolution.decision.kind === 'allow'
? this.guardReason(exec)
: askResolution.decision.reason
tools/pre-execute 可以 allow、deny 或 ask。它是 waterfall,前面的监听器可以直接决定,也可以调用 next() 把决定权交给后面。
守卫不一样。tools.guard() 只返回拒绝理由或不表态,而且在 waterfall 得到 allow 以后才检查。也就是说,可扩展策略即使允许,拥有方守卫仍能收紧权限;没有任何后续监听器能把这个拒绝翻回允许。这就是代码里所说的 monotonic guard。
被拒绝的调用不会进入工具本体,但仍会生成一个普通的错误结果,继续经过需要的后处理并写回 Session。对模型来说,它不是"调用消失了",而是"调用明确失败了"。
通用审批和 Bash 沙箱升级不是一回事
当 tools/pre-execute 返回 ask,ToolRuntime 才会查找 ctx.approval。
只有 allowed-once 会继续执行。没有 ApprovalService、调用没有所属 Agent、用户拒绝、请求被取消,或者审批通道无人应答,最后都会变成拒绝。ApprovalService 还会在当前 Turn 中成对写入 approval/asked 和 approval/decided,让一次授权为什么发生、得到什么结果都能回放。
这里很容易把所有审批都归到 tools/pre-execute,但 Bash 的沙箱升级不是这条路径。
packages/shell/tool-bash/src/index.ts 在工具本体内部检查 sandbox_permissions 和 justification,再调用共享的升级审批逻辑:
ts
const approvedMode =
args.sandbox_permissions !== undefined
&& args.justification !== undefined
? await approveBashEscalation(
args.sandbox_permissions, args.justification, exec, standingPolicy,
)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...standingPolicy, mode: approvedMode }
两类审批最终都使用 ApprovalService,但发起者不同:前者是工具运行时对整次调用的通用门禁,后者是 Bash 已经进入工具实现后,对某个更宽沙箱模式的一次性升级。
这个区别会直接影响排查。看到 approval/asked 不能立刻断言某个 tools/pre-execute 插件要求了审批,还要看调用参数里是否带了 Bash 升级请求。
真正并发的只有 dispatch
每个工具可以用 isConcurrencySafe(args) 声明当前调用是否适合并发。ToolRuntime 的判断很保守:
ts
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
return tool.isConcurrencySafe(exec.arguments) === true
? { kind: 'parallel' }
: { kind: 'exclusive' }
} catch {
return { kind: 'exclusive' }
}
}
只有严格返回 true 才能并发。没有声明、抛异常、工具不可见或工具未知,都会按独占处理。
独占调用还是一道屏障。假设模型依次请求"读 A、写 B、读 C",两个读调用都并发安全,也不能越过中间的写调用同时运行。调度器会形成三个组:读 A 完成后执行写 B,最后才开始读 C。
并行组内部也不是一次 Promise.all() 全部放出去。它维护一个滚动池,最多同时运行 maxParallelToolCalls 个调用,默认上限是 10。每个新调用启动前还会重新读取执行模式,工具注册或策略在运行中变化时,尚未启动的调用可以立即退回独占屏障。
最关键的一段在 startCall():它会等待当前调用的 prepare 完成,但只把 dispatch Promise 放进 inFlight,不会在这里等工具结束。
ts
const prepared = await scheduler.prepare(call.exec)
if (prepared.kind === 'dispatch') {
const promise = scheduler.dispatch(prepared.exec).then(outcome => {
slots[index] = {
exec: prepared.exec, result: outcome.result,
needsPost: outcome.kind === 'post-result',
}
return index
})
inFlight.set(index, promise)
}
因此,tool/call、prepare、tools/pre-execute 和审批仍按模型顺序发生;能够重叠的只有 dispatch,也就是 tools/execute 包装器与真正的工具本体。
工具完成后先把结果放进对应 slot。谁先完成不等于谁先提交,commitReady() 只从当前模型序号开始,提交一段连续就绪的结果:
ts
const commitReady = async (): Promise<void> => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break
const result = slot.needsPost
? await scheduler.finalize(slot.exec, slot.result)
: scheduler.finish(slot.exec, slot.result)
appendToolResult(
session, turn, step, group[committed]!.block,
result, callSeqs[committed]!,
)
committed++
}
}
如果模型顺序是 A、B、C,而完成顺序是 B、C、A,B 和 C 只会先待在 slots 里。A 到达以后,调度器才按 A、B、C 依次运行 post-execute、最终内容处理和 Session 提交。
这张图把"有序外层、并发中段"放在一起:

超时插件也只包住这段可并发区。packages/guard/timeout-policy/src/index.ts 临时换上派生 signal,等待下游真正停稳,再判断是不是自己的定时器先触发:
ts
using deadlineState = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
const upstream = exec.signal
exec.signal = deadlineState.signal
try {
const result = await next()
if (timeoutOf(deadlineState.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(timeoutMs)
}
return result
} finally {
exec.signal = upstream
}
这里没有用超时 Promise 抢跑后直接返回。工具必须配合 exec.signal 结束清理,await next() 才会回来;随后插件把结果改写成结构化的 TOOL_TIMEOUT。原始取消 signal 还会在工具本体前重新融合,所以包装器也不能把用户取消悄悄摘掉。
工具返回值不会直接塞回模型
工具本体返回的是内部值 value。ToolRuntime 先做无损快照,再按工具声明的 output schema 校验,最后才调用 render() 生成模型可见的 content:
ts
const detached = snapshotToolValue(tool.name, candidate)
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) {
throw new ToolOutputError(tool.name, violations)
}
const value = deepFreeze(detached)
const rendered = tool.output.render(exec.arguments, value)
const content = snapshotProjection(tool.name, 'render', rendered)
这一步把"工具内部结果"和"给模型看的内容"分开了。比如文件编辑工具内部可以保留结构化 diff,模型只看到适合继续推理的文本块;UI 需要的卡片信息则通过 meta 单独投影。
接着才轮到 tools/post-execute。它可以接受原结果、替换 content、替换成功 value、附加 additionalContexts,或者用 block 把反馈变成错误。替换 value 时会重新走 schema 和 render();同时替换 value 与 content 会直接报错,避免内部值和模型内容彼此矛盾。
additionalContexts 不会混进当前 tool/result 文本。Agent Loop 在按序提交结果时,把它们放进 next-step Inbox,下一次模型请求才会看到。工具调用乱序完成,也不会改变这些上下文的模型顺序。
工具定义还可以提供 finalizeContent,但它只能在最后改 content,碰不到 value、错误分类和调用身份。到这里,最终结果会再次物化并深度冻结。
多一个 s 的 tools/result 不是会话事件
结果冻结后,ToolRuntime 先发出 tools/result。这是 Cordis 的实时观察事件,给指标、日志桥接和 UI 之类的监听器看;监听器失败只记警告,不能反过来改写工具结果。
随后 Agent Loop 才追加 tool/result:
ts
const message = createToolResultMessage({
callId: block.id,
content: result.content,
isError: result.isError,
})
session.append('tool/result', {
turn, step, message,
...(result.error?.info ? { error: result.error.info } : {}),
...(result.meta !== undefined ? { meta: result.meta } : {}),
}, {
surfaceOp: 'append',
sourceEventSeqs: [callSeq],
})
两个名字只差一个 s,职责完全不同:
tools/result是运行时通知,不承担持久化;tool/result是 Session 事实,会进入回放和下一次deriveMessages();- 会话事件保存模型可见
content、错误信息和展示meta,不会保存工具内部成功值value。
最后一条能避免 Session 格式被某个工具的私有对象结构绑死。需要重放 UI 卡片的字段放进 meta;只对当前执行树有意义的规范值,留在内存里。
concludeTurn() 也在这里收口。它只能附着在成功结果上,Agent Loop 等整批结果按序提交完,才根据 concludesTurn 决定是否结束当前 Turn。某个并发工具先说"结束",不会让它后面的已请求调用凭空消失。
取消不会留下半截工具批次
用户取消时,调度器先停止补充新调用,但已经开始的调用仍要排空。这里确实使用 Promise.race() 等待"下一个完成的是谁",却不会拿它提前返回并遗弃其他 Promise;所有已启动工作都会走到停稳和有序提交。
尚未启动的调用则补一对合成事件:
ts
function appendSkippedToolCall(
session: Session, turn: number, step: number, block: ToolCallBlock,
): void {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
}, callSeq)
}
所以日志里出现 tool/call,不一定证明工具本体真的执行过。它只证明模型提出了调用,而且调度器已经为它给出最终交代;要判断是否启动,应结合 tool/result 的错误码。
这套处理多写了几条事件,却换来一个很实用的性质:无论正常完成、策略拒绝、超时还是中途取消,模型提出的每个调用都有对应结果。Session 不会留下"Assistant 发出了四个调用,历史里只回来两个"的断裂尾巴。
以后排查工具问题,我会先分清三种顺序:模型调用顺序、工具实际完成顺序、Session 提交顺序。第一种决定语义,第二种影响耗时,第三种保证回放。DeepSeek Harness 只放开中间那一种。
下一篇继续沿着工具本体往外看:为什么 Bash、模型、文件系统和子代理可以更换 Provider,而 Agent Loop 和 Consumer 不需要跟着重写。