跳到主要内容

人审(HITL)与 RunState 序列化

这一章讲一个很实用的特性:危险工具(删库、转账)执行前先暂停、等人点「同意/拒绝」。难点不在「暂停」,在于暂停可能跨进程——用户可能 10 分钟后才在另一台服务器上点同意。所以这章其实讲两件事:怎么暂停,怎么把暂停状态完整存盘再恢复。

1. 它要解决的小问题

模型说「我要调 deleteDatabase()」。你不想让它直接执行,而是:把这个请求晾起来,问人类「批不批?」。人批了才执行,没批就把「被拒绝」当工具结果回喂模型。

2. 思路/直觉

三步:

  1. 标记:工具配 needsApproval,执行前检查——没批过就不执行,而是产出一个 RunToolApprovalItem
  2. 暂停:这一拍的下一步变成 next_step_interruption,主循环直接 return RunResult(带 interruptions 列表)。
  3. 恢复:调用方拿到结果,对每个待审项调 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 标记 + 暂停

函数工具执行前,handleFunctionApprovalrunner/toolExecution.ts:402):

  • 先查审批表 state._context.isToolApproved({ toolName, callId })toolExecution.ts:409)——已批返回 'approved' 直接放行。
  • 否则调 toolRun.tool.needsApproval(...)toolExecution.ts:423);不需要审批就放行(toolExecution.ts:429)。
  • 需要审批且没批过 → 返回一个 function_approval 结果,里面是 RunToolApprovalItembuildApprovalRequestResulttoolExecution.ts:326)。

这些审批项最终让 maybeCompleteTurnFromToolResultsturnResolution.ts:1176)走到 toolOutcome.isInterrupted 分支,产出 next_step_interruptionturnResolution.ts:1208-1220)。主循环看到这个标签就 return new RunResultrun.ts:1134run.ts:944)。

4.2 RunResult 暴露中断

返回的 RunState 上,getInterruptions()runState.ts:788)列出所有待审 RunToolApprovalItem。调用方据此弹 UI。

4.3 同意/拒绝

RunState.approve(item, options?)runState.ts:844)和 reject(item, options?)runState.ts:871)转调 RunContext.approveTool / rejectToolrunContext.ts:280 / :310)——它们往审批表(#approvals,一个 Map<string, ApprovalRecord>)里记一笔。默认只对当前这次调用生效;传 options 可让整工具长期批准。reject 还能带 message 作为回喂模型的拒绝文本(runState.ts:862-877)。

RunState 的注释特意提醒:不要直接改这些字段,要用 approve/rejectrunState.ts:472-473)。

4.4 恢复时只跑「该跑的」

恢复后,resolveInterruptedTurnturnResolution.ts:557)很讲究:它不是把这一拍全重跑,而是精确筛出「已批准 + 还没出结果」的调用来执行。它对照 completedFunctionCallIds / completedComputerCallIds 等集合(turnResolution.ts:578-593),跳过已完成的,避免重复执行——这对「一拍里多个工具、只批了一部分」的场景至关重要。

4.5 序列化

  • RunState.toString()runState.ts:1002)= JSON.stringify(this.toJSON())toJSONrunState.ts:894)把 schema 版本、context(含审批表)、当前 agent、已生成 items、当前步骤、模型响应、tracking 全序列化。
  • RunState.fromString(agent, str)runState.ts:1011)异步重建——它需要 agent 是因为序列化里 agent 只存了名字(agent.ts:1117 toJSON 只输出 name),恢复时要用传入的真实 agent 对象重新挂上工具/指令。
  • 版本守门CURRENT_SCHEMA_VERSION = '1.13'runState.ts:95)。反序列化时若版本不符会报错或拒绝(runState.ts:1056 等),并且有针对性的提示(如「该版本不支持 tool_search items,请用新 schema 重新序列化」runState.ts:1091)。这保证旧存档不会被静默误读。

5. 巧妙之处

  • 「暂停」复用了「中断」这个已有控制流标签:HITL 没有引入新机制,它就是 next_step_interruption——和「计算机动作要审批」「MCP 远程审批」共用同一条暂停路径(APPROVAL_ITEM_TYPESturnResolution.ts:125)。一套机制覆盖多种审批。

  • 审批表跟着 context 走、能合并:恢复时 RunState.fromStringWithContextrunState.ts:1018)支持 merge 策略,把序列化里的审批和当前 context 的审批合并(runContext.ts:172 _mergeApprovals),这让「agent-as-tool 嵌套 + 人审」也能正确续跑(回看 agent.ts:809-825)。

  • 持久化计数会「回退」:审批被提出时已经持久化过一次,恢复执行真正的工具输出时要把计数 rewind 回去(turnResolution.ts:843-847 rewindTurnPersistence),确保最终工具结果也写进 session,不丢不重。

6. 边界

  • 恢复必须传回原 agent 对象(或等价重建的),因为序列化不存工具实现、只存名字。
  • schema 版本跨大版本不保证向后兼容——存档久了可能需要重新序列化。

7. 代码地图

主题文件符号
审批判定runner/toolExecution.tshandleFunctionApprovalbuildApprovalRequestResult
中断决议runner/turnResolution.tsmaybeCompleteTurnFromToolResultsresolveInterruptedTurn
列出待审runState.tsRunState.getInterruptions
同意/拒绝runState.ts · runContext.tsRunState.approve/rejectapproveTool/rejectTool/isToolApproved
序列化runState.tsRunState.toStringRunState.fromStringCURRENT_SCHEMA_VERSION
审批表合并runContext.ts_mergeApprovals_rebuildApprovals