一台 Agent 运行时的安全观 2 / 3

第 2 章

三层 Turn Loop,任务壳、轮次、采样

源码核对基于 openai/codex commit `4f39251a01`(2026-08-22),tag `course-anchor-20260822`对照 DSH 课页:`dsh-3.html`本章字数:约 13972 字(不含代码与图)

场景还原

你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」。

忙的时候其实叠了四件事:模型调了三次工具,你中途补了一句「测试用 pytest」,它写完助手消息后 Stop hook 说还没跑 linter,于是又采了一次样。

这四件事如果塞进同一个 while,就只能靠几个布尔抢出口。谁先检查、谁能打断谁,会变成口头约定。

Codex 拆成三层。任务壳 RegularTask::run 决定这一趟还要不要再开一轮。run_turn 决定工具续跑、插话和 hook 要不要继续。采样层只把一次模型流收到 Completed。少一层,就少一个干净插口:插话、续跑和流重试会搅在一起。

逐行精读

这张图回答:一句话从对外入口走到模型流,经过哪三层循环。

flowchart TD A[CodexThread.start_or_steer_turn] --> B{idle or active} B -->|idle| C[spawn RegularTask] B -->|active| D[steer into pending_input] C --> E[RegularTask.run loop] E --> F[run_turn loop] F --> G[run_sampling_request retry loop] G --> H[try_run_sampling_request event loop] H --> I{ResponseEvent.Completed} I --> J[drain_in_flight] J --> K{model_needs_follow_up or pending} K -->|yes| F K -->|no| L{stop hook block} L -->|continue| F L -->|stop| M{RegularTask has_pending_input} M -->|yes| F M -->|no| N[task returns]

对外入口:先 steer,steer 不成再开工

CodexThread::start_or_steer_turn 自己不看会话空不空闲。注释写明:返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。调用方不必先问「现在有没有活动轮」,把请求扔进来即可。

它甚至不自己投递。真正出门的是下面这个私有函数:Steer 模式跳过开工容量检查,其余模式先问 ensure_execution_capacity_for_turn_start,然后把请求和模式一起交给 io.submit_turn_input。会话锁、活动轮、任务表都不在这一层打开。

codex-rs/core/src/codex_thread.rs333:344
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    }
codex-rs/core/src/codex_thread.rs466:479
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 是「空闲就开、忙着就插」。另外两个把决定权留给调用方。

codex-rs/protocol/src/turn_input.rs127:136
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 上用。

codex-rs/core/src/session/turn_input.rs1:9
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 的失败原因、ActiveTurnspawn_taskCodexThread 若自己判断空闲,判断和真正开工之间会裂开一条缝:中间可能被别的任务抢先。handle 按模式分到 start_or_steerstart_if_idlesteer 三个函数,空闲判定只发生一次,和开工在同一把会话锁的视野里。

真正的分流在 start_or_steer。它先调用 session.steer_input。成功就返回 Steered。只有 NoActiveTurn 才拼好 task_input,再 spawn_task(..., RegularTask::new())

codex-rs/core/src/session/turn_input.rs167:250
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。输出是 TurnInputSubmissionStartedSteeredNotSubmitted。协议注释写明,前两个都不等于采样已经开始。对外入口只判断:这句话是插进正在跑的任务,还是新开一个 RegularTask。三层循环从任务壳才开始转。

任务壳:一种任务,一个小 trait

会话里能跑的工作不只有普通对话。压缩和审查是平级的任务类型。

codex-rs/core/src/state/turn.rs67:72
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]68pub(crate) enum TaskKind {69    Regular,70    Review,71    Compact,72}

三种任务共用 SessionTask。模块注释写明这个 trait 故意很小:报自己的 kind,在 run 里干活,取消时可以覆写 abort

codex-rs/core/src/tasks/mod.rs179:211
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;

CompactTaskReviewTask 各走自己的 run,不进 run_turn 那条采样环。普通对话才是 RegularTask

接下来这段要证明什么:任务壳只做三件事。发一次 TurnStarted,吃掉启动预热,然后只要队列里还有待处理输入,就再调一次 run_turn

codex-rs/core/src/tasks/regular.rs30:92
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_turnnext_input 为空,新消息从 input_queue 取。TurnStarted 只发一次,turn_id 取自 ctx.sub_id,多次 run_turn 共用它。

事件本身很小。前端和回放都靠这一条边界把后续事件收成一簇。

codex-rs/protocol/src/protocol.rs2035:2049
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 桶。

codex-rs/core/src/thread_rollout_truncation.rs158:163
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_idtasks/mod.rs 第 463 行)。终止条件:has_pending_input 为假就返回。那一次会换一个新的 sub_id,界面上才是下一轮。

轮次层:一次回复,两种下场

run_turn 的文件头注释把合同写成两段。模型每次采样原则上回两类东西:函数调用,或助手消息。一次采样里可以带回多条,实务上通常一条。有函数调用就执行,结果送进下一次采样。只有助手消息,这一轮可以收工。

codex-rs/core/src/session/turn.rs139:159
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 默认在下次建模前排进历史,开轮和自动压缩后两处要推迟。

codex-rs/core/src/session/turn.rs287:323
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_inputsturn.rs 第 615 行),hook 要停且没有被接受的用户消息,就 break。然后组 prompt,调用 run_sampling_request。回来后轮次层把两件事合成一个布尔。

codex-rs/core/src/session/turn.rs394:420
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。信箱若已切到 NextTurnhas_pending_input 故意返回假,邮件留给下一趟。判定先看 turn 上的 pending 和投递阶段,阶段不是 CurrentTurn 就直接假。

codex-rs/core/src/session/input_queue.rs342:363
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

codex-rs/core/src/state/turn.rs37:56
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_turninput_queue.rs 第 206 行)。pending 里已有用户 steer 或带 trigger_turn 的邮件,它不切。调用点在 record_completed_response_item_with_finalized_factsstream_events_utils.rs 第 99 行):条目是用户可见的收尾,就推迟信箱。

轮次层在 !needs_follow_up 时才跑 stop hook。hook 要续跑,这一层自己 continue,任务壳和采样层都还没退。

codex-rs/core/src/session/turn.rs500:550
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_turnshould_stop 返回后,任务直接结束。队列里后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务。TurnStarted 会再闪一次,回放里变成两个 turn 桶。

这张状态图回答:run_turn 在什么条件下继续,什么条件下把控制权交回任务壳。

stateDiagram-v2 [*] --> DrainPending: loop start DrainPending --> Sample: hooks accepted input DrainPending --> TurnDone: hook blocked all input Sample --> FollowUp: model needs tools Sample --> FollowUp: pending input exists Sample --> Compact: token limit and still follow up Compact --> DrainPending: compact done Sample --> StopHook: no follow up StopHook --> DrainPending: should_block with prompt StopHook --> TurnDone: should_stop or clean finish TurnDone --> [*]

采样层:流收到 Completed,再 drain 工具

采样层自己还有两圈。外圈 run_sampling_request 处理可重试错误。内圈 try_run_sampling_request 消费一条 SSE 流。

返回值很小:要不要续跑,以及最后一条助手消息。

codex-rs/core/src/session/turn.rs1573:1577
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

codex-rs/core/src/session/turn.rs1383:1440
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_promptparallel_tool_calls 写成 true。模型被允许一次点多个工具。执行侧是否真并行,是工具运行时的事,采样层不管。

try_run_sampling_requeststream(),再 or_cancel。取消在取下一条事件时变成 CodexErr::TurnAborted。流在 Completed 之前关掉,报 stream closed before response.completed

codex-rs/core/src/session/turn.rs2266:2286
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

codex-rs/codex-api/src/common.rs76:123
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 置真。然后带着当前累加值离开事件循环。

codex-rs/core/src/session/turn.rs2539:2584
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

codex-rs/core/src/stream_events_utils.rs196:201
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}
codex-rs/core/src/stream_events_utils.rs316:327
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 仍会发出。

codex-rs/core/src/session/turn.rs2130:2154
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}
codex-rs/core/src/session/turn.rs2749:2762
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。

codex-rs/core/src/session/turn.rs2361:2366
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,

这张时序图回答:用户一句话、一次工具、一次插话,控制权在哪一层。

sequenceDiagram participant API as CodexThread participant Shell as RegularTask participant Turn as run_turn participant Sample as sampling participant Model as model stream API->>Shell: start_or_steer spawn RegularTask Shell->>Turn: run_turn initial input Turn->>Sample: run_sampling_request Sample->>Model: stream prompt Model-->>Sample: OutputItemDone tool call Sample->>Sample: push in_flight future Model-->>Sample: Completed Sample->>Sample: drain_in_flight Sample-->>Turn: needs_follow_up true Turn->>Sample: next sampling with tool output API->>Turn: steer mid turn into pending Sample-->>Turn: Completed no follow up Turn->>Turn: stop hooks should_stop Turn-->>Shell: last agent message Shell-->>API: task done

三层各自的出口如下。

  1. 任务壳:run_turn 返回后 has_pending_input 为假,结束;为真,空输入再进 run_turn
  2. 轮次层:needs_follow_up 为真就继续采样;为假再问 stop hook。should_block 带 prompt 就续跑,should_stop 或干净收工才 break
  3. 采样层:重试留在本层;取消和流中断走 ErrCompleted 后看 end_turn;mailbox 预抢提前离开。drain 完工具才把结果交回 run_turn

设计决策分析

AGENTS.md 不管循环怎么切,管的是模块体积和 match 纪律。turn.rswc -l 计得 2791 行,已经超过「大约 800 行就该拆」的红线。评审条款原文如下。

AGENTS.md49:57
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_requestResponseEvent 的 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_inputNextTurn 下会撒谎。

边界条件剖析

1. run_turn 中途用户又发一条消息,哪一层接住

分四个时刻。

时刻 A:空闲。steer_input 返回 NoActiveTurn,消息作为 run_turninput 进去,循环开头不排队列。

时刻 B:任务已在跑。start_or_steerSteered,消息进 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。这是例外,不是主路径。

codex-rs/core/src/session/turn.rs2397:2404
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。

codex-rs/hooks/src/events/stop.rs66:74
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。

codex-rs/hooks/src/events/stop.rs367:373
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 的三个出口:

  1. should_block 且拼出了 continuation prompt:写入历史,打开 CurrentTurncontinue。采样层早已返回。任务壳还在等这次 run_turn。控制权留在轮次层。
  2. should_block 但没有 prompt:警告 ignoring the block,再看 should_stop
  3. should_stopbreak 离开 run_turnRegularTask 随后检查 has_pending_input。队列空,任务结束;队列不空,空输入再进 run_turn

所以 block 是轮次层内部续跑。stop 是轮次层把控制权交回任务壳。采样层两种情况都不重新进入。

3. 采样收到 Completed 之后,工具 future 还没跑完怎么办

事件循环在 Completedbreak,函数还没返回。drain_in_flight 按顺序等完工具。token 事件和取消检查都在 drain 之后:流已结束,工具结果仍要落历史,然后才回报 TurnAborted。模块注释把顺序写成合同:先 persist 再 drain。工具失败走 error_or_panic,不把整次采样打成 Err

4. RegularTaskrun_turn 都看 pending,会不会转两圈

会,职责不同。同一句用户话最多只该被一处取走。

run_turn 在采样后看 pending,是为了同一轮里把工具结果或插话再送给模型。只要 has_pending_input 为真,它自己 continue,任务壳那一圈还没轮到。RegularTaskrun_turn 返回后再看,是为了界面已收工、队列里又来了必须处理的输入。第二次调用时 next_input 为空,can_drain_pending_input 初值为真,循环开头就会把队列排进历史。

NextTurnhas_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.tspackages/core/agent/src/inbox.ts。文件头把这个 driver 写成「queued turns and step-boundary input」,循环从 session log 派生每一次请求。

DSH 的外圈是 kickwhile (await this.turn()) {}turn() 返回真,表示 inbox 里还有活,同一条 driver 继续开下一轮。返回假,driver 退回 idle。这和 RegularTaskloop { run_turn; if !has_pending_input { return } } 是同一类骨架。

packages/core/agent-loop/src/agent.ts210:212
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

packages/core/agent-loop/src/agent.ts246:330
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() 再开下一步。

packages/core/agent-loop/src/agent.ts332:401
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-turnnext-step。三个 API 是同一套 send() 的参数预设。

packages/core/agent-loop/src/agent.ts113:132
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。

packages/core/agent/src/inbox.ts71:78
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 处写回整袋状态。

restored-src/src/query.ts265:307
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 块,就置真。

restored-src/src/query.ts826:835
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_turnstate = next,回到 while (true) 顶部。

restored-src/src/query.ts1062:1063
1062    if (!needsFollowUp) {1063      const lastMessage = assistantMessages.at(-1)
restored-src/src/query.ts1722:1729
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,要同时核对 stopHookActiveturnCounttransition。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,插话和压缩卡在同一个出口。

形态是模拟器,叫三层循环剖面图。

舞台元素:

  1. 三条泳道,时间从左向右。色块表示控制权所在层,另外两层灰掉。
  2. 预设剧本:用户提问、模型调三个工具、模型回答、用户中途插话、stop hook 要求续跑、最终收工。
  3. 右侧逻辑轨迹面板,一行一句白话,标源码行号。
  4. 「合并成一层」开关。打开后,插话必须等当前 while 迭代结束,压缩和 stop hook 跟采样重试抢同一个 continue。
  5. 单步、播放、重置。

分步:

  1. 用户提问落入空闲会话。任务壳发出 TurnStarted,调用第一次 run_turn。字幕:班长接了第一班,turn_id 钉死。
  2. 控制权下到采样。三条工具的 OutputItemDone 挂上 future,Completed 后 drain。字幕:检票口只负责这一列车。
  3. 回到轮次。model_needs_follow_up 为真,继续采样,不发第二次 TurnStarted。字幕:司机自己续开。
  4. 读者点「中途插话」。卡片落在 pending,当前采样不中断。字幕:插话先坐候车凳。
  5. 最终答案出来,stop hook 带 prompt 拦收工,轮次 continue。字幕:司机再开一站。
  6. hook 放行,任务壳见队列空,下班。打开「合并成一层」后重放 4、5 步。字幕:合成一层之后,候车凳和红灯抢同一扇门。

读者能操作:单步、播放、重置、「中途插话」、「stop hook 拦一次」、「合并成一层」。

逻辑轨迹面板伪代码:

text
对外入口 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 就能落地。

ts
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)。平台差异在工具执行和沙箱。

思考题

  1. codex-rs/core/src/tasks/regular.rs 第 86 行,把 has_pending_input 的判断改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。用本机 CLI 开一轮对话,等模型开始调工具时再发一句无害的后续(例如「顺便列出当前目录」)。观察:这句后续是消失、等到下一轮用户主动发送才出现,还是由 maybe_start_turn_for_pending_work 新开一个 turn_id。改完后请还原文件。

  2. 打开 codex-rs/core/src/session/turn.rs 第 500 行附近。在 run_turn_stop_hooks 返回之后打断点,或临时 eprintln! 打出 should_blockshould_stopcontinuation_fragments.len()。写一个会在 Stop hook 里返回 block 且带 prompt 的本地 hook(按仓库 hooks 文档的最小配置)。跑一轮只回一句话、不调工具的对话。确认控制权留在 run_turn 循环,RegularTask 没有返回,界面上的 turn_id 没有变。

  3. 用上一节的 TypeScript 草案写一个假模型:第一次采样返回工具调用,第二次返回纯文本。在第二次采样期间往 pending 数组里塞一句用户话。确认这句出现在第三次采样的 prompt 里,且 runTask 没有因此新建一层外部循环。这道题不需要 Codex 仓库。