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 的交互连续性、流式执行和工具安全链路提供了源码依据。
5.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 -- "否" --> S["开新 turn<br/>tasks/mod.rs: spawn_task"]
Q -- "是" --> ST["steer_input 守卫矩阵<br/>turn_input.rs:551"]
ST -- "可插" --> QN["写入 pending_input 队列"]
ST -- "不可插" --> S
S --> LOOP["外层 loop<br/>tasks/regular.rs:77"]
QN --> LOOP
LOOP --> PRE["run_turn 准备段<br/>hooks / compact / step_context"]
PRE --> IN["内层 loop 采样<br/>turn.rs:304-600"]
IN --> STREAM["读流 + 工具提前启动<br/>stream_events_utils.rs:320"]
STREAM --> SAFE{"工具安全链<br/>五道关口 → execpolicy → 审批"}
SAFE -- "Forbidden / Denied / Abort" --> BACK["拒绝或失败结果回灌模型"]
SAFE -- "Skip / Approved" --> EXE["沙箱内真实执行"]
BACK --> NEED
EXE --> NEED{"needs_follow_up?<br/>end_turn / 有工具调用 / 有排队输入"}
NEED -- "是" --> IN
NEED -- "否" --> STOP["Stop hooks"]
STOP --> RET["run_turn 返回"]
RET --> OUT{"外层 loop:还有排队输入且无终态错误?"}
OUT -- "是" --> LOOP
OUT -- "否" --> DONE["TurnComplete 收尾"]
单元 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;
四个容易看错的地方 :
can_drain_pending_input三个赋值点语义完全不同::268初值= input.is_empty()(本轮带新输入进来时先不抽队列,避免用户刚打的字和上一轮 steer 挤在一起、顺序乱);:411采样成功后置true;:499= !model_needs_follow_up(模型刚说要继续调工具时,先让它继续干活):416的has_pending_input只看不抽,真正抽干在:310(split_off(0),实现见session/input_queue.rs:305)。所以同一句用户输入不会被处理两次needs_follow_up是“或”不是“二选一”:模型要调工具 || 队列有货。两者都让内层continue,下一圈把工具结果 + 用户插话 放进同一个请求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["用户回车 / 中途补充输入"] --> Q{"turn 正在跑?"}
Q -- "否" --> S["开新 turn<br/>tasks/mod.rs: spawn_task"]
Q -- "是" --> ST["steer_input 守卫矩阵<br/>turn_input.rs:551"]
ST -- "可插" --> QN["写入 pending_input 队列"]
ST -- "不可插" --> S
S --> LOOP["外层 loop<br/>tasks/regular.rs:77"]
QN --> LOOP
LOOP --> PRE["run_turn 准备段<br/>hooks / compact / step_context"]
PRE --> IN["内层 loop 采样<br/>turn.rs:304-600"]
IN --> STREAM["读流 + 工具提前启动<br/>stream_events_utils.rs:320"]
STREAM --> SAFE{"工具安全链<br/>五道关口 → execpolicy → 审批"}
SAFE -- "Forbidden / Denied / Abort" --> BACK["拒绝或失败结果回灌模型"]
SAFE -- "Skip / Approved" --> EXE["沙箱内真实执行"]
BACK --> NEED
EXE --> NEED{"needs_follow_up?<br/>end_turn / 有工具调用 / 有排队输入"}
NEED -- "是" --> IN
NEED -- "否" --> STOP["Stop hooks"]
STOP --> RET["run_turn 返回"]
RET --> OUT{"外层 loop:还有排队输入且无终态错误?"}
OUT -- "是" --> LOOP
OUT -- "否" --> DONE["TurnComplete 收尾"]
单元 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;
四个容易看错的地方 :
can_drain_pending_input三个赋值点语义完全不同::268初值= input.is_empty()(本轮带新输入进来时先不抽队列,避免用户刚打的字和上一轮 steer 挤在一起、顺序乱);:411采样成功后置true;:499= !model_needs_follow_up(模型刚说要继续调工具时,先让它继续干活):416的has_pending_input只看不抽,真正抽干在:310(split_off(0),实现见session/input_queue.rs:305)。所以同一句用户输入不会被处理两次needs_follow_up是"或"不是"二选一":模型要调工具 || 队列有货。两者都让内层continue,下一圈把工具结果 + 用户插话 放进同一个请求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 is
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 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 只有两个来源:
- payload 与工具类型不匹配(
registry.rs:577) - 工具任务本身挂掉(
parallel.rs:215Fatal("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["① Approval<br/>orchestrator.rs:144"] --> B{"ExecApprovalRequirement?"}
B -- "Forbidden" --> C["拒绝,不提问<br/>:205"]
B -- "Skip" --> D{"strict_auto_review?"}
D -- "是" --> E["仍要过审<br/>:174-203"]
D -- "否" --> F["直接执行<br/>第 ② 段 :232"]
B -- "NeedsApproval" --> E
E --> G["三级提问者依次接力<br/>Hook → Guardian/Reviewer → User"]
G --> H{"ReviewDecision"}
H -- "Approved / ApprovedForSession…" --> F
H -- "Denied" --> I["RespondToModel:只把理由回给模型<br/>turn 继续跑"]
H -- "Abort" --> J["TurnAborted:整个 turn 终止"]
F --> K["② First attempt:沙箱内执行<br/>:306"]
K --> L{"沙箱拒绝?"}
L -- "否" --> M["③ 收尾,结果回灌模型"]
L -- "是" --> N{"三重门 :352-396<br/>escalate_on_failure<br/>wants_no_sandbox_approval<br/>unsandboxed_allowed"}
N -- "任一不满足" --> O["return Err,不放行"]
N -- "全满足" --> P{"bypass_retry_approval?<br/>:410"}
P -- "否" --> Q["★ 第二次问<br/>:436"]
P -- "是" --> R["免问,直接升级重试"]
Q --> R
R --> S["无沙箱重试执行"]
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。审批只是这五分之一。
三个细节
- 先登记再发事件 (
:2556注释原文Add the tx_approve callback to the map before sending the request.)—— 防 UI 抢答时找不到回信地址。 unwrap_or(ReviewDecision::Abort)与clear_pending_waiters(state/turn.rs:131)配对:turn 取消时清空等待表 → 所有tx被 drop →await拿到Err→ 统一落成Abort。→Abort有两个来源:① 用户主动 Cancel;② 通道被丢弃(turn 已取消)。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,不是行内输出):
- 标题 (
: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. - 正文 (
build_header :693-733):命令全文、Reason: xxx(斜体)、stdin 场景的Input: ... - 选项列表 + 底部快捷键提示 (
: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 步)
orchestrator.rs:209判定NeedsApproval:226session.request_approval→ 三级路由(Hook → Guardian → User)落到Usersession/mod.rs:2536:建oneshot(:2557)→ 登记tx(:2563)→ 算available_decisions(:2585):2621send_event(EventMsg::ExecApprovalRequest)—— 事件离开 core- app-server 转发 → TUI 构造视图模型
ExecApprovalRequest(approval_overlay.rs:82-93) - overlay 渲染
- 用户按键 →
apply_selection(:324)→handle_exec_decision(:367) command_decision_to_review_decision(:808)翻回 core 枚举notify_approval(session/mod.rs:3100)→ 取出tx→send(decision)(:3113)- 卡在步骤 3 的
rx_approve.await(:2622)返回,工具执行流继续
步骤 4 → 9 之间整个工具执行链停住 (模型也早已停产出)。
用户操作的本质:2 个方向 × 2 条范围轴
- 同意 方向的 4 个选项其实是同一个「批准」,区别只在记住多久 / 记到哪 :这一次 → 本会话 → 本前缀(持久规则) → 本主机(网络规则)。
- 不同意 方向只有 2 个,区别只在要不要连带停掉回合 :
Denied(继续)vsAbort(中断)。
体感 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)
- 命令是字符串数组
["adb","kill-server"];外壳命令先拆段 (exec_policy.rs:835把bash -lc "a && b"拆成a、b)——不拆的话,任何命令套个bash -c就能冒充bash混过白名单。 - 用第一个词 当 key 取候选(
execpolicy/src/policy.rs:334,rules_by_program是 MultiMap)。 - 逐条前缀匹配 (
rule.rs:46-59):只比前 N 个 token,后面的参数不管 ⇒pattern=["adb","install"]就能管住adb install -r <任意包名>;单个位置可写备选[a|b]。 - 精确层空手 → 命令若是绝对路径,用 basename 反查 +
host_executables校验真实路径(policy.rs:344-371,防"别处放个同名假 adb");仍空 → 走启发式兜底(exec_policy.rs:735-819)。 - 多条命中取最严 (
policy.rs:402-411):Decision派生Ord且声明序为Allow < Prompt < Forbidden,故map(decision).max()= 最严者胜。
判定链图
flowchart TB
A["命令字符串数组<br/>例:adb install -r pkg"] --> B["剥 shell 包装 + 拆命令段<br/>executable_identity.rs:20<br/>commands_for_exec_policy :835"]
B --> C{"按首 token 查前缀规则<br/>policy.rs:334"}
C -- "命中" --> D["PrefixRuleMatch<br/>只比前 N 个 token :46-59"]
C -- "未命中,且首段是绝对路径" --> E["basename 反查 + host_executables 校验<br/>policy.rs:344-371"]
E -- "命中" --> D
E -- "未命中" --> F["HeuristicsRuleMatch<br/>approval_policy × 沙箱类型<br/>exec_policy.rs:735-819"]
F --> D
D --> G["from_matches:max 取最严<br/>policy.rs:402-411"]
G --> H{"decision"}
H -- "Forbidden" --> I["Forbidden:直接拒"]
H -- "Prompt" --> J{"approval_policy 允许弹窗?"}
J -- "否,如 never" --> I
J -- "是" --> K["NeedsApproval<br/>+ 提议持久化 amendment"]
H -- "Allow" --> L{"每一段都被显式 allow 规则命中?"}
L -- "是" --> M["Skip<br/>bypass_sandbox = true"]
L -- "否" --> N["Skip<br/>bypass_sandbox = false"]
核心伪代码
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→ 沙箱「不受限」直接跑;「受限」则只有命令要求脱离沙箱 时才问
设计点
- 取最严,不做「最后一条赢」 :规则可以叠加而不会互相削弱;加规则只能更严,放宽必须先改/删原规则(对端侧的含义:白名单是单调收紧的,不会因为新增一条 allow 而绕过已有的 forbid)。
- 三层来源单向降级,且上层能分辨来源 :
is_policy_match(exec_policy.rs:203)把「真有.rules规则」与「代码启发式推断」分开,因为granular策略下两者走不同准入门 (:395的prompt_is_rule决定是查「规则审批」还是「沙箱审批」开关)。 - 白名单会自己长大 :一次审批里提议的 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),无需重启。 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)。⇒ 本机实际形态就是一份免问白名单 ,「要问 / 禁止」两个值没被用上。
最值得带走的结论
-
两层 loop 同源单队列 :内层每圈开头抽干、外层收尾兜底。不是两个输入源抢优先级,而是同一条队列在四个不同时刻被检查;真正消费(
split_off(0))只发生在turn.rs:310和tasks/mod.rs:467两处,所以不会重复处理。 -
结束判定靠模型显式的
end_turn,不是靠"它不调工具了";没这个字段的 provider 才回落到"没调工具就算说完"。这解释了模型"先吐一句再继续"的行为空间。 -
工具与模型流重叠执行 :
tokio::spawn发生在"创建 future"那一行(因为handle_tool_call是普通 fn,body 立即执行),FuturesOrdered只负责保序收结果。并发由工具自身的supports_parallel+ 一把读写锁决定。 -
工具调用只有「框架事故」会终止整轮,业务失败一律喂回模型 :
Fatal只用于 payload 不匹配 / task panic;命令失败、沙箱超时、沙箱拒绝、用户拒绝审批全部走RespondToModel,变成FunctionCallOutput { success: Some(false) }写回历史,在下一圈采样请求才送达模型 (单向 SSE 流没有回灌通道)。 -
审批的本质是「裁决 → 翻译 → 去向」三段,而不是"弹窗" :判定(工具自己)与提问(Hook → Guardian → User 三级)分离,一次调用可能问两次(执行前 + 沙箱拒绝后逃逸沙箱);出路只有两种 —— 能变成给模型的一段话(
Rejected)就继续,是关于整个回合的否定(Abort)就终止整轮。绝大多数"用户拒绝"根本不是用户点的。 -
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:551steer_input(守卫矩阵:562 :566 :573 :582 :587 :594)codex-rs/core/src/tasks/mod.rs:271spawn_task/:282start_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-603run_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:1362run_sampling_request(重试 loop:1390-1461)codex-rs/core/src/session/turn.rs:2207try_run_sampling_request(流建立:2238,in_flight:2252,流循环:2281-2766,end_turn判定:2610,mailbox 抢占:2430,drain:2782)codex-rs/core/src/session/turn.rs:2159-2182drain_in_flightcodex-rs/core/src/stream_events_utils.rs:290handle_output_item_done(tool future:317-328,needs_follow_up=true:327 :383)codex-rs/core/src/tools/parallel.rs:74handle_tool_call/:95handle_tool_call_with_source(spawn:148,并发闸门:155-159)codex-rs/core/src/session/input_queue.rs:305split_off(0)抽干 /:348has_pending_inputcodex-rs/core/src/state/turn.rs:46-50MailboxDeliveryPhasecodex-rs/codex-api/src/common.rs:114-121ResponseEvent::Completed { end_turn }/codex-rs/codex-api/src/sse/responses.rs:124codex-rs/core/src/tools/router.rs:325dispatch_tool_call_with_terminal_outcome/:348inner(组装ToolInvocation:367)codex-rs/core/src/tools/registry.rs:493dispatch_any_with_terminal_outcome(五道关口:532 :564 :582 :793 :738)/into_response :196/PostToolUseFeedbackOutput :232/notify_tool_finish_if_unclaimed :774codex-rs/core/src/safety.rs:20SafetyCheck三态 /:26assess_patch_safety(UnlessTrusted疑点 TODO:44-45)codex-rs/core/src/apply_patch.rs:22prepare_apply_patch(三态去向:36-60,判定与提问分离注释:45-47)codex-rs/core/src/tools/sandboxing.rs:152ExecApprovalRequirement(角色注释:150)codex-rs/core/src/tools/orchestrator.rs:208NeedsApproval消费点(request_approval:224-227)codex-rs/core/src/exec_policy.rs:409命令类工具产出NeedsApprovalcodex-rs/core/src/tools/events.rs:379-460finish()(第 6 阶段翻译点;ToolError::Rejected语义混淆注释:435-440)codex-rs/core/src/tools/parallel.rs:83-89结果三分支翻译 /:215task join 失败 /:219failure_responsecodex-rs/tools/src/function_call_error.rs:5-10FunctionCallError两变体codex-rs/history/src/lib.rs:39ResponseItemEnvelopecodex-rs/core/src/tools/orchestrator.rs:125run(三段式:144/:232/:309;两次审批:226 :436;bypass 判定:410;拒绝文案:542-546;沙箱结果打点:530-540)codex-rs/core/src/tools/approvals.rs:495request_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 :434codex-rs/core/src/tools/sandboxing.rs:194-222default_exec_approval_requirement(Forbidden真实触发条件:216)codex-rs/protocol/src/protocol.rs:4008-4043ReviewDecision(Abort语义:4040)codex-rs/core/src/session/mod.rs:2536request_command_approval(oneshot:2557;登记:2563;available_decisions:2585-2592;发事件:2621;await兜底:2622;request_patch_approval :2629)codex-rs/core/src/session/mod.rs:3100notify_approval(取tx:3106;send:3113;迟到告警:3116)codex-rs/core/src/state/turn.rs:90-107TurnState五张等待表/:116 :124insert-remove/:131-135clear_pending_waiterscodex-rs/protocol/src/approvals.rs:245-313ExecApprovalRequestEvent全字段/:315-334effective_available_decisions/:336-default_available_decisionscodex-rs/tui/src/bottom_pane/approval_overlay.rs:74-122TUI 视图模型(ApprovalRequest四变体)/:250-322标题与选项组装/:367handle_exec_decision/:808-827command_decision_to_review_decision/:829-915exec_options文案/:693-733build_headercodex-rs/app-server-protocol/src/protocol/v2/item.rs:63-82CommandExecutionApprovalDecision(6 变体,Decline/Cancel语义注释:78-81)/:84-107From<CoreReviewDecision>(TimedOut => Decline:104)codex-rs/core/src/exec_policy.rs:315create_exec_approval_requirement_for_command/:324create_exec_approval_requirement_for_parsed_commands(决策映射:379-444;Allow⇒Skip.bypass_sandbox判据:426-437)/:203is_policy_match/:216-238prompt_is_rejected_by_policy(Never直拒:221)/:735-819render_decision_for_unmatched_command(危险命令分支:763-771)/:831default_policy_path/:835-872commands_for_exec_policy/:53 :55RULES_DIR_NAME/DEFAULT_POLICY_FILE/:1079-1122collect_policy_files/:276-279ExecPolicyManager(ArcSwap热更新)/:447append_amendment_and_updatecodex-rs/core/src/exec_policy/executable_identity.rs:20create_exec_approval_requirement_for_shellcodex-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-194exec_approval_requirement(只把请求里的字段原样交回 orchestrator)codex-rs/execpolicy/src/policy.rs:305-332三层递降/:334-341精确前缀层/:344-371宿主路径层/:402-411from_matches取最严/:45fingerprint(未展开)codex-rs/execpolicy/src/rule.rs:46-59matches_prefix(前 N token /Alts备选)/:64-108RuleMatch两变体 +with_resolved_programcodex-rs/execpolicy/src/decision.rs:7-16Decision三值 + 派生Ord(声明序即Allow < Prompt < Forbidden)codex-rs/execpolicy/src/parser.rs:57-79Starlark 求值入口(Dialect::Extended+enable_f_strings)/:86-131PolicyBuilder(rules_by_program/network_rules/host_executables_by_name)codex-rs/execpolicy/src/amend.rs:65-81blocking_append_allow_prefix_rule(拼prefix_rule(pattern=[...], decision="allow")写回文件)codex-rs/core/src/tools/sandboxing.rs:150-187ExecApprovalRequirement三变体/:194-230default_exec_approval_requirement/:250-257Skip.bypass_sandbox覆盖面注释
本章状态
本章的 Turn 主线和工具安全链已经完成。沙箱、上下文压缩、事件出口、模型网络、Skill/Agent、Loop、工具路由、Session/Thread/Memory 分别见同目录后续章节;唯一保留的未展开点是 Policy::fingerprint 以及本机无法直接验证的 Linux 沙箱实现,这两项不影响本章主流程。
源码基线:OpenAI Codex
d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。