//! Decides when to compact provider-visible context and builds a stable fallback summary. use std::collections::HashSet; use crate::{ model::{ estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun, ProjectedMessage, }, store::ContextUsageAnchor, }; const FALLBACK_CHARS: usize = 12_000; pub(super) const RESERVE_TOKENS: u64 = 10_000; pub(super) const OUTPUT_TOKENS: u64 = 4_096; pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary."; pub(super) fn input_budget(prepared: &PreparedRun) -> Option { prepared .model .context_window_tokens .map(|window| window.saturating_sub(RESERVE_TOKENS)) } pub(super) fn estimated_tokens( prepared: &PreparedRun, projected_messages: &[ProjectedMessage], anchor: Option, ) -> u64 { anchor .filter(|anchor| anchor.message_count <= projected_messages.len()) .map(|anchor| { anchor .context_input_tokens .saturating_add(estimate_projected_messages_tokens( &projected_messages[anchor.message_count..], )) }) .unwrap_or_else(|| estimate_context_tokens(&prepared.prompt, projected_messages)) } pub(super) fn compaction_estimate( prepared: &PreparedRun, projected_messages: &[ProjectedMessage], anchor: Option, ) -> Option { let budget = input_budget(prepared)?; let estimated = estimated_tokens(prepared, projected_messages, anchor); (estimated > budget).then_some(estimated) } #[cfg(test)] pub(super) fn should_compact( prepared: &PreparedRun, projected_messages: &[ProjectedMessage], anchor: Option, ) -> bool { compaction_estimate(prepared, projected_messages, anchor).is_some() } pub(super) fn validate_compacted( prepared: &PreparedRun, projected_messages: &[ProjectedMessage], ) -> std::result::Result { let estimated = estimate_context_tokens(&prepared.prompt, projected_messages); let Some(budget) = input_budget(prepared) else { return Ok(estimated); }; if estimated <= budget { return Ok(estimated); } Err(format!( "context overflow after compaction: estimated input {estimated} tokens exceeds budget {budget} tokens" )) } pub(super) fn partition( messages: &[CanonicalMessage], current_ids: &HashSet<&str>, ) -> (Vec, Option) { let latest_request_context = messages .iter() .rposition(|message| message.message_id.starts_with("request-context:")); let compactable = messages .iter() .enumerate() .filter(|(index, message)| { Some(*index) != latest_request_context && !current_ids.contains(message.message_id.as_str()) }) .map(|(_, message)| message.clone()) .collect(); let retained = latest_request_context .and_then(|index| messages.get(index)) .filter(|message| !current_ids.contains(message.message_id.as_str())) .cloned(); (compactable, retained) } pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String { let serialized = serde_json::to_string(messages).unwrap_or_default(); let start = serialized .char_indices() .rev() .nth(FALLBACK_CHARS.saturating_sub(1)) .map_or(0, |(index, _)| index); format!( "Durable recent conversation state:\n{}", &serialized[start..] ) } #[cfg(test)] mod tests { use super::*; use crate::model::{ project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role, RunAction, RunId, RunKind, }; fn prepared(context_window_tokens: u64) -> PreparedRun { let mut model = ModelSpec::new("model"); model.context_window_tokens = Some(context_window_tokens); PreparedRun { run_id: RunId::new("run"), cursor_request_id: None, conversation_id: ConversationId::new("conversation"), kind: RunKind::Root, model, prompt: PromptSpec { instructions: String::new(), tools: Vec::new(), }, initial_messages: Vec::new(), action: RunAction::Start, base_checkpoint_id: CheckpointId(1), } } #[test] fn automatic_compaction_uses_fixed_reserve_for_every_action() { let messages = vec![CanonicalMessage::text( "user", Role::User, Origin::Runtime, "x".repeat(40_000), )]; let projected = project_messages(&messages).unwrap(); let estimated = estimate_context_tokens(&prepared(1).prompt, &projected); let mut prepared = prepared(estimated + RESERVE_TOKENS); assert!(!should_compact(&prepared, &projected, None)); prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1); assert!(should_compact(&prepared, &projected, None)); prepared.action = RunAction::Resume { pending_tool_round: None, }; assert!(should_compact(&prepared, &projected, None)); } #[test] fn provider_usage_anchor_only_estimates_messages_added_after_last_request() { let messages = vec![ CanonicalMessage::text("old", Role::User, Origin::Runtime, "x".repeat(400_000)), CanonicalMessage::text("new", Role::User, Origin::Runtime, "short follow-up"), ]; let projected = project_messages(&messages).unwrap(); let anchor = ContextUsageAnchor { context_input_tokens: 103_904, message_count: 1, }; let expected = 103_904 + estimate_projected_messages_tokens(&projected[1..]); assert_eq!( estimated_tokens(&prepared(200_000), &projected, Some(anchor)), expected ); assert!(!should_compact( &prepared(200_000), &projected, Some(anchor) )); } #[test] fn provider_usage_anchor_triggers_after_new_messages_cross_budget() { let messages = vec![ CanonicalMessage::text("old", Role::User, Origin::Runtime, "old"), CanonicalMessage::text("new", Role::User, Origin::Runtime, "x".repeat(80_000)), ]; let projected = project_messages(&messages).unwrap(); assert!(should_compact( &prepared(200_000), &projected, Some(ContextUsageAnchor { context_input_tokens: 180_000, message_count: 1, }) )); } #[test] fn missing_anchor_uses_full_fallback() { let messages = vec![CanonicalMessage::text( "user", Role::User, Origin::Runtime, "x".repeat(40_000), )]; let projected = project_messages(&messages).unwrap(); let prepared = prepared(200_000); assert_eq!( estimated_tokens(&prepared, &projected, None), estimate_context_tokens(&prepared.prompt, &projected) ); } #[test] fn invalid_anchor_message_count_uses_full_fallback() { let messages = vec![CanonicalMessage::text( "user", Role::User, Origin::Runtime, "x".repeat(40_000), )]; let projected = project_messages(&messages).unwrap(); let expected = estimate_context_tokens(&prepared(200_000).prompt, &projected); assert_eq!( estimated_tokens( &prepared(200_000), &projected, Some(ContextUsageAnchor { context_input_tokens: 1, message_count: 2, }) ), expected ); } #[test] fn compacted_history_is_validated_against_the_same_budget() { let messages = vec![CanonicalMessage::text( "user", Role::User, Origin::Runtime, "x".repeat(40_000), )]; let projected = project_messages(&messages).unwrap(); let estimated = estimate_context_tokens(&prepared(1).prompt, &projected); assert_eq!( validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected), Ok(estimated) ); assert!( validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected) .unwrap_err() .contains("context overflow after compaction") ); } }