use std::sync::Arc; use std::sync::OnceLock; use crate::compact::CompactedHistoryMetadata; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::build_compaction_initial_context; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::compact_model_fallback::record_model_fallback; use crate::compact_model_fallback::should_retry_with_current_model; use crate::compact_remote_history::HistoryItemGroup; use crate::compact_remote_history::history_item_groups; use crate::context::world_state::WorldState; use crate::context_manager::ContextManager; use crate::context_manager::estimate_item_token_count; 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::CompactionTurnMetadata; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; use codex_history::ResponseItemEnvelope; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::BaseInstructions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; use codex_utils_output_truncation::approx_token_count; use tokio_util::sync::CancellationToken; #[path = "compact_remote_request.rs"] mod request; use request::RemoteCompactAttempt; use request::run_remote_compact_attempt; const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = "Output exceeded the available model context and was truncated"; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, fallback_step_context: Option>, turn_state: Arc>, initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { let compaction_metadata = CompactionTurnMetadata::new( CompactionTrigger::Auto, reason, CompactionImplementation::ResponsesCompact, phase, ); run_remote_compact_task_inner( &sess, &step_context, fallback_step_context.as_ref(), Some(turn_state), initial_context_injection, compaction_metadata, ) .await?; Ok(()) } pub(crate) async fn run_remote_compact_task( sess: Arc, turn_context: Arc, ) -> CodexResult<()> { // Standalone compaction is its own request boundary, so it captures a fresh step. let step_context = sess .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) .await?; let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.mode(), }); sess.send_event(&turn_context, start_event).await; let compaction_metadata = CompactionTurnMetadata::new( CompactionTrigger::Manual, CompactionReason::UserRequested, CompactionImplementation::ResponsesCompact, CompactionPhase::StandaloneTurn, ); run_remote_compact_task_inner( &sess, &step_context, /*fallback_step_context*/ None, /*turn_state*/ None, InitialContextInjection::DoNotInject, compaction_metadata, ) .await?; Ok(()) } async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; let trigger = compaction_metadata.trigger(); let reason = compaction_metadata.reason(); let implementation = compaction_metadata.implementation(); let phase = compaction_metadata.phase(); let mut analytics_details = CompactionAnalyticsDetails { active_context_tokens_before: Some(sess.get_total_token_usage().await), ..Default::default() }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), trigger, reason, implementation, 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(), codex_analytics::CompactionStatus::Interrupted, Some(&error), analytics_details, ) .await; return Err(error); } } let result = run_remote_compact_task_inner_impl( sess, step_context, fallback_step_context, turn_state, initial_context_injection, compaction_metadata, &mut analytics_details, ) .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, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt .track(sess.as_ref(), status, codex_error, analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); sess.send_event(turn_context, event).await; return Err(err); } Ok(()) } async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let turn_context = &step_context.turn; let context_compaction_item = ContextCompactionItem::new(); let compaction_id = context_compaction_item.id.clone(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, // endpoint attempts, and the installed history checkpoint all have one join key. let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), compaction_id.as_str(), turn_context.model_info().slug.as_str(), turn_context.provider.info().name.as_str(), ); let compaction_item = TurnItem::ContextCompaction(context_compaction_item); sess.emit_turn_item_started(turn_context, &compaction_item) .await; let attempt = run_remote_compact_attempt( sess, step_context, turn_state.clone(), &compaction_trace, compaction_metadata, analytics_details, ) .await; let (attempt, compaction_turn_context) = match attempt { Ok(attempt) => (attempt, turn_context), Err(error) => { let Some(fallback_step_context) = fallback_step_context else { return Err(error); }; if !should_retry_with_current_model(&error) { return Err(error); } sess.set_last_known_step_context(fallback_step_context) .await; let fallback_turn_context = &fallback_step_context.turn; let fallback_compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( fallback_turn_context.sub_id.as_str(), compaction_id.as_str(), fallback_turn_context.model_info().slug.as_str(), fallback_turn_context.provider.info().name.as_str(), ); let fallback_result = run_remote_compact_attempt( sess, fallback_step_context, turn_state, &fallback_compaction_trace, compaction_metadata, analytics_details, ) .await; record_model_fallback( &sess.services.session_telemetry, turn_context.model_info().slug.as_str(), fallback_turn_context.model_info().slug.as_str(), compaction_metadata.reason(), compaction_metadata.implementation(), fallback_result.as_ref().err(), ); match fallback_result { Ok(attempt) => (attempt, fallback_turn_context), Err(_) => return Err(error), } } }; let RemoteCompactAttempt { new_history, trace_input_history, } = attempt; let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history(sess.as_ref(), new_history, &initial_context_injection).await; let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage { .. } => { Some(compaction_turn_context.to_turn_context_item()) } }; // Install is the semantic boundary where the compact endpoint's output becomes live // thread history. Keep it distinct from the later inference request so the reducer can // still represent repeated developer/context prefix items exactly as the model saw them. if let Some(trace_input_history) = trace_input_history.as_deref() { compaction_trace.record_installed(&CompactionCheckpointTracePayload { input_history: trace_input_history, replacement_history: &new_history, }); } // Legacy `/responses/compact` returns provider-normalized items without a stable link to their // original envelopes, so it does not preserve harness metadata. Compaction-trigger/v2 does. let new_history = new_history .into_iter() .map(ResponseItemEnvelope::new) .collect(); sess.replace_compacted_history( new_history, reference_context_item, world_state_baseline, CompactedHistoryMetadata { message: String::new(), window_number: new_window_number, window_ids: new_window_ids, compaction_response_id: None, }, ) .await; sess.recompute_token_usage(compaction_turn_context).await; sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; Ok(()) } pub(crate) async fn process_compacted_history( sess: &Session, compacted_history: Vec, initial_context_injection: &InitialContextInjection, ) -> (Vec, Option>) { let compacted_history = compacted_history .into_iter() .map(ResponseItemEnvelope::new) .collect(); let (compacted_history, world_state_baseline) = process_annotated_compacted_history(sess, compacted_history, initial_context_injection) .await; ( compacted_history .into_iter() .map(ResponseItemEnvelope::into_item) .collect(), world_state_baseline, ) } /// Installs already-annotated remote compaction output without dropping its metadata sidecar. pub(crate) async fn process_annotated_compacted_history( sess: &Session, compacted_history: Vec, initial_context_injection: &InitialContextInjection, ) -> (Vec, Option>) { // Mid-turn compaction is the only path that must inject initial context above the last user // message in the replacement history. Pre-turn compaction instead injects context after the // compaction item, but mid-turn compaction keeps the compaction item last for model training. let (initial_context, world_state_baseline) = build_compaction_initial_context(sess, initial_context_injection).await; let compacted_history = history_item_groups(compacted_history) .filter(|group| should_keep_compacted_history_item(&group.source.item)) .flat_map(HistoryItemGroup::into_items) .collect(); ( insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context), world_state_baseline, ) } /// Returns whether an item from remote compaction output should be preserved. /// /// Called while processing the model-provided compacted transcript, before we /// append fresh canonical context from the current session. /// /// We drop: /// - `developer` messages because remote output can include stale/duplicated /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), /// while preserving real user messages and persisted hook prompts. /// /// This intentionally keeps: /// - `assistant` messages (future remote compaction models may emit them) /// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary /// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this /// check. pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } if role == "developer" => false, ResponseItem::Message { role, .. } if role == "user" => { matches!( crate::event_mapping::parse_turn_item(item), Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_)) ) } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, ResponseItem::AdditionalTools { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Other => false, } } pub(crate) fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, base_instructions: &BaseInstructions, ) -> (usize, i64) { let Some(context_window) = turn_context.model_context_window() else { return (0, 0); }; // Keep the unclamped total so replacing an item cannot lose an overflow hidden by i64 // saturation in the normal history estimator. let base_tokens = i128::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i128::MAX); let original_items = history.annotated_items(); let mut estimated_tokens = history_item_groups(original_items.iter().map(|item| &item.item)) .map(|group| group.estimated_token_count()) .fold(base_tokens, i128::saturating_add); let initial_estimated_tokens = i64::try_from(estimated_tokens).unwrap_or(i64::MAX); let mut rewritten_items = Vec::new(); let mut consumed_items: usize = 0; for group in history_item_groups(original_items.iter().map(|item| &item.item)) .collect::>() .into_iter() .rev() { if i64::try_from(estimated_tokens).unwrap_or(i64::MAX) <= context_window { break; } let group_item_count = 1 + usize::from(group.attached_notice.is_some()); let source_index = original_items .len() .saturating_sub(consumed_items.saturating_add(group_item_count)); let Some(rewritten_item) = original_items .get(source_index) .and_then(rewritten_output_for_context_window) else { break; }; estimated_tokens = estimated_tokens .saturating_sub(group.estimated_token_count()) .saturating_add(i128::from(estimate_item_token_count(&rewritten_item.item))); consumed_items += group_item_count; rewritten_items.push(rewritten_item); } let rewritten_outputs = rewritten_items.len(); if rewritten_outputs > 0 { let retained_len = original_items.len() - consumed_items; let mut items = original_items[..retained_len].to_vec(); items.extend(rewritten_items.into_iter().rev()); history.replace_annotated(items); } let final_estimated_tokens = i64::try_from(estimated_tokens).unwrap_or(i64::MAX); let estimated_deleted_tokens = initial_estimated_tokens.saturating_sub(final_estimated_tokens); (rewritten_outputs, estimated_deleted_tokens) } fn rewritten_output_for_context_window( envelope: &ResponseItemEnvelope, ) -> Option { let item = match &envelope.item { ResponseItem::FunctionCallOutput { id, call_id, name, namespace, output, internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), namespace: namespace.clone(), output: truncated_output_payload(output), internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::ToolSearchOutput { id, call_id, status, execution, internal_chat_message_metadata_passthrough: metadata, .. } => ResponseItem::ToolSearchOutput { id: id.clone(), call_id: call_id.clone(), status: status.clone(), execution: execution.clone(), tools: Vec::new(), internal_chat_message_metadata_passthrough: metadata.clone(), }, _ => return None, }; Some(ResponseItemEnvelope { item, metadata: envelope.metadata.clone(), }) } fn truncated_output_payload(output: &FunctionCallOutputPayload) -> FunctionCallOutputPayload { FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()), success: output.success, } } #[cfg(test)] #[path = "compact_remote_metadata_tests.rs"] mod metadata_tests;