乐于分享
好东西不私藏

从源码看Codex(三):Agent Loop 到底怎么跑起来?

从源码看Codex(三):Agent Loop 到底怎么跑起来?

现在讲 Agent Loop(Agent 执行循环),很容易画出那个熟悉的圆:

看上下文 -> 调模型 -> 选工具 -> 执行工具 -> 看结果 -> 再调模型

这个圆能解释概念,但解释不了 Codex。

Codex 要解决的是一个更硬的工程问题:当模型想查文件、跑命令、改代码、等待审批,甚至被用户中途打断时,这个循环怎么保持可控、可观察、可继续。

上一篇我们追到这里:一次用户输入从 TUI(Terminal User Interface,终端交互界面)进入 turn/start,最后变成 Op::UserInput,被送进 Codex 的 Submission(提交队列请求)里。

进入队列只是上半场。接下来,Codex 要把这次输入变成一个会持续推进的 Coding Agent(编程 Agent)任务。

让它跑起来的是后面这条链:

Submission  -> submission_loop  -> user_input_or_turn  -> RegularTask  -> run_turn  -> model / tool / event loop

这篇文章只追一个问题:

Agent Loop 落到 Codex 源码里,到底怎么跑起来?

 Loop 开始前,turn 先有任务壳

先把 turn 这个词说清楚。

在这组文章里,turn 可以理解成“一次 Agent 执行轮次”:用户给出一个任务,系统围绕这个任务组上下文、调模型、执行工具、返回结果,直到这次任务结束。

第二篇讲到 submission_loop 会处理 Op::UserInput。继续往下看,codex-rs/core/src/session/handlers.rs 里的 user_input_or_turn_inner 会做一件很关键的事:

如果当前没有正在运行的 active turn(活跃执行轮次),它会把用户输入和额外上下文整理成 TurnInput(turn 输入项),然后调用:

sess.spawn_task(  current_context,  task_input,  RegularTask::new())

这里有一个容易忽略的设计点:Codex 没有让用户输入直接调用 run_turn。它先把这次执行包装成 RegularTask(常规任务)。

为什么要多这一层?

因为 Agent Loop 需要生命周期:什么时候开始,什么时候结束,什么时候被中断,什么时候清理 active turn,什么时候通知前端。

这些事情不是模型负责的,也不应该塞进模型请求里。Codex 把它们放在任务框架里。

codex-rs/core/src/tasks/regular.rs 里的 RegularTask 做了第一件事:发送 TurnStarted(turn 开始事件),然后才调用 run_turn

codex-rs/core/src/tasks/mod.rs 里的 Session::start_task 做外围工作:创建 cancellation token(取消令牌)、登记 active turn、准备 turn state(turn 状态),并在任务结束时统一发送 TurnComplete(turn 完成事件)或 TurnAborted(turn 中断事件)。

Agent Loop 启动前,Codex 先搭了一个任务壳:

UserInput  -> TurnInput  -> RegularTask  -> TurnStarted  -> run_turn  -> TurnComplete / TurnAborted

这个任务壳的意义很直接:Loop 可以跑,但它必须被系统管理。

这张图只需要看三件事:UserInput 不是直接冲进 run_turn,它先被装进 RegularTaskTurnStarted 和 TurnComplete 是这个任务壳上的生命周期卡扣;Aborted 是系统保留的中断出口。

 循环入口在 run_turn

进入 codex-rs/core/src/session/turn.rs 后,run_turn 的文件注释已经把核心逻辑说得很直白。

它会运行一个循环。每次 sampling request(一次模型采样请求)里,模型可能返回两类东西:

  • 工具调用:例如要执行命令、搜索工具、调用 MCP(Model Context Protocol,模型上下文协议)工具。
  • 助手消息:也就是最终或阶段性的文本回答。

如果模型返回工具调用,Codex 执行工具,把工具输出放回下一次模型请求。

如果模型只返回助手消息,Codex 把它记录进对话历史,然后认为这次 turn 可以结束。

把源码逻辑压成伪代码,大概是这样:

run_turn(input):  先处理压缩、skills(技能指令)、plugins(插件注入)、hooks(钩子)和用户输入记录  loop:    从历史里组装本轮要发给模型的输入    调用 run_sampling_request    如果模型还需要 follow-up:      继续下一轮    如果有用户中途追加输入:      继续下一轮    如果上下文快满了:      先 compact,再继续    否则:      跑 stop hook(停止前钩子)      结束 turn

这里的 follow-up(后续跟进)不是产品文案里的“继续聊”,而是源码里的一个判断:这次模型输出之后,系统是否还要再发一次模型请求。

工具调用会让 needs_follow_up 变成 true。用户在模型运行时追加输入,也会让 Loop 继续。上下文达到限制时,Codex 会先做 compact(上下文压缩),再继续执行。

这就是它和普通聊天窗口的分叉点。

普通聊天窗口可以把一次用户消息对应成一次模型回答。Coding Agent 不行。它可能要先调用 shell,拿到报错;再调用 apply_patch,改文件;再运行测试;再根据测试结果继续修改;最后才给用户一个回答。

Codex 的 run_turn 维护的就是这个过程。

 每一轮模型请求都要重新组现场

Agent Loop 每次转起来,都要先问一个问题:

这一轮模型应该看到什么?

在 run_turn 里,进入每次模型调用前,Codex 会从 session history(会话历史)里构造 sampling_request_input。随后 run_sampling_request 会继续构造 Prompt(模型请求提示包)。

第一次出现 Prompt,可以把它理解成“发给模型的一整包请求材料”。除了用户文本,它还包括:

  • 历史消息。
  • base instructions(基础指令,模型每次请求都要遵守的底层行为说明)。
  • tools(模型可见工具规格)。
  • personality(人格配置)。
  • output schema(输出结构约束)。
  • parallel tool calls(是否允许并行工具调用)。

先把这一步看成“重新组现场”:历史、工具、基础指令和运行上下文会一起被装进 Prompt,再交给模型。

源码里的入口是 build_prompt

Prompt {  input,  tools: router.model_visible_specs(),  parallel_tool_calls,  base_instructions,  personality,  output_schema,}

这里出现了一个新角色:ToolRouter(工具路由器)。

ToolRouter 的工作不是执行工具,而是把当前 turn 能用的工具整理出来。一部分工具会以 spec(工具规格)的形式给模型看;当模型真的发起工具调用时,它再负责把模型输出转换成统一的 ToolCall(工具调用对象)。

run_sampling_request 每次都会调用 built_tools,再用 ToolRouter::from_turn_context 构造工具路由器。这个路由器会结合 MCP tools(MCP 工具)、deferred MCP tools(延迟暴露的 MCP 工具)、discoverable tools(可发现工具)、extension tools(扩展工具)和 dynamic tools(动态工具)。

每个 turn 里,系统都会根据当前 TurnContext(turn 运行上下文)重新整理工具边界。

TurnContext 可以理解成这次 turn 的运行现场:模型、权限策略、沙箱策略、cwd、workspace roots、人格、协作模式、动态工具、上下文窗口等信息都在这里。

这也接上了前一篇的结论:用户输入进入系统后,会被补上 cwd、模型、审批策略、权限 profile 等上下文。到了 run_turn,这些东西开始真正影响 Loop 怎么跑。

 工具调用不会直接结束,它会回到下一轮模型请求

Agent Loop 最核心的一跳,是模型输出工具调用之后发生什么。

在 try_run_sampling_request 里,Codex 读取模型返回的 stream(流式响应)。当收到一个完成的输出项,也就是 ResponseEvent::OutputItemDone 时,它会交给 handle_output_item_done

第一次出现 ResponseItem,可以把它理解成“模型响应条目”。一段助手文本是一个 ResponseItem,一次工具调用也是一个 ResponseItem

handle_output_item_done 会先调用:

ToolRouter::build_tool_call(item.clone())

如果这个 ResponseItem 是工具调用,ToolRouter 会把它转成统一的 ToolCall。源码里主要处理几类:

  • ResponseItem::FunctionCall
    :普通函数工具调用。
  • ResponseItem::ToolSearchCall
    :工具搜索调用。
  • ResponseItem::CustomToolCall
    :自定义工具调用。

接下来 Codex 不会马上把 turn 结束。它会做三件事:

  1. 把这次工具调用记录进 conversation history(对话历史)。
  2. 启动 ToolCallRuntime::handle_tool_call 执行工具。
  3. needs_follow_up = true

第三点是 Agent Loop 的开关。

needs_follow_up = true 的意思是:工具跑完以后,这个 turn 还没结束,模型还需要看工具结果,再决定下一步。

工具结果会变成 ResponseInputItem(回喂给模型的输入条目)。随后 drain_in_flight 会把正在运行的工具 future(异步工具任务)收回来,转成 ResponseItem,写入 history。

下一轮 run_sampling_request 再从 history 组 prompt 时,模型就能看到刚才的工具输出。

这条链路就是 Agent Loop 的骨架:

模型输出 ToolCall  -> ToolRouter 识别工具  -> ToolCallRuntime 执行工具  -> 工具结果变成 ResponseInputItem  -> 写回 conversation history  -> 下一轮模型请求读取 history

图里最重要的是那条回流槽:工具结果要回到 history,下一轮模型请求才看得到它。

如果工具结果只展示给 UI,不回灌给模型,Loop 就断了。模型不知道命令输出了什么,也不知道补丁是否成功,更不可能根据测试结果继续修改。

 并行、取消和失败都在 Loop 里处理

ToolCallRuntime(工具调用运行时)负责执行工具。它里面有一个细节值得看:并不是所有工具都能随便并行跑。

源码里会先问路由器:

router.tool_supports_parallel(&call)

支持并行的工具走读锁,不支持并行的工具走写锁。换成人话就是:有些工具可以同时跑,有些工具必须排队。

这对 Coding Agent 很现实。读文件、查资料、某些外部工具可能可以并行;会修改同一个工作区的命令和补丁,如果无序并发,很容易把状态搞乱。

ToolCallRuntime 还处理取消。

如果用户中断 turn,cancellation token 会触发。某些工具运行时可以等待它自己收尾,某些工具会被 abort。无论哪种,Codex 都会生成一个工具输出,让模型或历史知道:这个工具调用被用户中止了。

失败也类似。

工具执行失败不会简单地让整个系统崩掉。很多失败会被转换成模型可见的输出,例如命令退出码非 0、沙箱拒绝、用户拒绝审批。这样模型还能基于失败信息继续判断:换个命令、解释原因,或者请求新的权限。

Codex 的 Agent Loop 也因此覆盖了失败、拒绝和取消,而不只是成功路径。

 审批和事件在 Loop 的主路径上

很多 Agent Loop 图会把审批、安全、事件系统放在旁边,好像它们只是外围能力。

在 Codex 源码里,它们就在 Loop 的执行路径上。

比如命令执行需要审批时,session 会发送 ExecApprovalRequest(命令执行审批请求事件)。补丁需要审批时,会发送 ApplyPatchApprovalRequest(补丁审批请求事件)。工具运行中还可能请求 RequestPermissions(权限请求事件)或 RequestUserInput(向用户追问事件)。

这些都定义在 EventMsg(事件消息类型)里。

EventMsg 是 Codex 前后端沟通的重要协议枚举。它里面不只有最终回答,还有:

  • TurnStarted
    :turn 开始。
  • ItemStarted
     / ItemCompleted:某个输出项开始和完成。
  • AgentMessageContentDelta
    :助手文本增量。
  • ExecCommandBegin
     / ExecCommandEnd:命令执行开始和结束。
  • ExecApprovalRequest
    :命令审批请求。
  • ApplyPatchApprovalRequest
    :补丁审批请求。
  • TurnDiff
    :本轮产生的代码 diff(代码差异)。
  • TurnComplete
    :turn 完成。
  • TurnAborted
    :turn 中断。

如果把 Loop 看成一台后台机器,EventMsg 就是它不断吐出来的状态纸条:命令开始、等待审批、代码变化、turn 完成,前端都是沿着这些事件看到过程的。

这让 Agent Loop 不再是一个后台黑盒。

它每推进一步,都要把可观察状态发给前端。用户看到的“正在运行命令”“等待批准”“文件发生变化”“模型正在输出”,都不是 UI 猜出来的,而是 Loop 在执行过程中持续发出的事件。

对 Coding Agent 来说,这不是装饰性体验。

用户不只是等一个答案。用户要看它准备干什么、正在干什么、哪里需要审批、改了哪些文件、最后是否完成。没有这些事件,Agent Loop 就算能跑,也很难被信任。

 turn 结束靠一组工程条件

最后看 Loop 怎么停。

在 run_turn 里,每次 run_sampling_request 返回后,Codex 会综合几个条件:

  • model_needs_follow_up
    :模型输出是否触发后续动作,比如工具调用。
  • has_pending_input
    :用户是否在 turn 运行中追加了输入。
  • token_limit_reached
    :上下文 token(模型上下文计量单位)是否达到压缩阈值。
  • stop hook(停止前钩子):停止前的 hook 是否要求继续。

只有这些条件都不再要求继续时,run_turn 才会退出。

随后任务框架会调用 on_task_finished,发送 TurnComplete。如果 turn 被用户打断,任务框架会走 abort 流程,发送 TurnAborted

Codex 的 turn 结束,不等于“模型吐完一段话”。它的结束条件更工程化:

没有工具结果要回喂没有用户追加输入没有上下文压缩后的续跑没有 stop hook(停止前钩子)要求继续没有任务中断

这才是 Coding Agent 需要的结束判断。

 把 Loop 压回五个工程部件

把这篇文章压回一张图,Codex 的 Agent Loop 大概是这样:

RegularTask  -> TurnStarted  -> run_turn      -> 组装 Prompt      -> run_sampling_request          -> 模型流式响应          -> ResponseItem          -> ToolRouter          -> ToolCallRuntime          -> ResponseInputItem      -> 写回 history      -> 继续下一轮 / compact / stop  -> TurnComplete

从源码看,Agent Loop 至少需要五个部件:

第一,任务生命周期。

Codex 用 RegularTask 和 Session::start_task 管 turn 的开始、结束和中断。Loop 不是裸跑的函数,它有生命周期边界。

第二,上下文组装。

Codex 用 TurnContext、history、skills、plugins、tools 和 base instructions 组出 Prompt。模型看到的是一整包运行现场,不是一句孤立 prompt。

第三,工具路由。

Codex 用 ToolRouter 把模型输出的工具调用映射到真实工具运行时。模型只提出动作,系统决定这个动作怎么被执行。

第四,结果回灌。

Codex 把工具输出转成 ResponseInputItem,写回 conversation history,再进入下一轮模型请求。没有这一步,循环就会停在工具结果外面。

第五,事件外发。

Codex 用 EventMsg 把文本、工具、审批、diff、token 和完成状态持续发给前端。Loop 不只是推理结构,也是可观察的工程流程。

现在可以回答标题里的问题:

Agent Loop 在 Codex 里,靠 RegularTask 管生命周期,靠 run_turn 管循环,靠 run_sampling_request 管模型请求,靠 ToolRouter / ToolCallRuntime 管工具执行,再通过 history 和 EventMsg 把结果回灌给模型、把过程暴露给用户。

这也是 Codex 对 Agent 工程的一个重要启发:

能工作的 Agent Loop,必须把上下文、工具、审批、历史、事件和中断都纳入同一条执行链路。

下一篇可以顺着这篇里的工具部分继续拆:

Codex 的工具系统是怎么接进 Agent Loop 的?

因为一旦工具进入 Loop,新的问题就来了:哪些工具能暴露给模型?哪些工具需要审批?哪些工具可以并行?工具结果如何截断、如何展示、如何影响后续上下文?