Record reasoning effort changes in conversation history behind a flag (#43110)

## What changed

Add the disabled-by-default `reasoning_effort_override` feature for OpenAI models with `use_responses_lite` enabled. Append a trusted `configuration_update` after accepted input when no effort is established in surviving history or the resolved effort changes. Preserve the existing history prefix and continue sending the request-level reasoning effort.

Share effort normalization through `ModelInfo::resolve_reasoning_effort` so requests and history updates resolve `ultra` consistently and translate `persistent` to `disabled`. Exclude other custom effort values from history updates, and compare only against harness-authored configuration items.

## Testing

Add integration coverage for effort transitions, deduplication, history prefix and cache-key preservation, alias normalization, and feature/provider/model gating. Add unit coverage for model-specific `ultra` resolution, fallbacks, and `persistent` translation.

GitOrigin-RevId: c3fe7050058076454d8ea20054a618bca9d48c7c
This commit is contained in:
felixxia-oai
2026-09-05 21:56:43 +00:00
committed by copyberry
parent e67a8ae6c9
commit 56a8470aa0
11 changed files with 523 additions and 39 deletions
+6
View File
@@ -879,6 +879,9 @@
"realtime_conversation": {
"type": "boolean"
},
"reasoning_effort_override": {
"type": "boolean"
},
"recommended_plugins": {
"type": "boolean"
},
@@ -6271,6 +6274,9 @@
"realtime_conversation": {
"type": "boolean"
},
"reasoning_effort_override": {
"type": "boolean"
},
"recommended_plugins": {
"type": "boolean"
},
+2 -38
View File
@@ -183,42 +183,6 @@ pub(crate) struct CompactConversationRequestSettings {
pub(crate) service_tier: Option<String>,
}
fn reasoning_effort_for_request(
model_info: &ModelInfo,
effort: ReasoningEffortConfig,
) -> ReasoningEffortConfig {
match effort {
ReasoningEffortConfig::Ultra => model_info
.multi_agent_reasoning_effort
.as_ref()
.filter(|effort| {
*effort != &ReasoningEffortConfig::Ultra
&& model_info
.supported_reasoning_levels
.iter()
.any(|preset| &preset.effort == *effort)
})
.cloned()
.or_else(|| {
let supported_reasoning_levels = &model_info.supported_reasoning_levels;
supported_reasoning_levels
.iter()
.find(|preset| preset.effort == ReasoningEffortConfig::Max)
.or_else(|| {
supported_reasoning_levels
.iter()
.rev()
.find(|preset| preset.effort != ReasoningEffortConfig::Ultra)
})
.map(|preset| preset.effort.clone())
})
.unwrap_or(ReasoningEffortConfig::Medium),
// Keep "persistent" in local settings; the Responses API calls it "disabled".
ReasoningEffortConfig::Persistent => ReasoningEffortConfig::Custom("disabled".to_string()),
effort => effort,
}
}
fn session_telemetry_for_request(
session_telemetry: &SessionTelemetry,
request: &ResponsesApiRequest,
@@ -806,7 +770,7 @@ impl ModelClient {
model: model_info.slug.clone(),
raw_memories,
reasoning: effort
.map(|effort| reasoning_effort_for_request(model_info, effort))
.map(|effort| model_info.resolve_reasoning_effort(effort))
.map(|effort| Reasoning {
effort: Some(effort),
summary: None,
@@ -912,7 +876,7 @@ impl ModelClient {
Reasoning {
effort: effort
.or_else(|| model_info.default_reasoning_level.clone())
.map(|effort| reasoning_effort_for_request(model_info, effort)),
.map(|effort| model_info.resolve_reasoning_effort(effort)),
summary: (model_info.supports_reasoning_summary_parameter
&& summary != ReasoningSummaryConfig::None)
.then_some(summary),
+1
View File
@@ -230,6 +230,7 @@ mod mcp_refresh;
mod mcp_runtime;
pub(crate) mod multi_agents;
mod realtime_history;
mod reasoning_effort;
mod retained_context;
mod review;
mod rollout_budget;
@@ -0,0 +1,87 @@
//! Gated reasoning-effort updates appended after accepted input.
//!
//! Only trusted harness items establish an effort. Updates append to surviving
//! history without replacing the request-level reasoning effort.
use super::session::Session;
use super::step_context::StepContext;
use super::step_settings::ResolvedStepSettings;
use codex_features::Feature;
use codex_history::CodexHarnessMetadata;
use codex_history::ResponseItemEnvelope;
use codex_protocol::models::ConfigurationReasoning;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
impl Session {
/// Establishes the selected effort in surviving history, independent of replayed settings.
pub(crate) async fn record_reasoning_effort_override(&self, step_context: &StepContext) {
let settings = &step_context.settings;
let Some(effort) = self.effort_for_configuration_update(settings).await else {
return;
};
let should_skip = {
let state = self.state.lock().await;
let established_effort =
state
.history
.annotated_items()
.iter()
.rev()
.find_map(|envelope| {
if !envelope
.metadata
.as_ref()
.is_some_and(|metadata| metadata.harness_authored_configuration)
{
return None;
}
match &envelope.item {
ResponseItem::ConfigurationUpdate { reasoning } => {
Some(&reasoning.effort)
}
_ => None,
}
});
established_effort == Some(&effort)
};
if should_skip {
return;
}
self.record_annotated_conversation_items(
step_context.turn.as_ref(),
vec![ResponseItemEnvelope {
item: ResponseItem::ConfigurationUpdate {
reasoning: ConfigurationReasoning { effort },
},
metadata: Some(CodexHarnessMetadata {
harness_authored_configuration: true,
..Default::default()
}),
}],
)
.await;
}
async fn effort_for_configuration_update(
&self,
settings: &ResolvedStepSettings,
) -> Option<ReasoningEffort> {
if !self.enabled(Feature::ReasoningEffortOverride)
|| !settings.model_info.use_responses_lite
|| !self.provider().await.is_openai()
{
return None;
}
let effort = settings
.model_info
.resolve_reasoning_effort(settings.effective_reasoning_effort()?);
// Persistent normalizes to "disabled". Keep unknown custom values out of
// durable updates so injected items stay bounded to known backend modes.
if matches!(&effort, ReasoningEffort::Custom(value) if value != "disabled") {
return None;
}
Some(effort)
}
}
+4
View File
@@ -413,6 +413,10 @@ pub(crate) async fn run_turn(
.record_step_world_state_if_changed(&world_state, step_context.as_ref())
.await?;
// Keep the override after accepted input so ordinary turn rollback removes it too.
sess.record_reasoning_effort_override(step_context.as_ref())
.await;
// Construct the input that we will send to the model.
let sampling_request_input: Vec<ResponseItem> = async {
sess.clone_history()
+1
View File
@@ -128,6 +128,7 @@ mod quota_exceeded;
mod realtime_conversation;
mod realtime_initial_items;
mod realtime_sideband_endpoint;
mod reasoning_effort_override;
mod remote_env;
mod remote_models;
mod request_compression;
@@ -0,0 +1,316 @@
//! Trusted reasoning-effort updates follow surviving history and the next turn's selected settings.
use codex_features::Feature;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::ThreadSettingsOverrides;
use core_test_support::responses;
use core_test_support::responses::ResponsesRequest;
use core_test_support::skip_if_no_network;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use test_case::test_case;
fn override_builder() -> TestCodexBuilder {
test_codex()
.with_model_info_override("gpt-5.4", |model| {
model.use_responses_lite = true;
})
.with_config(|config| {
config
.features
.enable(Feature::ReasoningEffortOverride)
.expect("enable reasoning effort overrides");
config.model_reasoning_effort = Some(ReasoningEffort::Medium);
})
}
fn effort_updates(request: &ResponsesRequest) -> Vec<Value> {
request
.input()
.into_iter()
.filter(|item| item["type"] == "configuration_update")
.collect()
}
fn effort_update(effort: ReasoningEffort) -> Value {
serde_json::json!({
"type": "configuration_update",
"reasoning": {"effort": effort},
})
}
fn message(role: &str, text: &str) -> Value {
serde_json::json!({
"type": "message",
"role": role,
"content": [{"type": "input_text", "text": text}],
})
}
#[test_case(ReasoningEffort::High; "high to persistent and back")]
#[test_case(ReasoningEffort::Persistent; "persistent to high and back")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_persistent_transitions(
initial_effort: ReasoningEffort,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut mocks = Vec::new();
for id in ["first", "changed", "unchanged", "restored"] {
mocks.push(
responses::mount_sse_once(&server, responses::sse(vec![responses::ev_completed(id)]))
.await,
);
}
let high = effort_update(ReasoningEffort::High);
let disabled = effort_update(ReasoningEffort::Custom("disabled".to_string()));
let (changed_effort, initial_update, changed_update) =
if initial_effort == ReasoningEffort::Persistent {
(ReasoningEffort::High, disabled, high)
} else {
(ReasoningEffort::Persistent, high, disabled)
};
let test = override_builder().build_with_auto_env(&server).await?;
for effort in [
initial_effort.clone(),
changed_effort.clone(),
changed_effort,
initial_effort,
] {
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(effort)),
..Default::default()
},
)
.await?;
test.submit_text_turn("continue").await?;
}
assert_eq!(
mocks
.iter()
.map(|mock| effort_updates(&mock.single_request()))
.collect::<Vec<_>>(),
vec![
vec![initial_update.clone()],
vec![initial_update.clone(), changed_update.clone()],
vec![initial_update.clone(), changed_update.clone()],
vec![initial_update.clone(), changed_update, initial_update],
],
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_preserves_prefix_and_only_appends_on_change()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut mocks = Vec::new();
for id in ["first", "changed", "unchanged", "lowered"] {
mocks.push(
responses::mount_sse_once(&server, responses::sse(vec![responses::ev_completed(id)]))
.await,
);
}
let test = override_builder().build_with_auto_env(&server).await?;
test.submit_text_turn("first message").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::High)),
..Default::default()
},
)
.await?;
test.submit_text_turn("second message").await?;
test.submit_text_turn("third message").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::Low)),
..Default::default()
},
)
.await?;
test.submit_text_turn("fourth message").await?;
let requests = mocks
.iter()
.map(responses::ResponseMock::single_request)
.collect::<Vec<_>>();
let inputs = requests
.iter()
.map(|request| {
responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(
Value::Array(request.input()),
))
.as_array()
.expect("input array")
.clone()
})
.collect::<Vec<_>>();
assert_eq!(
requests
.iter()
.map(|request| request.body_json()["reasoning"]["effort"].clone())
.collect::<Vec<_>>(),
vec![
Value::from("medium"),
Value::from("high"),
Value::from("high"),
Value::from("low"),
],
);
let cache_keys = requests
.iter()
.map(|request| request.body_json()["prompt_cache_key"].clone())
.collect::<Vec<_>>();
assert_eq!(cache_keys, vec![cache_keys[0].clone(); requests.len()]);
assert_eq!(
inputs[0][inputs[0].len() - 2..],
[
message("user", "first message"),
effort_update(ReasoningEffort::Medium)
],
);
let mut expected = inputs[0].clone();
expected.extend([
message("user", "second message"),
effort_update(ReasoningEffort::High),
]);
assert_eq!(inputs[1], expected);
expected.push(message("user", "third message"));
assert_eq!(inputs[2], expected);
expected.extend([
message("user", "fourth message"),
effort_update(ReasoningEffort::Low),
]);
assert_eq!(inputs[3], expected);
Ok(())
}
#[test_case(ReasoningEffort::High; "model maps ultra to high")]
#[test_case(ReasoningEffort::Max; "model maps ultra to max")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_normalizes_ultra_before_comparing_updates(
resolved_effort: ReasoningEffort,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let first = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("first")]),
)
.await;
let second = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("second")]),
)
.await;
let model_effort = resolved_effort.clone();
let test = override_builder()
.with_model_info_override("gpt-5.4", move |model| {
model.multi_agent_reasoning_effort = Some(model_effort.clone());
model.supported_reasoning_levels = vec![ReasoningEffortPreset {
effort: model_effort,
description: "Model effort".to_string(),
}];
})
.with_config(|config| config.model_reasoning_effort = Some(ReasoningEffort::Ultra))
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("ultra selection").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(resolved_effort.clone())),
..Default::default()
},
)
.await?;
test.submit_text_turn("equivalent explicit effort").await?;
let requests = [first.single_request(), second.single_request()];
assert_eq!(
requests.each_ref().map(effort_updates),
[
vec![effort_update(resolved_effort.clone())],
vec![effort_update(resolved_effort.clone())],
]
);
assert_eq!(
requests.map(|request| request.body_json()["reasoning"]["effort"].clone()),
[
serde_json::to_value(&resolved_effort)?,
serde_json::to_value(&resolved_effort)?
]
);
Ok(())
}
#[derive(Clone, Copy)]
enum OverrideUnavailable {
FeatureDisabled,
NonOpenAiProvider,
ResponsesLiteDisabled,
}
#[test_case(OverrideUnavailable::FeatureDisabled; "feature disabled")]
#[test_case(OverrideUnavailable::NonOpenAiProvider; "unsupported provider")]
#[test_case(OverrideUnavailable::ResponsesLiteDisabled; "responses lite disabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_unavailable_uses_request_effort(
unavailable: OverrideUnavailable,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let first = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("first")]),
)
.await;
let second = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("second")]),
)
.await;
let mut builder = match unavailable {
OverrideUnavailable::FeatureDisabled => override_builder().with_config(|config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable overrides");
}),
OverrideUnavailable::NonOpenAiProvider => override_builder().with_config(|config| {
config.model_provider.name = "unsupported provider".into();
}),
OverrideUnavailable::ResponsesLiteDisabled => override_builder()
.with_model_info_override("gpt-5.4", |model| model.use_responses_lite = false),
};
let test = builder.build_with_auto_env(&server).await?;
test.submit_text_turn("first").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::High)),
..Default::default()
},
)
.await?;
test.submit_text_turn("second").await?;
let requests = [first.single_request(), second.single_request()];
assert_eq!(
requests.each_ref().map(effort_updates),
[Vec::<Value>::new(), Vec::new()]
);
assert_eq!(
requests.map(|request| request.body_json()["reasoning"]["effort"].clone()),
[Value::from("medium"), Value::from("high")],
);
Ok(())
}
+8
View File
@@ -324,6 +324,8 @@ pub enum Feature {
ContextManagement,
/// Track and report a shared token budget across a session's agent threads.
RolloutBudget,
/// Append trusted response configuration items when the selected reasoning effort changes.
ReasoningEffortOverride,
/// Add current-time reminders to model-visible context.
CurrentTimeReminder,
/// Route MCP tool approval prompts through the MCP elicitation request path.
@@ -1607,6 +1609,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ReasoningEffortOverride,
key: "reasoning_effort_override",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::CurrentTimeReminder,
key: "current_time_reminder",
+2 -1
View File
@@ -41,6 +41,8 @@ pub use guardian::GuardianScope;
#[path = "openai_models/guardian_v2.rs"]
mod guardian_v2;
#[path = "openai_models/reasoning_effort.rs"]
mod reasoning_effort;
pub use guardian_v2::GuardianV2ModelConfig;
pub use guardian_v2::GuardianV2TranscriptModelConfig;
@@ -1020,7 +1022,6 @@ mod tests {
multi_agent_reasoning_effort: None,
}
}
fn personality_variables() -> ModelInstructionsVariables {
ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -0,0 +1,44 @@
//! Model-owned effort normalization shared by native requests and configuration update items.
//!
//! UI aliases resolve through the same model defaults and fallbacks in both paths.
use super::ModelInfo;
use super::ReasoningEffort;
impl ModelInfo {
/// Resolves a selected effort to the value sent in an ordinary inference request.
pub fn resolve_reasoning_effort(&self, effort: ReasoningEffort) -> ReasoningEffort {
match effort {
ReasoningEffort::Ultra => self
.multi_agent_reasoning_effort
.as_ref()
.filter(|effort| {
*effort != &ReasoningEffort::Ultra
&& self
.supported_reasoning_levels
.iter()
.any(|preset| &preset.effort == *effort)
})
.cloned()
.or_else(|| {
self.supported_reasoning_levels
.iter()
.find(|preset| preset.effort == ReasoningEffort::Max)
.or_else(|| {
self.supported_reasoning_levels
.iter()
.rev()
.find(|preset| preset.effort != ReasoningEffort::Ultra)
})
.map(|preset| preset.effort.clone())
})
.unwrap_or(ReasoningEffort::Medium),
// Keep "persistent" in local settings; the Responses API calls it "disabled".
ReasoningEffort::Persistent => ReasoningEffort::Custom("disabled".to_string()),
effort => effort,
}
}
}
#[cfg(test)]
#[path = "reasoning_effort_tests.rs"]
mod tests;
@@ -0,0 +1,52 @@
use super::super::ReasoningEffortPreset;
use super::super::tests::test_model;
use super::ReasoningEffort;
use pretty_assertions::assert_eq;
#[test]
fn resolves_ultra_using_model_override_and_supported_fallbacks() {
use ReasoningEffort::High;
use ReasoningEffort::Low;
use ReasoningEffort::Max;
use ReasoningEffort::Medium;
use ReasoningEffort::Ultra;
use ReasoningEffort::XHigh;
for (supported, multi_agent_effort, expected) in [
(vec![High, Max, Ultra], Some(High), High),
(vec![High, Max, Ultra], None, Max),
(vec![High, XHigh, Ultra], None, XHigh),
(vec![High, XHigh, Ultra], Some(Ultra), XHigh),
(vec![High, XHigh, Ultra], Some(Low), XHigh),
(vec![], None, Medium),
] {
let mut model = test_model(/*spec*/ None);
model.supported_reasoning_levels = supported
.into_iter()
.map(|effort| ReasoningEffortPreset {
effort,
description: String::new(),
})
.collect();
model.multi_agent_reasoning_effort = multi_agent_effort;
assert_eq!(model.resolve_reasoning_effort(Ultra), expected);
}
}
#[test]
fn preserves_native_efforts_and_translates_persistent_mode() {
let model = test_model(/*spec*/ None);
for effort in [
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
ReasoningEffort::Custom("future-effort".to_string()),
] {
assert_eq!(model.resolve_reasoning_effort(effort.clone()), effort);
}
assert_eq!(
model.resolve_reasoning_effort(ReasoningEffort::Persistent),
ReasoningEffort::Custom("disabled".to_string()),
);
}