[codex] Add internal auto-compaction opt-out (#28260)

## Summary

- add a default-on `auto_compaction` feature flag as an internal escape
hatch
- skip pre-turn, model-switch/hash, and mid-turn automatic compaction
when the flag is disabled
- preserve manual `/compact` behavior and surface the existing
context-window error when the provider runs out of room
- add integration coverage for disabled pre-turn and mid-turn compaction

## Motivation

Long-running SPO optimization rollouts need the option to preserve their
full context and fail on context exhaustion instead of entering another
compaction window. This deliberately uses the existing feature-flag
mechanism rather than adding a dedicated public config or app-server
API.

Disable it with:

```sh
codex --disable auto_compaction
```

## Testing

- `just test -p codex-features` — 51 passed
- `just test -p codex-core auto_compaction_feature_disabled` — 2 passed
- `just fix -p codex-core -p codex-features`
- `just write-config-schema`
- `just test -p codex-core` — the new compaction tests passed; the
overall local run had 54 unrelated environment failures, primarily
missing first-party test binaries and shell-snapshot timeouts
This commit is contained in:
rhan-oai
2026-06-21 20:11:50 -07:00
committed by GitHub
Unverified
parent eb8c1ee85f
commit b21f0e7a98
6 changed files with 281 additions and 2 deletions
+6
View File
@@ -440,6 +440,9 @@
"auth_elicitation": {
"type": "boolean"
},
"auto_compaction": {
"type": "boolean"
},
"browser_use": {
"type": "boolean"
},
@@ -4734,6 +4737,9 @@
"auth_elicitation": {
"type": "boolean"
},
"auto_compaction": {
"type": "boolean"
},
"browser_use": {
"type": "boolean"
},
+8 -1
View File
@@ -333,7 +333,10 @@ pub(crate) async fn run_turn(
}
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
if turn_context.features.enabled(Feature::AutoCompaction)
&& token_limit_reached
&& needs_follow_up
{
if let Err(err) = run_auto_compact(
&sess,
&turn_context,
@@ -844,6 +847,10 @@ async fn run_pre_sampling_compact(
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<()> {
if !turn_context.features.enabled(Feature::AutoCompaction) {
return Ok(());
}
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
// Compact if the configured auto-compaction budget or usable context window is exhausted.
+3 -1
View File
@@ -708,7 +708,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}
if features.enabled(Feature::TokenBudget) {
planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly);
if features.enabled(Feature::AutoCompaction) {
planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly);
}
planned_tools.add(GetContextRemainingHandler);
}
+208
View File
@@ -1,5 +1,6 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_core::CodexThread;
use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::compact::SUMMARY_PREFIX;
use codex_core::config::Config;
@@ -35,6 +36,7 @@ use core_test_support::responses;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::responses::mount_models_once;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
@@ -92,6 +94,48 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.";
async fn build_auto_compaction_disabled_codex(server: &MockServer) -> TestCodex {
let mut model_provider = non_openai_model_provider(server);
model_provider.stream_max_retries = Some(0);
test_codex()
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_context_window = Some(100);
config.model_auto_compact_token_limit = Some(90);
let _ = config.features.disable(Feature::AutoCompaction);
})
.build(server)
.await
.expect("build codex")
}
async fn submit_context_window_exceeded_turn(codex: &Arc<CodexThread>, text: &str) {
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
.expect("submit context window exceeded turn");
let error_message = wait_for_event_match(codex, |event| match event {
EventMsg::Error(err) => Some(err.message.clone()),
_ => None,
})
.await;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
assert!(
error_message.contains("ran out of room in the model's context window"),
"expected context window exceeded message, got {error_message}"
);
}
fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value {
ev_function_call(
call_id,
@@ -2276,6 +2320,93 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_feature_disabled_skips_comp_hash_model_switch_compaction() {
skip_if_no_network!();
let server = MockServer::start().await;
let previous_model = "gpt-5.3-codex";
let next_model = "gpt-5.2";
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![
model_info_with_optional_comp_hash(previous_model, Some("hash-a")),
model_info_with_optional_comp_hash(next_model, Some("hash-b")),
],
},
)
.await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_assistant_message("m1", "before switch"),
ev_completed_with_tokens("r1", /*total_tokens*/ 100),
]),
sse(vec![
ev_assistant_message("m2", "after switch"),
ev_completed_with_tokens("r2", /*total_tokens*/ 100),
]),
],
)
.await;
let model_provider = non_openai_model_provider(&server);
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model(previous_model)
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
let _ = config.features.disable(Feature::AutoCompaction);
});
let test = builder.build(&server).await.expect("build test codex");
test.codex
.submit(disabled_permission_user_turn(
"before switch",
test.cwd.path().to_path_buf(),
previous_model.to_string(),
))
.await
.expect("submit first user turn");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex
.submit(disabled_permission_user_turn(
"after switch",
test.cwd.path().to_path_buf(),
next_model.to_string(),
))
.await
.expect("submit second user turn");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = request_log.requests();
assert_eq!(models_mock.requests().len(), 1);
assert_eq!(
requests.len(),
2,
"disabled auto-compaction should skip compaction on a comp-hash model switch"
);
let first = requests[0].body_json();
let second = requests[1].body_json();
assert_eq!(first["model"].as_str(), Some(previous_model));
assert_eq!(second["model"].as_str(), Some(next_model));
assert!(second.to_string().contains("before switch"));
assert!(second.to_string().contains("after switch"));
assert!(
!body_contains_text(&second.to_string(), SUMMARIZATION_PROMPT),
"disabled auto-compaction should preserve history instead of requesting a summary"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() {
skip_if_no_network!();
@@ -3655,6 +3786,45 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_feature_disabled_skips_mid_turn_compaction() {
skip_if_no_network!();
let server = start_mock_server().await;
let over_limit_tokens = 100 * 95 / 100 + 1;
let first_turn = sse(vec![
ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"),
ev_completed_with_tokens("r1", over_limit_tokens),
]);
let request_log = mount_sse_sequence(
&server,
vec![
first_turn,
sse_failed(
"response-failed",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
),
],
)
.await;
let test = build_auto_compaction_disabled_codex(&server).await;
submit_context_window_exceeded_turn(&test.codex, FUNCTION_CALL_LIMIT_MSG).await;
let requests = request_log.requests();
assert_eq!(requests.len(), 2);
let continuation_request = &requests[1];
continuation_request.function_call_output(DUMMY_CALL_ID);
assert!(
!body_contains_text(
&continuation_request.body_json().to_string(),
SUMMARIZATION_PROMPT
),
"disabled auto-compaction should continue without a compaction request"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compact_clamps_config_limit_to_context_window() {
skip_if_no_network!();
@@ -4488,6 +4658,44 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_feature_disabled_skips_pre_turn_compaction() {
skip_if_no_network!();
let server = start_mock_server().await;
let first_turn = sse(vec![
ev_assistant_message("m1", FIRST_REPLY),
ev_completed_with_tokens("r1", /*total_tokens*/ 500),
]);
let request_log = mount_sse_sequence(
&server,
vec![
first_turn,
sse_failed(
"response-failed",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
),
],
)
.await;
let test = build_auto_compaction_disabled_codex(&server).await;
test.submit_turn("USER_ONE")
.await
.expect("submit first turn");
submit_context_window_exceeded_turn(&test.codex, "USER_TWO").await;
let requests = request_log.requests();
assert_eq!(requests.len(), 2);
let second_request_body = requests[1].body_json().to_string();
assert!(second_request_body.contains("USER_TWO"));
assert!(
!body_contains_text(&second_request_body, SUMMARIZATION_PROMPT),
"disabled auto-compaction should sample without a pre-turn compaction request"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_manual_compact_without_previous_user_messages() {
skip_if_no_network!();
+48
View File
@@ -549,3 +549,51 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auto_compaction_feature_disabled_hides_new_context_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-1"),
ev_completed("resp-1"),
])],
)
.await;
let test = test_codex()
.with_config(|config| {
config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW);
config
.features
.enable(Feature::TokenBudget)
.expect("test config should allow token budget");
config
.features
.disable(Feature::AutoCompaction)
.expect("test config should allow disabling auto-compaction");
})
.build(&server)
.await?;
test.submit_turn("preserve the current context window")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 1);
let tool_names = tool_names(&requests[0]);
assert!(
tool_names
.iter()
.any(|name| name == "get_context_remaining"),
"token budget should continue to expose get_context_remaining"
);
assert!(
!tool_names.iter().any(|name| name == "new_context"),
"disabled auto-compaction should hide new_context"
);
Ok(())
}
+8
View File
@@ -231,6 +231,8 @@ pub enum Feature {
RealtimeConversation,
/// Prevent idle system sleep while a turn is actively running.
PreventIdleSleep,
/// Enable automatic context compaction before or during a turn.
AutoCompaction,
/// Enable remote compaction v2 over the normal Responses API.
RemoteCompactionV2,
/// Use Agent Identity for ChatGPT-authenticated sessions.
@@ -1335,6 +1337,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {
id: Feature::AutoCompaction,
key: "auto_compaction",
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteCompactionV2,
key: "remote_compaction_v2",