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
This commit is contained in:
Henry Levy
2026-09-19 00:52:56 +00:00
committed by copyberry
parent d5b29951ac
commit 49e248d4c3
10 changed files with 131 additions and 31 deletions
+1
View File
@@ -465,6 +465,7 @@ pub enum CompactionPhase {
StandaloneTurn,
PreTurn,
MidTurn,
PostTurn,
}
#[derive(Clone, Copy, Debug, Serialize)]
+6
View File
@@ -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<AutoCompactTokenLimitScope>,
/// 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<u8>,
/// Default approval policy for executing commands.
#[schemars(with = "Option<crate::schema::ConfigAskForApproval>")]
pub approval_policy: Option<AskForApproval>,
+7
View File
@@ -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"
+54 -30
View File
@@ -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<ResponseItem>,
}
async fn drain_to_completed(
sess: &Session,
turn_context: &TurnContext,
client_session: &mut ModelClientSession,
responses_metadata: &CodexResponsesMetadata,
prompt: &Prompt,
) -> CodexResult<String> {
phase: CompactionPhase,
) -> CodexResult<CompactionResponse> {
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),
+6 -1
View File
@@ -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.
+13
View File
@@ -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,
@@ -16,6 +16,7 @@ pub(crate) struct ContextWindowTokenStatus {
pub(crate) auto_compact_window_prefill_tokens: Option<i64>,
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<i64>, used: i64) -> Option<i64> {
@@ -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,
}
}
+32
View File
@@ -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;
@@ -13,6 +13,8 @@ pub(crate) fn build_reviewer_config(parent_config: &Config) -> anyhow::Result<Co
let mut config = parent_config.clone();
config.model_provider.request_max_retries = Some(1);
config.model_provider.stream_max_retries = Some(1);
// Approvals wait for TurnComplete; post-turn compaction must not delay it.
config.model_post_turn_compact_threshold_percent = 0;
config.include_skill_instructions = false;
config.memories.use_memories = false;
config.memories.dedicated_tools = false;
@@ -196,6 +196,7 @@ fn new_config(model: Option<String>, 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,