中文资讯 掘金 AI Sat, 26 Sep 2026 正文 ok 4910 words

Codex 源码导读:第二部分——一次 Turn 的完整生命周期

英文标题:Codex 源码导读:第二部分——一次 Turn 的完整生命周期

本文沿着一次用户输入的完整路径,拆解 Codex 中「模型 → 工具 → 模型」循环的启动、持续、结束与中断机制。文章以 `codex-rs` 检出版本 `d58d0e5841` 为基准,定位 `turn_input.rs`、`tasks/regular.rs`、`session/turn.rs` 等关键文件和函数。核心结论是:一个 session 同时只允许一个活动 turn,新输入会替换旧 turn,或在满足条件时作为 steer 输入进入当前 turn 的待处理队列。turn 是否继续并不只取决于工具调用,还取决于 Responses API 中的 `response.end_turn` 信号、待处理输入队列以及工具执行结果,这为开发者理解 Agent 的交互连续性、流式执行和工具安全链路提供了源码依据。
打开原文相关 3 · 机会 0
media3.2 KB media589 B media5.0 KB

中文

Codex 源码导读:第二部分——一次 Turn 的完整生命周期

本文承接《第一部分——工程分层》,沿着第一部分预告的主线往下走:一次用户输入如何驱动「模型 → 工具 → 模型」的循环,并在什么时候终止。

阅读粒度是「微单元」:一次只钉住一个函数或一条分支,给出真实文件路径、行号与骨架伪代码。所有行号基于 codex-rs 当前检出 d58d0e5841;行号会随仓库演进漂移,回看时先用 git log -1 与 grep -n 复核。

阅读主线(六个微单元):

用户回车
  └─ 1. 开新 turn 还是插进正在跑的 turn       session/turn_input.rs
      └─ 2. Turn 任务的启动 / 结束 / 打断      tasks/mod.rs
          └─ 3. 为什么 turn 结束了还会再跑一轮  tasks/regular.rs
              └─ 4. run_turn 骨架              session/turn.rs
                  └─ 5. 一轮如何判定结束       session/turn.rs(end_turn)
                      └─ 6. 流式读取与工具执行  session/turn.rs + tools/parallel.rs

阅读主线(后五个微单元:工具安全链,接在第 6 单元之后):

工具并发闸门(tools/parallel.rs:155-159)
  └─ 7. 工具执行的阶段划分与结果去向      tools/registry.rs + tools/events.rs
      └─ 8. 审批三段式与三级提问者        tools/orchestrator.rs + tools/approvals.rs
          └─ 9. 审批怎么变成一次「等待」  session/mod.rs + state/turn.rs
              └─ 10. 审批弹窗全流程       protocol/src/approvals.rs + tui/approval_overlay.rs
                  └─ 11. 谁判「要不要问」 exec_policy.rs + execpolicy/

总图:一次 turn 的正常流程(端到端)

flowchart TB
    U["用户回车 / 中途补充输入"] --> Q{"turn 正在跑?"}
    Q -- &#34;否&#34; --> S[&#34;开新 turn<br/>tasks/mod.rs: spawn_task&#34;]
    Q -- &#34;是&#34; --> ST[&#34;steer_input 守卫矩阵<br/>turn_input.rs:551&#34;]
    ST -- &#34;可插&#34; --> QN[&#34;写入 pending_input 队列&#34;]
    ST -- &#34;不可插&#34; --> S
    S --> LOOP[&#34;外层 loop<br/>tasks/regular.rs:77&#34;]
    QN --> LOOP
    LOOP --> PRE[&#34;run_turn 准备段<br/>hooks / compact / step_context&#34;]
    PRE --> IN[&#34;内层 loop 采样<br/>turn.rs:304-600&#34;]
    IN --> STREAM[&#34;读流 + 工具提前启动<br/>stream_events_utils.rs:320&#34;]
    STREAM --> SAFE{&#34;工具安全链<br/>五道关口 → execpolicy → 审批&#34;}
    SAFE -- &#34;Forbidden / Denied / Abort&#34; --> BACK[&#34;拒绝或失败结果回灌模型&#34;]
    SAFE -- &#34;Skip / Approved&#34; --> EXE[&#34;沙箱内真实执行&#34;]
    BACK --> NEED
    EXE --> NEED{&#34;needs_follow_up?<br/>end_turn / 有工具调用 / 有排队输入&#34;}
    NEED -- &#34;是&#34; --> IN
    NEED -- &#34;否&#34; --> STOP[&#34;Stop hooks&#34;]
    STOP --> RET[&#34;run_turn 返回&#34;]
    RET --> OUT{&#34;外层 loop:还有排队输入且无终态错误?&#34;}
    OUT -- &#34;是&#34; --> LOOP
    OUT -- &#34;否&#34; --> DONE[&#34;TurnComplete 收尾&#34;]

单元 1:回车之后——开新 turn 还是插进正在跑的 turn

落点 :codex-rs/core/src/session/turn_input.rs:551 steer_input

用户按下回车后,系统要回答的第一个问题是:这是新的一轮,还是对正在跑的那一轮的补充? 答案不是猜的,而是靠“当前有没有可 steer 的活动 turn”来判定。

steer_input 的守卫条件(按顺序):

async fn steer_input(&self, input, ...) -> Result<String, NotSubmittedReason> {
    let mut active = self.active_turn.lock().await;
    let Some(active_turn) = active.as_mut() else {
        return Err(NotSubmittedReason::NoActiveTurn);      
    };
    let Some(active_task) = active_turn.task.as_ref() else {
        return Err(NotSubmittedReason::NoActiveTurn);      
    };
    let active_turn_id = &active_task.turn_context.sub_id;

    if let Some(expected) = expected_turn_id && expected != active_turn_id {
        return Err(NotSubmittedReason::ExpectedTurnMismatch { .. });   
    }

    match active_task.kind {
        TaskKind::Regular => {}                                        
        TaskKind::Review  => return Err(ActiveTurnNotSteerable { .. }),
        TaskKind::Compact => return Err(ActiveTurnNotSteerable { .. }),
    }

    if matches!(input, SubmittedTurnInput::UserInput { content, .. } if content.is_empty()) {
        return Err(NotSubmittedReason::EmptyInput);                    
    }
    
    let mut pending_input = merge_additional_context_input(self, additional_context).await;  

机制小结 :

  • 返回 Err(NoActiveTurn) → 上层据此开一轮新 turn
  • 返回 Err(ActiveTurnNotSteerable) / ExpectedTurnMismatch / EmptyInput → 既不开新 turn 也插不进去 ,向用户报错
  • 成功 → 输入被塞进当前 turn 自己的 TurnState.pending_input.items,steer_input 全程不碰 cancellation_token,不会打断正在执行的那一圈

易踩的认知点 :TaskKind::Review/Compact 是不可 steer 的——审查轮和压缩轮期间用户插话会被拒绝,而不是排队。


单元 2:Turn 任务的启动、结束与打断

落点 :codex-rs/core/src/tasks/mod.rs:271 spawn_task → :282 start_task

pub async fn spawn_task<T: SessionTask>(self: &Arc<Self>, turn_context, input, task: T) {
    self.abort_all_tasks(TurnAbortReason::Replaced).await;   
    self.clear_connector_selection().await;
    self.start_task(turn_context, input, task).await;        
}

pub(crate) async fn start_task<T: SessionTask>(...) {
    let task: Arc<dyn AnySessionTask> = Arc::new(task);
    let started_at = Instant::now();
    let turn_started_at_unix_ms = turn_context.turn_timing_state.mark_turn_started(started_at).await;
    let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default();

    let cancellation_token = CancellationToken::new();       
    let done = Arc::new(Notify::new());                      
    
}

关键设计 :spawn_task 的第一件事是 abort_all_tasks(Replaced) —— Codex 的模型是「一个 session 同时只有一个活动 turn」 ,新的把旧的顶掉(TurnAbortReason::Replaced),不存在两个 turn 并行跑。

任务的生命周期由三样东西管理:cancellation_token(打断)、done(Notify,结束)、以及 Session.active_turn 里的登记项(决定 steer_input 能否找到它)。


插讲:span 是什么

落点 :tasks/mod.rs:350(TurnStarted 事件发送)、:397、:712

读到这里会反复遇到 trace_span! / field::Empty。这不是 agent 业务逻辑,是 tracing 框架的链路追踪单元 :

  • 一个 span = 一段有时间跨度的上下文(如“这一轮 turn”、“这一次工具调用”),有自己的名字和一组 key-value 字段
  • span 可以嵌套,形成树;parent: &receiving_span 就是显式挂父节点
  • field::Empty 是先占位、后填值 :声明时值还不知道(如 tool_name、token_usage),后面拿到再 record
  • 它不参与控制流,去掉不影响行为,但决定了 Codex 的 otel 遥测质量

读源码时看到 span,直接跳过 ,只当它是“这段代码属于哪个逻辑单元”的标注。


单元 3:为什么 turn 结束了还会再跑一轮

落点 :codex-rs/core/src/tasks/regular.rs:39-96(RegularTask::run)

这是整个生命周期里最容易看糊的一段:最外层是一圈loop,但它不是在跑模型。

async fn run(self: Arc<Self>, sess, ctx, input, cancellation_token) -> SessionTaskResult {
    let run_turn_span = trace_span!("run_turn");

    
    let prewarmed_client_session = async {
        sess.send_event(ctx.as_ref(), EventMsg::TurnStarted(TurnStartedEvent { .. })).await;
        sess.set_server_reasoning_included(false).await;
        sess.consume_startup_prewarm_for_regular_turn(&cancellation_token).await
    }.instrument(trace_span!("regular_task.prepare_run_turn")).await;

    let prewarmed_client_session = match prewarmed_client_session {
        Cancelled => { run_hooks_and_record_inputs(..).await; return Ok(None); }   
        Unavailable { .. } => None,
        Ready(session) => Some(*session),
    };

    let mut next_input = input;
    let mut prewarmed_client_session = prewarmed_client_session;
    loop {                                                       
        let last_agent_message = run_turn(
            Arc::clone(&sess), Arc::clone(&ctx), next_input,
            prewarmed_client_session.take(),                     
            cancellation_token.child_token(),
        ).instrument(run_turn_span.clone()).await?;

        if ctx.terminal_error.lock().await.is_some() {
            return Ok(last_agent_message);                       
        }
        if !sess.input_queue.has_pending_input(&sess.active_turn).await {
            return Ok(last_agent_message);                       
        }
        next_input = Vec::new();                                 
    }
}

为什么要有外层 loop :run_turn 返回只说明“内层循环结束了”,不说明“用户没话说了”。存在一个时间窗——run_turn 收尾(跑 Stop hook、收工具结果)期间用户又打了字——这些输入属于“这一轮结束时才到”,只能由外层接住,再开一轮run_turn。

注意 :94 next_input = Vec::new():第二轮起不再传新输入,因为输入已经通过队列被消费过了,避免重复。

TurnStarted 事件的位置也值得注意(:47-48 的注释):Regular turn 把 TurnStarted 内联发 ,为的是“第一轮生命周期不等启动预热解析”,即 UI 能立刻显示“开始了”,而不是卡在预热的 await 上。


单元 4:run_turn 骨架

落点 :codex-rs/core/src/session/turn.rs:156-603(448 行,turn.rs 最大函数)

结构分三段:准备156-302 → 内层 loop 304-600 → 返回 602。

准备阶段(:164-286)

做什么 行 内容
收尾上一轮 :164 drain_async_hook_results(before_user_prompt=true)——hook 是用户可配置的外部脚本钩子(turn 前/后、工具前/后执行),异步执行、结果可能迟到
定连接 :166 复用 prewarmed_client_session,否则新建;整轮重试共用这一个
定视野 :172-263 :172 run_pre_sampling_compact(发请求前先看要不要压历史;:180 TurnAborted → 先记输入再抛 Err;:192 其他错误 → 报 Error 事件后 return Ok(None))→ :196 扫输入确定需要哪些 MCP server → :210 capture_step_context(required)(“拍照”:本轮模型看什么、用哪个模型)→ :227 record_context_updates() + 并发算 diff 根目录 → :253 注入 skills/plugins 条目
记输入 :265-286 :269 用户输入以 PersistContext::TurnStart 记进历史;:268 can_drain_pending_input = input.is_empty();跑 session-start hook、合并 connector 选择、记 analytics

内层 loop(:304-600)

loop {
    if can_drain_pending_input && sess.input_queue.has_pending_input(..).await {
        input = sess.input_queue.get_pending_input(..).await;   
    }
    if record_conversation_items(&sess, &turn_context, &input).await { break; }   

    
    let req_input = sess.clone_history().await.for_prompt(..);   
    let result = run_sampling_request(.. req_input ..).await;    
    match result {
        Ok(SamplingRequestResult { needs_follow_up, last_agent_message }) => {
            can_drain_pending_input = true;                      
            let needs_follow_up = model_needs_follow_up
                || sess.input_queue.has_pending_input(&sess.active_turn).await;   
            if 上下文撑不住 {                                     
                run_auto_compact(..).await;                      
                continue;
            }
            can_drain_pending_input = !model_needs_follow_up;     
            if !needs_follow_up {
                
                break;                                            
            }
        }
        Err(CodexErr::TurnAborted) => return Err(..),             
        Err(e) => { 发 Error 事件; break; }                        
    }
}
return last_agent_message;                                        

四个容易看错的地方 :

  1. can_drain_pending_input 三个赋值点语义完全不同::268 初值 = input.is_empty()(本轮带新输入进来时先不抽队列,避免用户刚打的字和上一轮 steer 挤在一起、顺序乱);:411 采样成功后置 true;:499 = !model_needs_follow_up(模型刚说要继续调工具时,先让它继续干活)
  2. :416 的 has_pending_input 只看不抽,真正抽干在 :310(split_off(0),实现见 session/input_queue.rs:305)。所以同一句用户输入不会被处理两次
  3. needs_follow_up 是“或”不是“二选一”:模型要调工具 || 队列有货。两者都让内层 continue,下一圈把工具结果 + 用户插话 放进同一个请求
  4. Ok(None) ≠ 失败。:192 :262 :266 :270 :494 :497 :560 处的 Ok(None) 表示“这一轮什么都没产出但不算失败”(被 hook 拦住 / 被压缩中断 / 错误已作为事件报给 UI);Err 才是往上抛给外层任务

关于「两个 loop 优先级」的结论

内层和外层不是两个来源在抢优先级 ,而是同一条队列被检查了多次 :

同一队列(session 级 mailbox + turn 级 steer)
  ├─ 内层 loop :310  抽干式抽(split_off(0))  ← 常态路径,每圈开头
  ├─ 内层 loop :416  只看不抽                  ← 算 needs_follow_up
  ├─ 外层 :91        只看不抽                  ← 兜底,接住收尾瞬间新到的输入
  └─ tasks/mod.rs:467 抽干                    ← 任务收尾时保存未消费的输入

用户体感的优先级 :

场景 结果
模型还在干活(要调工具/还在采样),你插话 不打断当前圈;等这圈结束,下一圈开头被吃进去——同一轮 turn 内消化
模型已给出最终答案、这轮在收尾,你插话 另起一轮 turn (外层接手),语义接近“你又发了条新消息”

“不可打断”是有代码依据的:steer_input 里没有任何 cancel 调用;真正打断是独立操作 Op::Interrupt(session/handlers.rs),对应 UI 上的 Esc / Ctrl-C。

mailbox 的额外门控

MailboxDeliveryPhase(state/turn.rs:50)两态:

  • CurrentTurn(默认):mailbox 消息(子 agent / 外部投递)可被当前轮消费
  • NextTurn:本轮已吐出可见最终答案,mailbox 消息留到下一轮

turn.rs:404 在 model_needs_follow_up 为真时调 accept_mailbox_delivery_for_current_turn() 把状态重开为 CurrentTurn。state/turn.rs:46-48 的注释解释了动机: “如果同一个 task 后来又有了明确的同轮工作(用户 steer,或一个工具调用跟在无标签的前言之后),我们重新打开 CurrentTurn,让积压的子 agent 消息被吸进那个 follow-up 请求里。”


单元 5:一轮如何判定结束

反直觉点:Codex 不是靠“模型不调工具了”判断结束的,而是靠模型自己发的明确信号。

Responses API 的 response.completed 事件带字段 response.end_turn(codex-api/src/sse/responses.rs:124),解析进 ResponseEvent::Completed { .., end_turn }(codex-api/src/common.rs:114-121)。注释写得很直白(common.rs:118):

“模型是否明确结束了自己的 turn?一些 provider 不会设置此字段,因此当它为 None 时,我们依赖兜底逻辑。”

if let Some(false) = end_turn {
    needs_follow_up = true;      
}

只处理Some(false):Some(true) 和 None 都不动 needs_follow_up,让它保持由“这一轮响应里有没有工具调用”决定的值。

三层判定

第一层:一次采样流有没有要接着干的事                [try_run_sampling_request 内部]
  OutputItemDone 里:
      是 tool call       → needs_follow_up = true(stream_events_utils.rs:327),工具挂进 in_flight
      是普通消息         → 记 last_agent_message(:361)
      工具请求被拒       → needs_follow_up = true(:383)
  response.completed:
      end_turn == Some(false)     → needs_follow_up = true(turn.rs:2610)
      end_turn == Some(true)/None → 不改(None 时靠“没调工具”兜底)
  返回 SamplingRequestResult { needs_follow_up, last_agent_message }(:2613)

第二层:这一轮 turn 还转不转                          [turn.rs 内层 loop]
  needs_follow_up = 模型还要继续 || 队列有新输入(:426)
      真 → continue     假 → 跑 Stop hooks → break(:562)

第三层:整个 task 还跑不跑 run_turn                   [tasks/regular.rs 外层 loop]
  break 之后外层看队列 → 有货就再开一轮(:91)

fallback 到底是怎么兜的

provider 不返回 end_turn(None)时,判据退化为“这一轮响应里没有工具调用,就认为模型说完了”。实际生效的表格:

情形 needs_follow_up turn 是否结束
只有文字消息,end_turn=true false ✅ 结束
只有文字消息,end_turn=None(provider 没给) false ✅ 结束(兜底规则生效)
只有文字消息,但 end_turn=false true ❌ 再转一圈
有工具调用 true ❌ 执行工具后再转一圈

第三行是关键 :模型可以“嘴上说完了但声明我还要继续”,于是 run_turn 会什么都不带地再采样一圈 ——这是 GPT-5 类“先说一句、再接着说”行为的实现空间。

唯一的“抢占”通道

turn.rs:2430(在处理流的过程中):

if pr

English / 原文

Codex 源码导读:第二部分——一次 Turn 的完整生命周期

本文承接《第一部分——工程分层》,沿着第一部分预告的主线往下走:一次用户输入如何驱动「模型 → 工具 → 模型」的循环,并在什么时候终止。

阅读粒度是「微单元」:一次只钉住一个函数或一条分支,给出真实文件路径、行号与骨架伪代码。所有行号基于 codex-rs 当前检出 d58d0e5841;行号会随仓库演进漂移,回看时先用 git log -1 与 grep -n 复核。

阅读主线(六个微单元):

用户回车
  └─ 1. 开新 turn 还是插进正在跑的 turn       session/turn_input.rs
      └─ 2. Turn 任务的启动 / 结束 / 打断      tasks/mod.rs
          └─ 3. 为什么 turn 结束了还会再跑一轮  tasks/regular.rs
              └─ 4. run_turn 骨架              session/turn.rs
                  └─ 5. 一轮如何判定结束       session/turn.rs(end_turn)
                      └─ 6. 流式读取与工具执行  session/turn.rs + tools/parallel.rs

阅读主线(后五个微单元:工具安全链,接在第 6 单元之后):

工具并发闸门(tools/parallel.rs:155-159)
  └─ 7. 工具执行的阶段划分与结果去向      tools/registry.rs + tools/events.rs
      └─ 8. 审批三段式与三级提问者        tools/orchestrator.rs + tools/approvals.rs
          └─ 9. 审批怎么变成一次「等待」  session/mod.rs + state/turn.rs
              └─ 10. 审批弹窗全流程       protocol/src/approvals.rs + tui/approval_overlay.rs
                  └─ 11. 谁判「要不要问」 exec_policy.rs + execpolicy/

总图:一次 turn 的正常流程(端到端)

flowchart TB
    U[&#34;用户回车 / 中途补充输入&#34;] --> Q{&#34;turn 正在跑?&#34;}
    Q -- &#34;否&#34; --> S[&#34;开新 turn<br/>tasks/mod.rs: spawn_task&#34;]
    Q -- &#34;是&#34; --> ST[&#34;steer_input 守卫矩阵<br/>turn_input.rs:551&#34;]
    ST -- &#34;可插&#34; --> QN[&#34;写入 pending_input 队列&#34;]
    ST -- &#34;不可插&#34; --> S
    S --> LOOP[&#34;外层 loop<br/>tasks/regular.rs:77&#34;]
    QN --> LOOP
    LOOP --> PRE[&#34;run_turn 准备段<br/>hooks / compact / step_context&#34;]
    PRE --> IN[&#34;内层 loop 采样<br/>turn.rs:304-600&#34;]
    IN --> STREAM[&#34;读流 + 工具提前启动<br/>stream_events_utils.rs:320&#34;]
    STREAM --> SAFE{&#34;工具安全链<br/>五道关口 → execpolicy → 审批&#34;}
    SAFE -- &#34;Forbidden / Denied / Abort&#34; --> BACK[&#34;拒绝或失败结果回灌模型&#34;]
    SAFE -- &#34;Skip / Approved&#34; --> EXE[&#34;沙箱内真实执行&#34;]
    BACK --> NEED
    EXE --> NEED{&#34;needs_follow_up?<br/>end_turn / 有工具调用 / 有排队输入&#34;}
    NEED -- &#34;是&#34; --> IN
    NEED -- &#34;否&#34; --> STOP[&#34;Stop hooks&#34;]
    STOP --> RET[&#34;run_turn 返回&#34;]
    RET --> OUT{&#34;外层 loop:还有排队输入且无终态错误?&#34;}
    OUT -- &#34;是&#34; --> LOOP
    OUT -- &#34;否&#34; --> DONE[&#34;TurnComplete 收尾&#34;]

单元 1:回车之后——开新 turn 还是插进正在跑的 turn

落点 :codex-rs/core/src/session/turn_input.rs:551 steer_input

用户按下回车后,系统要回答的第一个问题是:这是新的一轮,还是对正在跑的那一轮的补充? 答案不是猜的,而是靠"当前有没有可 steer 的活动 turn"来判定。

steer_input 的守卫条件(按顺序):

async fn steer_input(&self, input, ...) -> Result<String, NotSubmittedReason> {
    let mut active = self.active_turn.lock().await;
    let Some(active_turn) = active.as_mut() else {
        return Err(NotSubmittedReason::NoActiveTurn);      
    };
    let Some(active_task) = active_turn.task.as_ref() else {
        return Err(NotSubmittedReason::NoActiveTurn);      
    };
    let active_turn_id = &active_task.turn_context.sub_id;

    if let Some(expected) = expected_turn_id && expected != active_turn_id {
        return Err(NotSubmittedReason::ExpectedTurnMismatch { .. });   
    }

    match active_task.kind {
        TaskKind::Regular => {}                                        
        TaskKind::Review  => return Err(ActiveTurnNotSteerable { .. }),
        TaskKind::Compact => return Err(ActiveTurnNotSteerable { .. }),
    }

    if matches!(input, SubmittedTurnInput::UserInput { content, .. } if content.is_empty()) {
        return Err(NotSubmittedReason::EmptyInput);                    
    }
    
    let mut pending_input = merge_additional_context_input(self, additional_context).await;  

机制小结 :

  • 返回 Err(NoActiveTurn) → 上层据此开一轮新 turn
  • 返回 Err(ActiveTurnNotSteerable) / ExpectedTurnMismatch / EmptyInput → 既不开新 turn 也插不进去 ,向用户报错
  • 成功 → 输入被塞进当前 turn 自己的 TurnState.pending_input.items,steer_input 全程不碰 cancellation_token,不会打断正在执行的那一圈

易踩的认知点 :TaskKind::Review/Compact 是不可 steer 的——审查轮和压缩轮期间用户插话会被拒绝,而不是排队。


单元 2:Turn 任务的启动、结束与打断

落点 :codex-rs/core/src/tasks/mod.rs:271 spawn_task → :282 start_task

pub async fn spawn_task<T: SessionTask>(self: &Arc<Self>, turn_context, input, task: T) {
    self.abort_all_tasks(TurnAbortReason::Replaced).await;   
    self.clear_connector_selection().await;
    self.start_task(turn_context, input, task).await;        
}

pub(crate) async fn start_task<T: SessionTask>(...) {
    let task: Arc<dyn AnySessionTask> = Arc::new(task);
    let started_at = Instant::now();
    let turn_started_at_unix_ms = turn_context.turn_timing_state.mark_turn_started(started_at).await;
    let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default();

    let cancellation_token = CancellationToken::new();       
    let done = Arc::new(Notify::new());                      
    
}

关键设计 :spawn_task 的第一件事是 abort_all_tasks(Replaced) —— Codex 的模型是「一个 session 同时只有一个活动 turn」 ,新的把旧的顶掉(TurnAbortReason::Replaced),不存在两个 turn 并行跑。

任务的生命周期由三样东西管理:cancellation_token(打断)、done(Notify,结束)、以及 Session.active_turn 里的登记项(决定 steer_input 能否找到它)。


插讲:span 是什么

落点 :tasks/mod.rs:350(TurnStarted 事件发送)、:397、:712

读到这里会反复遇到 trace_span! / field::Empty。这不是 agent 业务逻辑,是 tracing 框架的链路追踪单元 :

  • 一个 span = 一段有时间跨度的上下文(如"这一轮 turn"、"这一次工具调用"),有自己的名字和一组 key-value 字段
  • span 可以嵌套,形成树;parent: &receiving_span 就是显式挂父节点
  • field::Empty 是先占位、后填值 :声明时值还不知道(如 tool_name、token_usage),后面拿到再 record
  • 它不参与控制流,去掉不影响行为,但决定了 Codex 的 otel 遥测质量

读源码时看到 span,直接跳过 ,只当它是"这段代码属于哪个逻辑单元"的标注。


单元 3:为什么 turn 结束了还会再跑一轮

落点 :codex-rs/core/src/tasks/regular.rs:39-96(RegularTask::run)

这是整个生命周期里最容易看糊的一段:最外层是一圈loop,但它不是在跑模型。

async fn run(self: Arc<Self>, sess, ctx, input, cancellation_token) -> SessionTaskResult {
    let run_turn_span = trace_span!("run_turn");

    
    let prewarmed_client_session = async {
        sess.send_event(ctx.as_ref(), EventMsg::TurnStarted(TurnStartedEvent { .. })).await;
        sess.set_server_reasoning_included(false).await;
        sess.consume_startup_prewarm_for_regular_turn(&cancellation_token).await
    }.instrument(trace_span!("regular_task.prepare_run_turn")).await;

    let prewarmed_client_session = match prewarmed_client_session {
        Cancelled => { run_hooks_and_record_inputs(..).await; return Ok(None); }   
        Unavailable { .. } => None,
        Ready(session) => Some(*session),
    };

    let mut next_input = input;
    let mut prewarmed_client_session = prewarmed_client_session;
    loop {                                                       
        let last_agent_message = run_turn(
            Arc::clone(&sess), Arc::clone(&ctx), next_input,
            prewarmed_client_session.take(),                     
            cancellation_token.child_token(),
        ).instrument(run_turn_span.clone()).await?;

        if ctx.terminal_error.lock().await.is_some() {
            return Ok(last_agent_message);                       
        }
        if !sess.input_queue.has_pending_input(&sess.active_turn).await {
            return Ok(last_agent_message);                       
        }
        next_input = Vec::new();                                 
    }
}

为什么要有外层 loop :run_turn 返回只说明"内层循环结束了",不说明"用户没话说了"。存在一个时间窗——run_turn 收尾(跑 Stop hook、收工具结果)期间用户又打了字——这些输入属于"这一轮结束时才到",只能由外层接住,再开一轮run_turn。

注意 :94 next_input = Vec::new():第二轮起不再传新输入,因为输入已经通过队列被消费过了,避免重复。

TurnStarted 事件的位置也值得注意(:47-48 的注释):Regular turn 把 TurnStarted 内联发 ,为的是"第一轮生命周期不等启动预热解析",即 UI 能立刻显示"开始了",而不是卡在预热的 await 上。


单元 4:run_turn 骨架

落点 :codex-rs/core/src/session/turn.rs:156-603(448 行,turn.rs 最大函数)

结构分三段:准备156-302 → 内层 loop 304-600 → 返回 602。

准备阶段(:164-286)

做什么 行 内容
收尾上一轮 :164 drain_async_hook_results(before_user_prompt=true)——hook 是用户可配置的外部脚本钩子(turn 前/后、工具前/后执行),异步执行、结果可能迟到
定连接 :166 复用 prewarmed_client_session,否则新建;整轮重试共用这一个
定视野 :172-263 :172 run_pre_sampling_compact(发请求前先看要不要压历史;:180 TurnAborted → 先记输入再抛 Err;:192 其他错误 → 报 Error 事件后 return Ok(None))→ :196 扫输入确定需要哪些 MCP server → :210 capture_step_context(required)("拍照":本轮模型看什么、用哪个模型)→ :227 record_context_updates() + 并发算 diff 根目录 → :253 注入 skills/plugins 条目
记输入 :265-286 :269 用户输入以 PersistContext::TurnStart 记进历史;:268 can_drain_pending_input = input.is_empty();跑 session-start hook、合并 connector 选择、记 analytics

内层 loop(:304-600)

loop {
    if can_drain_pending_input && sess.input_queue.has_pending_input(..).await {
        input = sess.input_queue.get_pending_input(..).await;   
    }
    if record_conversation_items(&sess, &turn_context, &input).await { break; }   

    
    let req_input = sess.clone_history().await.for_prompt(..);   
    let result = run_sampling_request(.. req_input ..).await;    
    match result {
        Ok(SamplingRequestResult { needs_follow_up, last_agent_message }) => {
            can_drain_pending_input = true;                      
            let needs_follow_up = model_needs_follow_up
                || sess.input_queue.has_pending_input(&sess.active_turn).await;   
            if 上下文撑不住 {                                     
                run_auto_compact(..).await;                      
                continue;
            }
            can_drain_pending_input = !model_needs_follow_up;     
            if !needs_follow_up {
                
                break;                                            
            }
        }
        Err(CodexErr::TurnAborted) => return Err(..),             
        Err(e) => { 发 Error 事件; break; }                        
    }
}
return last_agent_message;                                        

四个容易看错的地方 :

  1. can_drain_pending_input 三个赋值点语义完全不同::268 初值 = input.is_empty()(本轮带新输入进来时先不抽队列,避免用户刚打的字和上一轮 steer 挤在一起、顺序乱);:411 采样成功后置 true;:499 = !model_needs_follow_up(模型刚说要继续调工具时,先让它继续干活)
  2. :416 的 has_pending_input 只看不抽,真正抽干在 :310(split_off(0),实现见 session/input_queue.rs:305)。所以同一句用户输入不会被处理两次
  3. needs_follow_up 是"或"不是"二选一":模型要调工具 || 队列有货。两者都让内层 continue,下一圈把工具结果 + 用户插话 放进同一个请求
  4. Ok(None) ≠ 失败。:192 :262 :266 :270 :494 :497 :560 处的 Ok(None) 表示"这一轮什么都没产出但不算失败"(被 hook 拦住 / 被压缩中断 / 错误已作为事件报给 UI);Err 才是往上抛给外层任务

关于「两个 loop 优先级」的结论

内层和外层不是两个来源在抢优先级 ,而是同一条队列被检查了多次 :

同一队列(session 级 mailbox + turn 级 steer)
  ├─ 内层 loop :310  抽干式抽(split_off(0))  ← 常态路径,每圈开头
  ├─ 内层 loop :416  只看不抽                  ← 算 needs_follow_up
  ├─ 外层 :91        只看不抽                  ← 兜底,接住收尾瞬间新到的输入
  └─ tasks/mod.rs:467 抽干                    ← 任务收尾时保存未消费的输入

用户体感的优先级 :

场景 结果
模型还在干活(要调工具/还在采样),你插话 不打断当前圈;等这圈结束,下一圈开头被吃进去——同一轮 turn 内消化
模型已给出最终答案、这轮在收尾,你插话 另起一轮 turn (外层接手),语义接近"你又发了条新消息"

"不可打断"是有代码依据的:steer_input 里没有任何 cancel 调用;真正打断是独立操作 Op::Interrupt(session/handlers.rs),对应 UI 上的 Esc / Ctrl-C。

mailbox 的额外门控

MailboxDeliveryPhase(state/turn.rs:50)两态:

  • CurrentTurn(默认):mailbox 消息(子 agent / 外部投递)可被当前轮消费
  • NextTurn:本轮已吐出可见最终答案,mailbox 消息留到下一轮

turn.rs:404 在 model_needs_follow_up 为真时调 accept_mailbox_delivery_for_current_turn() 把状态重开为 CurrentTurn。state/turn.rs:46-48 的注释解释了动机: "如果同一个 task 后来又有了明确的同轮工作(用户 steer,或一个工具调用跟在无标签的前言之后),我们重新打开 CurrentTurn,让积压的子 agent 消息被吸进那个 follow-up 请求里。"


单元 5:一轮如何判定结束

反直觉点:Codex 不是靠"模型不调工具了"判断结束的,而是靠模型自己发的明确信号。

Responses API 的 response.completed 事件带字段 response.end_turn(codex-api/src/sse/responses.rs:124),解析进 ResponseEvent::Completed { .., end_turn }(codex-api/src/common.rs:114-121)。注释写得很直白(common.rs:118):

"Did the model affirmatively end its turn? Some providers do not set this, so we rely on fallback logic when this isNone."

if let Some(false) = end_turn {
    needs_follow_up = true;      
}

只处理Some(false):Some(true) 和 None 都不动 needs_follow_up,让它保持由"这一轮响应里有没有工具调用"决定的值。

三层判定

第一层:一次采样流有没有要接着干的事                [try_run_sampling_request 内部]
  OutputItemDone 里:
      是 tool call       → needs_follow_up = true(stream_events_utils.rs:327),工具挂进 in_flight
      是普通消息         → 记 last_agent_message(:361)
      工具请求被拒       → needs_follow_up = true(:383)
  response.completed:
      end_turn == Some(false)     → needs_follow_up = true(turn.rs:2610)
      end_turn == Some(true)/None → 不改(None 时靠"没调工具"兜底)
  返回 SamplingRequestResult { needs_follow_up, last_agent_message }(:2613)

第二层:这一轮 turn 还转不转                          [turn.rs 内层 loop]
  needs_follow_up = 模型还要继续 || 队列有新输入(:426)
      真 → continue     假 → 跑 Stop hooks → break(:562)

第三层:整个 task 还跑不跑 run_turn                   [tasks/regular.rs 外层 loop]
  break 之后外层看队列 → 有货就再开一轮(:91)

fallback 到底是怎么兜的

provider 不返回 end_turn(None)时,判据退化为"这一轮响应里没有工具调用,就认为模型说完了"。实际生效的表格:

情形 needs_follow_up turn 是否结束
只有文字消息,end_turn=true false ✅ 结束
只有文字消息,end_turn=None(provider 没给) false ✅ 结束(兜底规则生效)
只有文字消息,但 end_turn=false true ❌ 再转一圈
有工具调用 true ❌ 执行工具后再转一圈

第三行是关键 :模型可以"嘴上说完了但声明我还要继续",于是 run_turn 会什么都不带地再采样一圈 ——这是 GPT-5 类"先说一句、再接着说"行为的实现空间。

唯一的"抢占"通道

turn.rs:2430(在处理流的过程中):

if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await {
    break Ok(SamplingRequestResult { needs_follow_up: true, last_agent_message });
}

开了 preempt_for_mailbox_mail 且有子 agent 的 mailbox 消息等待时,会直接打断当前采样流 、提前返回并要求 follow-up。这是全机制里唯一能抢占正在进行的模型流的地方,且专给多 agent 用(代码自带 todo)。用户的 steer 没有这个能力。

一句话总结

turn 结束 ⟺ 没有待执行工具 且 模型没声明"还要继续"(end_turn) 且 队列没新输入 且 上下文没过载
                     ↑ 前两个 = needs_follow_up = false
                     ↑ 后两个 = :426 那一"或"的两边

单元 6:流式读取——工具在流还没读完时就跑了

落点 :turn.rs:1362 run_sampling_request → :2207 try_run_sampling_request

两层函数,各自管一件事

run_sampling_request          :1362   只管"重试":包一个 loop,可重试错误就再来一遍
   └─ try_run_sampling_request :2207   管真正"读流":一次 SSE 流从头读到尾

重试策略(:1390-1461):ContextWindowExceeded / UsageLimitReached 直接抛(不重试),其他错误走 handle_retryable_response_stream_error,上限来自 provider.info().stream_max_retries()(:1385)。重试时重新组装 prompt (:1391-1409),因为历史可能已变。

流循环骨架

loop {                                              
    let event = match stream.next().or_cancel(&token).await {
        Ok(e) => e,
        Err(Cancelled) => break Err(CodexErr::TurnAborted),                       
    };
    let event = match event {
        Some(Ok(e)) => e,
        Some(Err(e)) => break Err(e),
        None => break Err(CodexErr::Stream("stream closed before response.completed")),  
    };
    match event {  }
}

None => Err 那行值得注意:流没等到response.completed 就断开,算错误,不是"正常结束"。

事件分三类:

类型 事件 处理
内容 (流式渲染) OutputTextDelta、ReasoningSummaryDelta、ReasoningContentDelta 转成 EventMsg::*Delta 发给 UI(:2618 :2741 :2759)——就是用户看到的逐字输出
结构 Created、OutputItemAdded、OutputItemDone 标记 item 起止;Done 里做真正的分派
终止 Completed 取 end_turn、算 token、结束流循环 (:2570-2616)

★ 核心机制:工具的"提前启动 + 延后收集"

OutputItemDone 收到工具调用时,走 handle_output_item_done(stream_events_utils.rs:290):

record_completed_response_item(...).await;                 
let tool_future: InFlightFuture<'static> = Box::pin(
    ctx.tool_runtime.clone().handle_tool_call(call, cancellation_token),   
);
output.needs_follow_up = true;
output.tool_future = Some(tool_future);

然后(turn.rs:2422-2423):

if let Some(tool_future) = output_result.tool_future {
    in_flight.push_back(tool_future);      
}

看起来要等后面才跑,其实不是。 关键在 handle_tool_call 的签名(tools/parallel.rs:74-92):

pub(crate) fn handle_tool_call(self, call, cancellation_token)
    -> impl std::future::Future<Output = Result<ResponseItemEnvelope, CodexErr>>   
{
    let future = self.handle_tool_call_with_source(call, source, cancellation_token);   
    async move { future.await ... }                                                     
}

而 handle_tool_call_with_source(:95)也是普通 fn,body 里第一件事就是:

let mut dispatch_handle = AbortOnDropHandle::new(tokio::spawn(async move {
    
    router.dispatch_tool_call_with_terminal_outcome(...).await
}));

普通 fn 的 body 在调用时即执行 ,所以 tokio::spawn 在那一瞬间就发生——模型还在继续吐 token、流还没读完,工具已经在后台跑了。

FuturesOrdered 那个"箱子"的作用只是保序收集结果 。流读完后(:2766 循环退出):

flush_assistant_text_segments_all(...).await;          

let tool_blocking_timing_guard = if in_flight.is_empty() { None }
    else { Some(begin_tool_blocking()) };              
drain_in_flight(&mut in_flight, sess, turn_context).await?;   


while let Some(res) = in_flight.next().await {         
    sess.record_annotated_conversation_items(&turn_context, vec![envelope]).await;  
}

真实时间线:

模型流:  ──token──token──[tool_call 完成]──token──...──[response.completed]
                              ↓ spawn
工具:                        └──────执行中──────┐
                                                ↓
                              流读完 → drain_in_flight 收结果 → 写回历史
                                                        ↓
                                          下一圈采样(带上工具结果)

收益 :模型输出剩余内容与工具执行并行 ,省掉"等模型说完才开始干活"的空档。代价 :流最后报错中断时,工具可能白跑一部分,靠取消机制兜底。

并发闸门:一个读写锁

工具支不支持并行,看 router.tool_supports_parallel(&call)(parallel.rs:116),然后在 spawn 的任务里抢锁(:155-159):

let _guard = if supports_parallel {
    Either::Left(lock.read().await)     
} else {
    Either::Right(lock.write().await)   
};

一个 RwLock 干完所有并发控制——"哪些工具能并发"不是散落各处的 if,而是工具自身的supports_parallel 属性说了算。

取消靠 AbortOnDropHandle(:147)+ tokio::select!(:182-184):future 被 drop 或 token 被 cancel 时工具任务被 abort。细节在 :185-186——如果工具已跑到"有终局结果",就等它把结果拿出来(dispatch_handle.await),不再 abort。

伪代码总览

run_sampling_request(input):
  loop:                                              
    prompt = build_prompt(历史 或 首次 input)
    match try_run_sampling_request(prompt):
      Ok(result) → return
      Err(ContextWindowExceeded / UsageLimitReached) → 抛(不重试)
      Err(可重试) → 退避重试,超上限才抛

try_run_sampling_request(prompt):
  stream = client.session.stream(prompt, ...)        
  in_flight = FuturesOrdered::new()
  while let Some(event) = stream.next():             
      OutputTextDelta / ReasoningDelta → send_event(EventMsg::*Delta)     
      OutputItemAdded                 → 记 active_item
      OutputItemDone(item):
          记进历史
          if item 是工具调用:
              tool_future = handle_tool_call(...)    
              in_flight.push_back(tool_future)       
              needs_follow_up = true
          else: last_agent_message = 模型这段话
      Completed{end_turn} → 算 token;end_turn==Some(false) → needs_follow_up=true;break

  flush 流式文本残留                                  
  drain_in_flight(in_flight) → 按序收工具结果,逐个写回历史              
  if cancelled → Err(TurnAborted)
  return SamplingRequestResult{needs_follow_up, last_agent_message}

单元 7:工具执行的阶段划分与结果去向

上一单元停在 parallel.rs:167 的 spawn 之后,这一单元补齐「一条工具调用从被分派到写回历史」的全部阶段,以及每一阶段的结果去向。

分派链

tools/parallel.rs:167  router.dispatch_tool_call_with_terminal_outcome(...)
  └─ tools/router.rs:325  dispatch_tool_call_with_terminal_outcome
       └─ tools/router.rs:348  dispatch_tool_call_with_code_mode_result_inner
            │  只组装 ToolInvocation(:367)
            └─ tools/registry.rs:493  dispatch_any_with_terminal_outcome   ★真正的分派
                 ├─ ① 工具存在吗               :532
                 ├─ ② payload 类型匹配吗       :564
                 ├─ ③ PreToolUse hook 拦不拦   :582
                 ├─ ④ tool.handle(invocation)  :793(handle_any_tool)
                 └─ ⑤ PostToolUse hook 拦不拦  :697 :738

五道关口与「错误去向」的对应

关口 位置 失败类型 去向
① 工具存在性 registry.rs:532 RespondToModel 模型可见,可自我纠正
② payload 匹配 registry.rs:564 Fatal 协议级错误,打断整轮
③ PreToolUse hook registry.rs:582 Blocked → RespondToModel 模型可见;Continue{updated_input} 可改写参数
④ tool.handle() registry.rs:793 见下方 见下方
⑤ PostToolUse hook registry.rs:738 should_block → RespondToModel 只能拒结果,不能撤已发生的副作用

判据:RespondToModel 用于「模型有可能自己纠正」(工具不存在、hook 拦截、业务失败);Fatal 用于「协议/框架级坏了、纠正不了」。

FunctionCallError 全部变体只有两个(codex-rs/tools/src/function_call_error.rs:5-10)。

权限申请:判定与提问分离

权限判定发生在第④层内部 ,不在这五道关口里。

第④层 tool.handle()
  ├─ 命令类(shell/unified_exec):exec_policy.rs:409        ┐
  └─ 补丁类(apply_patch):safety.rs:26 → apply_patch.rs:51  ┘
                                    ↓ 都产出 ExecApprovalRequirement
                          tools/orchestrator.rs:208  ← 统一"问用户"入口

apply_patch 三态去向(apply_patch.rs:36-60):

SafetyCheck 变成 结果
AutoApprove ExecApprovalRequirement::Skip 直接执行
AskUser ExecApprovalRequirement::NeedsApproval 交给 orchestrator 问用户
Reject { reason } FunctionCallError::RespondToModel("patch rejected: ...") 直接给模型,不问

apply_patch.rs:45-47 注释说明这是有意设计: "Delegate the approval prompt (including cached approvals) to the tool runtime, consistent with how shell/unified_exec approvals are orchestrator-driven."

ExecApprovalRequirement(tools/sandboxing.rs:152,注释: "Specifies what tool orchestrator should do with a given tool call." ):

pub(crate) enum ExecApprovalRequirement {
    Skip { bypass_sandbox: bool, proposed_execpolicy_amendment: Option<ExecPolicyAmendment> },
    NeedsApproval { reason: Option<String>, proposed_execpolicy_amendment: Option<ExecPolicyAmendment> },
    Forbidden { reason: String },
}

→ 策略分散(每个工具自己判)、执行集中(统一由 orchestrator 问) 。proposed_execpolicy_amendment 允许一次审批同时提议「以后类似的都别再问」,把一次性授权升级为持久策略。

结果形态:四次变形

① handler 返回        AnyToolResult { call_id, payload, result, post_tool_use_payload }  registry.rs:806
② parallel.rs:83 翻译   Ok(r)      → r.into_response()                   正常输出
                       Err(Fatal) → Err(CodexErr::Fatal)                ★唯一打断整轮
                       Err(其它)  → Ok(failure_response(call, err))     ★错误也变输出
③ 包装               ResponseItemEnvelope { item, metadata }            history/src/lib.rs:39
④ 写回历史           turn.rs:2168 record_annotated_conversation_items

最终形态(parallel.rs:219 failure_response):

ResponseItem::FunctionCallOutput {
    call_id,
    output: FunctionCallOutputPayload {
        body: FunctionCallOutputBody::Text(message),
        success: Some(false),
    },
}

match 三分支都 产出一条与 call_id 配对的输出 —— 这是协议硬约束(有 function_call 就必须有对应 output),也是 in_flight 必须逐个收集 future 的原因。

一次工具调用的全部阶段 × 是否给模型

# 阶段 出问题时 给模型?
1 查工具是否存在 RespondToModel ✅
2 payload 类型匹配 Fatal ❌
3 PreToolUse hook RespondToModel ✅
4 权限判定 Reject/Forbidden → RespondToModel ✅
5 审批提问 用户拒绝 → ToolError::Rejected → RespondToModel ✅
6 真正执行 见下 ✅ 几乎全部
7 PostToolUse hook RespondToModel ✅
8 写回历史 — —
9 下一圈采样 — ✅ 真正送达

第 6 阶段的证据(tools/events.rs:379-460 finish())—— exec 类工具唯一的「结果 → 给不给模型」翻译点:

match out {
    Ok(output) if exit_code == 0 => Ok(content)                             
    Ok(output)                   => Err(RespondToModel(content))            
    Err(Codex(Sandbox(Timeout))) => Err(RespondToModel(response))           
    Err(Codex(Sandbox(Denied)))  => Err(RespondToModel(response))           
    Err(Codex(其他))             => Err(RespondToModel("execution error"))   
    Err(Rejected(msg))           => Err(RespondToModel(normalized))         
}

exec 类工具的失败路径几乎全是RespondToModel —— 命令失败、沙箱超时、沙箱拒绝、用户拒绝,全部变成给模型的一条失败输出。

Fatal 只有两个来源:

  1. payload 与工具类型不匹配(registry.rs:577)
  2. 工具任务本身挂掉(parallel.rs:215 Fatal("tool task failed to receive: ..."))

| 性质| 处理
---|---|---
业务失败(命令失败 / 沙箱拒绝 / 用户拒绝)| 信息| RespondToModel → 喂模型,让它补救
框架失败(payload 不匹配 / task panic)| 事故| Fatal → 无法补救,打断整轮

「这次给」还是「下次给」:永远是下次

一次采样请求是服务端 → 客户端 的单向 SSE 流,客户端在流中间读到 function_call,但没有通道往这个已开的流里塞东西 。所以:

采样请求 
                                                          │
                                          needs_follow_up = true → continue
                                                          ▼
采样请求 

准确表述:工具结果是「这一次调用」产生的,但「送达模型」发生在「下一次采样请求」 。内层 loop 存在的意义之一就是这个。

两个附带发现

① 源码承认的语义混淆(看日志别全信) —— tools/events.rs:435-440:

即用户拒绝 与运行时 setup 失败 共用 ToolError::Rejected,一部分非用户失败会被上报成 "Declined"。排查时看到「用户拒绝了」不能默认真是用户点的 —— 与端侧埋点「事件名语义过载」是同一类坑。

②「模型可见」与「日志真实」可以分离 —— PostToolUseFeedbackOutput(registry.rs:232-244):

fn success_for_logging(&self) -> bool { self.original.success_for_logging() }               
fn to_response_item(...) -> ResponseInputItem { self.model_visible.to_response_item(...) }  

hook 改写结果时,模型看到 hook 的话,埋点记工具的真实结果。排查「模型为什么不知道刚发生的错误」时,先查有没有 hook 换了结果。

另:AnyToolResult::into_response(registry.rs:196-212)会把 fallback_token_limit_override 放进 metadata —— 工具返回值能反向调整后续 token 预算 。


单元 8:审批 —— 最多问两次,且不一定是问用户

flowchart TB
    A[&#34;① Approval<br/>orchestrator.rs:144&#34;] --> B{&#34;ExecApprovalRequirement?&#34;}
    B -- &#34;Forbidden&#34; --> C[&#34;拒绝,不提问<br/>:205&#34;]
    B -- &#34;Skip&#34; --> D{&#34;strict_auto_review?&#34;}
    D -- &#34;是&#34; --> E[&#34;仍要过审<br/>:174-203&#34;]
    D -- &#34;否&#34; --> F[&#34;直接执行<br/>第 ② 段 :232&#34;]
    B -- &#34;NeedsApproval&#34; --> E
    E --> G[&#34;三级提问者依次接力<br/>Hook → Guardian/Reviewer → User&#34;]
    G --> H{&#34;ReviewDecision&#34;}
    H -- &#34;Approved / ApprovedForSession…&#34; --> F
    H -- &#34;Denied&#34; --> I[&#34;RespondToModel:只把理由回给模型<br/>turn 继续跑&#34;]
    H -- &#34;Abort&#34; --> J[&#34;TurnAborted:整个 turn 终止&#34;]
    F --> K[&#34;② First attempt:沙箱内执行<br/>:306&#34;]
    K --> L{&#34;沙箱拒绝?&#34;}
    L -- &#34;否&#34; --> M[&#34;③ 收尾,结果回灌模型&#34;]
    L -- &#34;是&#34; --> N{&#34;三重门 :352-396<br/>escalate_on_failure<br/>wants_no_sandbox_approval<br/>unsandboxed_allowed&#34;}
    N -- &#34;任一不满足&#34; --> O[&#34;return Err,不放行&#34;]
    N -- &#34;全满足&#34; --> P{&#34;bypass_retry_approval?<br/>:410&#34;}
    P -- &#34;否&#34; --> Q[&#34;★ 第二次问<br/>:436&#34;]
    P -- &#34;是&#34; --> R[&#34;免问,直接升级重试&#34;]
    Q --> R
    R --> S[&#34;无沙箱重试执行&#34;]

ToolOrchestrator::run 三段式(源码自带编号注释)

match &requirement {
    Skip { .. } => if strict_auto_review {  },
    Forbidden { reason } => return Err(ToolError::Rejected(reason)),      
    NeedsApproval { .. } => {
        session.request_approval(action, ctx).await?;                     
        already_approved = true;                                          
    }
}

let (first_result, _) = Self::run_attempt(...).await;                     

Err(Codex(Sandbox(Denied { output, .. }))) => {
    
    
    let retry_reason = build_denial_reason_from_output(output);           
    let bypass_retry_approval = !strict_auto_review
        && tool.should_bypass_approval(policy, already_approved)          
        && network_approval_context.is_none();
    if !bypass_retry_approval {
        session.request_approval(action, ctx).await?;                     
    }
    
}

同一次工具调用最多问两次 ::226(执行前)+ :436(沙箱拒绝后要逃逸沙箱时)。 第二次的提示语写死在 build_denial_reason_from_output(:542-546):

"command failed; retry without sandbox?"

两次审批用 run_id 区分:approvals.rs:517 给重试那次加 :retry 后缀(format!("{}:retry", ctx.call_id))。

三级提问者:Hooks → Guardian → User

let resolution = match run_permission_request_hooks(...).await {
    Some(PermissionRequestDecision::Allow) => Approved (source: Hook),
    Some(PermissionRequestDecision::Deny { message }) => denied (source: Hook),
    None => self.request_reviewer_approval(action, &ctx).await,            
};

let reviewer = if ctx.strict_auto_review { ApprovalReviewer::Guardian }
               else { ApprovalReviewer::for_policy(policy, approvals_reviewer) };
match reviewer {
    ApprovalReviewer::Guardian => request_guardian_approval(...),  
    ApprovalReviewer::User     => request_user_approval(...),      
}

→ 「权限申请」这个词偏窄 :真正去问人只是三级里的第三级。

缓存审批在你被问之前就生效了 (approvals.rs:733-756):

let cache_keys = action.cache_keys().map(|k| (k, &policy_fingerprint));   
with_cached_approval(&self.services, "unified_exec", cache_keys, || async {
    self.request_command_approval(...).await     
}).await

policy_fingerprint 参与 key → 改了 exec policy,缓存自动失效 。

决定的去向(approvals.rs:463-491 into_tool_result)

ReviewDecision 变成 后果
Approved / ApprovedForSession / ApprovedExecpolicyAmendment Ok(decision) 放行(原样上递,上层要用它写缓存/落策略)
Denied { rejection } ToolError::Rejected → RespondToModel,给模型换招
TimedOut ToolError::Rejected(超时文案) 同上
Abort CodexErr::TurnAborted 终止整轮 turn

判据:能不能变成"给模型的一段话"。 能 → Rejected;不能(因为模型也得一起停)→ TurnAborted。 Abort 语义(protocol/src/protocol.rs:4040-4042): "User has denied this command and the agent should not do anything until the user's next command."

五条岔路(同一动作,五种"被拒绝")

以 rm -rf /var/log/prod/* 为例,前三条连弹窗都不会出现 :

# 谁拒绝 弹窗 产出 去向
1 策略不许问(Forbidden) 否 Rejected("approval policy disallowed sandbox approval prompt") 给模型
2 Hook 否 ReviewDecision::denied(source Hook) 给模型
3 Guardian 自动审查 否 Denied(文案 automatic approval review denied the action) 给模型
4 用户点 No 是 Denied(rejected by user) 给模型
5 用户点 Stop 是 Abort 终止整轮

1-4 的去向完全相同,日志上只差文案 —— 这就是单元 7 那个埋点坑的具体面目:看板上的"用户拒绝率"大部分不是用户点的。

Forbidden 的真实触发条件(sandboxing.rs:194-222)不是"这事不许做",而是**"本该问你,但配置说连问都不许问"**:

let needs_approval = match policy {
    Never              => false,
    OnRequest | Granular(_) => matches!(fs_policy.kind, FileSystemSandboxKind::Restricted),
    UnlessTrusted      => true,
};
if needs_approval && matches!(policy, Granular(cfg) if !cfg.allows_sandbox_approval()) {
    Forbidden { reason: "approval policy disallowed sandbox approval prompt" }   
}

反例:同一个 Abort,来源不同去向不同

在网络审批 这条路径上,如果 Abort 来自 Guardian(不是你),会被降级 成一次普通失败(approvals.rs:559-563):

(ReviewDecision::Abort, ApprovalResolutionSource::Guardian) =>
    return Err(ToolError::Rejected("automatic approval review was cancelled")),

理由:你没叫停,不该停掉你的回合。→ 别背"Abort → 终止",要记「看是谁给的、以及是不是关于整个回合的意见」。


单元 9:审批怎么变成一次「等待」

统一套路:oneshot channel + 一张等待表

等待方(core/src/session/mod.rs:2536):

pub async fn request_command_approval(...) -> ReviewDecision {
    let (tx_approve, rx_approve) = oneshot::channel();                     
    turn_state.insert_pending_approval(approval_id, tx_approve);           
    self.send_event(turn_context, EventMsg::ExecApprovalRequest { .. }).await;  
    rx_approve.await.unwrap_or(ReviewDecision::Abort)                     
}

回答方(core/src/session/mod.rs:3100):

pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
    let tx = turn_state.remove_pending_approval(approval_id);             
    match tx {
        Some(tx_approve) => { tx_approve.send(decision).ok(); }           
        None => warn!("No pending approval found for call_id: {approval_id}"),
    }
}

TurnState 是一张「等待中的问题」总表(state/turn.rs:90-107)

pending_approvals:           HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
pending_user_input:          HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_elicitations:        HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools:       HashMap<String, oneshot::Sender<DynamicToolResponse>>,

凡「turn 中途停下等外部回答」都是同一套 :建 oneshot → 发送端登记进 TurnState → 发事件 → await。审批只是这五分之一。

三个细节

  1. 先登记再发事件 (:2556 注释原文 Add the tx_approve callback to the map before sending the request.)—— 防 UI 抢答时找不到回信地址。
  2. unwrap_or(ReviewDecision::Abort) 与 clear_pending_waiters(state/turn.rs:131)配对:turn 取消时清空等待表 → 所有 tx 被 drop → await 拿到 Err → 统一落成 Abort。→Abort 有两个来源:① 用户主动 Cancel;② 通道被丢弃(turn 已取消)。
  3. ApprovalAction::RequestPermissions 永远到不了用户:approvals.rs:888-890 写的是 unreachable!("permission requests are routed directly to Guardian")。

apply_patch 的三条分支(approvals.rs:816-855):

if *permissions_preapproved && reason.is_none() { return Approved; }    
if reason.is_some() { return self.request_patch_approval(...).await; }  
with_cached_approval(&self.services, "apply_patch", action.cache_keys(), || ...)  

单元 10:一次审批弹窗 —— 数据 / 表现 / 操作 / 流程

数据:ExecApprovalRequestEvent(protocol/src/approvals.rs:245-313)

kind: ExecApprovalKind,          
call_id: String,                 
approval_id: Option<String>,     
turn_id: String,
environment_id: Option<String>,
started_at_ms: i64,              
command: Vec<String>,            
cwd: LegacyAppPathString,
reason: Option<String>,          
parsed_cmd: Vec<ParsedCommand>,  
available_decisions: Option<Vec<ReviewDecision>>,  

四个值得注意的点:

  • command 是 Vec<String>(argv 形式),不是 "rm -rf /var/log/prod/*" 这样的字符串,没有 shell 解析。
  • reason 是"为什么突然问我"的说明 —— 沙箱重试那句 command failed; retry without sandbox? 从这里进 UI。
  • available_decisions 由服务端算好(session/mod.rs:2585-2592),UI 不自己判断该显示哪些按钮,只负责渲染。
  • parsed_cmd 是解析结果(Read / ListFiles / Search 之类),UI 拿它做语义化展示而非纯字符串。

表现:tui/src/bottom_pane/approval_overlay.rs

三段式浮层(overlay,不是行内输出):

  1. 标题 (:256-273):Would you like to run the following command? / Do you want to approve network access to "{host}"? / Would you like to make the following edits? / Would you like to grant these permissions? / {server} needs your approval.
  2. 正文 (build_header :693-733):命令全文、Reason: xxx(斜体)、stdin 场景的 Input: ...
  3. 选项列表 + 底部快捷键提示 (:304-319)

所以看到的从来不是"是 / 否",而是一个带快捷键的纵向选项列表。

操作:UI 6 个选项 → core 5 个 ReviewDecision

映射函数 command_decision_to_review_decision(approval_overlay.rs:808-827):

UI 文案 UI 枚举 core ReviewDecision 后果
Yes, proceed / Yes, just this once Accept Approved 放行这一次
Yes, and don't ask again for this command in this session AcceptForSession ApprovedForSession 放行 + 本会话免问
Yes, and don't ask again for commands that start with \x`` AcceptWithExecpolicyAmendment ApprovedExecpolicyAmendment 放行 + 持久规则
Yes, and allow this host in the future ApplyNetworkPolicyAmendment NetworkPolicyAmendment 放行 + 网络规则
No, continue without running it Decline Denied 拒绝,turn 继续
No, and tell Codex what to do differently Cancel Abort 拒绝,turn 立即中断

Decline / Cancel 的语义写在枚举注释里(app-server-protocol/src/protocol/v2/item.rs:78-81):

Decline,

Cancel,

⚠️ 反直觉点 :No, and tell Codex what to do differently 读起来像「我拒绝但还想接着说」,实际是 Abort(立刻掐掉整个 turn)。想「拒绝但让模型换招」应选听起来更干脆的 No, continue without running it。

反向映射(core → 协议,用于回放/展示):app-server-protocol/src/protocol/v2/item.rs:84-107(其中 CoreReviewDecision::TimedOut => Self::Decline,超时在 UI 上呈现为"拒绝")。

流程(10 步)

  1. orchestrator.rs:209 判定 NeedsApproval
  2. :226 session.request_approval → 三级路由(Hook → Guardian → User)落到 User
  3. session/mod.rs:2536:建 oneshot(:2557)→ 登记 tx(:2563)→ 算 available_decisions(:2585)
  4. :2621 send_event(EventMsg::ExecApprovalRequest) —— 事件离开 core
  5. app-server 转发 → TUI 构造视图模型 ExecApprovalRequest(approval_overlay.rs:82-93)
  6. overlay 渲染
  7. 用户按键 → apply_selection(:324)→ handle_exec_decision(:367)
  8. command_decision_to_review_decision(:808)翻回 core 枚举
  9. notify_approval(session/mod.rs:3100)→ 取出 tx → send(decision)(:3113)
  10. 卡在步骤 3 的 rx_approve.await(:2622)返回,工具执行流继续

步骤 4 → 9 之间整个工具执行链停住 (模型也早已停产出)。

用户操作的本质:2 个方向 × 2 条范围轴

  • 同意 方向的 4 个选项其实是同一个「批准」,区别只在记住多久 / 记到哪 :这一次 → 本会话 → 本前缀(持久规则) → 本主机(网络规则)。
  • 不同意 方向只有 2 个,区别只在要不要连带停掉回合 :Denied(继续)vs Abort(中断)。

体感 vs 机制 :用户体感是"审批 = 一个弹窗",但弹窗只是三级路由的最后一级;Hook 与 Guardian 都在弹窗之前就把事情办完了(见单元 8)。仅凭"有没有弹窗"判断用户参与度会严重高估。


单元 11:同一个命令,凭什么这次不弹窗问你

一句话定性 :execpolicy 不是安全检查,也不是权限数据库——它是一份用户可维护的「免问白名单」文本 + 一次查表动作 ,只回答一个问题:这条命令要不要弹窗问你 。答案只有三种:不问直接跑 / 要问 / 直接拒。它不判断危险、不与模型交互,就是拿命令的第一个词去比对前缀字符串。

先修正两个常见误解

  • 不是数据库 :~/.codex/rules/default.rules 是纯文本,一行一条规则(Starlark 语法),启动时全读进内存;程序自己也会往里追加(弹窗勾「以后别再问」⇒ 追加一行并热更新内存策略,无需重启)。
  • 主要记的是「不用问」 :文件里可写 allow / prompt / forbidden 三种,但本机 29 条全是 allow 。「哪些要问」基本不在文件里,是代码按 approval_policy + 沙箱类型兜底现算的。

三层各管一件事(别混成一层)

  • execpolicy(这个文本文件)→ 要不要问你
  • approval_policy(配置)→ 没写进表时默认问不问
  • 沙箱 → 允许跑,也只能在划定的范围内动(免问 ≠ 能随便干 )

它站在哪:判定在「构造请求」时做完,orchestrator 只消费

单元 8 的三段式第 ① 段(tools/orchestrator.rs:144)不做判定 ,只读一个已经填好的字段:

fn exec_approval_requirement(&self, req) -> Option<ExecApprovalRequirement> {
    Some(req.exec_approval_requirement.clone())
}

真实判定链(判定发生在构造请求阶段):

process_manager.rs:1373  组装 ExecApprovalRequest{command, approval_policy,
                          permission_profile, sandbox_permissions, prefix_rule…}
 → executable_identity.rs:20  剥 shell 包装
 → exec_policy.rs:315  create_exec_approval_requirement_for_command
 → exec_policy.rs:324  …_for_parsed_commands        ★ 大脑

查表五步(换成自己机器走一遍:adb kill-server)

  1. 命令是字符串数组 ["adb","kill-server"];外壳命令先拆段 (exec_policy.rs:835 把 bash -lc "a && b" 拆成 a、b)——不拆的话,任何命令套个 bash -c 就能冒充 bash 混过白名单。
  2. 用第一个词 当 key 取候选(execpolicy/src/policy.rs:334,rules_by_program 是 MultiMap)。
  3. 逐条前缀匹配 (rule.rs:46-59):只比前 N 个 token,后面的参数不管 ⇒ pattern=["adb","install"] 就能管住 adb install -r <任意包名>;单个位置可写备选 [a|b]。
  4. 精确层空手 → 命令若是绝对路径,用 basename 反查 + host_executables 校验真实路径(policy.rs:344-371,防"别处放个同名假 adb");仍空 → 走启发式兜底(exec_policy.rs:735-819)。
  5. 多条命中取最严 (policy.rs:402-411):Decision 派生 Ord 且声明序为 Allow < Prompt < Forbidden,故 map(decision).max() = 最严者胜。

判定链图

flowchart TB
    A[&#34;命令字符串数组<br/>例:adb install -r pkg&#34;] --> B[&#34;剥 shell 包装 + 拆命令段<br/>executable_identity.rs:20<br/>commands_for_exec_policy :835&#34;]
    B --> C{&#34;按首 token 查前缀规则<br/>policy.rs:334&#34;}
    C -- &#34;命中&#34; --> D[&#34;PrefixRuleMatch<br/>只比前 N 个 token :46-59&#34;]
    C -- &#34;未命中,且首段是绝对路径&#34; --> E[&#34;basename 反查 + host_executables 校验<br/>policy.rs:344-371&#34;]
    E -- &#34;命中&#34; --> D
    E -- &#34;未命中&#34; --> F[&#34;HeuristicsRuleMatch<br/>approval_policy × 沙箱类型<br/>exec_policy.rs:735-819&#34;]
    F --> D
    D --> G[&#34;from_matches:max 取最严<br/>policy.rs:402-411&#34;]
    G --> H{&#34;decision&#34;}
    H -- &#34;Forbidden&#34; --> I[&#34;Forbidden:直接拒&#34;]
    H -- &#34;Prompt&#34; --> J{&#34;approval_policy 允许弹窗?&#34;}
    J -- &#34;否,如 never&#34; --> I
    J -- &#34;是&#34; --> K[&#34;NeedsApproval<br/>+ 提议持久化 amendment&#34;]
    H -- &#34;Allow&#34; --> L{&#34;每一段都被显式 allow 规则命中?&#34;}
    L -- &#34;是&#34; --> M[&#34;Skip<br/>bypass_sandbox = true&#34;]
    L -- &#34;否&#34; --> N[&#34;Skip<br/>bypass_sandbox = false&#34;]

核心伪代码

func decide(command, approval_policy, sandbox) -> Skip | NeedsApproval | Forbidden:
    matches = []
    for seg in split_shell_wrapper(command):        
        hit = exact_prefix_rules(seg)               
        if hit.empty and seg[0] 是绝对路径:
            hit = host_executable_rules(seg)        
        if hit.empty:
            hit = [Heuristic(decision = heuristic_policy(seg, approval_policy, sandbox))]
        matches += hit

    d = max(matches.decision)                       
    if d == Forbidden: return Forbidden(reason)
    if d == Prompt:
        if approval_policy 不允许弹窗:                
            return Forbidden(reason)                
        return NeedsApproval(reason, 提议持久化的 amendment)
    return Skip(bypass_sandbox = 每段都被显式 allow 规则命中)

兜底策略 (表里一个字都没命中时,render_decision_for_unmatched_command,exec_policy.rs:735-819):

  • 危险命令命中(或 Windows 关了沙箱后端但仍有托管 FS 限制)→ never 拒 / 其他策略问
  • never → 直接跑(安全交给沙箱);unless-trusted → 一律问
  • on-request / granular → 沙箱「不受限」直接跑;「受限」则只有命令要求脱离沙箱 时才问

设计点

  1. 取最严,不做「最后一条赢」 :规则可以叠加而不会互相削弱;加规则只能更严,放宽必须先改/删原规则(对端侧的含义:白名单是单调收紧的,不会因为新增一条 allow 而绕过已有的 forbid)。
  2. 三层来源单向降级,且上层能分辨来源 :is_policy_match(exec_policy.rs:203)把「真有 .rules 规则」与「代码启发式推断」分开,因为 granular 策略下两者走不同准入门 (:395 的 prompt_is_rule 决定是查「规则审批」还是「沙箱审批」开关)。
  3. 白名单会自己长大 :一次审批里提议的 amendment 由 execpolicy/src/amend.rs:65-81 拼成一行 prefix_rule(pattern=[...], decision="allow") 追加进规则文件(append_amendment_and_update,exec_policy.rs:447)。本机 default.rules 的 29 条 allow 就是这么攒出来的,且追加后内存策略热更新 (ArcSwap,exec_policy.rs:277),无需重启。
  4. never 的语义最容易抄反:命中 Prompt 规则而 approval_policy = never(不许弹窗)⇒ 结果是 Forbidden ,不是「不问就跑」(decision.rs:12 注释、exec_policy.rs:221)。

本机现状(只统计、不 dump 内容)

~/.codex/rules/default.rules:29 条规则,全部decision="allow";前缀集中在 /bin/bash(10)、bytedcli(5)、adb(4)、node(3)、lark-cli(2)。⇒ 本机实际形态就是一份免问白名单 ,「要问 / 禁止」两个值没被用上。

最值得带走的结论

  1. 两层 loop 同源单队列 :内层每圈开头抽干、外层收尾兜底。不是两个输入源抢优先级,而是同一条队列在四个不同时刻被检查;真正消费(split_off(0))只发生在 turn.rs:310 和 tasks/mod.rs:467 两处,所以不会重复处理。

  2. 结束判定靠模型显式的end_turn,不是靠"它不调工具了";没这个字段的 provider 才回落到"没调工具就算说完"。这解释了模型"先吐一句再继续"的行为空间。

  3. 工具与模型流重叠执行 :tokio::spawn 发生在"创建 future"那一行(因为 handle_tool_call 是普通 fn,body 立即执行),FuturesOrdered 只负责保序收结果。并发由工具自身的 supports_parallel + 一把读写锁决定。

  4. 工具调用只有「框架事故」会终止整轮,业务失败一律喂回模型 :Fatal 只用于 payload 不匹配 / task panic;命令失败、沙箱超时、沙箱拒绝、用户拒绝审批全部走 RespondToModel,变成 FunctionCallOutput { success: Some(false) } 写回历史,在下一圈采样请求才送达模型 (单向 SSE 流没有回灌通道)。

  5. 审批的本质是「裁决 → 翻译 → 去向」三段,而不是"弹窗" :判定(工具自己)与提问(Hook → Guardian → User 三级)分离,一次调用可能问两次(执行前 + 沙箱拒绝后逃逸沙箱);出路只有两种 —— 能变成给模型的一段话(Rejected)就继续,是关于整个回合的否定(Abort)就终止整轮。绝大多数"用户拒绝"根本不是用户点的。

  6. execpolicy 是「免问白名单 + 兜底策略」,不是权限系统:它只决定「要不要弹窗问用户」,答案来自一份用户可增量追加的纯文本前缀表(可写 allow/prompt/forbidden,本机 29 条全是 allow);没命中才按 approval_policy + 沙箱类型兜底。多条命中取最严 ,且 never 遇到 prompt 规则是拒绝而非放行 。真正限制能力的是沙箱。

与 Agent Team 端侧的对照点

Codex 机制 端侧可借鉴处
steer_input 的守卫矩阵(NoActiveTurn / NotSteerable / EmptyInput) 端侧"用户在流式中途输入"的分支判定可直接照搬这套显式原因枚举,避免用 boolean 猜
end_turn 显式结束信号 端侧与模型服务约定显式结束标记,比"没 tool_call 就算完"更稳,也更容易埋点区分"说完"与"说不完"
工具 spawn 与流读取重叠 端侧若有"模型吐字 + 同时执行动作"的场景,值得评估提前启动的收益与"流失败白跑"的代价
supports_parallel + RwLock 单闸门 比在调用点散写互斥判断更集中,容易测试
单活动 turn + abort_all_tasks(Replaced) 端侧会话若允许并发 turn,需要明确 Replaced 语义,否则会出现两个 turn 抢 UI
ExecApprovalRequirement(判定与提问分离,Skip/NeedsApproval/Forbidden) 端侧权限流程可照搬:工具只输出「要不要问」,由统一入口负责「怎么问 + 缓存审批」,避免每个工具各写一套弹窗逻辑
proposed_execpolicy_amendment 一次授权同时提议持久策略("以后类似的都别再问"),比每次单独授权体验好
execpolicy 前缀白名单(文本文件 + 前缀匹配 + 多条命中取最严 ) 端侧「免弹窗清单」不必写成代码常量或数据库:一份可增量追加的文本/配置 + 前缀匹配 + 取最严,就能覆盖高频命令,并随用户授权自我生长;取最严保证规则只收紧不放宽
Fatal vs RespondToModel 二分 端侧错误回传若能区分「业务失败(可让模型补救)」与「框架事故(终止)」,模型的自愈率会明显不同
PostToolUseFeedbackOutput 的「模型可见 / 日志真实」分离 端侧埋点若也有中间件改写,需明确「用户/模型看到的」与「上报的」是两个口径,否则对不上账
三级审批路由(Hook → Guardian → User)+ 缓存审批 端侧「自动放行 / 自动审查 / 弹窗」不必只有两态,三级路由 + 按 policy 指纹失效的缓存是可照搬的结构
Denied vs Abort 的语义区分 端侧必须把「拒绝这个动作」和「中止整个会话」分成两个结果,否则用户点"停止"会被当成一次普通失败,模型还会接着干活
审批提示语集中在一个函数(build_denial_reason_from_output) 面向用户的拒绝文案应收敛到单点,便于统一口径与测试,避免散落在各工具里

源码证据清单

  • codex-rs/core/src/session/turn_input.rs:551 steer_input(守卫矩阵 :562 :566 :573 :582 :587 :594)
  • codex-rs/core/src/tasks/mod.rs:271 spawn_task / :282 start_task(Replaced 语义 :277,取消令牌 :301)
  • codex-rs/core/src/tasks/regular.rs:39-96(TurnStarted 内联 :49-63,外层 loop :76-95)
  • codex-rs/core/src/session/turn.rs:156-603 run_turn(准备 :164-286,内层 loop :304-600,关键行 :268 :310 :317 :373 :384 :411 :416 :426 :461 :499 :503 :562 :566 :602)
  • codex-rs/core/src/session/turn.rs:1362 run_sampling_request(重试 loop :1390-1461)
  • codex-rs/core/src/session/turn.rs:2207 try_run_sampling_request(流建立 :2238,in_flight :2252,流循环 :2281-2766,end_turn 判定 :2610,mailbox 抢占 :2430,drain :2782)
  • codex-rs/core/src/session/turn.rs:2159-2182 drain_in_flight
  • codex-rs/core/src/stream_events_utils.rs:290 handle_output_item_done(tool future :317-328,needs_follow_up=true :327 :383)
  • codex-rs/core/src/tools/parallel.rs:74 handle_tool_call / :95 handle_tool_call_with_source(spawn :148,并发闸门 :155-159)
  • codex-rs/core/src/session/input_queue.rs:305 split_off(0) 抽干 / :348 has_pending_input
  • codex-rs/core/src/state/turn.rs:46-50 MailboxDeliveryPhase
  • codex-rs/codex-api/src/common.rs:114-121 ResponseEvent::Completed { end_turn } / codex-rs/codex-api/src/sse/responses.rs:124
  • codex-rs/core/src/tools/router.rs:325 dispatch_tool_call_with_terminal_outcome / :348 inner(组装 ToolInvocation :367)
  • codex-rs/core/src/tools/registry.rs:493 dispatch_any_with_terminal_outcome(五道关口 :532 :564 :582 :793 :738)/into_response :196/PostToolUseFeedbackOutput :232/notify_tool_finish_if_unclaimed :774
  • codex-rs/core/src/safety.rs:20 SafetyCheck 三态 / :26 assess_patch_safety(UnlessTrusted 疑点 TODO :44-45)
  • codex-rs/core/src/apply_patch.rs:22 prepare_apply_patch(三态去向 :36-60,判定与提问分离注释 :45-47)
  • codex-rs/core/src/tools/sandboxing.rs:152 ExecApprovalRequirement(角色注释 :150)
  • codex-rs/core/src/tools/orchestrator.rs:208 NeedsApproval 消费点(request_approval :224-227)
  • codex-rs/core/src/exec_policy.rs:409 命令类工具产出 NeedsApproval
  • codex-rs/core/src/tools/events.rs:379-460 finish()(第 6 阶段翻译点;ToolError::Rejected 语义混淆注释 :435-440)
  • codex-rs/core/src/tools/parallel.rs:83-89 结果三分支翻译 / :215 task join 失败 / :219 failure_response
  • codex-rs/tools/src/function_call_error.rs:5-10 FunctionCallError 两变体
  • codex-rs/history/src/lib.rs:39 ResponseItemEnvelope
  • codex-rs/core/src/tools/orchestrator.rs:125 run(三段式 :144 / :232 / :309;两次审批 :226 :436;bypass 判定 :410;拒绝文案 :542-546;沙箱结果打点 :530-540)
  • codex-rs/core/src/tools/approvals.rs:495 request_approval(优先级注释 :521-523;hook 分支 :524-541;网络 Abort 降级 :559-563;request_reviewer_approval :570;request_guardian_approval :602;request_user_approval :696;缓存审批 :733-756)/ApprovalContext :55/ApprovalAction :67/into_tool_result :463/ApprovalReviewer :434
  • codex-rs/core/src/tools/sandboxing.rs:194-222 default_exec_approval_requirement(Forbidden 真实触发条件 :216)
  • codex-rs/protocol/src/protocol.rs:4008-4043 ReviewDecision(Abort 语义 :4040)
  • codex-rs/core/src/session/mod.rs:2536 request_command_approval(oneshot :2557;登记 :2563;available_decisions :2585-2592;发事件 :2621;await 兜底 :2622;request_patch_approval :2629)
  • codex-rs/core/src/session/mod.rs:3100 notify_approval(取 tx :3106;send :3113;迟到告警 :3116)
  • codex-rs/core/src/state/turn.rs:90-107 TurnState 五张等待表/:116 :124 insert-remove/:131-135 clear_pending_waiters
  • codex-rs/protocol/src/approvals.rs:245-313 ExecApprovalRequestEvent 全字段/:315-334 effective_available_decisions/:336- default_available_decisions
  • codex-rs/tui/src/bottom_pane/approval_overlay.rs:74-122 TUI 视图模型(ApprovalRequest 四变体)/:250-322 标题与选项组装/:367 handle_exec_decision/:808-827 command_decision_to_review_decision/:829-915 exec_options 文案/:693-733 build_header
  • codex-rs/app-server-protocol/src/protocol/v2/item.rs:63-82 CommandExecutionApprovalDecision(6 变体,Decline/Cancel 语义注释 :78-81)/:84-107 From<CoreReviewDecision>(TimedOut => Decline :104)
  • codex-rs/core/src/exec_policy.rs:315 create_exec_approval_requirement_for_command/:324 create_exec_approval_requirement_for_parsed_commands(决策映射 :379-444;Allow⇒Skip.bypass_sandbox 判据 :426-437)/:203 is_policy_match/:216-238 prompt_is_rejected_by_policy(Never 直拒 :221)/:735-819 render_decision_for_unmatched_command(危险命令分支 :763-771)/:831 default_policy_path/:835-872 commands_for_exec_policy/:53 :55 RULES_DIR_NAME / DEFAULT_POLICY_FILE/:1079-1122 collect_policy_files/:276-279 ExecPolicyManager(ArcSwap 热更新)/:447 append_amendment_and_update
  • codex-rs/core/src/exec_policy/executable_identity.rs:20 create_exec_approval_requirement_for_shell
  • codex-rs/core/src/unified_exec/process_manager.rs:1373 真实调用点(组 ExecApprovalRequest 后取 exec_approval_requirement)
  • codex-rs/core/src/tools/runtimes/apply_patch.rs:147-151 / unified_exec.rs:190-194 exec_approval_requirement(只把请求里的字段原样交回 orchestrator)
  • codex-rs/execpolicy/src/policy.rs:305-332 三层递降/:334-341 精确前缀层/:344-371 宿主路径层/:402-411 from_matches 取最严/:45 fingerprint(未展开)
  • codex-rs/execpolicy/src/rule.rs:46-59 matches_prefix(前 N token / Alts 备选)/:64-108 RuleMatch 两变体 + with_resolved_program
  • codex-rs/execpolicy/src/decision.rs:7-16 Decision 三值 + 派生 Ord(声明序即 Allow < Prompt < Forbidden)
  • codex-rs/execpolicy/src/parser.rs:57-79 Starlark 求值入口(Dialect::Extended + enable_f_strings)/:86-131 PolicyBuilder(rules_by_program / network_rules / host_executables_by_name)
  • codex-rs/execpolicy/src/amend.rs:65-81 blocking_append_allow_prefix_rule(拼 prefix_rule(pattern=[...], decision="allow") 写回文件)
  • codex-rs/core/src/tools/sandboxing.rs:150-187 ExecApprovalRequirement 三变体/:194-230 default_exec_approval_requirement/:250-257 Skip.bypass_sandbox 覆盖面注释

本章状态

本章的 Turn 主线和工具安全链已经完成。沙箱、上下文压缩、事件出口、模型网络、Skill/Agent、Loop、工具路由、Session/Thread/Memory 分别见同目录后续章节;唯一保留的未展开点是 Policy::fingerprint 以及本机无法直接验证的 Linux 沙箱实现,这两项不影响本章主流程。

源码基线:OpenAI Codex d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。