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 捕获。
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 循环。
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。
/// 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_reviewer | per-step effective config | 本次请求的服务层与审批语义 | 可随模型能力变化 |
session_telemetry | model-attributed telemetry | 将本次请求归因到实际模型 | 可随 step 更新 |
environments | Turn selections 的 readiness 刷新 | cwd、workspace roots、权限、执行后端 | 可以 |
selected_capability_roots | thread roots + ready environment roots | MCP、skills、extension 输入 | 可以 |
executor_capability_discovery | roots + sandbox contexts | 受限文件发现、MCP/skills共享物化结果 | 可以 |
mcp | 当前 published MCP runtime binding | client handles、工具目录、connector snapshot | 可以 |
tool_router | core tools + MCP + apps + dynamic tools | 同时生成模型 specs 并执行调用 | 可以 |
loaded_agents_md | AgentsMdManager cache | WorldState 的 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。
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。
#[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。
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。
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。
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。
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。
#[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。
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。
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。
// 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”的路径做只读追踪:
- 在
capture_step_context_with_required_mcp_servers记下Arc<McpBinding>和Arc<ToolRouter>的创建点; - 到
build_prompt确认工具规格来自该 router; - 到
ToolCallRuntime::handle_tool_call_with_source确认 dispatch 仍携带同一 StepContext; - 工具输出令
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 主线落回源码:
rg -n "StepContext|build_world_state|Mcp|Router|sampling" codex-rs/core/src