From 49e248d4c3ad76fc29519bd0aa9e532f448fa90f Mon Sep 17 00:00:00 2001 From: Henry Levy Date: Fri, 18 Sep 2026 20:06:54 +0000 Subject: [PATCH] Add opt-in compaction after final responses (#46541) ## What changed Add `model_post_turn_compact_threshold_percent` to trigger compaction after a final response when context usage reaches the configured percentage of the usable window or an existing auto-compaction limit. Accept values from `0` to `100`; omitted or zero disables the feature. Skip post-turn compaction when input is pending, the turn is cancelled, or token-budget mode is enabled. Disable it for approval reviewers to avoid delaying approval completion, and record a distinct `PostTurn` analytics phase. Buffer local post-turn summaries until compaction succeeds and require a nonempty assistant summary before replacing history. Log compaction failures without failing the completed turn, while still propagating interruptions and turn aborts. GitOrigin-RevId: b616994175829bd83b5d7974f95dabb042a39a89 --- codex-rs/analytics/src/facts.rs | 1 + codex-rs/config/src/config_toml.rs | 6 ++ codex-rs/core/config.schema.json | 7 ++ codex-rs/core/src/compact.rs | 84 ++++++++++++------- codex-rs/core/src/compact_remote_v2.rs | 7 +- codex-rs/core/src/config/mod.rs | 13 +++ codex-rs/core/src/session/context_window.rs | 9 ++ codex-rs/core/src/session/turn.rs | 32 +++++++ .../src/sync_reviewer/reviewer_config.rs | 2 + codex-rs/thread-manager-sample/src/main.rs | 1 + 10 files changed, 131 insertions(+), 31 deletions(-) diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 78492faab5..a61a000a1a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -465,6 +465,7 @@ pub enum CompactionPhase { StandaloneTurn, PreTurn, MidTurn, + PostTurn, } #[derive(Clone, Copy, Debug, Serialize)] diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index d0808fd9bd..bdaee87179 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -173,6 +173,12 @@ pub struct ConfigToml { /// only to tokens after the carried prefix in the current compaction window. pub model_auto_compact_token_limit_scope: Option, + /// Percentage of the usable context window that triggers compaction after a final + /// response. Existing auto-compaction limits still apply. Omitted or zero disables + /// turn-end compaction; valid values are 0–100. + #[schemars(range(min = 0, max = 100))] + pub model_post_turn_compact_threshold_percent: Option, + /// Default approval policy for executing commands. #[schemars(with = "Option")] pub approval_policy: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 164ef7093e..9973ee5133 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -7086,6 +7086,13 @@ ], "description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance." }, + "model_post_turn_compact_threshold_percent": { + "description": "Percentage of the usable context window that triggers compaction after a final response. Existing auto-compaction limits still apply. Omitted or zero disables turn-end compaction; valid values are 0–100.", + "format": "uint8", + "maximum": 100.0, + "minimum": 0.0, + "type": "integer" + }, "model_provider": { "description": "Provider to use from the model_providers map.", "type": "string" diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e77b2c88cf..e102da28b4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -230,6 +230,20 @@ async fn run_compact_task_inner( CompactionAnalyticsDetails::default(), ) .await; + if let Err(err) = &result + && !matches!(phase, CompactionPhase::PostTurn) + && !matches!( + err.details(), + CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted + ) + { + sess.track_turn_codex_error(turn_context.as_ref(), err); + // Pre-turn failures are reported after preserving the incoming prompt. + if !matches!(phase, CompactionPhase::PreTurn) { + let event = EventMsg::Error(err.to_error_event(/*message_prefix*/ None)); + sess.send_event(&turn_context, event).await; + } + } result.map(|_| ()) } @@ -261,7 +275,7 @@ async fn run_compact_task_inner_impl( .compaction_responses_metadata(turn_context.as_ref(), compaction_metadata) .await; - let compaction_response_id = loop { + let compaction_response = loop { // Clone is required because of the loop let mut turn_input = history .clone() @@ -281,12 +295,13 @@ async fn run_compact_task_inner_impl( &mut client_session, &responses_metadata, &prompt, + compaction_metadata.phase(), ) .await; match attempt_result { - Ok(response_id) => { - break response_id; + Ok(response) => { + break response; } Err(err) if matches!( @@ -297,12 +312,6 @@ async fn run_compact_task_inner_impl( return Err(err); } Err(e) if matches!(e.details(), CodexErrorDetails::SessionBudgetExceeded) => { - sess.track_turn_codex_error(turn_context.as_ref(), &e); - // Pre-turn failures are reported after preserving the incoming prompt. - if !matches!(compaction_metadata.phase(), CompactionPhase::PreTurn) { - let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); - sess.send_event(&turn_context, event).await; - } return Err(e); } Err(e) if matches!(e.details(), CodexErrorDetails::ContextWindowExceeded) => { @@ -316,11 +325,6 @@ async fn run_compact_task_inner_impl( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; - sess.track_turn_codex_error(turn_context.as_ref(), &e); - if !matches!(compaction_metadata.phase(), CompactionPhase::PreTurn) { - let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); - sess.send_event(&turn_context, event).await; - } return Err(e); } Err(e) => { @@ -336,11 +340,6 @@ async fn run_compact_task_inner_impl( tokio::time::sleep(delay).await; continue; } else { - sess.track_turn_codex_error(turn_context.as_ref(), &e); - if !matches!(compaction_metadata.phase(), CompactionPhase::PreTurn) { - let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); - sess.send_event(&turn_context, event).await; - } return Err(e); } } @@ -349,8 +348,17 @@ async fn run_compact_task_inner_impl( let history_snapshot = sess.clone_history().await; let history_items = history_snapshot.annotated_items(); - let summary_suffix = - get_last_assistant_message_from_turn(history_snapshot.raw_items()).unwrap_or_default(); + let summary_suffix = if matches!(compaction_metadata.phase(), CompactionPhase::PostTurn) { + get_last_assistant_message_from_turn(compaction_response.output.iter()) + .filter(|summary| !summary.trim().is_empty()) + .ok_or_else(|| { + CodexErr::Stream( + "Post-turn compaction completed without an assistant summary".to_string(), + ) + })? + } else { + get_last_assistant_message_from_turn(history_snapshot.raw_items()).unwrap_or_default() + }; let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let identity = if sess.guardian_context_mode == GuardianContextMode::ThreadOwned { CompactedMessageIdentity::Preserve @@ -387,7 +395,7 @@ async fn run_compact_task_inner_impl( message: summary_text, window_number, window_ids, - compaction_response_id: Some(compaction_response_id), + compaction_response_id: Some(compaction_response.response_id), compaction_model_hash: turn_context.model_info().comp_hash.clone(), reviewer_compaction_hash: None, }, @@ -755,13 +763,19 @@ fn build_compacted_history_with_limit( history } +struct CompactionResponse { + response_id: String, + output: Vec, +} + async fn drain_to_completed( sess: &Session, turn_context: &TurnContext, client_session: &mut ModelClientSession, responses_metadata: &CodexResponsesMetadata, prompt: &Prompt, -) -> CodexResult { + phase: CompactionPhase, +) -> CodexResult { let mut stream = client_session .stream( prompt, @@ -780,6 +794,7 @@ async fn drain_to_completed( &InferenceTraceContext::disabled(), ) .await?; + let mut output = Vec::new(); loop { let maybe_event = stream.next().await; let Some(event) = maybe_event else { @@ -789,12 +804,18 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - sess.record_conversation_items( - turn_context, - turn_context.model_info(), - std::slice::from_ref(&item), - ) - .await; + if matches!(phase, CompactionPhase::PostTurn) { + // Commit post-turn summaries only after success; failures must leave both + // the live history and persisted rollout intact. + output.push(item); + } else { + sess.record_conversation_items( + turn_context, + turn_context.model_info(), + std::slice::from_ref(&item), + ) + .await; + } } Ok(ResponseEvent::ServerReasoningIncluded(included)) => { sess.set_server_reasoning_included(included).await; @@ -817,7 +838,10 @@ async fn drain_to_completed( .await; sess.update_token_usage_info(turn_context, token_usage.as_ref()) .await?; - return Ok(response_id); + return Ok(CompactionResponse { + response_id, + output, + }); } Ok(_) => continue, Err(e) => return Err(e), diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index e0dd97b999..a6436d15f7 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -199,7 +199,12 @@ async fn run_remote_compact_task_inner( .await; match result { Ok(()) => Ok(()), - Err(err) if matches!(err.details(), CodexErrorDetails::TurnAborted) => Err(err), + Err(err) + if matches!(err.details(), CodexErrorDetails::TurnAborted) + || matches!(phase, CompactionPhase::PostTurn) => + { + Err(err) + } Err(err) => { sess.track_turn_codex_error(turn_context, &err); // Pre-turn failures are reported by run_turn after preserving the incoming prompt. diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0c97b86f5b..1cea5685b5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -635,6 +635,10 @@ pub struct Config { /// active context or only tokens after the carried compaction-window prefix. pub model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope, + /// Percentage of the usable context window that triggers turn-end compaction. + /// Zero disables turn-end compaction. + pub model_post_turn_compact_threshold_percent: u8, + /// Key into the model_providers map that specifies which provider to use. pub model_provider_id: String, @@ -3176,6 +3180,12 @@ impl Config { validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if cfg.model_post_turn_compact_threshold_percent.is_some_and(|percent| percent > 100) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "model_post_turn_compact_threshold_percent must be between 0 and 100", + )); + } if let Some(responses_api_metadata) = cfg.responses_api_metadata.as_ref() { validate_extra_metadata(responses_api_metadata.iter()).map_err(|message| { std::io::Error::new(std::io::ErrorKind::InvalidInput, message) @@ -4171,6 +4181,9 @@ impl Config { model_auto_compact_token_limit_scope: cfg .model_auto_compact_token_limit_scope .unwrap_or_default(), + model_post_turn_compact_threshold_percent: cfg + .model_post_turn_compact_threshold_percent + .unwrap_or_default(), model_provider_id, model_provider, cwd: resolved_cwd, diff --git a/codex-rs/core/src/session/context_window.rs b/codex-rs/core/src/session/context_window.rs index 18ddccd043..edddb25461 100644 --- a/codex-rs/core/src/session/context_window.rs +++ b/codex-rs/core/src/session/context_window.rs @@ -16,6 +16,7 @@ pub(crate) struct ContextWindowTokenStatus { pub(crate) auto_compact_window_prefill_tokens: Option, pub(crate) full_context_window_limit_reached: bool, pub(crate) token_limit_reached: bool, + pub(crate) turn_end_compaction_threshold_reached: bool, } fn tokens_remaining(limit: Option, used: i64) -> Option { @@ -107,6 +108,13 @@ async fn context_window_token_status_with_config( let token_limit_reached = buffered_auto_compact_limit .is_some_and(|limit| auto_compact_scope_tokens >= limit) || full_context_window_limit_reached; + let post_turn_percent = config.model_post_turn_compact_threshold_percent; + let turn_end_compaction_threshold_reached = post_turn_percent > 0 + && (token_limit_reached + || full_context_window_limit.is_some_and(|limit| { + i128::from(active_context_tokens) * 100 + >= i128::from(limit) * i128::from(post_turn_percent) + })); ContextWindowTokenStatus { active_context_tokens, @@ -117,5 +125,6 @@ async fn context_window_token_status_with_config( auto_compact_window_prefill_tokens, full_context_window_limit_reached, token_limit_reached, + turn_end_compaction_threshold_reached, } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 3f396ef5db..360607f3f4 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -701,6 +701,38 @@ pub(crate) async fn run_turn( { return Ok(None); } + // Token-budget resets do not summarize, so preserve their existing rollover + // policy. Keep summarizing compaction in this task to serialize history updates. + let config = &turn_context.config; + if config.model_post_turn_compact_threshold_percent > 0 + && !config.features.enabled(Feature::TokenBudget) + && super::context_window::context_window_token_status( + sess.as_ref(), + turn_context.as_ref(), + ) + .await + .turn_end_compaction_threshold_reached + && !sess.input_queue.has_pending_input(&sess.active_turn).await + && !cancellation_token.is_cancelled() + && let Err(err) = run_auto_compact( + &sess, + Arc::clone(&step_context), + /*fallback_step_context*/ None, + &mut client_session, + InitialContextInjection::DoNotInject, + CompactionReason::ContextLimit, + CompactionPhase::PostTurn, + ) + .await + { + if matches!( + err.details(), + CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted + ) { + return Err(err); + } + warn!(error = %err, "Post-turn compaction failed; preserving the completed turn"); + } break; } continue; diff --git a/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config.rs b/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config.rs index c8748cb159..6fb27ee982 100644 --- a/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config.rs +++ b/codex-rs/ext/guardian-v2/src/sync_reviewer/reviewer_config.rs @@ -13,6 +13,8 @@ pub(crate) fn build_reviewer_config(parent_config: &Config) -> anyhow::Result, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_context_window: None, model_auto_compact_token_limit: None, model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, + model_post_turn_compact_threshold_percent: 0, model_provider_id, model_provider, personality: None,