mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -465,6 +465,7 @@ pub enum CompactionPhase {
|
||||
StandaloneTurn,
|
||||
PreTurn,
|
||||
MidTurn,
|
||||
PostTurn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user