Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第4章

第4章:Agent Loop —— Task / Turn / Step 三级循环

9 分钟 · 更新于 2026-09-01

第4章:Agent Loop —— Task / Turn / Step 三级循环

本章拆 core/src/session/turn.rs:153 那个 400 行的 run_turn。它是整个系统的主循环,也是理解 Codex 的钥匙——agent 循环的本质很简单,复杂度全在挂在循环上的横切关注点


一、三级结构

先把术语钉死。代码里的三级是:

text
Task(任务)
 └── Turn(轮次) ← run_turn,一个 loop
      └── Sampling Request(采样请求) ← run_sampling_request,又一个 loop(重试)
           └── try_run_sampling_request ← 一次真实的模型请求 + 流式消费 + 工具执行
层级代码位置边界循环条件
Tasktasks/mod.rs:284 start_task一次用户请求的完整生命周期不循环,一个 Session 同时只有一个
Turnsession/turn.rs:153 run_turn一轮对话模型还要继续 / 有待处理输入 / 需要压缩
Sampling Requestsession/turn.rs:1340 run_sampling_request一次模型采样可重试错误

术语陷阱 第 3 章提过:协议规范里的 "Turn" 是今天代码里的 "Sampling Request"。规范写于早期,那时一次 Turn 就是一次模型请求。现在的 run_turn 是包含多次采样的循环。

本章以及后续所有章节用代码里的术语。看到别人的文章里说 "Codex 的 Turn 是一次模型调用",那是在引用旧规范。


二、Task 层:四种任务共用一个 trait

Codex 把"一次用户请求"抽象成 SessionTask [源码 core/src/tasks/mod.rs:180]:

rust
pub(crate) trait SessionTask: Send + Sync + 'static {
    fn kind(&self) -> TaskKind;
    fn span_name(&self) -> &'static str;
    fn run(self: Arc<Self>, session: Arc<Session>, ctx: Arc<TurnContext>,
           input: Vec<TurnInput>, cancellation_token: CancellationToken)
        -> impl Future<Output = SessionTaskResult> + Send;
    fn abort(&self, session: Arc<Session>, ctx: Arc<TurnContext>)
        -> impl Future<Output = ()> + Send { /* 默认空实现 */ }
}

注释里明确了设计意图:

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/]:

任务文件行数干什么
RegularTaskregular.rs92普通对话轮次——调 run_turn
CompactTaskcompact.rs86手动压缩上下文(第 7 章)
ReviewTaskreview.rs276代码复核模式(独立的系统提示词与退出协议)
UserShellTaskuser_shell.rs475用户直接跑一条 shell 命令(不经过模型)

TaskKind 枚举只有三个值 [源码 core/src/state/turn.rs:68]:Regular / Review / Compact——UserShellTask 复用了其中之一。

可迁移的判断 ③ 把"压缩"和"代码复核"做成和普通对话平级的任务类型,而不是普通轮次里的特例分支。

它们和普通对话共享 Session、共享历史、共享取消机制,但有各自的系统提示词、各自的终止条件、各自的事件。如果把它们塞进主循环当 if 分支,主循环会迅速变成没人敢改的东西。

抢占语义

spawn_task 的第一行就是抢占 [源码 core/src/tasks/mod.rs:272]:

rust
pub async fn spawn_task<T: SessionTask>(…) {
    self.abort_all_tasks(TurnAbortReason::Replaced).await;
    self.clear_connector_selection().await;
    self.start_task(…).await;
}

新任务到来 = 旧任务被 Replaced 中止。这正是第 3 章那条"一个 Session 同时只能有一个 Task"约束的落地。

RunningTask 里存着取消所需的一切 [源码 core/src/state/turn.rs:74]:

rust
pub(crate) struct RunningTask {
    pub(crate) done: Arc<Notify>,
    pub(crate) kind: TaskKind,
    pub(crate) task: Arc<dyn AnySessionTask>,
    pub(crate) cancellation_token: CancellationToken,
    pub(crate) handle: AbortOnDropHandle<()>,
    pub(crate) turn_context: Arc<TurnContext>,
    …
}

注意 AbortOnDropHandle句柄一析构,Tokio 任务就被中止。这是 Rust 的 RAII 用在并发上——不需要在每条错误路径上记得清理。


三、Turn 层:run_turn 的解剖

400 行的 run_turn 可以分成"入场"和"循环"两段。

3.1 入场:8 件事

rust
pub(crate) async fn run_turn(
    sess: Arc<Session>,
    turn_context: Arc<TurnContext>,
    input: Vec<TurnInput>,
    prewarmed_client_session: Option<ModelClientSession>,
    cancellation_token: CancellationToken,
) -> CodexResult<Option<String>> {

进入循环之前依次做了 [源码 core/src/session/turn.rs:153-300]:

#动作说明
1drain_async_hook_results(before_user_prompt=true)收上一轮跑完的异步钩子结果
2run_pre_sampling_compact采样前压缩——如果历史已经太长,先压再问
3required_mcp_servers_for_input根据用户输入决定这轮需要哪些 MCP 服务器
4capture_step_context_with_required_mcp_servers冻结这一步的完整视图(下一节详述)
5record_context_updates_and_set_reference_context_item记录环境变化(world state)
6build_skills_and_plugins装配技能与插件的注入内容
7run_pending_session_start_hooks + run_hooks_and_record_inputs跑 SessionStart / UserPromptSubmit 钩子
8TurnDiffTracker 初始化开始追踪这一轮改了哪些文件

第 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 是读开源产品代码最大的收获之一——它告诉你哪些地方连原作者都还没想清楚。

3.2 StepContext:本章最值得抄的一个设计

第 4 步冻结的东西叫 StepContext。它的文档注释只有一句 [源码 core/src/session/step_context.rs:18]:

Request-scoped state that may change between model sampling requests.

(请求作用域的状态,可能在不同的模型采样请求之间发生变化。)

字段清单:

rust
pub(crate) struct StepContext {
    pub(crate) turn: Arc<TurnContext>,
    pub(crate) model_info: Arc<ModelInfo>,              // 这次用哪个模型、什么能力
    pub(crate) reasoning_effort: Option<ReasoningEffort>,
    pub(crate) reasoning_summary: ReasoningSummary,
    pub(crate) service_tier: Option<String>,
    pub(crate) approval_policy: AskForApproval,         // 这次的审批策略
    pub(crate) approvals_reviewer: ApprovalsReviewer,   // 谁来审批(人 or Guardian)
    pub(crate) session_telemetry: SessionTelemetry,
    pub(crate) environments: TurnEnvironmentSnapshot,
    pub(crate) selected_capability_roots: Vec<ResolvedSelectedCapabilityRoot>,
    pub(crate) executor_capability_discovery: Option<Arc<ExecutorCapabilityDiscoverySnapshot>>,
    pub(crate) mcp: Arc<McpBinding>,                    // 这次绑定的 MCP 连接与目录
    pub(crate) tool_router: Arc<ToolRouter>,            // 这次广告出去、也用来执行的工具集
    pub(crate) loaded_agents_md: Option<Arc<LoadedAgentsMd>>,  // 这次看到的 AGENTS.md
}

为什么需要它?因为这些东西在一轮之内可能变:模型可以中途切换(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 交出的答案清单。

3.3 循环体:六个判断

进入 loop 之后,每一圈做这些事:

text
① 取待处理输入(用户在模型跑的时候插的话)
    ↓
② 跑钩子并记录输入
    ↓
③ 记录 rollout 预算提醒 / 当前时间提醒
    ↓
④ 拿 step_context(首圈复用入场时冻的,之后按需重建)
    ↓
⑤ run_sampling_request ← 真正的模型交互在这里
    ↓
⑥ 看结果决定:压缩?继续?停?

第 ⑥ 步的判断逻辑是循环的心脏 [源码 core/src/session/turn.rs:394-551]:

rust
let needs_follow_up = model_needs_follow_up || has_pending_input;
let token_limit_reached = token_status.token_limit_reached;

let should_roll_over = needs_follow_up
    && (sess.take_new_context_window_request().await || token_limit_reached);

if should_roll_over {
    run_auto_compact(…, CompactionReason::ContextLimit, CompactionPhase::MidTurn).await?;
    continue;                       // 压缩完接着跑
}

if !needs_follow_up {
    let stop_outcome = run_turn_stop_hooks(…).await;
    if stop_outcome.should_block { …; continue; }   // Stop 钩子把它拽回来了
    if stop_outcome.should_stop { break; }
    break;                          // 正常结束
}
continue;                           // 模型还要继续

三条出路:压缩后继续钩子阻止停止正常结束

注意 stop_outcome.should_block 这条——Stop 钩子可以拒绝让轮次结束,往历史里塞一条继续指令然后 continue。这就是"agent 自动接着干"的机制来源。代码里还处理了钩子的误用:

rust
sess.send_event(&turn_context, EventMsg::Warning(WarningEvent {
    message: "Stop hook requested continuation without a prompt; ignoring the block.".to_string(),
})).await;

钩子说"别停"但没给继续的理由 → 警告 + 忽略。不给出无限循环的口子。

代码里还有一句注释直面了"会不会死循环"这个疑虑:

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 上限,就不用担心陷入无限循环。)

诚实但也脆弱——这个不变量没有代码强制,靠的是压缩效果足够好。

3.4 错误处理:三档

循环对错误分了三档,值得单独看:

错误类型处理理由
TurnAborted直接 return Err用户主动打断,不用报错给用户
InvalidImageRequest发一条面向人的提示后 break"Invalid image in your last message. Please remove it and try again."
其它EventMsg::Errorbreak注释:// let the user continue the conversation

最后一条尤其重要:出错不等于会话结束。发个错误事件,跳出循环,会话还在,用户可以接着说话。很多 agent 实现在这里直接 panic 或退出,用户就得从头再来。


四、Sampling Request 层:重试的边界

run_sampling_request 本身也是个循环,但它循环的原因只有一个:可重试的错误 [源码 core/src/session/turn.rs:1340]。

rust
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retry_state = ResponsesStreamRetryState::default();
loop {
    let prompt = build_prompt(prompt_input, step_context.as_ref(), base_instructions.clone());
    let err = match try_run_sampling_request(…).await {
        Ok(output) => return Ok((output, original_input.unwrap_or(prompt.input))),
        Err(err) => match err.details() {
            CodexErrorDetails::ContextWindowExceeded => { … return Err(err); }   // 不重试
            CodexErrorDetails::UsageLimitReached(e) => { … return Err(err); }    // 不重试
            _ => err,
        },
    };
    if !err.is_retryable() { return Err(err); }
    handle_retryable_response_stream_error(&mut retry_state, max_retries, err, …).await?;
    turn_context.turn_timing_state.record_sampling_retry();
}

两类错误被显式排除在重试之外:

  • 上下文超限:重试没用,得压缩(所以它冒泡到 Turn 层去触发 run_auto_compact
  • 用量超限:重试没用,还会更新速率限制状态给 UI 显示

这是一个很好的关注点分离示例:重试属于采样层,压缩属于轮次层。如果把压缩塞进重试循环,两个循环的终止条件会纠缠成一团。

还有一个细节:每次重试都重新构建 prompt,而不是复用上次的:

rust
let prompt_input = if let Some(input) = initial_input.take() {
    input
} else {
    sess.clone_history().await.for_prompt(&step_context.model_info.input_modalities)
};

因为重试期间历史可能已经变了(比如工具在流式过程中已经执行并写回了结果)。同时保留了 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 行(含测试)。三个数字的差距不是算法复杂度,是横切关注点的数量


六、用户插话:steer 的实现

第 3 章提到协议层有 turn/steer。它在循环里的落点是这样的:

rust
let pending_input = if can_drain_pending_input {
    sess.input_queue.get_pending_input(&sess.active_turn).await.0
} else {
    Vec::new()
};

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:

  1. At the start of a turn, so the fresh turn input in input gets sampled first.
  2. After auto-compact, when model/tool continuation needs to resume before any steer.

翻译:两种情况下要压住用户的插话——轮次刚开始(先把用户这轮真正说的话跑完)、刚压缩完(先让模型/工具的续接跑起来,再让插话进来)。

否则会出现"用户输入还没处理就被插话覆盖"或"压缩后模型丢失了续接线索"这类问题。这种时序细节是产品打磨出来的,不是设计出来的。


七、动手复核

bash
cd codex/codex-rs

# 1. 主循环全文(400 行,值得完整读一遍)
sed -n '153,590p' core/src/session/turn.rs

# 2. SessionTask trait 与四个实现
sed -n '176,220p' core/src/tasks/mod.rs
ls core/src/tasks/

# 3. StepContext 的 14 个字段
cat core/src/session/step_context.rs

# 4. 采样层的重试逻辑
sed -n '1340,1440p' core/src/session/turn.rs

# 5. 找出所有 TODO —— 原作者还没想清楚的地方
grep -rn 'TODO' core/src/session/ | head -20

跑一次并观察事件流:

bash
codex exec "列出当前目录的文件" --json 2>&1 | head -40

八、总结

  1. 三级循环:Task → Turn → Sampling Request。 Task 是任务类型(4 种实现共用一个小 trait),Turn 是对话轮(多次采样),Sampling Request 是一次模型交互(含重试)
  2. StepContext 冻结每一步的世界视图——模型、工具、MCP、AGENTS.md、审批策略一次性快照。"广告出去的和实际执行的必须是同一份",这是本章最值得抄的一招
  3. 重试属于采样层,压缩属于轮次层。 上下文超限和用量超限被显式排除在重试之外,冒泡到上层处理
  4. 出错不结束会话:发个错误事件、跳出循环、用户接着说
  5. 400 行里核心只有 20 行,其余是钩子、压缩、MCP、技能、世界状态、插话时序这些横切关注点

下一章往下走一层:模型调用。Codex 砍到只剩一种 wire 协议,然后在传输层做了些别人没做的事。


  • 第3章-协议先行-SQEQ队列与多前端
  • 第5章-模型调用-只说一种协议的客户端
  • 第7章-上下文压缩-本地与远端两条路 —— 循环里那三处压缩调用的去向
  • Pi 教程第 3 章 —— 极简循环的对照
  • dsh 教程第 5 章 —— 三级生命周期的对照

本章目录
一、三级结构二、Task 层:四种任务共用一个 trait三、Turn 层:runturn 的解剖四、Sampling Request 层:重试的边界五、挂在循环上的横切关注点六、用户插话:steer 的实现七、动手复核八、总结Related Documents
苏ICP备2025204887号-2