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

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

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

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

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

bash 复制代码
用户回车
  └─ 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 单元之后):

bash 复制代码
工具并发闸门(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 的正常流程(端到端)


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

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

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

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

rust 复制代码
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);      // :562 ★没有活动 turn
    };
    let Some(active_task) = active_turn.task.as_ref() else {
        return Err(NotSubmittedReason::NoActiveTurn);      // :566 ★有 turn 但任务已结束
    };
    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 { .. });   // :573 客户端指定的 turn 已过期
    }

    match active_task.kind {
        TaskKind::Regular => {}                                        // :580 只有 Regular 可插话
        TaskKind::Review  => return Err(ActiveTurnNotSteerable { .. }),// :582
        TaskKind::Compact => return Err(ActiveTurnNotSteerable { .. }),// :587
    }

    if matches!(input, SubmittedTurnInput::UserInput { content, .. } if content.is_empty()) {
        return Err(NotSubmittedReason::EmptyInput);                    // :594
    }
    // 输出 schema 不一致也拒绝(:599)
    let mut pending_input = merge_additional_context_input(self, additional_context).await;  // :604

机制小结:

  • 返回 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

rust 复制代码
pub async fn spawn_task<T: SessionTask>(self: &Arc<Self>, turn_context, input, task: T) {
    self.abort_all_tasks(TurnAbortReason::Replaced).await;   // :277 ★开新 turn 先杀掉旧的
    self.clear_connector_selection().await;
    self.start_task(turn_context, input, task).await;        // :279
}

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();       // :301 每个 task 一枚取消令牌
    let done = Arc::new(Notify::new());                      // :302 完成信号
    // ... 起 span、登记 active_turn、tokio::spawn 任务主体
}

关键设计 :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,但它不是在跑模型。

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

    // :49-63 ★先把 TurnStarted 事件发出去,不等启动预热完成
    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); }   // :65-68
        Unavailable { .. } => None,
        Ready(session) => Some(*session),
    };

    let mut next_input = input;
    let mut prewarmed_client_session = prewarmed_client_session;
    loop {                                                       // :76 ★外层 loop
        let last_agent_message = run_turn(
            Arc::clone(&sess), Arc::clone(&ctx), next_input,
            prewarmed_client_session.take(),                     // :81 预热连接只用一次
            cancellation_token.child_token(),
        ).instrument(run_turn_span.clone()).await?;

        if ctx.terminal_error.lock().await.is_some() {
            return Ok(last_agent_message);                       // :89 致命错误:不再重跑
        }
        if !sess.input_queue.has_pending_input(&sess.active_turn).await {
            return Ok(last_agent_message);                       // :92 ★队列空了才真正收工
        }
        next_input = Vec::new();                                 // :94 再跑一轮,但不带新输入
    }
}

为什么要有外层 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)

rust 复制代码
loop {
    if can_drain_pending_input && sess.input_queue.has_pending_input(..).await {
        input = sess.input_queue.get_pending_input(..).await;   // :310 ★抽干式(split_off(0))
    }
    if record_conversation_items(&sess, &turn_context, &input).await { break; }   // :317

    // :329-370 记时间提醒 / world_state 变化
    let req_input = sess.clone_history().await.for_prompt(..);   // :373
    let result = run_sampling_request(.. req_input ..).await;    // :384 ★工具在它内部执行完
    match result {
        Ok(SamplingRequestResult { needs_follow_up, last_agent_message }) => {
            can_drain_pending_input = true;                      // :411
            let needs_follow_up = model_needs_follow_up
                || sess.input_queue.has_pending_input(&sess.active_turn).await;   // :426 ★只看不抽
            if 上下文撑不住 {                                     // :461
                run_auto_compact(..).await;                      // :473
                continue;
            }
            can_drain_pending_input = !model_needs_follow_up;     // :499
            if !needs_follow_up {
                // :510-562 跑 Stop hooks;:522-538 hook 要求继续则 continue
                break;                                            // :562
            }
        }
        Err(CodexErr::TurnAborted) => return Err(..),             // :566
        Err(e) => { 发 Error 事件; break; }                        // :588-597
    }
}
return last_agent_message;                                        // :602

四个容易看错的地方:

  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 优先级」的结论

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

arduino 复制代码
同一队列(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 is None."

rust 复制代码
// turn.rs:2610(收到 response.completed 时)
if let Some(false) = end_turn {
    needs_follow_up = true;      // 模型明说"我还没说完" → 再转一圈
}

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

三层判定

ini 复制代码
第一层:一次采样流有没有要接着干的事                [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(在处理流的过程中):

rust 复制代码
// todo: remove before stabilizing multi-agent v2
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 没有这个能力。

一句话总结

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

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

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

两层函数,各自管一件事

arduino 复制代码
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),因为历史可能已变。

流循环骨架

rust 复制代码
loop {                                              // :2281-2766
    let event = match stream.next().or_cancel(&token).await {
        Ok(e) => e,
        Err(Cancelled) => break Err(CodexErr::TurnAborted),                       // :2304
    };
    let event = match event {
        Some(Ok(e)) => e,
        Some(Err(e)) => break Err(e),
        None => break Err(CodexErr::Stream("stream closed before response.completed")),  // :2313 ★
    };
    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):

rust 复制代码
// stream_events_utils.rs:317-328
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):

rust 复制代码
if let Some(tool_future) = output_result.tool_future {
    in_flight.push_back(tool_future);      // 只是"存起来"
}

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

rust 复制代码
pub(crate) fn handle_tool_call(self, call, cancellation_token)
    -> impl std::future::Future<Output = Result<ResponseItemEnvelope, CodexErr>>   // ★ 普通 fn,不是 async fn
{
    let future = self.handle_tool_call_with_source(call, source, cancellation_token);   // :81 立即调用
    async move { future.await ... }                                                     // 只有这个是惰性的
}

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

rust 复制代码
// tools/parallel.rs:147-178
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 循环退出):

rust 复制代码
// turn.rs:2769-2783
flush_assistant_text_segments_all(...).await;          // 刷残留的流式文本给 UI

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?;   // :2782 ★这里才收结果

// drain_in_flight(:2159-2182)
while let Some(res) = in_flight.next().await {         // 按序收
    sess.record_annotated_conversation_items(&turn_context, vec![envelope]).await;  // 结果写回历史
}

真实时间线:

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

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

并发闸门:一个读写锁

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

rust 复制代码
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。

伪代码总览

ini 复制代码
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, ...)        # :2238 建立 SSE
  in_flight = FuturesOrdered::new()
  while let Some(event) = stream.next():             # :2281
      OutputTextDelta / ReasoningDelta → send_event(EventMsg::*Delta)     # 渲染
      OutputItemAdded                 → 记 active_item
      OutputItemDone(item):
          记进历史
          if item 是工具调用:
              tool_future = handle_tool_call(...)    # ★ 内部已 tokio::spawn,工具此刻开跑
              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 流式文本残留                                  # :2769
  drain_in_flight(in_flight) → 按序收工具结果,逐个写回历史              # :2782
  if cancelled → Err(TurnAborted)
  return SamplingRequestResult{needs_follow_up, last_agent_message}

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

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

分派链

bash 复制代码
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)。

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

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

bash 复制代码
第④层 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."):

rust 复制代码
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 允许一次审批同时提议「以后类似的都别再问」,把一次性授权升级为持久策略。

结果形态:四次变形

css 复制代码
① 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):

rust 复制代码
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 类工具唯一的「结果 → 给不给模型」翻译点:

rust 复制代码
match out {
    Ok(output) if exit_code == 0 => Ok(content)                             // 成功
    Ok(output)                   => Err(RespondToModel(content))            // ★ 退出码非 0 → 给
    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,但没有通道往这个已开的流里塞东西。所以:

ini 复制代码
采样请求 #1 ──SSE流──▶ function_call ──▶ 工具执行 ──▶ 结果写回历史(turn.rs:2168)
                                                          │
                                          needs_follow_up = true → continue
                                                          ▼
采样请求 #2(prompt = 历史,含工具结果)──▶ 模型此刻才看到

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

两个附带发现

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

rust 复制代码
// NOTE: ToolError::Rejected is currently used for both user-declined approvals
// and some operational/runtime rejection paths (for example setup failures).
// ... which means a subset of non-user failures may be reported as Declined.
// TODO: We should add a new ToolError variant for user-declined approvals.

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

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

rust 复制代码
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 的话,埋点记工具的真实结果。排查「模型为什么不知道刚发生的错误」时,先查有没有 hook 换了结果。

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


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

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

rust 复制代码
// tools/orchestrator.rs:125
// ── 1) Approval ────────────────────────────────────────── :144
match &requirement {
    Skip { .. } => if strict_auto_review { /* ★即使是 Skip,严格审查下也要过审 :174-203 */ },
    Forbidden { reason } => return Err(ToolError::Rejected(reason)),      // :205 直接拒,不问
    NeedsApproval { .. } => {
        session.request_approval(action, ctx).await?;                     // :226 ★第一次问
        already_approved = true;                                          // :228
    }
}
// ── 2) First attempt under the selected sandbox. ─────────  :232
let (first_result, _) = Self::run_attempt(...).await;                     // :306
// ── 3) 沙箱拒绝 → 升级重试 ────────────────────────────── :309
Err(Codex(Sandbox(Denied { output, .. }))) => {
    // 三重门 :352-396:escalate_on_failure / wants_no_sandbox_approval / unsandboxed_allowed
    //   任一不满足 → return Err,不放行
    let retry_reason = build_denial_reason_from_output(output);           // :404
    let bypass_retry_approval = !strict_auto_review
        && tool.should_bypass_approval(policy, already_approved)          // :410 ← 用第 1 段的标记
        && network_approval_context.is_none();
    if !bypass_retry_approval {
        session.request_approval(action, ctx).await?;                     // :436 ★第二次问
    }
    // 重试时沙箱换成 SandboxType::None(:446-456)→ 再跑一次
}

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

rust 复制代码
"command failed; retry without sandbox?"

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

三级提问者:Hooks → Guardian → User

rust 复制代码
// approvals.rs:521-541  源码注释原文:
//   Approval precedence is:
//   1. Hooks
//   2. If StrictAutoReview || Guardian enabled, then Guardian. Else, 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,            // :540 ↓
};
// :570  request_reviewer_approval
let reviewer = if ctx.strict_auto_review { ApprovalReviewer::Guardian }
               else { ApprovalReviewer::for_policy(policy, approvals_reviewer) };
match reviewer {
    ApprovalReviewer::Guardian => request_guardian_approval(...),  // :592 不打扰人
    ApprovalReviewer::User     => request_user_approval(...),      // :593 ★真的发事件等人回答
}

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

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

rust 复制代码
let cache_keys = action.cache_keys().map(|k| (k, &policy_fingerprint));   // :733
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)不是"这事不许做",而是**"本该问你,但配置说连问都不许问"**:

rust 复制代码
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" }   // :216
}

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

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

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

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


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

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

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

rust 复制代码
pub async fn request_command_approval(...) -> ReviewDecision {
    let (tx_approve, rx_approve) = oneshot::channel();                     // :2557
    turn_state.insert_pending_approval(approval_id, tx_approve);           // :2563 先登记再发事件
    self.send_event(turn_context, EventMsg::ExecApprovalRequest { .. }).await;  // :2621
    rx_approve.await.unwrap_or(ReviewDecision::Abort)                     // :2622 ★停在这一行
}

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

rust 复制代码
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
    let tx = turn_state.remove_pending_approval(approval_id);             // :3106
    match tx {
        Some(tx_approve) => { tx_approve.send(decision).ok(); }           // :3113 ★唤醒
        None => warn!("No pending approval found for call_id: {approval_id}"),
    }
}

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

rust 复制代码
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):

rust 复制代码
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)

rust 复制代码
kind: ExecApprovalKind,          // 命令审批 / stdin 写入
call_id: String,                 // 挂在哪个 function_call 上
approval_id: Option<String>,     // 子命令/stdin 才有,否则等于 call_id
turn_id: String,
environment_id: Option<String>,
started_at_ms: i64,              // ★ 弹窗时刻,用于统计用户等待时长
command: Vec<String>,            // ★ argv 数组,非字符串,无 shell 解析
cwd: LegacyAppPathString,
reason: Option<String>,          // ★ "为什么突然问我",沙箱重试文案由此进 UI
parsed_cmd: Vec<ParsedCommand>,  // ★ 解析后的命令,UI 用它做语义化展示
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):

arduino 复制代码
/// User denied the command. The agent will continue the turn.
Decline,
/// User denied the command. The turn will also be immediately interrupted.
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
  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 不是安全检查,也不是权限数据库------它是一份用户可维护的「免问白名单」文本 + 一次查表动作 ,只回答一个问题:这条命令要不要弹窗问你。答案只有三种:不问直接跑 / 要问 / 直接拒。它不判断危险、不与模型交互,就是拿命令的第一个词去比对前缀字符串。

先修正两个常见误解

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

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

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

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

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

rust 复制代码
// core/src/tools/runtimes/apply_patch.rs:147-151(unified_exec.rs:190-194 同形)
fn exec_approval_requirement(&self, req) -> Option<ExecApprovalRequirement> {
    Some(req.exec_approval_requirement.clone())
}

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

bash 复制代码
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() = 最严者胜。

判定链图

核心伪代码

ini 复制代码
func decide(command, approval_policy, sandbox) -> Skip | NeedsApproval | Forbidden:
    matches = []
    for seg in split_shell_wrapper(command):        # bash -lc "a && b" → [a, b]
        hit = exact_prefix_rules(seg)               # key = seg[0],只比前缀
        if hit.empty and seg[0] 是绝对路径:
            hit = host_executable_rules(seg)        # basename 反查 + 路径白名单校验
        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 不允许弹窗:                # Never / Granular 关了对应开关
            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),且追加后内存策略热更新 (ArcSwap,exec_policy.rs:277),无需重启。
  4. never 的语义最容易抄反 :命中 Prompt 规则而 approval_policy = never(不许弹窗)⇒ 结果是 Forbidden ,不是「不问就跑」(decision.rs:12 注释、exec_policy.rs:221)。

发布说明

规则文件的具体内容属于运行环境配置,不在本文中列出。本文只说明源码中的判定算法:规则命中后与 approval_policy、沙箱类型共同决定 Allow、Prompt 或 Forbidden。

最值得带走的结论

  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);没命中才按 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 以及未在 macOS 上运行验证的 Linux 沙箱实现,这两项不影响本章主流程。

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

相关推荐
nyaomaru44 分钟前
你的 Type Guard 可能会悄悄地与 TypeScript 类型发生偏移 🔧
后端·typescript
明月_清风44 分钟前
数据平台到底是什么?一篇文章搞懂数据平台开发
大数据·后端·数据分析
心之语歌44 分钟前
DeerFlow Docker Desktop 部署教程
后端
据说幸运很容易44 分钟前
接口测试框架重构:YAML 用例驱动 + 分层设计
后端·架构
海岳云舟44 分钟前
SpringCloudGateway 动态转发后端服务
后端
我的div丢了肿么办44 分钟前
自定义类型和类型别名以及实例化结构体的5种方式
后端·go
小满zs1 小时前
Go语言第十三章(互斥锁,读写锁)
后端·go
IT_陈寒1 小时前
JavaScript的this指向问题又让我加了个班
前端·人工智能·后端
美好世界1 小时前
Codex 源码导读:第一部分——工程分层
后端