数据截至 (上游 commit 2d68a10f8c15)
人审(HITL)与 RunState 序列化
这一章讲一个很实用的特性:危险工具(删库、转账)执行前先暂停、等人点「同意/拒绝」。难点不在「暂停」,在于暂停可能跨进程——用户可能 10 分钟后才在另一台服务器上点同意。所以这章其实讲两件事:怎么暂停,怎么把暂停状态完整存盘再恢复。
1. 它要解决的小问题
模型说「我要调 deleteDatabase()」。你不想让它直接执行,而是:把这个请求晾起来,问人类「批不批?」。人批了才执行,没批就把「被拒绝」当工具结果回喂模型。
2. 思路/直觉
三步:
- 标记:工具配
needsApproval,执行前检查——没批过就不执行,而是产出一个RunToolApprovalItem。 - 暂停:这一拍的下一步变成
next_step_interruption,主循环直接return RunResult(带interruptions列表)。 - 恢复:调用方拿到结果,对每个待审项调
state.approve(item)或state.reject(item),然后 把同一个state再传回run()。引擎从中断处接着 跑。
跨进程时,第 3 步前后多两步:state.toString() 存库、取出后 RunState.fromString() 还原。
3. 图示:暂停 → 存盘 → 恢复
第一次 run (跨进程边界) 第二次 run
────────── ─────────── ──────────
模型: 调 deleteDatabase()
│
▼
needsApproval? 是, 且没批过
│
▼
产出 RunToolApprovalItem
│ next_step_interruption
▼
return RunResult ⏸ ──► state.toString() ──► 存进 DB
│
(人类在 UI 点「同意」) │ 取出
▼
RunState.fromString() ──► state.approve(item)
│
▼
run(agent, state) ──► 执行 deleteDatabase()
→ 结果回喂模型 → 继续循环
4. 真实实现
4.1 标记 + 暂停
函数工具执行前,handleFunctionApproval(runner/toolExecution.ts:915):
- 先查审批表
state._context.isToolApproved({ toolName, callId })(toolExecution.ts:925)——已批返回'approved'直接放行。 - 否则调
toolRun.tool.needsApproval(...)(toolExecution.ts:950);不需要审批就放行(toolExecution.ts:970)。 - 需要审批且没批过 → 返回一个
function_approval结果,里面是RunToolApprovalItem(buildApprovalRequestResult,toolExecution.ts:658)。
这些审批项最终让 maybeCompleteTurnFromToolResults(turnResolution.ts:1767)走到 toolOutcome.isInterrupted 分支,产出 next_step_interruption(turnResolution.ts:1799-1811)。主循环看到这个标签就 return new RunResult(run.ts:1861、run.ts:1863)。
4.2 RunResult 暴露中断
返回的 RunState 上,getInterruptions()(runState.ts:2517)列出所有待审 RunToolApprovalItem。调用方据此弹 UI。
4.3 同意/拒绝
RunState.approve(item, options?)(runState.ts:2595)和 reject(item, options?)(runState.ts:2622)转调 RunContext.approveTool / rejectTool(runContext.ts:1291 / runContext.ts:1337)——它们往审批表(#approvals,一个 Map<string, ApprovalRecord>)里记一笔。默认只对当前这次调用生效;传 options 可让整工具长期批准。reject 还能带 message 作为回喂模型的拒绝文本(runState.ts:2613-2627)。
RunState 的注释特意提醒:不要直接改这些字段,要用 approve/reject(runState.ts:1861-1862)。
4.4 恢复时只跑「该跑的」
恢复后,resolveInterruptedTurn(turnResolution.ts:982)很讲究:它不是把这一拍全重跑,而是精确筛出「已批准 + 还没出结果」的调用来执行。它对照 completedFunctionCallIds / completedComputerCallIds 等集合(turnResolution.ts:1011-1027),跳过已完成的,避免重复执行——这对「一拍里多个工具、只批了一部分」的场景至关重要。
4.5 序列化
- 存:
RunState.toString()(runState.ts:2906-2907)=JSON.stringify(this.toJSON(options))。toJSON(runState.ts:2645)把 schema 版本、context(含审批表)、当前 agent、已生成 items、当前步骤、模型响应、tracking 全序列化。 - 取:
RunState.fromString(agent, str)(runState.ts:2916)异步重建——它需要agent是因为序列化里 agent 只存了名字(agent.ts:1233toJSON只输出name),恢复时要用传入的真实 agent 对象重新挂上工具/指令。 - 版本守门:
CURRENT_SCHEMA_VERSION = '1.19'(runState.ts:165)。反序列化时若版本不符会报错或拒绝(runState.ts:2961等),并且有针对性的提示(如「该版本不支持 tool_search items,请用新 schema 重新序列化」runState.ts:3088)。这保证旧存档不会被静默误读。
5. 巧妙之处
-
「暂停」复用了「中断」这个已有控制流标签:HITL 没有引入新机制,它就是
next_step_interruption——和「计算机动作要审批」「MCP 远程审批」共用同一条暂停路径(APPROVAL_ITEM_TYPES,turnResolution.ts:406)。一套机制覆盖多种审批。 -
审批表跟着 context 走、能合并:恢复时
RunState.fromStringWithContext(runState.ts:2923)支持merge策略,把序列化里的审批和当前 context 的审批合并(runContext.ts:580_mergeApprovals),这让「agent-as-tool 嵌套 + 人审」也能正确续跑(回看agent.ts:809-825)。 -
持久化计数会「回退」:审批被提出时已经持久化过一次,恢复执行真正的工 具输出时要把计数 rewind 回去(
turnResolution.ts:1300-1304rewindTurnPersistence),确保最终工具结果也写进 session,不丢不重。
6. 边界
- 恢复必须传回原 agent 对象(或等价重建的),因为序列化不存工具实现、只存名字。
- schema 版本跨大版本不保证向后兼容——存档久了可能需要重新序列化。
7. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 审批判定 | runner/toolExecution.ts | handleFunctionApproval、buildApprovalRequestResult |
| 中断决议 | runner/turnResolution.ts | maybeCompleteTurnFromToolResults、resolveInterruptedTurn |
| 列出待审 | runState.ts | RunState.getInterruptions |
| 同意/拒绝 | runState.ts · runContext.ts | RunState.approve/reject、approveTool/rejectTool/isToolApproved |
| 序列化 | runState.ts | RunState.toString、RunState.fromString、CURRENT_SCHEMA_VERSION |
| 审批表合并 | runContext.ts | _mergeApprovals、_rebuildApprovals |