
zcode源码解析 Day6:Agent Loop 的具体实现本文是 zcode 源码学习系列第 6 篇。前面几篇都在看agent 用什么干活(子代理、工作流、通信),这篇回到心脏:Agent Loop 本身——用户敲一句话之后,程序内部到底是怎么一轮一轮转起来的。答案的骨架是三个文件:turn-state.ts定义规则,turn-machine.ts执行规则,runtime/methods/里的五个文件真正推动循环。读完你会得到一张 10 状态的转换图、一份驱动链调用地图,和三个只看类型永远发现不了的考古真相。一、先把三个词掰开:session、turn、model step读这套代码最容易被 turn 这个词绊住,先把时间尺度立起来:Session(会话)────一次完整对话,含多个 turn──── └─ Turn 1(你问:总结三个文件) ← turnNumber 1 ├─ 模型往返①:决定调 3 个 Read 工具 ├─ 模型往返②:看完结果,给出最终回答 └─ 回合结束 └─ Turn 2(你追问:第二个文件什么意思?) ← turnNumber 2Session:整场对话,消息历史跨 turn 累积;Turn(回合):从用户发一条消息到agent 给出最终回答的完整工作单元——中间可能包含好几次模型调用和工具执行;Model step(模型往返):turn 内部的一次发请求 → 收流式响应 → 跑工具 → 汇总。一个类比:session 是对话,turn 是回合,model step 是回合里的一次出拳。本文的状态机管的是回合这一层。二、三层结构:词汇表、执法者、驱动者Agent Loop 的实现刻意拆成了三层,职责分明:┌────────────────────────────────────────────────────────┐ │ 第 1 层 · 状态定义(词汇表) agent/turn-state.ts │ │ 10 个 TurnPhase 合法转换表 各种子状态类型 │ ├────────────────────────────────────────────────────────┤ │ 第 2 层 · 状态机(执法者) agent/turn-machine.ts │ │ TurnMachineImpl:每次迁移先查转换表,非法即抛错 │ ├────────────────────────────────────────────────────────┤ │ 第 3 层 · 真实驱动(实际运转) runtime/methods/ │ │ turn.ts → turn-loop.ts → turn-model-step.ts │ │ → turn-tools.ts → turn-stop.ts │ └────────────────────────────────────────────────────────┘第 1 层是纯类型加纯函数,没有任何驱动逻辑;第 2 层只做校验 拷贝出新状态;真正干活的循环在第 3 层。这个分层带来一个非常重要的读码心法,本文第九节会展开:词汇表 ≠ 实际用法——状态机能表达和驱动代码实际使用之间有缝隙,而缝隙里藏着架构演进史。三、10 个阶段和那张转换表turn-state.ts用const 对象 派生联合类型(而不是 enum)定义了 10 个阶段:exportconstTurnPhase{Idle:idle,// 出生态ProcessingInput:processing_input,// 消化输入、组装上下文AwaitingModelResponse:awaiting_model_response,// 已发请求等首字节Streaming:streaming,// 流式响应到达SchedulingTools:scheduling_tools,// 排工具执行计划ExecutingTools:executing_tools,// 工具执行中AggregatingResults:aggregating_results,// 步骤收尾、汇总AwaitingPermission:awaiting_permission,// 等权限审批(伏笔见第九节)Completing:completing,// 终态①:正常结束Error:error,// 终态②:异常结束}asconst;真正的心脏是文件末尾的canTransitionTo——一张Record的合法转换表。把它画成有向图后,最扎眼的是这条边:[TurnPhase.AggregatingResults]:[TurnPhase.AwaitingModelResponse,// ★ 唯一的回环边TurnPhase.SchedulingTools,TurnPhase.Completing,TurnPhase.Error,],aggregating_results → awaiting_model_response是整张图唯一的回环边。工具结果汇总后,带着新历史再问一次模型——Agent Loop 之所以是循环而不是流水线,全靠这条边。其余值得记住的规则:awaiting_permission 没有退路(只能去 executing_tools 或 error);两个终态只能复位回 idle;error 可以从大多数工作阶段直接进入。四、不可变状态机:每次转换都换个新对象TurnMachineImpl有个反直觉的设计:它从不原地修改state。每个操作返回一个全新的TurnState,调用方负责落地:// runtime/methods/turn-tools.ts:146state.turnMachinenewTurnMachineImpl(state.turnMachine.scheduleTools(coreToolCalls,this.toScheduleState(schedule)),);为什么这么绕?两个直接好处:抛错不毁现场:非法转换在transition()里查表直接抛CoreError(InvalidTurnPhase),因为旧 state 从未被改过,失败后机器完好无损;快照即留档:任何时刻把 state 存下来都不会被后续操作污染,这对事件溯源、回放、调试都是天然友好。配套的还有completeTool的设计——工具完成时只更新 toolCalls/toolResults,不改 phase:completeTool(toolCallId,result){constupdatedToolCallsthis.state.toolCalls.map((tc)tc.idtoolCallId?{...tc,status:result.success?completed:failed,...}:tc,);return{...this.state,toolCalls:updatedToolCalls,toolResults:[...]};}这让同批 3 个工具并发执行、乱序完成变得毫无压力:完成顺序不重要,phase 在整批工具跑完后才由aggregateResults()推进一次。五、真实驱动链:while(true) 里的一个循环体状态机自己不会动。谁在推它?把turnMachine.的全部调用点 grep 出来,就得到这张驱动地图:驱动文件行号调用时机turn.ts124TurnMachineImpl.create(...)回合开始,phaseidleturn.ts279.start()→ processing_inputturn-loop.ts188.startModelRequest(model, messages)每次模型往返开始turn-model-step.ts630.receiveModelResponse(...)模型响应落地 → streamingturn-tools.ts147.scheduleTools(calls, schedule)响应里有工具调用turn-tools.ts153.startToolExecution()→ executing_toolsturn-tools.ts262.completeTool(id, result)每个工具完成时回报turn-tools.ts269.aggregateResults()工具批次收尾turn-stop.ts190.complete(response, success)文本收尾 → completing发动机是turn-loop.ts的runRegularTurnLoop,文件开头就是:while(true){throwIfTurnAborted(state.turnAbortSignal);// ← 每个关键节点前都有这一句...}循环体每个 iteration 干的事:检查中断 → 按需压缩上下文(microcompact/autoCompact)→ 初始化 MCP 和工具 → 发模型请求 → 流式接收 → 有工具就排程执行 → 汇总结果 → 回到循环顶部。对应到状态机,就是那段会重复出现的序列。模型连续调 3 个工具的完整答案(同一响应里 3 个工具,实测验证):idle → processing_input → awaiting_model_response → streaming → scheduling_tools → executing_tools → aggregating_results → awaiting_model_response → streaming → completing如果是 3 轮串行(每轮 1 个工具),则是循环体awaiting → streaming → scheduling → executing → aggregating重复 3 次,最后一次纯文本收尾。六、停止条件:一轮怎么才算完从代码归纳,turn 的结束有五条路:停止条件代码位置resultType模型纯文本收尾(没有再要工具)turn-stop.ts 的finishModelStepWithoutToolCallssuccessStop hook 要求继续(收尾被续命)同上,aggregateResults()后 return “continue”(不结束)用户中断循环各处throwIfTurnAborted→ 外层 catchcancelled撞上限(轮数/预算/工具数)TurnResultType的 error_max_*error_max_*执行中异常fail()error_during_execution两个容易被忽略的细节:其一,用户中断算正常结束。TurnResultType里 cancelled 的注释写得明白:用户主动中断属于正常结束,复用 TurnComplete 上报而非 TurnError。所以中断不会走 error 相位,而是外层 catch 之后以complete(..., cancelled)收尾。其二,fail()绕过了转换表。它直接赋值phase: Error,不经过canTransitionTo校验——因为错误可能发生在任何状态,转换表没法穷举任意 → error。这是规则引擎里常见的逃生门。其三,Stop hook 能让结束变成继续。文本收尾前会跑一次 Stop hook,如果 hook 返回继续,机器从收尾点折返aggregateResults(),回合延长——同一个 product turn 可以被 hook 续命多次。七、中断:不是一个状态,而是一根随时绷断的线看转换表你会以为中断是某个 phase,其实不是。实现形态是:turn.ts用createTurnAbortScope(options?.abortSignal)造出本轮的 abort 信号,然后runRegularTurnLoop在每个关键节点前调用throwIfTurnAborted(state.turnAbortSignal)——用户按 Esc 就是拉响这根线,循环在下最近的检查点抛出异常,穿透所有层被外层 catch 接住,统一以 cancelled 收尾。工具执行中中断同理。turn-tools.ts里有一段注释专门解释:assistant 的 tool_use 声明已经进了历史,Stop 不能在 tool result 创建前直接抛,而是把 aborted signal 交给 executor,由现有取消路径给每个 tool call 生成 ToolCancelled result,再由循环感知 abort——保证历史账本永远配平(有声明必有回执)。八、动手验证:37 个断言的状态机实验这套机制完全可以脱离模型做单元级验证。我写了一个 400 行的实验脚本(packages/core/scratch/day2-turn-machine-lab.ts),自带微型断言框架,把TurnMachineImpl的驱动方式照真实 runtime 的写法复刻一遍:letmTurnMachineImpl.create(sid(session-1),1,看三个文件);mnewTurnMachineImpl(m.start());// → processing_inputmnewTurnMachineImpl(m.startModelRequest(test-model,MSGS));// → awaiting_model_responsemnewTurnMachineImpl(m.receiveModelResponse(我来读取这三个文件。));// → streamingmnewTurnMachineImpl(m.scheduleTools(calls,schedule));// → scheduling_toolsmnewTurnMachineImpl(m.startToolExecution());// → executing_toolsfor(constidof[t2,t3,t1]){// 乱序完成,phase 不动mnewTurnMachineImpl(m.completeTool(tid(id),{success:true,content:[...]}));}10 组用例共 37 个断言,30 秒跑完:并行 3 工具的完整序列、串行 3 轮的循环体、纯文本直通车、非法转换被拒(且旧状态完好)、权限分支、不可变性、cancelled 收尾……全部通过。比起在 TUI 里加日志盲猜,先把状态机当纯函数喂参数,是理解它最快的方式。九、词汇表 ≠ 实际用法:三个考古发现这是本文最想传达的读码方法论。三个发现全部可以用 grep 复现:发现一:getNextPhase()全仓库零调用。这个计算下一步该去哪的咨询函数,除了接口定义和实现,没有任何调用者。它是预留的 advisers,当前驱动层根本不用。发现二:状态机的权限词汇是摆设。turnMachine.requestPermission / resolvePermission无人调用——真实的权限审批在工具执行器内部的 permissionBroker(tool/executor/permission-flow.ts、runtime/helpers/permission-broker.ts)里完成。也就是说,AwaitingPermission 这个 phase 在主循环里永远不会出现:需要用户确认时,状态机原地停在 executing_tools,等待发生在更深的执行层。发现三:pendingInputs 没人用。用户插话(steering)在词汇表里是queuePendingInput / drainPendingInputs,但实际走的是 runtime 层的ActiveTurnSteeringState——插话在下一个模型往返起点被 drain 进请求,而不是存进 turn state。三个发现指向同一个结论:turn-state 是宪法,runtime 是实际政治。状态机先被设计出来,权限和插话后来下沉/外移到了更合适的层,词汇表里留下了演化的化石(同款化石还有一个:acceptsPendingInput初始为 true,全仓库没有任何代码把它翻成 false)。读架构代码,类型只能告诉你能说什么,调用点才告诉你实际说什么。十、总结把 Agent Loop 的实现要点收拢成一段话:用户一句话开启一个 turn;turn-loop 的 while(true) 每圈完成一次模型往返;模型要工具就排程执行、乱序回报、汇总后从唯一的回环边折返再问模型;纯文本回答、用户中断或撞上限时回合终结。整个过程的合法性由一张 10 状态的转换表守卫,状态以不可变方式更新,中断是一根随时绷断的线而不是一个状态,权限和插话则在词汇表之外自成体系。下一篇打算顺着数据流往下走:模型到底看见了什么——消息历史的结构、上下文窗口的组装、以及重开会话时的水合(hydration)机制。系列回顾:Day 1 动态 Subagent 与 AIMD 并发治理器 / Day 2 子代理文件冲突 / Day 3 父子代理通信 / Day 4 Subagent 与 Actor 的区别