Skip to content

StepContext模型请求

追踪一次模型采样如何冻结环境、AGENTS、能力根、MCP 与工具路由,并解释刷新、重试和取消的生效边界。

基于rust-v0.150.0
CodexRustRuntimeStepContext

StepContext模型请求 ​

一次 Codex Turn 不一定只请求模型一次。模型可能先返回工具调用,Core 执行工具并把结果加入 history,然后 再次 sampling;用户也可能在运行中注入输入。StepContext 就是每次 sampling 的请求级快照,它保证模型 看到的工具规格、实际执行工具的 router、MCP client 和环境状态来自同一次捕获。

本文默认读者已经理解 Session输入队列 的 pending/steer 语义,并读过 TurnContext字段。前者解释为什么下一轮可能带入新输入,后者 解释哪些配置在整个 Turn 内保持稳定。若还不熟悉启动期 MCP 和 skills 预热,可先看 Session启动预热。

本文不展开模型响应中每一种 item 的处理,也不把 StepContext 当成可持久化状态。目标是让读者能沿 run_turn → capture_step_context → build_prompt → stream → tool dispatch 走通一轮 sampling,并判断某次 刷新会影响当前请求、下一次 sampling,还是只能等下一 Turn。

1. Step边界竞态 ​

如果每个消费者都临时读取全局状态,模型可能收到旧 MCP 工具清单,但工具调用到达时却被新 router 执行; 也可能用环境 A 的权限生成工具规格,再用环境 B 的 cwd 执行。StepContext 把一次 sampling 的声明面与执行面 绑定在一起。

这张图中的两种“再试一次”不能混为一谈:

  • run_sampling_request 内部的 retryable stream error 仍使用同一个 router 和 StepContext;
  • 工具调用、pending input 或 stop hook 要求继续时,外层 run_turn 循环进入下一轮,再捕获 StepContext。

前者避免一次逻辑请求在重试中改变工具表面;后者让长 Turn 能在明确边界接收运行时更新。

2. Step创建路径 ​

首次输入先解析显式 plugin、skill 和 mcp:// mention,得到本轮必须等待的 MCP server;随后创建 first_step_context。这份 step 既用于建立初始 WorldState,也被放进 next_step_context,成为第一次模型 sampling 的精确快照,避免初始化和请求之间重复捕获。

源码位置:codex-rs/core/src/session/turn.rs,符号 run_turn 的首次 Step 捕获。

rust
let user_input = turn_user_input(&input);
let (required_servers, mentioned_plugins) =
    match required_mcp_servers_for_input(&sess, turn_context.as_ref(), &user_input)
        // Turn取消时停止等待mention解析/MCP刷新,不进入半初始化的Step。
        .or_cancel(&cancellation_token)
        .await
    {
        Ok(requirements) => requirements,
        Err(err) => {
            run_hooks_and_record_inputs(&sess, &turn_context, &input).await;
            return Err(err.into());
        }
    };

// run_turn owns the step used to seed context and make the first sampling request.
let first_step_context = match sess
    .capture_step_context_with_required_mcp_servers(
        Arc::clone(&turn_context),
        &cancellation_token,
        &required_servers,
    )
    .await
{
    Ok(step_context) => step_context,
    Err(err) if matches!(err.details(), CodexErrorDetails::TurnAborted) => {
        // 首次捕获取消仍记录输入与hooks,使用户请求不会从历史中静默消失。
        run_hooks_and_record_inputs(&sess, &turn_context, &input).await;
        return Err(err);
    }
    Err(err) => return Err(err),
};

// 初始上下文与display roots必须读取同一份Step,不能各自重新查询环境。
let (world_state, display_roots) = tokio::join!(
    sess.record_context_updates_and_set_reference_context_item(
        first_step_context.as_ref()
    ),
    turn_diff_display_roots(first_step_context.as_ref()),
);
let mut world_state = world_state?;

之后的循环用 next_step_context.take() 消费首份快照。若没有 pending input,普通捕获不要求额外 server;若 有新输入,则重新解析 mention,再用 required server 列表捕获。这意味着同一 Turn 的第二次 sampling 可能比 第一次等待更多 MCP server,但已经发出的第一次请求不会被回溯修改。

源码位置:codex-rs/core/src/session/turn.rs,符号 run_turn 的 sampling 循环。

rust
let mut next_step_context = Some(first_step_context);
loop {
    let pending_input = if can_drain_pending_input {
        sess.input_queue
            .get_pending_input(&sess.active_turn)
            .await
            .0
    } else {
        Vec::new()
    };

    // ...hooks、window id与reminder

    // Capture once so context, advertised tools, and tool calls share one request view.
    let step_context = match next_step_context.take() {
        // 第一轮复用初始化Step,WorldState和Prompt观察完全相同的动态状态。
        Some(step_context) => step_context,
        None if pending_input.is_empty() => {
            sess.capture_step_context(
                Arc::clone(&turn_context),
                &cancellation_token,
            )
            .await?
        }
        None => {
            let pending_user_input = turn_user_input(&pending_input);
            let (required_servers, _) = required_mcp_servers_for_input(
                &sess,
                turn_context.as_ref(),
                &pending_user_input,
            )
            .or_cancel(&cancellation_token)
            .await?;
            // 新mention只影响这次新捕获;上一Step的MCP/router保持不变。
            sess.capture_step_context_with_required_mcp_servers(
                Arc::clone(&turn_context),
                &cancellation_token,
                &required_servers,
            )
            .await?
        }
    };

    // ...由step_context更新WorldState、构造Prompt并发起sampling
}

3. 请求视图字段 ​

StepContext 当前不止七个字段;除了环境、能力根、MCP、ToolRouter 和 AGENTS snapshot,还直接保存本次 sampling 的 model、reasoning、summary、service tier、approval policy、reviewer 和 model-attributed telemetry。 这些字段不是彼此独立的缓存。环境 readiness 决定能力根和 sandbox;能力发现参与 MCP/runtime 投影;MCP 工具目录又参与 ToolRouter;WorldState 同时消费 AGENTS、环境、MCP 与 router。

源码位置:codex-rs/core/src/session/step_context.rs,符号 StepContext。

rust
/// Request-scoped state that may change between model sampling requests.
pub(crate) struct StepContext {
    // Turn级模型、配置与身份不复制;所有Step共享同一Arc<TurnContext>。
    pub(crate) turn: Arc<TurnContext>,
    pub(crate) model_info: Arc<ModelInfo>,
    pub(crate) reasoning_effort: Option<ReasoningEffort>,
    pub(crate) reasoning_summary: ReasoningSummary,
    pub(crate) service_tier: Option<String>,
    pub(crate) approval_policy: AskForApproval,
    pub(crate) approvals_reviewer: ApprovalsReviewer,
    pub(crate) session_telemetry: SessionTelemetry,
    // selections来自Turn,ready/loading结果在本次捕获时刷新。
    pub(crate) environments: TurnEnvironmentSnapshot,
    /// Capability roots bound to ready environments in this exact step.
    pub(crate) selected_capability_roots: Vec<ResolvedSelectedCapabilityRoot>,
    /// Executor-materialized capability files shared by MCP and skills in this exact step.
    pub(crate) executor_capability_discovery:
        Option<Arc<ExecutorCapabilityDiscoverySnapshot>>,
    /// The exact MCP connections, configuration, and catalog captured for this step.
    pub(crate) mcp: Arc<McpBinding>,
    /// The finalized tool plan advertised and executed for this exact sampling request.
    pub(crate) tool_router: Arc<ToolRouter>,
    /// The canonical AGENTS.md value observed with this environment snapshot.
    pub(crate) loaded_agents_md: Option<Arc<LoadedAgentsMd>>,
}
字段来源本 Step 中的作用下一 Step 是否可能变化
turn当前 Arc<TurnContext>Turn-wide 身份与兼容配置指针不变
model_info / reasoning_*TurnContext 与模型解析本次 sampling 的模型和推理设置模型切换时可变
service_tier / approval_policy / approvals_reviewerper-step effective config本次请求的服务层与审批语义可随模型能力变化
session_telemetrymodel-attributed telemetry将本次请求归因到实际模型可随 step 更新
environmentsTurn selections 的 readiness 刷新cwd、workspace roots、权限、执行后端可以
selected_capability_rootsthread roots + ready environment rootsMCP、skills、extension 输入可以
executor_capability_discoveryroots + sandbox contexts受限文件发现、MCP/skills共享物化结果可以
mcp当前 published MCP runtime bindingclient handles、工具目录、connector snapshot可以
tool_routercore tools + MCP + apps + dynamic tools同时生成模型 specs 并执行调用可以
loaded_agents_mdAgentsMdManager cacheWorldState 的 AGENTS 指令环境 selections 变化时重载

Step 捕获期间还创建一个局部 ExtensionData,放入 capability roots、discovery 和 sandbox contexts,供 built_tools 生成 handler runtime;它不是 StepContext 第八个字段。需要跨工具调用保留的结果已经进入 ToolRouter 或上表中的快照。

4. 捕获顺序链 ​

捕获不是七个独立 future 的无序 join。环境必须先刷新,AGENTS 和 capability roots 才有输入;discovery 必须先生成,MCP/runtime 才能共享相同能力文件;MCP binding 与推荐候选可并行;最后 ToolRouter 依赖所有 前置结果。

源码位置:codex-rs/core/src/session/mod.rs,符号 Session::capture_step_context_with_required_mcp_servers。

rust
pub(crate) async fn capture_step_context_with_required_mcp_servers(
    self: &Arc<Self>,
    turn_context: Arc<TurnContext>,
    cancellation_token: &CancellationToken,
    required_servers: &[String],
) -> CodexResult<Arc<StepContext>> {
    // Keep selections fixed for the turn while allowing their startup work to finish.
    // 只刷新ready/loading,不重新选择环境;Turn级selection仍然稳定。
    let environments = turn_context.environments.refresh_readiness();
    self.services
        .agents_md_manager
        .refresh(&turn_context.config, &environments)
        .await;
    let loaded_agents_md = self.services.agents_md_manager.get_loaded().await;

    let selected_capability_roots = self
        .resolve_selected_capability_roots_for_step(&environments)
        .await;
    let ready_selected_capability_roots =
        Self::ready_selected_capability_roots(&selected_capability_roots);
    let executor_capability_discovery = self
        .executor_capability_discovery_for_step(
            &turn_context.config,
            &ready_selected_capability_roots,
            &environments,
            turn_context.windows_sandbox_level,
        )
        .await;

    let extension_data =
        codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
    extension_data.insert(selected_capability_roots.clone());
    if let Some(discovery) = &executor_capability_discovery {
        extension_data.insert(discovery.as_ref().clone());
        if !discovery.sandbox_contexts().is_empty() {
            extension_data.insert(discovery.sandbox_contexts().clone());
        }
    } else if !turn_context
        .config
        .permissions
        .file_system_sandbox_policy()
        .has_full_disk_read_access()
    {
        // 没有discovery时仍为受限环境建立sandbox contexts,工具不能因缓存缺失变成full access。
        let sandbox_contexts = environments
            .turn_environments()
            .map(|environment| {
                (
                    environment.environment_id.clone(),
                    turn_context.file_system_sandbox_context(
                        /*additional_permissions*/ None,
                        environment,
                    ),
                )
            })
            .collect::<HashMap<_, _>>();
        extension_data.insert(sandbox_contexts);
    }

    let (mcp, prepared_recommendations) = async {
        // 两项只依赖前置快照,可以并行;ToolRouter必须等二者都完成。
        tokio::join!(
            self.mcp_runtime_for_step(
                turn_context.as_ref(),
                &selected_capability_roots,
                required_servers,
            ),
            turn::prepare_tool_recommendations(self.as_ref(), turn_context.as_ref()),
        )
    }
    .or_cancel(cancellation_token)
    .await?;

    let tool_router = turn::built_tools(
        self.as_ref(),
        turn_context.as_ref(),
        &environments,
        mcp.as_ref(),
        &extension_data,
        prepared_recommendations,
    )
    // 取消或ToolCollision时不会发布缺字段的StepContext。
    .or_cancel(cancellation_token)
    .await??;

    Ok(Arc::new(StepContext {
        turn: turn_context,
        environments,
        selected_capability_roots,
        executor_capability_discovery,
        mcp,
        tool_router,
        loaded_agents_md,
    }))
}

Arc<StepContext> 只在最后一次性创建,是这条依赖链的提交点。之前任何取消或 router 构建错误都只留下服务 自己的缓存/刷新状态,不会产生一个可被模型或工具消费者拿到的部分 Step。

5. 环境与 AGENTS ​

5.1 AGENTS ​

每次捕获都会调用 AgentsMdManager::refresh,但它并不机械重读文件。cache key 是 environments.to_selections();选择未变就复用 LoadedAgentsMd,选择改变才重新执行 load_project_instructions。

源码位置:codex-rs/core/src/agents_md_manager.rs,符号 AgentsMdManager::refresh。

rust
#[tracing::instrument(name = "agents_md.refresh", skip_all)]
pub(crate) async fn refresh(
    &self,
    config: &Config,
    environments: &TurnEnvironmentSnapshot,
) {
    let selections = environments.to_selections();
    if self.cache.lock().await.selections.as_ref() == Some(&selections) {
        // 同一选择直接复用canonical snapshot,避免每次sampling重复文件发现。
        return;
    }

    let loaded =
        load_project_instructions(config, self.user_instructions.clone(), environments)
            .await
            .map(Arc::new);
    let mut cache = self.cache.lock().await;
    // selections与loaded在同一锁内一起替换,读者不会观察到key/value错配。
    cache.selections = Some(selections);
    cache.loaded = loaded;
}

pub(crate) async fn get_loaded(&self) -> Option<Arc<LoadedAgentsMd>> {
    self.cache.lock().await.loaded.clone()
}

因此“每次 Step 都有 AGENTS 快照”不等于“每次 Step 都重新扫描磁盘”。本文所说的刷新,是确保 snapshot 与 当前环境选择相符;文件监听或其他失效机制不应被想当然地归入这个函数。

5.2 受限环境roots ​

capability roots 可能来自 thread,也可能来自已经 ready 的 environment。同 ID 指向不同 location 时只保留 先出现的 root 并 warning;在受限文件系统下,如果环境 root 找不到对应 sandbox context,则跳过该 root, 而不是用 host 权限继续发现。

源码位置:codex-rs/core/src/session/mcp.rs,符号 Session::executor_capability_discovery_for_step。

rust
let restricted_file_system = environments.primary().map_or_else(
    || {
        !config
            .permissions
            .file_system_sandbox_policy()
            .has_full_disk_read_access()
    },
    |_| {
        environments.turn_environments().any(|environment| {
            !environment
                .permission_profile()
                .file_system_sandbox_policy()
                .has_full_disk_read_access()
        })
    },
);
if !restricted_file_system
    && !config.features.enabled(Feature::ExecutorCapabilityDiscovery)
{
    // full access且feature关闭时无需创建discovery snapshot。
    return None;
}

// ...受限环境为每个environment创建FileSystemSandboxContext

let selected_capability_roots = ready_selected_capability_roots
    .iter()
    .filter(|selected_root| {
        if !restricted_file_system {
            return true;
        }
        let CapabilityRootLocation::Environment { environment_id, .. } =
            &selected_root.location;
        if sandbox_contexts.contains_key(environment_id) {
            return true;
        }
        // 缺sandbox context时丢弃root并记录warning,不能降级成不受限读取。
        warn!(
            selected_root = selected_root.id,
            environment_id,
            "skipping capability root without a filesystem sandbox context"
        );
        false
    })
    .cloned()
    .collect::<Vec<_>>();
Some(Arc::new(
    cache.snapshot(&selected_capability_roots, &sandbox_contexts).await,
))

6. MCP Binding ​

MCP runtime 是 Session 服务,但 McpBinding 是一次目录与 client handles 的捕获。Step 捕获前若 capability roots 与 runtime 当前值不同,会先把 runtime 标脏并刷新;随后 binding 对显式 required server 等待启动, 对普通 optional server 则允许在共享 grace 后暂时省略。

源码位置:codex-rs/core/src/session/mcp.rs,符号 Session::mcp_runtime_for_step。

rust
pub(crate) async fn mcp_runtime_for_step(
    self: &Arc<Self>,
    turn_context: &TurnContext,
    selected_capability_roots: &[ResolvedSelectedCapabilityRoot],
    required_servers: &[String],
) -> Arc<codex_mcp::McpBinding> {
    let ready_selected_capability_roots =
        Self::ready_selected_capability_roots(selected_capability_roots);
    if self
        .services
        .mcp_runtime
        .current_ready_selected_capability_roots()
        != ready_selected_capability_roots
    {
        // root集合改变会使runtime投影失效,必须在binding捕获前刷新。
        self.mark_mcp_runtime_dirty();
    }
    self.refresh_mcp_if_dirty().await;
    if let Some(binding) = self
        .services
        .mcp_runtime
        .current_binding_with_required_servers(required_servers)
        .await
    {
        return binding;
    }
    let config = Arc::new(self.runtime_mcp_config(&turn_context.config).await);
    // runtime尚未发布时返回带有效config的空binding;消费者仍得到完整对象而不是Option。
    Arc::new(codex_mcp::McpBinding::empty(config))
}

源码位置:codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs,符号 McpConnectionSet::capture_binding_with_metadata。

rust
if !view
    .connection
    .client
    .startup_complete
    .load(Ordering::Acquire)
{
    let required = self.required_servers.binary_search(server_name).is_ok();
    let has_cached_tools = view.connection.client.has_cached_tools();
    let must_wait_for_startup = required
        || self.is_selected_plugin_mcp_server(server_name)
        || required_servers
            .iter()
            .any(|required| required == server_name)
        || (server_name == CODEX_APPS_MCP_SERVER_NAME && !has_cached_tools);

    if !must_wait_for_startup && has_cached_tools {
        // optional server尚在启动但有cache时,本次binding直接使用稳定目录。
        return;
    }
    if !must_wait_for_startup {
        let startup_deadline = view
            .connection
            .client
            .tool_catalog_cache_context
            .as_ref()
            .map(|cache| cache.optional_startup_deadline(optional_startup_deadline))
            .unwrap_or(optional_startup_deadline);
        if tokio::time::timeout_at(startup_deadline, view.connection.client.client())
            .await
            .is_err()
        {
            trace!(server_name = %server_name,
                "omitting pending optional MCP server");
        }
        return;
    }
    // 显式mention或required server没有optional超时旁路;等待可由Turn token取消。
    let _ = view.connection.client.client().await;
}

“空 binding”与“某个 optional server 被省略”也不同:前者是 runtime 尚无 published config 的整体降级; 后者仍是有效 published runtime,只是本次工具目录未包含尚未 ready 的 optional server。

7. Prompt与工具 ​

一个 Step 内有两个关键消费者:build_prompt 调用 router.model_visible_specs() 告诉模型有哪些工具; ToolCallRuntime 保留同一个 Arc<StepContext>,等流式响应中的工具调用到达后再从相同 router 查 runtime。

关系图强调的不是数据库持久化,而是同一性约束:Prompt 中的 ToolSpec 和执行期 ToolRuntime 都来自同一个 ToolRouter。StepContext 本身不会写入 rollout;可持久化的是工具输入/输出与 Turn/WorldState 等协议 item。

源码位置:codex-rs/core/src/session/turn.rs,符号 build_prompt 与 run_sampling_request。

rust
pub(crate) fn build_prompt(
    input: Vec<ResponseItem>,
    router: &ToolRouter,
    turn_context: &TurnContext,
    base_instructions: BaseInstructions,
) -> Prompt {
    Prompt {
        input,
        // 模型只看到router判定为visible的规格;registered不等于visible。
        tools: router.model_visible_specs(),
        parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
        base_instructions,
        output_schema: turn_context.final_output_json_schema.clone(),
        output_schema_strict: !crate::guardian::is_guardian_reviewer_source(
            &turn_context.session_source,
        ),
    }
}

async fn run_sampling_request(
    sess: Arc<Session>,
    step_context: Arc<StepContext>,
    turn_store: Arc<codex_extension_api::ExtensionData>,
    turn_diff_tracker: SharedTurnDiffTracker,
    client_session: &mut ModelClientSession,
    responses_metadata: &CodexResponsesMetadata,
    input: Vec<ResponseItem>,
    cancellation_token: CancellationToken,
) -> CodexResult<(SamplingRequestResult, Vec<ResponseItem>)> {
    let turn_context = Arc::clone(&step_context.turn);
    // router在retry loop外clone;网络重试不会重新捕获或换工具目录。
    let router = Arc::clone(&step_context.tool_router);
    let base_instructions = sess.get_base_instructions().await;
    let tool_runtime = ToolCallRuntime::new(
        Arc::clone(&sess),
        Arc::clone(&step_context),
        Arc::clone(&turn_diff_tracker),
    );
    let _code_mode_worker = sess.services.code_mode_service.start_turn_worker(
        &sess,
        Arc::clone(&step_context),
        Arc::clone(&turn_diff_tracker),
    );
    let max_retries = turn_context.provider.info().stream_max_retries();
    let mut retries = 0;
    let mut initial_input = Some(input);
    let mut original_input = None;
    let mut executed_tool_calls_by_output = HashMap::new();
    loop {
        let prompt_input = if let Some(input) = initial_input.take() {
            input
        } else {
            sess.clone_history()
                .await
                .for_prompt(&turn_context.model_info.input_modalities)
        };
        let mut prompt_input = prompt_input;
        if let Some(executed_tool_calls) = sess.services.executed_tool_calls.as_ref()
            && executed_tool_calls.attach_pending_to_prompt(
                &mut prompt_input,
                &mut executed_tool_calls_by_output,
            )
        {
            codex_protocol::models::bound_executed_tool_calls_for_prompt(
                &mut prompt_input,
            );
        }
        let prompt = build_prompt(
            prompt_input,
            router.as_ref(),
            turn_context.as_ref(),
            base_instructions.clone(),
        );
        let err = match try_run_sampling_request(
            tool_runtime.clone(),
            Arc::clone(&sess),
            Arc::clone(&turn_context),
            Arc::clone(&turn_store),
            client_session,
            responses_metadata,
            Arc::clone(&turn_diff_tracker),
            &prompt,
            cancellation_token.child_token(),
        )
        .await
        {
            Ok(output) => {
                return Ok((output, original_input.unwrap_or(prompt.input)));
            }
            Err(err) => match err.details() {
                CodexErrorDetails::ContextWindowExceeded => {
                    sess.set_total_tokens_full(&turn_context).await;
                    return Err(err);
                }
                CodexErrorDetails::UsageLimitReached(e) => {
                    let rate_limits = e.rate_limits.clone();
                    if let Some(rate_limits) = rate_limits {
                        sess.update_rate_limits(&turn_context, *rate_limits).await;
                    }
                    return Err(err);
                }
                _ => err,
            },
        };
        if original_input.is_none() {
            original_input = Some(prompt.input);
        }
        if !err.is_retryable() {
            return Err(err);
        }
        // retry只重建Prompt input/stream,仍使用本Step的router和tool_runtime。
        handle_retryable_response_stream_error(
            &mut retries,
            max_retries,
            err,
            client_session,
            &sess,
            &turn_context,
            ResponsesStreamRequest::Sampling,
        )
        .await?;
        turn_context.turn_timing_state.record_sampling_retry();
    }
}

源码位置:codex-rs/core/src/tools/parallel.rs,符号 ToolCallRuntime。

rust
#[derive(Clone)]
pub(crate) struct ToolCallRuntime {
    session: Arc<Session>,
    // Tool calls may run later, so retain the step whose tool list advertised them.
    // 即使全局MCP在响应期间刷新,调用仍回到广告该工具的旧Step。
    step_context: Arc<StepContext>,
    tracker: SharedTurnDiffTracker,
    parallel_execution: Arc<RwLock<()>>,
}

pub(crate) fn handle_tool_call_with_source(
    self,
    call: ToolCall,
    source: ToolCallSource,
    cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
    let router = &self.step_context.tool_router;
    let supports_parallel = router.tool_supports_parallel(&call);
    let tool_runtime = router.tool_runtime(&call);
    let router = Arc::clone(router);
    let session = Arc::clone(&self.session);
    let step_context = Arc::clone(&self.step_context);
    let turn = Arc::clone(&step_context.turn);
    let tracker = Arc::clone(&self.tracker);
    let lock = Arc::clone(&self.parallel_execution);
    let invocation_cancellation_token = cancellation_token.clone();
    let started = Instant::now();
    let tool_call_timing_guard =
        ToolCallTimingGuard::capture(started, &session.thread_id, &turn.sub_id, &call, &source);
    let execution_started_at = tool_call_timing_guard
        .as_ref()
        .map(|timing| Arc::clone(&timing.execution_started_at));
    // ...准备abort与trace字段

    let mut dispatch_handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
        AbortOnDropHandle::new(tokio::spawn(async move {
            if let Some(tool_runtime) = tool_runtime
                && let Some(readiness) = tool_runtime.wait_until_ready(&session)
            {
                readiness.await;
            }

            let _guard = if supports_parallel {
                Either::Left(lock.read().await)
            } else {
                Either::Right(lock.write().await)
            };
            if let Some(execution_started_at) = execution_started_at {
                let _ = execution_started_at.set(Instant::now());
            }

            // dispatch继续传入同一step_context,handler读取的环境和MCP不会漂移。
            router
                .dispatch_tool_call_with_terminal_outcome(
                    session,
                    step_context,
                    invocation_cancellation_token,
                    tracker,
                    dispatch_call,
                    source,
                    dispatch_terminal_outcome_reached,
                )
                .instrument(dispatch_span.clone())
                .await
        }));
    // ...外层tokio::select处理完成与取消
}

8. 刷新时机 ​

变化已构造 Step同一 Turn 的下一 Step下一 Turn
环境从 loading 变 ready不变refresh_readiness() 后可见可见
MCP runtime/tool catalog 刷新旧 binding/router 不变新 binding/router 可见可见
pending input 新增 MCP mention不变捕获时等待该 required server可见
Session model/权限设置更新TurnContext不变仍使用旧 TurnContext新 Turn 才可见
retryable stream error同一 Step 重试尚未进入下一 Step不适用
AGENTS 环境选择改变不变cache key变化后重载可见

两个测试分别钉住“新 Step 看见刷新”和“旧 Step 保持稳定”。

8.1 旧Turn刷新 ​

源码位置:codex-rs/core/src/session/tests.rs,测试 refresh_mcp_servers_uses_latest_state_for_existing_turns。

rust
let old_step = session
    .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
    .await
    .expect("a fresh cancellation token cannot be cancelled");

// ...把名为refreshed的server写入Session config并mark_mcp_runtime_dirty

let next_turn = session.new_default_turn().await;
let new_step = session
    .capture_step_context(next_turn, &CancellationToken::new())
    .await
    .expect("a fresh cancellation token cannot be cancelled");
let rematerialized_old = session
    .mcp_runtime_for_step(
        &turn_context,
        /*selected_capability_roots*/ &[],
        /*required_servers*/ &[],
    )
    .await;

let configured_servers = codex_mcp::configured_mcp_servers(new_step.mcp.config());
// 新Step的配置值与刷新输入一致,而非仅仅出现同名server。
assert_eq!(
    configured_servers.get("refreshed"),
    refreshed_mcp_servers.get("refreshed")
);
// 基于旧Turn重新物化的binding也看见refreshed。
assert!(codex_mcp::configured_mcp_servers(rematerialized_old.config())
    .contains_key("refreshed"));
// 已绑定old_step继续保留旧目录,证明Step是稳定边界。
assert!(!codex_mcp::configured_mcp_servers(old_step.mcp.config())
    .contains_key("refreshed"));

这个测试还说明“TurnContext 固定”不代表所有请求级资源都固定:同一个旧 TurnContext 可以在下一次 capture 解析到最新 MCP runtime;只有已经构造的旧 Step 保留旧 binding。

8.2 Router ​

源码位置:codex-rs/core/src/session/tests.rs,测试 step_context_keeps_its_mcp_runtime_for_tools。

rust
let step_context = session
    .capture_step_context(turn_context, &CancellationToken::new())
    .await?;

// ...refresh_mcp_servers_now加入newer server

let next_step = session
    .capture_step_context(
        Arc::clone(&step_context.turn),
        &CancellationToken::new(),
    )
    .await
    .expect("a fresh cancellation token cannot be cancelled");
// 新Step绑定刷新后的MCP config。
assert!(codex_mcp::configured_mcp_servers(next_step.mcp.config())
    .contains_key("newer"));

let router = &step_context.tool_router;
// 旧Step的router没有新MCP resource tool,不能用新runtime执行未曾广告的工具。
assert!(
    !router
        .registered_tool_names_for_test()
        .iter()
        .any(|name| name.to_string() == "list_mcp_resources")
);

8.3 Capability ​

源码位置:codex-rs/core/src/session/tests.rs,测试 capability_discovery_uses_environment_permission_profile。

rust
// Thread Config故意设为不受限,环境则设置read-only并拒绝读取.env。
Arc::make_mut(&mut turn_context.config)
    .permissions
    .set_permission_profile(PermissionProfile::Disabled)
    .expect("unrestricted permission profile should be allowed");
let mut environment = turn_context
    .environments
    .primary()
    .expect("primary environment")
    .clone();
let mut file_system_policy = PermissionProfile::read_only()
    .file_system_sandbox_policy();
file_system_policy.entries.push(FileSystemSandboxEntry {
    path: FileSystemPath::GlobPattern {
        pattern: "**/*.env".to_string(),
    },
    access: FileSystemAccessMode::Deny,
    missing_path_behavior: None,
});
environment.config.permission_profile =
    PermissionProfileSnapshot::legacy(
        PermissionProfile::from_runtime_permissions(
            &file_system_policy,
            NetworkSandboxPolicy::Restricted,
        ),
    );
let expected_sandbox = turn_context
    .file_system_sandbox_context(/*additional_permissions*/ None, &environment);
let environment_id = environment.environment_id.clone();
turn_context.environments.environments[0] =
    TurnEnvironmentState::Ready(environment);

let discovery = session
    .executor_capability_discovery_for_step(
        &turn_context.config,
        /*ready_selected_capability_roots*/ &[],
        &turn_context.environments,
        turn_context.windows_sandbox_level,
    )
    .await
    .expect("restricted environment should trigger capability discovery");

// 实际snapshot必须使用环境sandbox,而不是更宽松的Thread Config。
assert_eq!(
    discovery.sandbox_contexts().get(&environment_id),
    Some(&expected_sandbox)
);

9. 取消降级失败 ​

Step 捕获没有独立状态机;它只有“尚未发布”与“完整 Arc<StepContext> 已返回”两个外部可见结果。不同失败 发生在不同层级,不能都描述成“请求失败”。

  • 取消:MCP/recommendation join 和 ToolRouter 构建都用 Turn token 包裹;取消返回 TurnAborted,不返回 部分 Step。首次捕获还会记录原始输入与 hooks。
  • 空 binding 降级:MCP runtime 尚无 published binding 时生成带有效配置的 empty binding,Core 工具仍可 构建;这不是取消,也不是 ToolCollision。
  • 工具冲突:built_tools 返回的内层 CodexResult 通过 .await?? 向上传播,Step 不发布。预采样阶段遇到 ToolCollision 同样不会吞掉错误。
  • stream 重试:Step 已经发布,重试只重建输入/连接流,不更换 router。超过 provider 的 retry 上限才把 错误交回 Turn 层处理。
  • MCP refresh 被取消:refresh guard 会重新留下 pending invalidation,下一次 capture 可以继续刷新,避免把 未发布结果误标成最新 runtime。

10. 用一次工具循环 ​

可以从 run_turn 选择一条“模型返回一个 MCP tool call”的路径做只读追踪:

  1. 在 capture_step_context_with_required_mcp_servers 记下 Arc<McpBinding> 和 Arc<ToolRouter> 的创建点;
  2. 到 build_prompt 确认工具规格来自该 router;
  3. 到 ToolCallRuntime::handle_tool_call_with_source 确认 dispatch 仍携带同一 StepContext;
  4. 工具输出令 needs_follow_up=true 后回到外层 loop,确认下一次才重新 capture。

再做一个故障定位练习:如果模型看不到刚启动完成的 optional MCP server,但下一轮工具调用后能看到,应先 比较前后两个 Step 的 binding,而不是修改 TurnContext;如果连下一 Step 都看不到,再检查 runtime dirty、 required mention 和 capability roots。若要继续研究模型响应为何决定 needs_follow_up、何时终止外层循环,应该 进入 Turn主循环与退出条件,而不是继续扩大 StepContext 的字段职责。

可以用下面的只读搜索把本文的 StepContext request 主线落回源码:

bash
rg -n "StepContext|build_world_state|Mcp|Router|sampling" codex-rs/core/src