本章拆 core/src/session/turn.rs:153 那个 400 行的 run_turn。它是整个系统的主循环,也是理解 Codex 的钥匙——agent 循环的本质很简单,复杂度全在挂在循环上的横切关注点。
先把术语钉死。代码里的三级是:
| 层级 | 代码位置 | 边界 | 循环条件 |
|---|---|---|---|
| Task | tasks/mod.rs:284 start_task | 一次用户请求的完整生命周期 | 不循环,一个 Session 同时只有一个 |
| Turn | session/turn.rs:153 run_turn | 一轮对话 | 模型还要继续 / 有待处理输入 / 需要压缩 |
| Sampling Request | session/turn.rs:1340 run_sampling_request | 一次模型采样 | 可重试错误 |
术语陷阱 第 3 章提过:协议规范里的 "Turn" 是今天代码里的 "Sampling Request"。规范写于早期,那时一次 Turn 就是一次模型请求。现在的 run_turn 是包含多次采样的循环。
本章以及后续所有章节用代码里的术语。看到别人的文章里说 "Codex 的 Turn 是一次模型调用",那是在引用旧规范。
Codex 把"一次用户请求"抽象成 SessionTask [源码 core/src/tasks/mod.rs:180]:
注释里明确了设计意图:
The trait is intentionally small: implementers identify themselves via kind, perform their work in run, and may release resources in abort.
(这个 trait 刻意做得很小:实现者用 kind 标识自己、在 run 里干活、可以在 abort 里释放资源。)
四个实现 [源码 core/src/tasks/]:
| 任务 | 文件 | 行数 | 干什么 |
|---|---|---|---|
| RegularTask | regular.rs | 92 | 普通对话轮次——调 run_turn |
| CompactTask | compact.rs | 86 | 手动压缩上下文(第 7 章) |
| ReviewTask | review.rs | 276 | 代码复核模式(独立的系统提示词与退出协议) |
| UserShellTask | user_shell.rs | 475 | 用户直接跑一条 shell 命令(不经过模型) |
TaskKind 枚举只有三个值 [源码 core/src/state/turn.rs:68]:Regular / Review / Compact——UserShellTask 复用了其中之一。
可迁移的判断 ③ 把"压缩"和"代码复核"做成和普通对话平级的任务类型,而不是普通轮次里的特例分支。
它们和普通对话共享 Session、共享历史、共享取消机制,但有各自的系统提示词、各自的终止条件、各自的事件。如果把它们塞进主循环当 if 分支,主循环会迅速变成没人敢改的东西。
spawn_task 的第一行就是抢占 [源码 core/src/tasks/mod.rs:272]:
新任务到来 = 旧任务被 Replaced 中止。这正是第 3 章那条"一个 Session 同时只能有一个 Task"约束的落地。
RunningTask 里存着取消所需的一切 [源码 core/src/state/turn.rs:74]:
注意 AbortOnDropHandle:句柄一析构,Tokio 任务就被中止。这是 Rust 的 RAII 用在并发上——不需要在每条错误路径上记得清理。
400 行的 run_turn 可以分成"入场"和"循环"两段。
进入循环之前依次做了 [源码 core/src/session/turn.rs:153-300]:
| # | 动作 | 说明 |
|---|---|---|
| 1 | drain_async_hook_results(before_user_prompt=true) | 收上一轮跑完的异步钩子结果 |
| 2 | run_pre_sampling_compact | 采样前压缩——如果历史已经太长,先压再问 |
| 3 | required_mcp_servers_for_input | 根据用户输入决定这轮需要哪些 MCP 服务器 |
| 4 | capture_step_context_with_required_mcp_servers | 冻结这一步的完整视图(下一节详述) |
| 5 | record_context_updates_and_set_reference_context_item | 记录环境变化(world state) |
| 6 | build_skills_and_plugins | 装配技能与插件的注入内容 |
| 7 | run_pending_session_start_hooks + run_hooks_and_record_inputs | 跑 SessionStart / UserPromptSubmit 钩子 |
| 8 | TurnDiffTracker 初始化 | 开始追踪这一轮改了哪些文件 |
第 2 步的那段 TODO 注释值得引用,它暴露了一个真实的未解决问题:
TODO(ccunningham): Pre-turn compaction runs before context updates and the new user message are recorded. Estimate pending incoming items (context diffs/full reinjection + user input) and trigger compaction preemptively when they would push the thread over the compaction threshold.
(轮前压缩发生在上下文更新和新用户消息被记录之前。应当预估待入队的条目——上下文 diff / 完整重注入 + 用户输入——并在它们会把线程推过压缩阈值时提前触发压缩。)
翻译成人话:现在的压缩判断是"看历史有多长",但漏算了"这一轮马上要塞进去多少",所以有可能压完仍然超限。这种 TODO 是读开源产品代码最大的收获之一——它告诉你哪些地方连原作者都还没想清楚。
第 4 步冻结的东西叫 StepContext。它的文档注释只有一句 [源码 core/src/session/step_context.rs:18]:
Request-scoped state that may change between model sampling requests.
(请求作用域的状态,可能在不同的模型采样请求之间发生变化。)
字段清单:
为什么需要它?因为这些东西在一轮之内可能变:模型可以中途切换(ModelReroute 事件)、MCP 服务器可能重连、AGENTS.md 可能被改、审批策略可能因为模型切换而收紧。
如果不冻结,就会出现经典的不一致 bug:"用 A 模型的工具列表发请求,用 B 模型的能力解析响应"。字段注释里对此写得很直接:
tool_router: The finalized tool plan advertised and executed for this exact sampling request. (为这次具体的采样请求最终确定的、既用于广告也用于执行的工具方案。)
一个词概括:"advertised and executed"(广告出去的和实际执行的)必须是同一份。
可迁移的判断 ④ 给每一次模型请求冻结一份不可变的"世界视图",把所有会变的东西一次性快照进去。
这是 agent 系统里最容易漏掉的一类 bug 来源:请求发出去到响应回来之间,配置变了。冻结之后,一次采样的行为就是可复现、可解释、可测试的。
代价是要显式列出"什么算这一步的视图"——StepContext 那 14 个字段就是 Codex 交出的答案清单。
进入 loop 之后,每一圈做这些事:
第 ⑥ 步的判断逻辑是循环的心脏 [源码 core/src/session/turn.rs:394-551]:
三条出路:压缩后继续、钩子阻止停止、正常结束。
注意 stop_outcome.should_block 这条——Stop 钩子可以拒绝让轮次结束,往历史里塞一条继续指令然后 continue。这就是"agent 自动接着干"的机制来源。代码里还处理了钩子的误用:
钩子说"别停"但没给继续的理由 → 警告 + 忽略。不给出无限循环的口子。
代码里还有一句注释直面了"会不会死循环"这个疑虑:
as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
(只要压缩能把我们压到远低于 token 上限,就不用担心陷入无限循环。)
诚实但也脆弱——这个不变量没有代码强制,靠的是压缩效果足够好。
循环对错误分了三档,值得单独看:
| 错误类型 | 处理 | 理由 |
|---|---|---|
| TurnAborted | 直接 return Err | 用户主动打断,不用报错给用户 |
| InvalidImageRequest | 发一条面向人的提示后 break | "Invalid image in your last message. Please remove it and try again." |
| 其它 | 发 EventMsg::Error 后 break | 注释:// let the user continue the conversation |
最后一条尤其重要:出错不等于会话结束。发个错误事件,跳出循环,会话还在,用户可以接着说话。很多 agent 实现在这里直接 panic 或退出,用户就得从头再来。
run_sampling_request 本身也是个循环,但它循环的原因只有一个:可重试的错误 [源码 core/src/session/turn.rs:1340]。
两类错误被显式排除在重试之外:
这是一个很好的关注点分离示例:重试属于采样层,压缩属于轮次层。如果把压缩塞进重试循环,两个循环的终止条件会纠缠成一团。
还有一个细节:每次重试都重新构建 prompt,而不是复用上次的:
因为重试期间历史可能已经变了(比如工具在流式过程中已经执行并写回了结果)。同时保留了 original_input,用于返回给上层——"第一次发的是什么"和"最后一次发的是什么"是两个不同的问题。
前面拆完了骨架。现在把挂在这个循环上的东西列出来,这才是 400 行的真实来源:
| 关注点 | 挂在哪 | 后续章节 |
|---|---|---|
| 钩子(6 个事件点) | 入场、循环每圈、停止时 | 第 13 章 |
| 上下文压缩(3 种时机) | 入场前、循环中、模型切换时 | 第 7 章 |
| MCP 服务器按需拉起 | 入场 + 每次重建 StepContext | 第 13 章 |
| 技能与插件注入 | 入场 | 第 13 章 |
| 世界状态(环境变化) | 入场 + 每圈检查 | 第 6 章 |
| 时间提醒 | 每圈 | 第 6 章 |
| Turn diff 追踪 | 全程 | 第 8 章 |
| 待处理输入(用户插话) | 每圈开头 | 本章 |
| 遥测与分析 | 每个环节 | —— |
| 计划模式渲染 | 流式解析中 | —— |
这就是 agent 循环的真相:核心 20 行,剩下 380 行是"还得管这个"。
Pi 教程第 3 章拆出来的核心循环只有几百行,dsh 的 agent-loop 是 1295 行,Codex 的 turn.rs 是 2791 行(含测试)。三个数字的差距不是算法复杂度,是横切关注点的数量。
第 3 章提到协议层有 turn/steer。它在循环里的落点是这样的:
can_drain_pending_input 的控制逻辑很讲究 [源码 core/src/session/turn.rs:295-298]:
Pending input is drained into history before building the next model request. However, we defer that drain until after sampling in two cases:
- At the start of a turn, so the fresh turn input in input gets sampled first.
- After auto-compact, when model/tool continuation needs to resume before any steer.
翻译:两种情况下要压住用户的插话——轮次刚开始(先把用户这轮真正说的话跑完)、刚压缩完(先让模型/工具的续接跑起来,再让插话进来)。
否则会出现"用户输入还没处理就被插话覆盖"或"压缩后模型丢失了续接线索"这类问题。这种时序细节是产品打磨出来的,不是设计出来的。
跑一次并观察事件流:
下一章往下走一层:模型调用。Codex 砍到只剩一种 wire 协议,然后在传输层做了些别人没做的事。