第 2 章
三层 Turn Loop,任务壳、轮次、采样
场景还原
你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」。
忙的时候其实叠了四件事:模型调了三次工具,你中途补了一句「测试用 pytest」,它写完助手消息后 Stop hook 说还没跑 linter,于是又采了一次样。
这四件事如果塞进同一个 while,就只能靠几个布尔抢出口。谁先检查、谁能打断谁,会变成口头约定。
Codex 拆成三层。任务壳 RegularTask::run 决定这一趟还要不要再开一轮。run_turn 决定工具续跑、插话和 hook 要不要继续。采样层只把一次模型流收到 Completed。少一层,就少一个干净插口:插话、续跑和流重试会搅在一起。
逐行精读
这张图回答:一句话从对外入口走到模型流,经过哪三层循环。
对外入口:先 steer,steer 不成再开工
CodexThread::start_or_steer_turn 自己不看会话空不空闲。注释写明:返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。调用方不必先问「现在有没有活动轮」,把请求扔进来即可。
它甚至不自己投递。真正出门的是下面这个私有函数:Steer 模式跳过开工容量检查,其余模式先问 ensure_execution_capacity_for_turn_start,然后把请求和模式一起交给 io.submit_turn_input。会话锁、活动轮、任务表都不在这一层打开。
333 /// Submits turn input without requiring the caller to inspect thread state.334 ///335 /// The result describes whether Core started a turn, steered an active336 /// turn, or declined it without recording or enqueueing the input. Only337 /// user input is accepted.338 pub async fn start_or_steer_turn(339 &self,340 request: TurnInputRequest,341 ) -> CodexResult<TurnInputSubmission> {342 self.submit_turn_input_with_mode(request, TurnInputMode::StartOrSteer)343 .await344 }466 async fn submit_turn_input_with_mode(467 &self,468 request: TurnInputRequest,469 mode: TurnInputMode,470 ) -> CodexResult<TurnInputSubmission> {471 if !matches!(mode, TurnInputMode::Steer { .. }) {472 self.session473 .services474 .agent_control475 .ensure_execution_capacity_for_turn_start(self)476 .await?;477 }478 self.io.submit_turn_input(request, mode).await479 }薄包装放在 CodexThread,是因为这一层面向 CLI 和 app-server。它们手里只有线程句柄,不该去读 active_turn。三种模式共用同一条提交口,调用方换模式,不必换一套会话内部 API。
模式是三个值。StartOrSteer 是「空闲就开、忙着就插」。另外两个把决定权留给调用方。
127/// How Core should route submitted turn input.128#[derive(Clone, Debug, Eq, PartialEq)]129pub enum TurnInputMode {130 /// Start a regular turn when idle, otherwise steer the active regular turn.131 StartOrSteer,132 /// Start only when the thread is idle.133 StartIfIdle,134 /// Steer only if this exact turn is active.135 Steer { expected_turn_id: String },136}分流为什么下沉,模块头写明了:这里是 Core 唯一决定「开工、插入、还是拒收」的地方。它答完就返回,不等 user-prompt hook,不改内存里的模型上下文,不写 rollout,也不开采样。持久设置在 Started 和 Steered 上都生效,开工专用选项只在 Started 上用。
1//! Handles reply-bearing turn-input operations.2//!3//! This is the one place Core decides whether submitted input starts a turn,4//! steers an active turn, or is rejected. It replies after that decision; it5//! does not wait for user-prompt hooks, updating the in-memory model context,6//! rollout persistence, or sampling.7//!8//! Persistent thread settings apply on Started and Steered. Turn start9//! options only apply on Started.把分流放在 session 侧,是因为只有这里同时看得见 steer_input 的失败原因、ActiveTurn 和 spawn_task。CodexThread 若自己判断空闲,判断和真正开工之间会裂开一条缝:中间可能被别的任务抢先。handle 按模式分到 start_or_steer、start_if_idle、steer 三个函数,空闲判定只发生一次,和开工在同一把会话锁的视野里。
真正的分流在 start_or_steer。它先调用 session.steer_input。成功就返回 Steered。只有 NoActiveTurn 才拼好 task_input,再 spawn_task(..., RegularTask::new())。
167async fn start_or_steer(168 session: &Arc<Session>,169 request: TurnInputRequest,170 submission_id: String,171) -> CodexResult<TurnInputSubmission> {172 let TurnInputRequest {173 input,174 thread_settings,175 start,176 additional_context,177 responsesapi_client_metadata,178 ..179 } = request;180 let SubmittedTurnInput::UserInput {181 content: mut items,182 client_id,183 } = input184 else {185 return Err(CodexErr::InvalidRequest(186 "only user input can steer a turn".to_string(),187 ));188 };189 let can_start_root_turn = start.parent_turn_id.is_none() && start.root_turn_id.is_none();190 let incoming_root_turn_id = start191 .parent_turn_id192 .as_ref()193 .map(|_| start.root_turn_id.clone());194 let settings = PreparedTurnInputSettings::prepare(session, thread_settings, start).await?;195 match session196 .steer_input(197 &mut items,198 additional_context.clone(),199 /*expected_turn_id*/ None,200 settings.required_active_final_output_json_schema(),201 client_id.clone(),202 responsesapi_client_metadata.clone(),203 incoming_root_turn_id,204 )205 .await206 {207 Ok(turn_id) => {208 settings.apply_steered(session, submission_id).await?;209 Ok(TurnInputSubmission::Steered { turn_id })210 }211 Err(NotSubmittedReason::NoActiveTurn) => {212 let turn_context = settings213 .apply_started(session, submission_id.clone())214 .await?;215 if can_start_root_turn216 && !items.is_empty()217 && turn_context218 .turn_metadata_state219 .can_start_root_turn(&turn_context.session_source)220 {221 turn_context222 .turn_metadata_state223 .set_root_turn_id(submission_id.clone());224 }225 if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {226 turn_context227 .turn_metadata_state228 .set_responsesapi_client_metadata(responsesapi_client_metadata);229 }230 session231 .maybe_emit_model_warnings_for_turn(turn_context.as_ref())232 .await;233 turn_context.session_telemetry.user_prompt(&items);234 let mut task_input = merge_additional_context_input(session, additional_context).await;235 if !items.is_empty() {236 task_input.push(TurnInput::UserInput {237 content: items,238 client_id,239 });240 }241 session242 .spawn_task(turn_context, task_input, RegularTask::new())243 .await;244 Ok(TurnInputSubmission::Started {245 turn_id: submission_id,246 })247 }248 Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),249 }250}输入是 TurnInputRequest。输出是 TurnInputSubmission:Started、Steered、NotSubmitted。协议注释写明,前两个都不等于采样已经开始。对外入口只判断:这句话是插进正在跑的任务,还是新开一个 RegularTask。三层循环从任务壳才开始转。
任务壳:一种任务,一个小 trait
会话里能跑的工作不只有普通对话。压缩和审查是平级的任务类型。
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]68pub(crate) enum TaskKind {69 Regular,70 Review,71 Compact,72}三种任务共用 SessionTask。模块注释写明这个 trait 故意很小:报自己的 kind,在 run 里干活,取消时可以覆写 abort。
179/// Async task that drives a [`Session`] turn.180///181/// Implementations encapsulate a specific Codex workflow (regular chat,182/// reviews, ghost snapshots, etc.). Each task instance is owned by a183/// [`Session`] and executed on a background Tokio task. The trait is184/// intentionally small: implementers identify themselves via185/// [`SessionTask::kind`], perform their work in [`SessionTask::run`], and may186/// release resources in [`SessionTask::abort`].187pub(crate) trait SessionTask: Send + Sync + 'static {188 /// Describes the type of work the task performs so the session can189 /// surface it in telemetry and UI.190 fn kind(&self) -> TaskKind;191192 /// Returns the tracing name for a spawned task span.193 fn span_name(&self) -> &'static str;194195 /// Executes the task until completion or cancellation.196 ///197 /// Implementations typically stream protocol events using `session` and198 /// `ctx`, returning an optional final agent message when finished. The199 /// provided `cancellation_token` is cancelled when the session requests an200 /// abort; implementers should watch for it and terminate quickly once it201 /// fires. Returning [`Some`] yields a final message that202 /// [`Session::on_task_finished`] will emit to the client. Returning203 /// [`CodexErr::TurnAborted`] completes the task through the aborted-turn204 /// lifecycle instead.205 fn run(206 self: Arc<Self>,207 session: Arc<Session>,208 ctx: Arc<TurnContext>,209 input: Vec<TurnInput>,210 cancellation_token: CancellationToken,211 ) -> impl std::future::Future<Output = SessionTaskResult> + Send;CompactTask 和 ReviewTask 各走自己的 run,不进 run_turn 那条采样环。普通对话才是 RegularTask。
接下来这段要证明什么:任务壳只做三件事。发一次 TurnStarted,吃掉启动预热,然后只要队列里还有待处理输入,就再调一次 run_turn。
30impl SessionTask for RegularTask {31 fn kind(&self) -> TaskKind {32 TaskKind::Regular33 }3435 fn span_name(&self) -> &'static str {36 "session_task.turn"37 }3839 async fn run(40 self: Arc<Self>,41 sess: Arc<Session>,42 ctx: Arc<TurnContext>,43 input: Vec<TurnInput>,44 cancellation_token: CancellationToken,45 ) -> SessionTaskResult {46 let run_turn_span = trace_span!("run_turn");47 // Regular turns emit `TurnStarted` inline so first-turn lifecycle does48 // not wait on startup prewarm resolution.49 let prewarmed_client_session = async {50 let event = EventMsg::TurnStarted(TurnStartedEvent {51 turn_id: ctx.sub_id.clone(),52 trace_id: ctx.trace_id.clone(),53 started_at: ctx.turn_timing_state.started_at_unix_secs().await,54 model_context_window: ctx.model_context_window(),55 collaboration_mode_kind: ctx.mode,56 });57 sess.send_event(ctx.as_ref(), event).await;58 sess.set_server_reasoning_included(/*included*/ false).await;59 sess.consume_startup_prewarm_for_regular_turn(&cancellation_token)60 .await61 }62 .instrument(trace_span!("regular_task.prepare_run_turn"))63 .await;64 let prewarmed_client_session = match prewarmed_client_session {65 SessionStartupPrewarmResolution::Cancelled => {66 run_hooks_and_record_inputs(&sess, &ctx, &input, PersistContext::Standard).await;67 return Ok(None);68 }69 SessionStartupPrewarmResolution::Unavailable { .. } => None,70 SessionStartupPrewarmResolution::Ready(prewarmed_client_session) => {71 Some(*prewarmed_client_session)72 }73 };74 let mut next_input = input;75 let mut prewarmed_client_session = prewarmed_client_session;76 loop {77 let last_agent_message = run_turn(78 Arc::clone(&sess),79 Arc::clone(&ctx),80 next_input,81 prewarmed_client_session.take(),82 cancellation_token.child_token(),83 )84 .instrument(run_turn_span.clone())85 .await?;86 if !sess.input_queue.has_pending_input(&sess.active_turn).await {87 return Ok(last_agent_message);88 }89 next_input = Vec::new();90 }91 }92}输入是初始用户消息加可选预热会话。输出是最后一条助手消息。第二次进 run_turn 时 next_input 为空,新消息从 input_queue 取。TurnStarted 只发一次,turn_id 取自 ctx.sub_id,多次 run_turn 共用它。
事件本身很小。前端和回放都靠这一条边界把后续事件收成一簇。
2035pub struct TurnStartedEvent {2036 pub turn_id: String,2037 // Persist for rollout consumers that correlate turns with telemetry traces.2038 #[serde(default, skip_serializing_if = "Option::is_none")]2039 #[ts(optional)]2040 pub trace_id: Option<String>,2041 /// Unix timestamp (in seconds) when the turn started.2042 #[serde(default, skip_serializing_if = "Option::is_none")]2043 #[ts(type = "number | null", optional)]2044 pub started_at: Option<i64>,2045 // TODO(aibrahim): make this not optional2046 pub model_context_window: Option<i64>,2047 #[serde(default)]2048 pub collaboration_mode_kind: ModeKind,2049}对前端意味着:TUI 用 user_turn_pending_start 表示「用户已经提交,Core 的 TurnStarted 还没到」。第二次 run_turn 不再发事件,输入框不会再闪一次「新的一轮开始了」,工具续跑和 stop hook 续跑都挂在同一条时间轴上。UserShellCommandTask 的注释把这件事写成禁令:已经有活动轮时,辅助命令不许再发一对 TurnStarted / TurnComplete。
对回放意味着:rollout 用 TurnStarted 当规范边界。按 turn_id 截断时,必须在原始条目里找到这条事件,合成出来的旧 ID 不能当 fork 点。同一任务里的多次采样、多次 run_turn,回放时仍是同一个 turn 桶。
158/// Return a rollout prefix ending after the requested persisted terminal turn.159///160/// The turn must still be present in the effective post-rollback history and161/// must have an explicit persisted TurnStarted boundary. Synthetic IDs162/// generated while projecting legacy rollouts are intentionally unsupported163/// because they do not provide a stable raw rollout boundary for a fork.源码中没有单独的设计文档,以下为从实现反推,标注为推断:任务壳要保住的是「这一趟还活着」,好让 hook 收工后再补的一句话不必重发 TurnStarted。空闲时信箱叫醒走 maybe_start_turn_for_pending_work_with_sub_id(tasks/mod.rs 第 463 行)。终止条件:has_pending_input 为假就返回。那一次会换一个新的 sub_id,界面上才是下一轮。
轮次层:一次回复,两种下场
run_turn 的文件头注释把合同写成两段。模型每次采样原则上回两类东西:函数调用,或助手消息。一次采样里可以带回多条,实务上通常一条。有函数调用就执行,结果送进下一次采样。只有助手消息,这一轮可以收工。
139/// Takes initial turn input and runs a loop where, at each sampling request,140/// the model replies with either:141///142/// - requested function calls143/// - an assistant message144///145/// While it is possible for the model to return multiple of these items in a146/// single sampling request, in practice, we generally one item per sampling request:147///148/// - If the model requests a function call, we execute it and send the output149/// back to the model in the next sampling request.150/// - If the model sends only an assistant message, we record it in the151/// conversation history and consider the turn complete.152///153pub(crate) async fn run_turn(154 sess: Arc<Session>,155 turn_context: Arc<TurnContext>,156 input: Vec<TurnInput>,157 prewarmed_client_session: Option<ModelClientSession>,158 cancellation_token: CancellationToken,159) -> CodexResult<Option<String>> {进主循环之前先做采样前压缩、拍 StepContext、注入 skills、写入初始输入。任一步失败可能 return Ok(None),任务壳再看队列。主循环注释写明:pending 默认在下次建模前排进历史,开轮和自动压缩后两处要推迟。
287 // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains288 // many turns, from the perspective of the user, it is a single turn.289 let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(290 TurnDiffTracker::with_environment_display_roots(display_roots),291 ));292293 // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse294 // one instance across retries within this turn.295 // Pending input is drained into history before building the next model request.296 // However, we defer that drain until after sampling in two cases:297 // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first.298 // 2. After auto-compact, when model/tool continuation needs to resume before any steer.299300 let mut next_step_context = Some(first_step_context);301 loop {302 // Note that pending_input would be something like a message the user303 // submitted through the UI while the model was running. Though the UI304 // may support this, the model might not.305 let pending_input = if can_drain_pending_input {306 sess.input_queue307 .get_pending_input(&sess.active_turn)308 .await309 .0310 } else {311 Vec::new()312 };313314 if run_hooks_and_record_inputs(315 &sess,316 &turn_context,317 &pending_input,318 PersistContext::Standard,319 )320 .await321 {322 break;323 }can_drain_pending_input 初值是 input.is_empty():开轮时先采 input 里的主题,采样成功后再改成 true。pending 先过 run_hooks_and_record_inputs(turn.rs 第 615 行),hook 要停且没有被接受的用户消息,就 break。然后组 prompt,调用 run_sampling_request。回来后轮次层把两件事合成一个布尔。
394 match sampling_request_result {395 Ok((sampling_request_output, sampling_request_input)) => {396 let SamplingRequestResult {397 needs_follow_up: model_needs_follow_up,398 last_agent_message: sampling_request_last_agent_message,399 } = sampling_request_output;400 if model_needs_follow_up {401 sess.input_queue402 .accept_mailbox_delivery_for_current_turn(403 &sess.active_turn,404 &turn_context.sub_id,405 )406 .await;407 }408 can_drain_pending_input = true;409 // Process async hooks only after sampling and its tools have finished.410 drain_async_hook_results(&sess, &turn_context, /*before_user_prompt*/ false).await;411 let (has_pending_input, token_status) = async {412 let has_pending_input =413 sess.input_queue.has_pending_input(&sess.active_turn).await;414 let token_status = super::context_window::context_window_token_status(415 sess.as_ref(),416 turn_context.as_ref(),417 )418 .await;419 (has_pending_input, token_status)420 }needs_follow_up 在这一层被重算:model_needs_follow_up || has_pending_input。信箱若已切到 NextTurn,has_pending_input 故意返回假,邮件留给下一趟。判定先看 turn 上的 pending 和投递阶段,阶段不是 CurrentTurn 就直接假。
342 pub(crate) async fn has_pending_input(&self, active_turn: &Mutex<Option<ActiveTurn>>) -> bool {343 let (has_turn_pending_input, accepts_mailbox_delivery) = {344 let active = active_turn.lock().await;345 match active.as_ref() {346 Some(active_turn) => {347 let turn_state = active_turn.turn_state.lock().await;348 (349 !turn_state.pending_input.items.is_empty(),350 turn_state.accepts_mailbox_delivery_for_current_turn(),351 )352 }353 None => (false, true),354 }355 };356 if !accepts_mailbox_delivery {357 return false;358 }359 if has_turn_pending_input {360 return true;361 }362 self.has_pending_mailbox_items().await363 }阶段机本身写在 TurnState 旁边。开轮默认 CurrentTurn。模型写出用户可见的最终答案后切到 NextTurn。同一任务又接到 steer 或工具续跑,再开回 CurrentTurn。
37/// Whether mailbox deliveries should still be folded into the current turn.38///39/// State machine:40/// - A turn starts in `CurrentTurn`, so queued child mail can join the next41/// model request for that turn.42/// - After user-visible terminal output is recorded, we switch to `NextTurn`43/// to leave late child mail queued instead of extending an already shown44/// answer.45/// - If the same task later gets explicit same-turn work again (a steered user46/// prompt or a tool call after an untagged preamble), we reopen `CurrentTurn`47/// so that pending child mail is drained into that follow-up request.48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]49pub(crate) enum MailboxDeliveryPhase {50 /// Incoming mailbox messages can still be consumed by the current turn.51 #[default]52 CurrentTurn,53 /// The current turn already emitted visible final answer text; mailbox54 /// messages should remain queued for a later turn.55 NextTurn,56}切到 NextTurn 的动作在 defer_mailbox_delivery_to_next_turn(input_queue.rs 第 206 行)。pending 里已有用户 steer 或带 trigger_turn 的邮件,它不切。调用点在 record_completed_response_item_with_finalized_facts(stream_events_utils.rs 第 99 行):条目是用户可见的收尾,就推迟信箱。
轮次层在 !needs_follow_up 时才跑 stop hook。hook 要续跑,这一层自己 continue,任务壳和采样层都还没退。
500 if !needs_follow_up {501 last_agent_message = sampling_request_last_agent_message;502 let stop_outcome = run_turn_stop_hooks(503 &sess,504 &step_context,505 stop_hook_active,506 last_agent_message.clone(),507 )508 .await;509 if stop_outcome.should_block {510 if let Some(hook_prompt_message) =511 build_hook_prompt_message(&stop_outcome.continuation_fragments)512 {513 sess.record_response_item_and_emit_turn_item(514 &turn_context,515 hook_prompt_message,516 )517 .await;518 sess.input_queue519 .accept_mailbox_delivery_for_current_turn(520 &sess.active_turn,521 &turn_context.sub_id,522 )523 .await;524 stop_hook_active = true;525 continue;526 } else {527 sess.send_event(528 &turn_context,529 EventMsg::Warning(WarningEvent {530 message: "Stop hook requested continuation without a prompt; ignoring the block.".to_string(),531 }),532 )533 .await;534 }535 }536 if stop_outcome.should_stop {537 break;538 }539 if run_legacy_after_agent_hook(540 &sess,541 &turn_context,542 &sampling_request_input,543 last_agent_message.clone(),544 )545 .await546 {547 return Ok(None);548 }549 break;550 }should_block 带 prompt,就写成一条 hook 消息,重新打开 CurrentTurn,继续 run_turn 循环。should_block 却没有 prompt,发一条警告,假装没拦。should_stop 只退出 run_turn,控制权回到 RegularTask。任务壳再问一次 has_pending_input。
两处都读同一个函数,语义不同。轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。
去掉轮次层那一问:模型写出最终答案后,run_turn 会去跑 stop hook 并 break,中途那句「测试用 pytest」只能等任务壳再进一次 run_turn。功能上还能补上,只是多一次函数返回,stop hook 会在插话进模型之前先跑一轮。
去掉任务壳那一问:run_turn 因 should_stop 返回后,任务直接结束。队列里后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务。TurnStarted 会再闪一次,回放里变成两个 turn 桶。
这张状态图回答:run_turn 在什么条件下继续,什么条件下把控制权交回任务壳。
采样层:流收到 Completed,再 drain 工具
采样层自己还有两圈。外圈 run_sampling_request 处理可重试错误。内圈 try_run_sampling_request 消费一条 SSE 流。
返回值很小:要不要续跑,以及最后一条助手消息。
1573#[derive(Debug)]1574struct SamplingRequestResult {1575 needs_follow_up: bool,1576 last_agent_message: Option<String>,1577}run_sampling_request 每次重试都重新 build_prompt。成功就带着这次的 output 和原始 input 返回。上下文超限、用量见顶直接上抛。其余错误看 is_retryable(),可重试才进 handle_retryable_response_stream_error。
1383 let prompt = build_prompt(1384 prompt_input,1385 step_context.as_ref(),1386 base_instructions.clone(),1387 );1388 let err = match try_run_sampling_request(1389 tool_runtime.clone(),1390 Arc::clone(&sess),1391 Arc::clone(&step_context),1392 Arc::clone(&turn_store),1393 client_session,1394 responses_metadata,1395 Arc::clone(&turn_diff_tracker),1396 &prompt,1397 cancellation_token.child_token(),1398 )1399 .await1400 {1401 Ok(output) => {1402 return Ok((output, original_input.unwrap_or(prompt.input)));1403 }1404 Err(err) => match err.details() {1405 CodexErrorDetails::ContextWindowExceeded => {1406 sess.set_total_tokens_full(&turn_context).await;1407 return Err(err);1408 }1409 CodexErrorDetails::UsageLimitReached(e) => {1410 let rate_limits = e.rate_limits.clone();1411 if let Some(rate_limits) = rate_limits {1412 sess.update_rate_limits(&turn_context, *rate_limits).await;1413 }1414 return Err(err);1415 }1416 _ => err,1417 },1418 };14191420 if original_input.is_none() {1421 original_input = Some(prompt.input);1422 }14231424 if !err.is_retryable() {1425 return Err(err);1426 }14271428 handle_retryable_response_stream_error(1429 &mut retry_state,1430 max_retries,1431 err,1432 client_session,1433 &sess,1434 &turn_context,1435 ResponsesStreamRequest::Sampling,1436 )1437 .await?;1438 turn_context.turn_timing_state.record_sampling_retry();1439 }1440}组 prompt 时 build_prompt 把 parallel_tool_calls 写成 true。模型被允许一次点多个工具。执行侧是否真并行,是工具运行时的事,采样层不管。
try_run_sampling_request 先 stream(),再 or_cancel。取消在取下一条事件时变成 CodexErr::TurnAborted。流在 Completed 之前关掉,报 stream closed before response.completed。
2266 let event = match stream2267 .next()2268 .instrument(trace_span!(parent: &handle_responses, "receiving"))2269 .or_cancel(&cancellation_token)2270 .await2271 {2272 Ok(event) => event,2273 Err(codex_async_utils::CancelErr::Cancelled) => {2274 break Err(CodexErr::TurnAborted);2275 }2276 };22772278 let event = match event {2279 Some(Ok(event)) => event,2280 Some(Err(err)) => break Err(err),2281 None => {2282 break Err(CodexErr::Stream(2283 "stream closed before response.completed".into(),2284 ));2285 }2286 };ResponseEvent 把流上的事穷举成一组变体。采样层的 match event 必须覆盖全部,这和 AGENTS.md 要求 match 尽量穷尽是同一条纪律。终止采样的主出口是 Completed。
76pub enum ResponseEvent {77 Created,78 SafetyBuffering(SafetyBuffering),79 OutputItemDone(ResponseItem),80 OutputItemAdded(ResponseItem),81 /// Emitted when the server includes `OpenAI-Model` on the stream response.82 /// This can differ from the requested model when backend safety routing applies.83 ServerModel(String),84 /// Emitted when the server recommends additional account verification.85 ModelVerifications(Vec<ModelVerification>),86 /// Emitted when the server includes moderation metadata for first-party turn presentation.87 TurnModerationMetadata(TurnModerationMetadataEvent),88 /// Emitted when `X-Reasoning-Included: true` is present on the response,89 /// meaning the server already accounted for past reasoning tokens and the90 /// client should not re-estimate them.91 ServerReasoningIncluded(bool),92 Completed {93 response_id: String,94 token_usage: Option<TokenUsage>,95 /// Did the model affirmatively end its turn? Some providers do not set this,96 /// so we rely on fallback logic when this is `None`.97 end_turn: Option<bool>,98 },99 OutputTextDelta(String),100 ToolCallInputDelta {101 item_id: String,102 call_id: Option<String>,103 delta: String,104 },105 ReasoningSummaryDelta {106 delta: String,107 summary_index: i64,108 },109 ReasoningSummaryDone {110 item_id: String,111 text: String,112 summary_index: i64,113 },114 ReasoningContentDelta {115 delta: String,116 content_index: i64,117 },118 ReasoningSummaryPartAdded {119 summary_index: i64,120 },121 RateLimits(RateLimitSnapshot),122 ModelsEtag(String),123}Completed 到来时,采样层先刷完助手文本,再记 token,再看 end_turn。服务端明确说还没结束(Some(false)),就把 needs_follow_up 置真。然后带着当前累加值离开事件循环。
2539 ResponseEvent::Completed {2540 response_id,2541 token_usage,2542 end_turn,2543 } => {2544 sess.services2545 .analytics_events_client2546 .track_code_mode_tool_call(2547 codex_analytics::CodeModeToolCallFact::SamplingResponseCompleted {2548 thread_id: sess.thread_id.to_string(),2549 turn_id: turn_context.sub_id.clone(),2550 response_id: response_id.clone(),2551 tool_call_ids: std::mem::take(&mut analytics_tool_call_ids),2552 },2553 );2554 flush_assistant_text_segments_all(2555 &sess,2556 &turn_context,2557 plan_mode_state.as_mut(),2558 &mut assistant_message_stream_parsers,2559 )2560 .await;2561 sess.send_event(2562 &turn_context,2563 EventMsg::RawResponseCompleted(RawResponseCompletedEvent {2564 response_id,2565 token_usage: token_usage.clone(),2566 }),2567 )2568 .await;2569 let budget_result = sess2570 .record_token_usage_info(&turn_context, token_usage.as_ref())2571 .await;2572 should_emit_token_count = true;2573 should_emit_turn_diff = true;2574 if let Err(err) = budget_result {2575 break Err(err);2576 }2577 if let Some(false) = end_turn {2578 needs_follow_up = true;2579 }2580 break Ok(SamplingRequestResult {2581 needs_follow_up,2582 last_agent_message,2583 });2584 }工具调用不在 Completed 才开始。OutputItemDone 当时就会 handle_output_item_done。命中工具时,结果结构里的 needs_follow_up 被置真,future 推进 FuturesOrdered。
196#[derive(Default)]197pub(crate) struct OutputItemResult {198 pub last_agent_message: Option<String>,199 pub needs_follow_up: bool,200 pub tool_future: Option<InFlightFuture<'static>>,201}316 record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)317 .await;318319 let cancellation_token = ctx.cancellation_token.child_token();320 let tool_future: InFlightFuture<'static> = Box::pin(321 ctx.tool_runtime322 .clone()323 .handle_tool_call(call, cancellation_token),324 );325326 output.needs_follow_up = true;327 output.tool_future = Some(tool_future);事件循环结束之后,采样层还要 drain_in_flight。工具结果按完成顺序写入历史。某个 future 失败只记日志。取消检查放在 drain 之后,已经记过的 token 仍会发出。
2130async fn drain_in_flight(2131 in_flight: &mut FuturesOrdered<BoxFuture<'static, CodexResult<ResponseInputItem>>>,2132 sess: Arc<Session>,2133 turn_context: Arc<TurnContext>,2134) -> CodexResult<()> {2135 while let Some(res) = in_flight.next().await {2136 match res {2137 Ok(response_input) => {2138 let response_item = response_input.into();2139 sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item))2140 .await;2141 mark_thread_memory_mode_polluted_if_external_context(2142 sess.as_ref(),2143 turn_context.as_ref(),2144 &response_item,2145 )2146 .await;2147 }2148 Err(err) => {2149 error_or_panic(format!("in-flight tool future failed during drain: {err}"));2150 }2151 }2152 }2153 Ok(())2154}2749 drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;2750 drop(tool_blocking_timing_guard);27512752 if should_emit_token_count {2753 // A tool call such as request_user_input can intentionally pause the turn. Emit token2754 // counts only after pending tools resolve so clients do not see progress events while the2755 // turn is waiting on the user. This also needs to happen before returning cancellation so2756 // token usage already recorded from the completed response is still persisted.2757 sess.send_token_count_event(&turn_context).await;2758 }27592760 if cancellation_token.is_cancelled() {2761 return Err(CodexErr::TurnAborted);2762 }早期资料常把采样层的出口写成「收到 Completed」。当前源码里,这一层至少有五种离开方式。它们都发生在 run_turn 还没重算 pending、也还没跑 stop hook 之前。
第一种,重试。run_sampling_request 自己是个 loop。上下文超限和用量见顶直接 return Err。其余错误问 is_retryable()。可重试才进 handle_retryable_response_stream_error,然后 record_sampling_retry,重新 build_prompt。控制权没离开采样层。run_turn 看见的仍是同一次调用。
第二种,取消。事件循环用 or_cancel 取下一条。token 一响,立刻 break Err(CodexErr::TurnAborted)。流已经 Completed、正在 drain_in_flight 时再取消,走另一条:工具结果仍写入历史,token 事件照发,然后函数末尾再查 cancellation_token.is_cancelled(),回报同一个 TurnAborted。取消检查故意放在 drain 之后,避免界面以为这一轮没发生过。
第三种,流中断。stream.next() 得到 None,还没见到 Completed,报 stream closed before response.completed。这是 Err,不是带着半成品 SamplingRequestResult 回去。run_turn 按采样失败处理,不会把半截助手消息当成收工。
第四种,end_turn: Some(false)。Completed 已经到了,事件循环会离开。服务端明确说模型还没结束这一轮,采样层把 needs_follow_up 置真再返回。None 表示提供方没设这个字段,走后面的兜底:看有没有工具、pending、助手消息。Some(true) 不会在这里清掉已经累加的 needs_follow_up。工具调用在 OutputItemDone 时就把这个布尔点亮了。
第五种,mailbox 预抢。流里出现 commentary 或 reasoning,并且信箱非空,采样层会提前带着 needs_follow_up: true 离开,不等 Completed。源码留了一句 todo: remove before stabilizing multi-agent v2。这是例外路径,主合同仍是先 Completed 再 drain。
2361 let preempt_for_mailbox_mail = match &item {2362 ResponseItem::Message { role, phase, .. } => {2363 role == "assistant" && matches!(phase, Some(MessagePhase::Commentary))2364 }2365 ResponseItem::Reasoning { .. } => true,2366 ResponseItem::AgentMessage { .. } => false,这张时序图回答:用户一句话、一次工具、一次插话,控制权在哪一层。
三层各自的出口如下。
- 任务壳:
run_turn返回后has_pending_input为假,结束;为真,空输入再进run_turn。 - 轮次层:
needs_follow_up为真就继续采样;为假再问 stop hook。should_block带 prompt 就续跑,should_stop或干净收工才break。 - 采样层:重试留在本层;取消和流中断走
Err;Completed后看end_turn;mailbox 预抢提前离开。drain 完工具才把结果交回run_turn。
设计决策分析
AGENTS.md 不管循环怎么切,管的是模块体积和 match 纪律。turn.rs 用 wc -l 计得 2791 行,已经超过「大约 800 行就该拆」的红线。评审条款原文如下。
49- Avoid large modules:50 - Prefer adding new modules instead of growing existing ones.51 - Target Rust modules under 500 LoC, excluding tests.52 - If a file exceeds roughly 800 LoC, add new functionality in a new module instead of extending53 the existing file unless there is a strong documented reason not to.54 - This rule applies especially to high-touch files that already attract unrelated changes, such55 as `codex-rs/tui/src/app.rs`, `codex-rs/tui/src/bottom_pane/chat_composer.rs`,56 `codex-rs/tui/src/bottom_pane/footer.rs`, `codex-rs/tui/src/chatwidget.rs`,57 `codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules.同一份文件里,SessionTask 的注释要求 trait 保持很小。任务种类可以增加,循环骨架不许跟着膨胀。只有 Regular 进入三层环。
AGENTS.md 第 21 行要求 match 尽量穷尽。try_run_sampling_request 对 ResponseEvent 的 17 个变体逐个处理。
源码中没有单独的循环设计文档。以下从实现反推,标为推断。
三层按「谁有资格决定继续」切开。采样层只看见这一次流,能重试,不能收整轮。轮次层看见工具、pending、预算和 hook,能续采样,不能重发 TurnStarted。任务壳看见任务还在、队列里是否还有活。
不这样做的后果可以从分支里读出来。采样层若直接看 pending,流没结束就插入用户消息,prompt 缓存和 in-flight 工具会对不齐。run_turn 若在 hook 续跑时拆任务,TurnStarted 会再闪一次,回放也会多出一个 turn 桶。没有任务壳,hook 刚结束后到达的那句话会掉进 maybe_start_turn_for_pending_work,换成新的 turn_id。
分流下沉也是同一类保护。CodexThread 若自己读 active_turn 再决定开工,读和 spawn_task 之间可能被另一条提交抢先。turn_input.rs 把「看空闲」和「开工」放在同一处,薄包装只负责容量检查和投递。
推迟 drain 的两条例外是同一类保护:开轮先采 input,压缩后先让模型续上。MailboxDeliveryPhase 用阶段代替第二条语义队列。代价是 has_pending_input 在 NextTurn 下会撒谎。
边界条件剖析
1. run_turn 中途用户又发一条消息,哪一层接住
分四个时刻。
时刻 A:空闲。steer_input 返回 NoActiveTurn,消息作为 run_turn 的 input 进去,循环开头不排队列。
时刻 B:任务已在跑。start_or_steer 走 Steered,消息进 pending_input。当前采样看不见它。Completed 且 drain 之后,run_turn 重算 has_pending_input,为真就续跑。
时刻 C:最终答案已写出,阶段切到 NextTurn。用户 steer 仍在 turn 的 pending 里。子 agent 旁路邮件被当成下一轮的货,has_pending_input 返回假,由 maybe_start_turn_for_pending_work 新开任务。
时刻 D:流里出现 commentary 或 reasoning 且信箱非空。采样层会提前带着 needs_follow_up: true 离开。源码留了一句 todo: remove before stabilizing multi-agent v2。这是例外,不是主路径。
2397 needs_follow_up |= output_result.needs_follow_up;2398 // todo: remove before stabilizing multi-agent v22399 if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await {2400 break Ok(SamplingRequestResult {2401 needs_follow_up: true,2402 last_agent_message,2403 });2404 }结论:用户插话由对外入口写入队列,由轮次层在下一次循环开头取走。任务壳只处理「run_turn 已经返回、队列里还有货」。采样层默认不接插话。
2. stop hook 返回 block,是三层一起退还是只退一层
只退零层,或只退轮次层。不会三层一起退。
run_turn_stop_hooks 在 !needs_follow_up 之后才跑。它按会话来源选 Stop 或 SubagentStop。内部子 agent 直接返回默认 StopOutcome,等于没有 hook。
66#[derive(Debug, Default)]67pub struct StopOutcome {68 pub hook_events: Vec<HookCompletedEvent>,69 pub should_stop: bool,70 pub stop_reason: Option<String>,71 pub should_block: bool,72 pub block_reason: Option<String>,73 pub continuation_fragments: Vec<HookPromptFragment>,74}聚合规则写在 aggregate_results:任一 handler 要 stop,整次就是 stop。should_block 只有在没人要 stop 时才为真。stop 优先于 block。
367fn aggregate_results<'a>(368 results: impl IntoIterator<Item = &'a StopHandlerData>,369) -> StopHandlerData {370 let results = results.into_iter().collect::<Vec<_>>();371 let should_stop = results.iter().any(|result| result.should_stop);372 let stop_reason = results.iter().find_map(|result| result.stop_reason.clone());373 let should_block = !should_stop && results.iter().any(|result| result.should_block);回到 run_turn 的三个出口:
should_block且拼出了 continuation prompt:写入历史,打开CurrentTurn,continue。采样层早已返回。任务壳还在等这次run_turn。控制权留在轮次层。should_block但没有 prompt:警告ignoring the block,再看should_stop。should_stop:break离开run_turn。RegularTask随后检查has_pending_input。队列空,任务结束;队列不空,空输入再进run_turn。
所以 block 是轮次层内部续跑。stop 是轮次层把控制权交回任务壳。采样层两种情况都不重新进入。
3. 采样收到 Completed 之后,工具 future 还没跑完怎么办
事件循环在 Completed 处 break,函数还没返回。drain_in_flight 按顺序等完工具。token 事件和取消检查都在 drain 之后:流已结束,工具结果仍要落历史,然后才回报 TurnAborted。模块注释把顺序写成合同:先 persist 再 drain。工具失败走 error_or_panic,不把整次采样打成 Err。
4. RegularTask 和 run_turn 都看 pending,会不会转两圈
会,职责不同。同一句用户话最多只该被一处取走。
run_turn 在采样后看 pending,是为了同一轮里把工具结果或插话再送给模型。只要 has_pending_input 为真,它自己 continue,任务壳那一圈还没轮到。RegularTask 在 run_turn 返回后再看,是为了界面已收工、队列里又来了必须处理的输入。第二次调用时 next_input 为空,can_drain_pending_input 初值为真,循环开头就会把队列排进历史。
NextTurn 下 has_pending_input 为假。两处判断此时都会放手。信箱里剩下的 trigger_turn 邮件由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务,再发一次 TurnStarted。用户 steer 仍在 turn 的 pending 里,阶段机不会为了旁路邮件把已经上屏的答案再拉长。
源码中没有单独说明「为什么要问两次」。以下为从实现反推,标注为推断:两次判断夹住 stop hook。hook 续跑时控制权留在轮次层,任务壳看不见。hook 放行后任务壳才有机会接手「收工瞬间又来的那一句」。只留一处,就会把 hook 和后到消息挤进同一个出口。
横向对比
同一问题:一轮对话要拆几层,层与层之间用什么交接。三边给了三种切法。
Codex:按生命周期切成任务、轮次、采样
代价是读者要同时记住三个 loop、两个 needs_follow_up(采样累加值,轮次重算值)以及 MailboxDeliveryPhase。换来三个独立插口:插话等轮次下一圈,流重试关在采样层,stop hook 续跑不必重发 TurnStarted。
DSH:按语义切成 Turn、Step、Inbox
对照页是 dsh-3.html。实现落在 packages/core/agent-loop/src/agent.ts 和 packages/core/agent/src/inbox.ts。文件头把这个 driver 写成「queued turns and step-boundary input」,循环从 session log 派生每一次请求。
DSH 的外圈是 kick:while (await this.turn()) {}。turn() 返回真,表示 inbox 里还有活,同一条 driver 继续开下一轮。返回假,driver 退回 idle。这和 RegularTask 的 loop { run_turn; if !has_pending_input { return } } 是同一类骨架。
210 private async kick(): Promise<void> {211 try {212 while (await this.turn()) {}turn() 自己再套一层 while (true)。每一圈先 preStep,再 this.step(...)。终止条件写在两行:turnEnds 有值,并且 inbox.nextStep 空了,才 break。工具若往 next-step 塞了后续,这一轮还继续。turn() 末尾再问 inbox.hasPending,有货就重置 step 计数、换新的 AbortController,返回真给 kick。
246 private async turn(): Promise<boolean> {247 if (this.phase.kind !== 'running') {248 this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))249 }250 const phase = this.phase251 const { signal } = phase.abort252 signal.throwIfAborted()253 const turn = phase.turn + 1254 try {255 this.session.append('turn/start', { turn })256 } catch (error: unknown) {257 this.throwError(error)258 }259 phase.turn = turn260 let turnEnds: TurnEndReason | null = null261 let target: InboxTarget = 'next-turn'262 try {263 while (true) {264 signal.throwIfAborted()265 const step = phase.step + 1266 const decision = await this.preStep(target, { turn, step })267 if (decision.kind === 'reject') {268 turnEnds = { kind: 'blocked' }269 return false270 }271 if (turnEnds && decision.messages.length === 0) break272 // A removed waking message or an enter decision rewritten to empty273 // still owns the initial turn boundary, but it spends no model call.274 if (phase.step === 0 && decision.messages.length === 0) {275 turnEnds = { kind: 'completed' }276 return false277 }278 signal.throwIfAborted()279 this.session.append('step/start', { turn, step })280 phase.step = step281 try {282 for (const message of decision.messages) {283 this.session.append('user/message', message, { surfaceOp: 'append' })284 }285 // max-tokens is sticky: once any step hits the ceiling, later steps286 // that complete normally must not downgrade the turn outcome.287 const stepEnd = await this.step(decision.assembly)288 // max-tokens stays sticky: a later completed step must not289 // downgrade the turn outcome.290 if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd291 } finally {292 this.session.append('step/end', { turn, step })293 }294 signal.throwIfAborted()295 if (turnEnds && this.inbox.nextStep.length === 0) {296 await this.dispatch.serial('agent/turn-stopping', { turn, signal })297 signal.throwIfAborted()298 }299 if (turnEnds && this.inbox.nextStep.length === 0) break300 target = 'next-step'301 }302 } catch (error: unknown) {303 if (signal.aborted) {304 turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }305 throw error306 }307 // Every failure is structured: an `LlmError` keeps its facts, anything308 // else flattens to `errorChain` text under the `UNKNOWN` code.309 turnEnds = {310 kind: 'error',311 error: error instanceof LlmError312 ? error.failure313 : { message: errorChain(error), code: 'UNKNOWN' },314 }315 this.throwError(error)316 } finally {317 try {318 // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending319 this.session.append('turn/end', { turn, reason: turnEnds! })320 } catch (error: unknown) {321 this.throwError(error)322 }323 }324 if (!this.inbox.hasPending) return false325 phase.abort = new AbortController()326 // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.327 phase.wakeRequested = false328 phase.step = 0329 return true330 }step() 才是一次模型请求。内层 while (true) 只为请求出错后重试。流用 for await 收完,没有 Codex 那种按 ResponseEvent 穷举的事件循环。没有工具就 completed。有工具就执行,工具带 concludesTurn 也当 completed,否则返回 null,让外层 turn() 再开下一步。
332 private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {333 /* v8 ignore next -- private callers establish the running phase before executing a step */334 if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)335 const { turn, step, abort: { signal } } = this.phase336 signal.throwIfAborted()337 const system = renderPrompt(assembly)338339 while (true) {340 const { request, preparedCall } = await this.buildRequest(341 turn, step, assembly.tools, system, this.session.deriveMessages(), signal,342 )343 const assembler = new BlockAssembler()344 const chunkSeqs: number[] = []345 const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)346 signal.throwIfAborted()347 for await (const chunk of stream) {348 signal.throwIfAborted()349 chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)350 assembler.push(chunk)351 }352 signal.throwIfAborted()353 const finish = assembler.finish354 if (finish.kind === 'error' || finish.kind === 'aborted') {355 const action = await this.dispatch.waterfall(356 'agent/request-error', {357 turn,358 step,359 provider: request.provider,360 failure: finish.failure,361 retryPolicy: preparedCall?.retryPolicy,362 signal,363 },364 () => Promise.resolve<RequestErrorAction>(undefined),365 )366 signal.throwIfAborted()367 if (action?.kind !== 'retry') {368 throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)369 }370 continue371 }372373 const message = createAssistantMessage({374 content: assembler.blocks(),375 source: {376 provider: request.provider,377 model: request.model,⋯ ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},· },· })· this.session.append(· 'assistant/message',· {· turn,· step,· message,⋯ ...assembler.usage === undefined ? {} : { usage: assembler.usage },388 },389 { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },390 )391 if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }392393 const toolCalls = message.content.filter(block => block.type === 'tool-call')394 if (toolCalls.length === 0) return { kind: 'completed' }395 const { concluded } = await executeToolCalls(396 this.loopCtx, turn, step, toolCalls, signal,397 context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),398 )399 return concluded ? { kind: 'completed' } : null400 }401 }第三层不是第三条 while。Inbox 是两条数组:next-turn 和 next-step。三个 API 是同一套 send() 的参数预设。
113 send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {114 // Waking input cannot join an aborted activity, so it starts the next turn.115 // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.116 const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted117 const resolvedTarget = wakingAfterAbort ? 'next-turn' : target118 this.inbox.splice(resolvedTarget, Infinity, 0, [message])119 if (wakeup) this.wakeDriver(wakingAfterAbort)120 }121122 followup(input: UserMessage): void {123 this.send(input, 'next-turn', true)124 }125126 steer(input: UserMessage): void {127 this.send(input, 'next-step', true)128 }129130 inject(input: UserMessage): void {131 this.send(input, 'next-step', false)132 }取消息用 claim。每个 step 开头先取走 next-step 的全部,轮次边界再多取一条 next-turn。被拒批次不回队。中断后还想叫醒司机,入队前会被改投 next-turn。
71 claim(target: InboxTarget, turn: number): UserMessage[] {72 const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)73 if (target === 'next-turn') {74 claimed.push(...this.mutate('next-turn', 0, 1, [], false))75 }76 for (const message of claimed) this.notifications.claimed(message, turn)77 return claimed78 }所以两边都叫三层,切分维度不同。DSH 按语义:Turn 是一轮完整工作,Step 是一次模型请求加工具,Inbox 是说话时机。Codex 按生命周期:任务壳问「这一趟还活着吗」,轮次问「这一轮还要再采吗」,采样问「这一条流结束了吗」。
DSH 因此能做到的:调用方在入队时选 followup、steer 还是 inject;两条队列可从 session 事件重放;工具后续靠往 next-step splice,不必另开一层采样循环。它没有单独做成一层的,是流事件的穷举合同:重试、取消、drain 工具都挤在 step() 里。
Codex 因此能做到的:调用方只丢 start_or_steer,不必在入队时选队列;流重试、取消、end_turn、mailbox 预抢各有出口;TurnStarted 只闪一次,回放按一个 turn_id 截。它没有的,是 Inbox 那种可重放的双队列。pending 和 mailbox 用阶段机共用一条「现在能不能取」的判断,NextTurn 下会故意撒谎。
Claude Code:单层 while (true) 加状态袋
还原源码里,主循环在 query.ts。可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋状态。
265 // Mutable cross-iteration state. The loop body destructures this at the top266 // of each iteration so reads stay bare-name (`messages`, `toolUseContext`).267 // Continue sites write `state = { ... }` instead of 9 separate assignments.268 let state: State = {269 messages: params.messages,270 toolUseContext: params.toolUseContext,271 maxOutputTokensOverride: params.maxOutputTokensOverride,272 autoCompactTracking: undefined,273 stopHookActive: undefined,274 maxOutputTokensRecoveryCount: 0,275 hasAttemptedReactiveCompact: false,276 turnCount: 1,277 pendingToolUseSummary: undefined,278 transition: undefined,279 }⋯ // ... 预算跟踪、QueryConfig、memory prefetch,与循环分层无关 ...306 // eslint-disable-next-line no-constant-condition307 while (true) {needsFollowUp 是循环体内的局部布尔。助手消息里只要出现 tool_use 块,就置真。
826 if (message.type === 'assistant') {827 assistantMessages.push(message)828829 const msgToolUseBlocks = message.message.content.filter(830 content => content.type === 'tool_use',831 ) as ToolUseBlock[]832 if (msgToolUseBlocks.length > 0) {833 toolUseBlocks.push(...msgToolUseBlocks)834 needsFollowUp = true835 }没有 follow-up 时,同一层接着做 413 恢复、stop hook、进入下一 turn。turnCount 加一,transition 写成 next_turn,state = next,回到 while (true) 顶部。
1062 if (!needsFollowUp) {1063 const lastMessage = assistantMessages.at(-1)1722 pendingToolUseSummary: nextPendingToolUseSummary,1723 maxOutputTokensOverride: undefined,1724 stopHookActive,1725 transition: { reason: 'next_turn' },1726 }1727 state = next1728 } // while (true)1729}Claude Code 把续跑、压缩、stop hook 收成同一袋状态。needsFollowUp 只由工具块点亮,用户插话走旁边的 messageQueueManager。代价是所有出口挤在一个 while 里。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。Codex 把这三件事分给三层,DSH 把插话分给 Inbox,两边都不必在同一个布尔上抢门。
三边各自能省掉对方的东西。DSH 用 Inbox 做成数据,省掉第三条生命周期循环,Turn 和 Step 两层 while 已经够用。Claude Code 用 state = next 省掉任务壳,turnCount++ 和 transition: next_turn 写在同一个 while 的尾部。Codex 用 start_or_steer 加阶段机,调用方不必在入队时选 followup 还是 steer,换来的是三个独立终止条件。学费分别是三个入队 API、一袋可变状态、三层循环加一套阶段机。
互动演示设计
演示要让读者明白:同一段对话在三层上同时走时间轴,控制权每次只在一层。
舞台比喻:三条平行泳道。任务壳是值班班长,轮次是当班司机,采样是检票口。右侧「合并成一层」开关打开后,三条泳道收成一条 while,插话和压缩卡在同一个出口。
形态是模拟器,叫三层循环剖面图。
舞台元素:
- 三条泳道,时间从左向右。色块表示控制权所在层,另外两层灰掉。
- 预设剧本:用户提问、模型调三个工具、模型回答、用户中途插话、stop hook 要求续跑、最终收工。
- 右侧逻辑轨迹面板,一行一句白话,标源码行号。
- 「合并成一层」开关。打开后,插话必须等当前
while迭代结束,压缩和 stop hook 跟采样重试抢同一个 continue。 - 单步、播放、重置。
分步:
- 用户提问落入空闲会话。任务壳发出
TurnStarted,调用第一次run_turn。字幕:班长接了第一班,turn_id 钉死。 - 控制权下到采样。三条工具的
OutputItemDone挂上 future,Completed后 drain。字幕:检票口只负责这一列车。 - 回到轮次。
model_needs_follow_up为真,继续采样,不发第二次TurnStarted。字幕:司机自己续开。 - 读者点「中途插话」。卡片落在 pending,当前采样不中断。字幕:插话先坐候车凳。
- 最终答案出来,stop hook 带 prompt 拦收工,轮次
continue。字幕:司机再开一站。 - hook 放行,任务壳见队列空,下班。打开「合并成一层」后重放 4、5 步。字幕:合成一层之后,候车凳和红灯抢同一扇门。
读者能操作:单步、播放、重置、「中途插话」、「stop hook 拦一次」、「合并成一层」。
逻辑轨迹面板伪代码:
对外入口 start_or_steer L338 codex_thread.rs
空闲则 spawn RegularTask L242 turn_input.rs
忙碌则 Steered 写入 pending L207 turn_input.rs
任务壳发 TurnStarted 后进入 loop L76 regular.rs
调用 run_turn L77 regular.rs
返回后问 has_pending_input L86 regular.rs
轮次循环开头按开关排 pending L305 turn.rs
采样 run_sampling_request L381 turn.rs
重算 needs_follow_up L423 turn.rs
无续跑则跑 stop hook L502 turn.rs
should_block 带 prompt 则 continue L525 turn.rs
采样事件循环收到 Completed 才离开 L2539 turn.rs
drain_in_flight 后才返回 L2749 turn.rs动画步进时高亮当前行。合并开关打开后,轨迹收成「while true:采样、看标志、continue」。
可迁移结论
值得抄的是三层各自的终止条件,写成三个函数。不要收成一个 while 加三个布尔。最小形态用 TypeScript 或 Python 就能落地。
type SamplingResult = { needsFollowUp: boolean; text?: string };
async function runSampling(prompt: unknown, signal: AbortSignal): Promise<SamplingResult> {
const stream = await openModelStream(prompt, signal);
let needsFollowUp = false;
let text: string | undefined;
for await (const event of stream) {
if (event.type === "tool_call") needsFollowUp = true;
if (event.type === "assistant") text = event.text;
if (event.type === "completed") break;
}
await drainTools();
return { needsFollowUp, text };
}
async function runTurn(input: string[], signal: AbortSignal): Promise<string | undefined> {
let pending = [...input];
while (!signal.aborted) {
const sampled = await runSampling(buildPrompt(pending), signal);
pending = takePending();
if (sampled.needsFollowUp || pending.length > 0) continue;
const stop = await runStopHooks(sampled.text);
if (stop.block && stop.prompt) {
pending = [stop.prompt];
continue;
}
if (stop.stop) return sampled.text;
return sampled.text;
}
}
async function runTask(input: string[], signal: AbortSignal): Promise<string | undefined> {
let next = input;
while (!signal.aborted) {
const last = await runTurn(next, signal);
if (!hasPending()) return last;
next = [];
}
}三个函数的返回值就是终止条件。插话只进 takePending()。stop hook 只进轮次循环。任务壳只在轮次返回后再看队列。对外入口也可以一起抄:先尝试插入当前轮,插不进去再开工。
不必抄 2791 行的 turn.rs、17 路 ResponseEvent 和多 agent 信箱预抢。内部 agent 先抄三个函数加一个 pending 数组。阶段机等你真有「答案已上屏、晚到旁路不许延长这一轮」再加。
和平台无关。三层循环不读 cfg!(target_os)。平台差异在工具执行和沙箱。
思考题
-
在
codex-rs/core/src/tasks/regular.rs第 86 行,把has_pending_input的判断改成恒为假,让任务壳在第一次run_turn返回后立刻结束。用本机 CLI 开一轮对话,等模型开始调工具时再发一句无害的后续(例如「顺便列出当前目录」)。观察:这句后续是消失、等到下一轮用户主动发送才出现,还是由maybe_start_turn_for_pending_work新开一个turn_id。改完后请还原文件。 -
打开
codex-rs/core/src/session/turn.rs第 500 行附近。在run_turn_stop_hooks返回之后打断点,或临时eprintln!打出should_block、should_stop、continuation_fragments.len()。写一个会在 Stop hook 里返回 block 且带 prompt 的本地 hook(按仓库 hooks 文档的最小配置)。跑一轮只回一句话、不调工具的对话。确认控制权留在run_turn循环,RegularTask没有返回,界面上的turn_id没有变。 -
用上一节的 TypeScript 草案写一个假模型:第一次采样返回工具调用,第二次返回纯文本。在第二次采样期间往 pending 数组里塞一句用户话。确认这句出现在第三次采样的 prompt 里,且
runTask没有因此新建一层外部循环。这道题不需要 Codex 仓库。