use crate::context::UserGoalUpdate; use std::collections::HashSet; use std::sync::Arc; use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; use crate::context::BaseInstructionsFragment; use crate::context::CompactionSummary; use crate::context::ContextualUserFragment; use crate::context::world_state::WorldState; use crate::context::world_state::WorldStateSnapshot; use crate::context::world_state::split_prefix_updates; use crate::context_manager::updates::merge_world_state_updates; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::CompactionTurnMetadata; use crate::session::RequestEffortUsage; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::turn::get_last_assistant_message_from_turn; use crate::session::turn_context::TurnContext; use crate::state::AutoCompactWindowIds; use crate::util::backoff; use codex_analytics::CodexCompactionEvent; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_analytics::now_unix_seconds; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::set_annotated_content; use codex_context_fragments::truncate_annotated_text; use codex_history::CodexHarnessMetadata; use codex_history::ResponseItemEnvelope; use codex_protocol::ResponseItemId; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::user_input::UserInput; use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use futures::prelude::*; use tracing::error; pub use codex_prompts::SUMMARIZATION_PROMPT; pub use codex_prompts::SUMMARY_PREFIX; const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; /// Thread-local policy that preserves inherited context without changing model or skill budgets. /// Insert into `StartThreadOptions::thread_extension_init` to disable automatic compaction. pub struct AutoCompactionDisabled; /// Metadata for a new compaction checkpoint, kept separate from its replacement history. /// /// `Session::replace_compacted_history` assigns missing item IDs before constructing the persisted /// `CompactedItem`, ensuring the live and persisted histories remain identical. pub(crate) struct CompactedHistoryMetadata { pub(crate) input_goal_ids: HashSet, pub(crate) message: String, pub(crate) window_number: u64, pub(crate) window_ids: AutoCompactWindowIds, pub(crate) compaction_response_id: Option, pub(crate) compaction_model_hash: Option, pub(crate) reviewer_compaction_hash: Option, } /// Renders the window prefix and ordinary context with their comparison baseline. pub(crate) async fn build_compaction_replacement_history( sess: &Session, step_context: &StepContext, world_state: &WorldState, compacted_history: Vec, ) -> (Vec, WorldStateSnapshot) { let (updates, snapshot) = sess .build_initial_context_with_world_state(step_context, world_state) .await; let (prefix, context) = split_prefix_updates(updates); let context = merge_world_state_updates(context); ( assemble_compaction_history(compacted_history, prefix, context), snapshot, ) } fn assemble_compaction_history( compacted_history: Vec, prefix: Vec, context: Vec, ) -> Vec { let history = insert_initial_context_before_last_real_user_or_summary( compacted_history, context.into_iter().map(ResponseItemEnvelope::new).collect(), ); prefix .into_iter() .map(ResponseItemEnvelope::new) .chain(history) .collect() } pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, replacement_step_context: Arc, world_state: Arc, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { let prompt = turn_context .config .compact_prompt .as_deref() .unwrap_or(SUMMARIZATION_PROMPT) .to_string(); let input = vec![UserInput::Text { text: prompt, // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; run_compact_task_inner( sess, turn_context, replacement_step_context, input, world_state, CompactionTurnMetadata::new( CompactionTrigger::Auto, reason, CompactionImplementation::Responses, phase, ), ) .await?; Ok(()) } pub(crate) async fn run_compact_task( sess: Arc, step_context: Arc, world_state: Arc, input: Vec, ) -> CodexResult<()> { run_compact_task_inner( sess.clone(), Arc::clone(&step_context.turn), step_context, input, world_state, CompactionTurnMetadata::new( CompactionTrigger::Manual, CompactionReason::UserRequested, CompactionImplementation::Responses, CompactionPhase::StandaloneTurn, ), ) .await?; Ok(()) } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, replacement_step_context: Arc, input: Vec, world_state: Arc, compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let trigger = compaction_metadata.trigger(); let reason = compaction_metadata.reason(); let phase = compaction_metadata.phase(); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), trigger, reason, CompactionImplementation::Responses, phase, ) .await; let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await; match pre_compact_outcome { PreCompactHookOutcome::Continue => {} PreCompactHookOutcome::Stopped => { let error = CodexErr::TurnAborted; attempt .track( sess.as_ref(), CompactionStatus::Interrupted, Some(&error), CompactionAnalyticsDetails::default(), ) .await; return Err(error); } } let result = run_compact_task_inner_impl( Arc::clone(&sess), Arc::clone(&turn_context), replacement_step_context, input, world_state, compaction_metadata, ) .await; let status = compaction_status_from_result(&result); let codex_error = result.as_ref().err(); if result.is_ok() { let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt .track( sess.as_ref(), status, codex_error, CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); } } attempt .track( sess.as_ref(), status, codex_error, 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(|_| ()) } async fn run_compact_task_inner_impl( sess: Arc, turn_context: Arc, replacement_step_context: Arc, input: Vec, world_state: Arc, compaction_metadata: CompactionTurnMetadata, ) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) .await; let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; let input_goal_ids = UserGoalUpdate::message_ids(history.raw_items()); history.record_items( &[initial_input_for_turn.into()], turn_context.model_info().truncation_policy.into(), ); let mut fallback_instructions = sess.get_prompt_base_instructions().await; if let Some(text) = history .raw_items() .find_map(BaseInstructionsFragment::text_from_item) { fallback_instructions.text = text.to_owned(); } else if history.has_tool_declarations() { // An incremental window has no recorded base item when its instructions are empty. fallback_instructions.text.clear(); } let max_retries = turn_context.provider.info().stream_max_retries(); let mut retries = 0; // Reuse one client session so turn-scoped state (sticky routing and websocket incremental // request tracking) survives retries within this compact turn. let mut client_session = sess.services.model_client.new_session(); let compaction_response = loop { // Clone is required because of the loop let mut turn_input = history .clone() .for_prompt(&turn_context.model_info().input_modalities); sess.services .executed_tool_calls .attach_to_compaction_prompt(&mut turn_input); let turn_input_len = turn_input.len(); let base_instructions = if turn_input .iter() .any(|item| BaseInstructionsFragment::text_from_item(item).is_some()) { BaseInstructions { text: String::new(), provenance: None, } } else { fallback_instructions.clone() }; let prompt = Prompt { input: turn_input, base_instructions, cyber_access_program: turn_context.cyber_access_program, ..Default::default() }; let responses_metadata = sess .compaction_responses_metadata(turn_context.as_ref(), compaction_metadata) .await; let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), &mut client_session, &responses_metadata, &prompt, compaction_metadata.phase(), ) .await; match attempt_result { Ok(response) => { break response; } Err(err) if matches!( err.details(), CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted ) => { return Err(err); } Err(e) if matches!( e.details(), CodexErrorDetails::SessionBudgetExceeded | CodexErrorDetails::ResponseIncomplete(_) ) => { return Err(e); } Err(e) if matches!(e.details(), CodexErrorDetails::ContextWindowExceeded) => { if turn_input_len > 1 { // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. error!( "Context window exceeded while compacting; removing oldest history item. Error: {e}" ); history.remove_first_item(); retries = 0; continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; return Err(e); } Err(e) => { if retries < max_retries { retries += 1; let delay = backoff(retries); sess.notify_stream_error( turn_context.as_ref(), format!("Reconnecting... {retries}/{max_retries}"), e, ) .await; tokio::time::sleep(delay).await; continue; } else { return Err(e); } } } }; let history_snapshot = sess.clone_history().await; let history_items = history_snapshot.annotated_items(); 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 user_messages = collect_annotated_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); if let Some(summary_item) = new_history.last_mut() { // This replacement history skips `record_conversation_items`; only the appended summary // belongs to this compaction turn. summary_item.set_turn_id_if_missing(&turn_context.sub_id); } let (window_number, window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = build_compaction_replacement_history( sess.as_ref(), &replacement_step_context, &world_state, new_history, ) .await; sess.replace_compacted_history( new_history, replacement_step_context.to_turn_context_item(), world_state_baseline, CompactedHistoryMetadata { input_goal_ids, message: summary_text, window_number, window_ids, compaction_response_id: Some(compaction_response.response_id), compaction_model_hash: turn_context.model_info().comp_hash.clone(), reviewer_compaction_hash: None, }, ) .await; sess.recompute_token_usage(&replacement_step_context.turn) .await; sess.emit_turn_item_completed(&turn_context, compaction_item) .await; Ok(summary_suffix) } pub(crate) struct CompactionAnalyticsAttempt { thread_id: String, turn_id: String, trigger: CompactionTrigger, reason: CompactionReason, implementation: CompactionImplementation, phase: CompactionPhase, active_context_tokens_before: i64, started_at: u64, start_instant: Instant, } #[derive(Clone, Copy, Default)] pub(crate) struct CompactionAnalyticsDetails { pub(crate) active_context_tokens_before: Option, pub(crate) retained_image_count: Option, pub(crate) compaction_summary_tokens: Option, pub(crate) cached_input_tokens: Option, pub(crate) cache_write_input_tokens: Option, } impl CompactionAnalyticsAttempt { pub(crate) async fn begin( sess: &Session, turn_context: &TurnContext, trigger: CompactionTrigger, reason: CompactionReason, implementation: CompactionImplementation, phase: CompactionPhase, ) -> Self { let active_context_tokens_before = sess.get_total_token_usage().await; Self { thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), trigger, reason, implementation, phase, active_context_tokens_before, started_at: now_unix_seconds(), start_instant: Instant::now(), } } pub(crate) async fn track( self, sess: &Session, status: CompactionStatus, codex_error: Option<&CodexErr>, details: CompactionAnalyticsDetails, ) { let CompactionAnalyticsDetails { active_context_tokens_before, retained_image_count, compaction_summary_tokens, cached_input_tokens, cache_write_input_tokens, } = details; let active_context_tokens_before = active_context_tokens_before.unwrap_or(self.active_context_tokens_before); let active_context_tokens_after = sess.get_total_token_usage().await; sess.services .analytics_events_client .track_compaction(CodexCompactionEvent { thread_id: self.thread_id, turn_id: self.turn_id, trigger: self.trigger, reason: self.reason, implementation: self.implementation, phase: self.phase, strategy: CompactionStrategy::Memento, status, codex_error_kind: codex_error.map(Into::into), codex_error_http_status_code: codex_error .and_then(CodexErr::http_status_code_value), usage_limit_window_minutes: codex_error.and_then(|error| match error.details() { CodexErrorDetails::UsageLimitReached(error) => error.limit_window_minutes, _ => None, }), active_context_tokens_before, active_context_tokens_after, retained_image_count, compaction_summary_tokens, cached_input_tokens, cache_write_input_tokens, started_at: self.started_at, completed_at: now_unix_seconds(), duration_ms: Some( u64::try_from(self.start_instant.elapsed().as_millis()).unwrap_or(u64::MAX), ), }); } } pub(crate) fn compaction_status_from_result(result: &CodexResult) -> CompactionStatus { match result { Ok(_) => CompactionStatus::Completed, Err(err) if matches!( err.details(), CodexErrorDetails::Interrupted | CodexErrorDetails::TurnAborted ) => { CompactionStatus::Interrupted } Err(_) => CompactionStatus::Failed, } } pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { match item { ContentItem::InputText { text } | ContentItem::OutputText { text, .. } => { if !text.is_empty() { pieces.push(text.as_str()); } } ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {} } } if pieces.is_empty() { None } else { Some(pieces.join("\n")) } } #[derive(Clone, Debug, PartialEq)] pub(crate) struct CompactedUserMessage<'a> { // Flattened text is only for budgeting and recognizing summaries. The // output is built from original parts, never reconstructed from this text. // Borrow from the history snapshot until selected output is materialized. message: String, original: &'a ResponseItem, harness_metadata: Option<&'a CodexHarnessMetadata>, } #[cfg(test)] pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec> { items .iter() .filter_map(|item| compacted_user_message(item, /*harness_metadata*/ None)) .collect() } pub(crate) fn collect_annotated_user_messages( items: &[ResponseItemEnvelope], ) -> Vec> { items .iter() .filter_map(|envelope| compacted_user_message(&envelope.item, envelope.metadata.as_ref())) .collect() } fn compacted_user_message<'a>( item: &'a ResponseItem, harness_metadata: Option<&'a CodexHarnessMetadata>, ) -> Option> { let Some(TurnItem::UserMessage(user)) = crate::event_mapping::parse_turn_item(item) else { return None; }; let message = user.message(); if is_summary_message(&message) { return None; } Some(CompactedUserMessage { message, original: item, harness_metadata, }) } pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } pub(crate) fn is_real_user_or_agent_message(item: &ResponseItem) -> bool { if let ResponseItem::AgentMessage { content, .. } = item { return !matches!( content.first(), Some(AgentMessageInputContent::InputText { text }) if text.starts_with("Message Type: FINAL_ANSWER\n") ); } matches!(item, ResponseItem::Message { role, .. } if role == "user") && matches!( crate::event_mapping::parse_turn_item(item), Some(TurnItem::UserMessage(user)) if !is_summary_message(&user.message()) ) } /// Inserts canonical initial context into compacted replacement history at the /// model-expected boundary. /// /// Placement rules: /// - Prefer immediately before the last real user, agent message, or retained standalone output. /// - If no real user messages remain, insert before the compaction summary so /// the summary stays last. /// - If there are no user messages, insert before the last compaction item so /// that item remains last (remote compaction may return only compaction items). /// - If there are no user messages or compaction items, append the context. pub(crate) fn insert_initial_context_before_last_real_user_or_summary( mut compacted_history: Vec, initial_context: Vec, ) -> Vec { let mut last_user_or_summary_index = None; let mut last_real_user_index = None; for (i, item) in compacted_history.iter().enumerate().rev() { if crate::compact_retained_tool_outputs::is_retained_tool_output(item) || is_real_user_or_agent_message(&item.item) { last_real_user_index = Some(i); break; } // Compaction summaries are encoded as user messages, so track both: // the last real user message (preferred insertion point) and the last // user-message-like item (fallback summary insertion point). if matches!( crate::event_mapping::parse_turn_item(&item.item), Some(TurnItem::UserMessage(_)) ) { last_user_or_summary_index.get_or_insert(i); } } let last_compaction_index = compacted_history .iter() .enumerate() .rev() .find_map(|(i, item)| { matches!( &item.item, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } ) .then_some(i) }); let insertion_index = last_real_user_index .or(last_user_or_summary_index) .or(last_compaction_index); // Re-inject canonical context from the current session since we stripped it // from the pre-compaction history. Prefer placing it before the last real // user message; if there is no real user message left, place it before the // summary or compaction item so the compaction item remains last. if let Some(insertion_index) = insertion_index { compacted_history.splice(insertion_index..insertion_index, initial_context); } else { compacted_history.extend(initial_context); } compacted_history } pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[CompactedUserMessage<'_>], summary_text: &str, ) -> Vec { build_compacted_history_with_limit( initial_context, user_messages, summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS, ) } fn build_compacted_history_with_limit( mut history: Vec, user_messages: &[CompactedUserMessage<'_>], summary_text: &str, max_tokens: usize, ) -> Vec { let mut selected_messages = Vec::new(); if max_tokens > 0 { let mut remaining = max_tokens; for message in user_messages.iter().rev() { if remaining == 0 { break; } let tokens = approx_token_count(&message.message); let ResponseItem::Message { id, content, internal_chat_message_metadata_passthrough, .. } = message.original else { continue; }; let mut harness_metadata = message.harness_metadata.cloned(); let preserve_content = tokens <= remaining && content .iter() .all(|part| matches!(part, ContentItem::InputText { .. })); let mut item = ResponseItem::Message { status: None, encrypted_content: None, id: id.clone(), role: "user".to_owned(), content: if preserve_content { content.clone() } else { Vec::new() }, phase: None, internal_chat_message_metadata_passthrough: internal_chat_message_metadata_passthrough.clone(), }; if !preserve_content { // Select text and its annotations by the same source index. // Do not clone discarded images/audio or infer their text's source. let metadata = internal_chat_message_metadata_passthrough.as_ref(); let mut parts: Vec<_> = crate::event_mapping::user_input_parts(content) .filter_map(|(index, part)| { let ContentItem::InputText { text } = part else { return None; }; let kind = metadata .and_then(|metadata| metadata.content_item_kinds.as_ref()) .and_then(|kinds| kinds.get(index)) .cloned() .unwrap_or_else(|| ContentItemKind("unknown".to_string())); let source = metadata .and_then(|metadata| metadata.content_item_metadata.as_ref()) .and_then(|sources| sources.get(index)) .cloned() .unwrap_or_default(); Some(AnnotatedContent::text(text, kind, source)) }) .collect(); if parts.is_empty() { // Preserve the existing empty-text fallback for media-only input. parts.push(AnnotatedContent::new( ContentItem::InputText { text: String::new(), }, ContentItemKind("unknown".to_string()), )); } set_annotated_content(&mut item, truncate_annotated_text(parts, remaining)); } if tokens > remaining && let Some(metadata) = &mut harness_metadata { metadata.mark_retained_sources_incomplete(); } selected_messages.push(ResponseItemEnvelope { item, metadata: harness_metadata, }); if tokens > remaining { break; } remaining = remaining.saturating_sub(tokens); } selected_messages.reverse(); } history.extend(selected_messages); let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { summary_text.to_string() }; history.push(ResponseItemEnvelope::new(ContextualUserFragment::into( CompactionSummary::new(summary_text), ))); 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, phase: CompactionPhase, ) -> CodexResult { sess.set_server_reasoning_included(turn_context.model_info().use_responses_lite) .await; let mut stream = client_session .stream( prompt, turn_context.model_info(), &turn_context.session_telemetry, sess.reasoning_effort_for_request( &turn_context.initial_settings, RequestEffortUsage::Compaction, ) .await, turn_context.reasoning_summary(), turn_context.config.service_tier.clone(), responses_metadata, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. &InferenceTraceContext::disabled(), ) .await?; let mut output = Vec::new(); loop { let maybe_event = stream.next().await; let Some(event) = maybe_event else { return Err(CodexErr::Stream( "stream closed before response.completed".into(), )); }; match event { // Keep each attempt isolated until the response completes successfully. Ok(ResponseEvent::OutputItemDone(item)) => output.push(item), Ok(ResponseEvent::ServerReasoningIncluded(included)) => { sess.set_server_reasoning_included(included).await; } Ok(ResponseEvent::RateLimits(snapshot)) => { sess.update_rate_limits(turn_context, snapshot).await; } Ok(ResponseEvent::Completed { response_id, token_usage, usage_metadata, .. }) => { if !matches!(phase, CompactionPhase::PostTurn) { for item in std::mem::take(&mut output) { sess.record_annotated_conversation_items( turn_context, turn_context.model_info(), vec![ResponseItemEnvelope { item, metadata: Some(CodexHarnessMetadata { compaction_output: true, ..Default::default() }), }], ) .await; } } sess.record_observed_response_completed( turn_context, &response_id, token_usage.as_ref(), usage_metadata.as_ref(), ) .await; sess.update_token_usage_info(turn_context, token_usage.as_ref()) .await?; return Ok(CompactionResponse { response_id, output, }); } Ok(_) => continue, Err(e) => { if let CodexErrorDetails::ResponseIncomplete(incomplete) = e.details() { sess.record_incomplete_response_usage( turn_context, &turn_context.initial_settings, incomplete, ) .await; sess.send_token_count_event(turn_context).await; } return Err(e); } } } } #[cfg(test)] #[path = "compact_tests.rs"] mod tests;