Keep active-turn model settings stable across updates (#38785)

## Why

Thread settings can change while a turn is still active, including between sampling requests. Those updates should apply to the next turn instead of changing the model configuration partway through the current turn.

## What changed

- Snapshot the model, reasoning settings, service tier, approval settings, and model-attributed telemetry in `StepContext`.
- Build prompts, model requests, world state, tracing, and startup prewarms from that step-scoped snapshot.
- Share immutable model metadata between turn and step contexts.

## Testing

Added an integration test that pauses an active turn, updates its thread settings, and verifies that all requests in the active turn retain the original settings while the next turn uses the updates.

GitOrigin-RevId: c51352218e463dcc9b7338566d16742292c49fbf
This commit is contained in:
joeflorencio-openai
2026-08-15 16:58:08 +00:00
committed by copyberry
parent c4941302c7
commit 00f6a8a60e
13 changed files with 307 additions and 104 deletions
+7
View File
@@ -3196,6 +3196,13 @@ impl Session {
.or_cancel(cancellation_token)
.await??;
Ok(Arc::new(StepContext {
model_info: Arc::clone(&turn_context.model_info),
reasoning_effort: turn_context.reasoning_effort.clone(),
reasoning_summary: turn_context.reasoning_summary,
service_tier: turn_context.config.service_tier.clone(),
approval_policy: turn_context.approval_policy(),
approvals_reviewer: turn_context.config.approvals_reviewer,
session_telemetry: turn_context.session_telemetry.clone(),
turn: turn_context,
environments,
selected_capability_roots,
+1 -1
View File
@@ -138,7 +138,7 @@ pub(super) async fn spawn_review_thread(
code_mode_available: parent_turn_context.code_mode_available,
config: per_turn_config,
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
model_info: Arc::new(model_info.clone()),
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
reasoning_effort,
+22
View File
@@ -7,10 +7,32 @@ use crate::tools::router::ToolRouter;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::McpBinding;
use codex_otel::SessionTelemetry;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
/// Request-scoped state that may change between model sampling requests.
pub(crate) struct StepContext {
pub(crate) turn: Arc<TurnContext>,
/// Concrete model and capabilities used by this sampling request.
pub(crate) model_info: Arc<ModelInfo>,
/// Reasoning effort selected for this sampling request.
pub(crate) reasoning_effort: Option<ReasoningEffort>,
/// Reasoning summary resolved for this sampling request.
pub(crate) reasoning_summary: ReasoningSummary,
/// Effective service tier supported by this sampling request's model.
pub(crate) service_tier: Option<String>,
/// Effective approval policy used by this sampling request's tool actions.
/// Model-specific Guardian requirements can change this during a model switch.
pub(crate) approval_policy: AskForApproval,
/// Effective reviewer selected for this sampling request's approvals.
/// Model-specific Guardian requirements can change this during a model switch.
pub(crate) approvals_reviewer: ApprovalsReviewer,
/// Telemetry context tagged with this sampling request's model.
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) environments: TurnEnvironmentSnapshot,
/// Capability roots bound to ready environments in this exact step.
pub(crate) selected_capability_roots: Vec<ResolvedSelectedCapabilityRoot>,
+14 -7
View File
@@ -210,6 +210,13 @@ impl StepContext {
pub(crate) fn for_test(turn: Arc<TurnContext>) -> Arc<Self> {
let environments = turn.environments.clone();
Arc::new(Self {
model_info: Arc::clone(&turn.model_info),
reasoning_effort: turn.reasoning_effort.clone(),
reasoning_summary: turn.reasoning_summary,
service_tier: turn.config.service_tier.clone(),
approval_policy: turn.approval_policy(),
approvals_reviewer: turn.config.approvals_reviewer,
session_telemetry: turn.session_telemetry.clone(),
turn: Arc::clone(&turn),
environments,
selected_capability_roots: Vec::new(),
@@ -2746,8 +2753,8 @@ async fn recompute_token_usage_updates_model_context_window() {
}));
}
turn_context.model_info.context_window = Some(128_000);
turn_context.model_info.effective_context_window_percent = 100;
Arc::make_mut(&mut turn_context.model_info).context_window = Some(128_000);
Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 100;
session.recompute_token_usage(&turn_context).await;
@@ -4435,7 +4442,7 @@ async fn turn_context_with_model_updates_model_fields() {
assert_eq!(updated.config.model.as_deref(), Some("gpt-5.4"));
assert_eq!(updated.collaboration_mode().model(), "gpt-5.4");
assert_eq!(updated.model_info, expected_model_info);
assert_eq!(updated.model_info.as_ref(), &expected_model_info);
assert_eq!(
updated.reasoning_effort,
Some(ReasoningEffortConfig::Medium)
@@ -9247,8 +9254,8 @@ async fn build_initial_context_includes_turn_context_fragments_from_extensions()
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor));
session.services.extensions = Arc::new(builder.build());
turn_context.model_info.context_window = Some(100);
turn_context.model_info.effective_context_window_percent = 50;
Arc::make_mut(&mut turn_context.model_info).context_window = Some(100);
Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 50;
turn_context
.extension_data
.insert(TurnContextExtensionTestState {
@@ -9274,8 +9281,8 @@ async fn record_context_updates_includes_turn_context_fragments_on_steady_state_
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor));
session.services.extensions = Arc::new(builder.build());
turn_context.model_info.context_window = Some(200);
turn_context.model_info.effective_context_window_percent = 25;
Arc::make_mut(&mut turn_context.model_info).context_window = Some(200);
Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 25;
turn_context
.extension_data
.insert(TurnContextExtensionTestState {
@@ -96,7 +96,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
.await;
let (mut session, mut turn_context_raw) = make_session_and_context().await;
turn_context_raw.model_info.node_repl_auto_review_required = true;
Arc::make_mut(&mut turn_context_raw.model_info).node_repl_auto_review_required = true;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::make_mut(&mut turn_context_raw.config)
.permissions
+26 -23
View File
@@ -350,7 +350,7 @@ pub(crate) async fn run_turn(
let sampling_request_input: Vec<ResponseItem> = async {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
.for_prompt(&step_context.model_info.input_modalities)
}
.instrument(trace_span!("run_turn.prepare_sampling_request_input"))
.await;
@@ -1293,13 +1293,13 @@ pub(super) fn collect_explicit_app_ids_from_skill_items(
#[instrument(level = "trace", skip_all)]
pub(crate) fn build_prompt(
input: Vec<ResponseItem>,
router: &ToolRouter,
turn_context: &TurnContext,
step_context: &StepContext,
base_instructions: BaseInstructions,
) -> Prompt {
let turn_context = &step_context.turn;
Prompt {
input,
tools: router.model_visible_specs(),
tools: step_context.tool_router.model_visible_specs(),
parallel_tool_calls: true,
base_instructions,
output_schema: turn_context.final_output_json_schema.clone(),
@@ -1315,7 +1315,7 @@ pub(crate) fn build_prompt(
skip_all,
fields(
turn_id = %step_context.turn.sub_id,
model = %step_context.turn.model_info.slug,
model = %step_context.model_info.slug,
cwd = %step_context.turn.cwd.display()
)
)]
@@ -1330,8 +1330,6 @@ async fn run_sampling_request(
cancellation_token: CancellationToken,
) -> CodexResult<(SamplingRequestResult, Vec<ResponseItem>)> {
let turn_context = Arc::clone(&step_context.turn);
let router = Arc::clone(&step_context.tool_router);
let base_instructions = sess.get_base_instructions().await;
let tool_runtime = ToolCallRuntime::new(
@@ -1355,7 +1353,7 @@ async fn run_sampling_request(
} else {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
.for_prompt(&step_context.model_info.input_modalities)
};
let mut prompt_input = prompt_input;
if let Some(executed_tool_calls) = sess.services.executed_tool_calls.as_ref()
@@ -1366,14 +1364,13 @@ async fn run_sampling_request(
}
let prompt = build_prompt(
prompt_input,
router.as_ref(),
turn_context.as_ref(),
step_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(&step_context),
Arc::clone(&turn_store),
client_session,
responses_metadata,
@@ -2144,14 +2141,14 @@ fn assign_missing_streamed_response_item_id(
#[instrument(level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
model = %turn_context.model_info.slug
turn_id = %step_context.turn.sub_id,
model = %step_context.model_info.slug
)
)]
async fn try_run_sampling_request(
tool_runtime: ToolCallRuntime,
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
step_context: Arc<StepContext>,
turn_store: Arc<codex_extension_api::ExtensionData>,
client_session: &mut ModelClientSession,
responses_metadata: &CodexResponsesMetadata,
@@ -2159,17 +2156,18 @@ async fn try_run_sampling_request(
prompt: &Prompt,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let turn_context = Arc::clone(&step_context.turn);
feedback_tags!(
model = turn_context.model_info.slug.clone(),
model = step_context.model_info.slug.clone(),
approval_policy = turn_context.approval_policy(),
sandbox_policy = &turn_context.sandbox_policy(),
effort = turn_context.reasoning_effort,
effort = step_context.reasoning_effort,
auth_mode = sess.services.auth_manager.auth_mode(),
features = sess.features.enabled_features(),
);
let inference_trace = sess.services.rollout_thread_trace.inference_trace_context(
turn_context.sub_id.as_str(),
turn_context.model_info.slug.as_str(),
step_context.model_info.slug.as_str(),
turn_context.provider.info().name.as_str(),
);
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
@@ -2181,11 +2179,11 @@ async fn try_run_sampling_request(
let mut stream = client_session
.stream(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
&step_context.model_info,
&step_context.session_telemetry,
step_context.reasoning_effort.clone(),
step_context.reasoning_summary,
step_context.service_tier.clone(),
responses_metadata,
&inference_trace,
)
@@ -2205,7 +2203,12 @@ async fn try_run_sampling_request(
let mut should_emit_token_count = false;
const MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE: usize = 256;
let mut analytics_tool_call_ids = Vec::new();
let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing();
let reasoning_effort = step_context
.reasoning_effort
.clone()
.or_else(|| step_context.model_info.default_reasoning_level.clone())
.map(|effort| effort.to_string())
.unwrap_or_else(|| "default".to_string());
let plan_mode = turn_context.mode == ModeKind::Plan;
let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode);
let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id));
+12 -3
View File
@@ -138,12 +138,18 @@ pub struct TurnContext {
pub(crate) trace_id: Option<String>,
pub(crate) realtime_active: bool,
pub(crate) code_mode_available: bool,
/// Turn-scoped configuration. Read step-specific settings such as service tier and
/// approvals reviewer from the corresponding `StepContext` fields instead.
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
/// Legacy turn model; step-scoped execution should use `StepContext::model_info`.
pub(crate) model_info: Arc<ModelInfo>,
/// Turn-wide telemetry; model-attributed step work should use `StepContext::session_telemetry`.
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) provider: SharedModelProvider,
/// Legacy turn effort; step-scoped execution should use `StepContext::reasoning_effort`.
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
/// Legacy turn summary; step-scoped execution should use `StepContext::reasoning_summary`.
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) history_mode: ThreadHistoryMode,
@@ -223,6 +229,8 @@ impl TurnContext {
.await
}
/// Approval policy admitted with the turn; step-scoped actions should use
/// `StepContext::approval_policy` instead.
pub(crate) fn approval_policy(&self) -> AskForApproval {
self.config.permissions.approval_policy.value()
}
@@ -352,6 +360,7 @@ impl TurnContext {
config.http_client_factory(),
)
.await;
let model_info = Arc::new(model_info);
Self {
sub_id: self.sub_id.clone(),
@@ -360,7 +369,7 @@ impl TurnContext {
code_mode_available: self.code_mode_available,
config: Arc::new(config),
auth_manager: self.auth_manager.clone(),
model_info: model_info.clone(),
model_info: Arc::clone(&model_info),
session_telemetry: self
.session_telemetry
.clone()
@@ -643,7 +652,7 @@ impl Session {
code_mode_available: true,
config: per_turn_config,
auth_manager,
model_info,
model_info: Arc::new(model_info),
session_telemetry: session_telemetry_for_context,
provider,
reasoning_effort,
+2 -2
View File
@@ -168,9 +168,9 @@ impl Session {
let model_messages = turn_context.model_info.model_messages.as_ref();
world_state.add_section(PermissionsState::new(
&permission_profile,
turn_context.approval_policy(),
step_context.approval_policy,
ApprovalPromptContext::new(
turn_context.config.approvals_reviewer,
step_context.approvals_reviewer,
model_messages.and_then(|messages| messages.approvals.as_ref()),
model_messages.and_then(|messages| messages.permissions.as_ref()),
),