use crate::bus::{ ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION, SYSTEM_CONTEXT_SCHEDULED_PROMPT, }; use crate::config::LLMProviderConfig; use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider}; use crate::text::{char_count, take_prefix_chars}; use crate::agent::{AgentError, AgentRuntimeConfig}; const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2; const CJK_CHARS_PER_TOKEN: f64 = 2.0; const OTHER_CHARS_PER_TOKEN: f64 = 4.0; const JSON_OVERHEAD_PER_MESSAGE: usize = 50; /// System context marker for the heavy compression (older segment) summary. pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_OLDER: &str = "history_compaction_older"; /// System context marker for the light compression (newer segment) summary. pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER: &str = "history_compaction_newer"; /// Default threshold ratio: compress when estimated tokens exceed 70% of context window. const DEFAULT_THRESHOLD_RATIO: f64 = 0.7; /// Default budget split: older segment gets 30% of summary budget, newer gets 70%. const OLDER_BUDGET_RATIO: f64 = 0.3; const NEWER_BUDGET_RATIO: f64 = 0.7; // ============================================================================ // HistoryUnit — atomic message units for compression // ============================================================================ /// An indivisible unit of conversation history. Compression operates at the /// unit level, so tool call sequences can never be split across boundaries. #[derive(Debug, Clone)] enum HistoryUnit { /// Always-preserved system messages (agent_prompt, scheduled_prompt). SystemGuard(ChatMessage), /// A user message. UserMessage(ChatMessage), /// An atomic tool call sequence: assistant(tool_calls) + all following /// tool-result messages. Must be preserved or compressed as a whole. ToolRound { assistant: ChatMessage, results: Vec, }, /// A plain assistant text response (no tool_calls). AssistantText(ChatMessage), } impl HistoryUnit { /// Estimate tokens for this unit alone. fn estimate_tokens(&self) -> usize { match self { HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => { estimate_tokens(std::slice::from_ref(msg)) } HistoryUnit::ToolRound { assistant, results } => { let mut all = vec![assistant.clone()]; all.extend(results.clone()); estimate_tokens(&all) } } } /// Expand this unit back into a flat list of ChatMessages. #[allow(dead_code)] fn into_messages(self) -> Vec { match self { HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => vec![msg], HistoryUnit::ToolRound { assistant, results } => { let mut msgs = vec![assistant]; msgs.extend(results); msgs } } } /// Returns true if this unit is a ToolRound. #[allow(dead_code)] fn is_tool_round(&self) -> bool { matches!(self, HistoryUnit::ToolRound { .. }) } } // ============================================================================ // Unit parser — one forward pass, O(n) // ============================================================================ fn is_system_guard(msg: &ChatMessage) -> bool { msg.role == "system" && (msg.has_system_context(SYSTEM_CONTEXT_AGENT_PROMPT) || msg.has_system_context(SYSTEM_CONTEXT_SCHEDULED_PROMPT)) } fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool { msg.role == "assistant" && msg .tool_calls .as_ref() .map_or(false, |calls| !calls.is_empty()) } /// Parse a flat message list into atomic units. Orphaned tool results /// (without a preceding assistant with tool_calls) are silently dropped. fn parse_to_units(messages: &[ChatMessage]) -> Vec { let mut units = Vec::new(); let mut i = 0; while i < messages.len() { let msg = &messages[i]; if is_system_guard(msg) { units.push(HistoryUnit::SystemGuard(msg.clone())); i += 1; } else if msg.role == "user" { units.push(HistoryUnit::UserMessage(msg.clone())); i += 1; } else if is_assistant_with_tool_calls(msg) { let assistant = msg.clone(); let mut results = Vec::new(); i += 1; while i < messages.len() && messages[i].role == "tool" { results.push(messages[i].clone()); i += 1; } units.push(HistoryUnit::ToolRound { assistant, results }); } else if msg.role == "assistant" { units.push(HistoryUnit::AssistantText(msg.clone())); i += 1; } else if msg.role == "tool" { // Orphaned tool result — drop with a warning tracing::warn!( tool_call_id = ?msg.tool_call_id, message_index = i, "Dropping orphaned tool result during unit parsing" ); i += 1; } else { // system messages that aren't guards — keep as plain messages units.push(HistoryUnit::AssistantText(msg.clone())); i += 1; } } units } /// Estimate total tokens from a slice of units. fn estimate_tokens_from_units(units: &[HistoryUnit]) -> usize { units.iter().map(|u| u.estimate_tokens()).sum() } /// Check if a character is CJK (Chinese, Japanese, Korean) fn is_cjk_char(c: char) -> bool { matches!(c, '\u{4E00}'..='\u{9FFF}' | // CJK Unified Ideographs '\u{3040}'..='\u{309F}' | // Hiragana '\u{30A0}'..='\u{30FF}' | // Katakana '\u{AC00}'..='\u{D7AF}' | // Korean Hangul '\u{3400}'..='\u{4DBF}' | // CJK Extension A '\u{20000}'..='\u{2A6DF}' // CJK Extension B ) } /// Token estimation using weighted character counting based on language pub fn estimate_tokens(messages: &[ChatMessage]) -> usize { let mut cjk_count = 0usize; let mut other_count = 0usize; let mut media_refs_count = 0usize; for msg in messages { // Count content characters for c in msg.content.chars() { if is_cjk_char(c) { cjk_count += 1; } else { other_count += 1; } } // Count media references media_refs_count += msg.media_refs.len(); } // Weighted token calculation: CJK chars need more tokens per character let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN) + (other_count as f64 / OTHER_CHARS_PER_TOKEN); // JSON serialization overhead for message structure (fields, brackets, etc.) let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE; // Media references add to JSON size (each path is a string in the array) let media_overhead = media_refs_count * 20; // Each media ref adds ~20 chars to JSON // Apply safety multiplier ((content_tokens + json_overhead as f64 + media_overhead as f64) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER) as usize } /// Configuration for context compression. #[derive(Debug, Clone)] pub struct ContextCompressionConfig { /// Preserve the latest N complete user turns in full. pub retain_last_user_turns: usize, /// Maximum characters in summary pub summary_max_chars: usize, } #[derive(Debug, Clone, PartialEq, Eq)] struct UserTurnRange { start: usize, end_exclusive: usize, } #[derive(Debug, Clone)] pub struct HistoryCompactionPlan { pub preserved_system_messages: Vec, pub summary_message: ChatMessage, pub preserved_messages: Vec, pub compressed_turns: usize, pub preserved_turns: usize, } impl Default for ContextCompressionConfig { fn default() -> Self { Self { retain_last_user_turns: 3, summary_max_chars: 20_000, } } } /// Context compressor that reduces message history when it exceeds token limits. #[derive(Clone)] pub struct ContextCompressor { config: ContextCompressionConfig, context_window: usize, /// Threshold ratio to trigger compression (70% of context window). threshold_ratio: f64, } impl ContextCompressor { #[cfg(test)] fn summary_char_budget_for_context_window(context_window: usize) -> usize { const SUMMARY_RATIO: f64 = 0.1; const CHARS_PER_TOKEN: f64 = 2.5; const MIN_SUMMARY_CHARS: usize = 1_500; const MAX_SUMMARY_CHARS: usize = 50_000; ((context_window as f64 * SUMMARY_RATIO * CHARS_PER_TOKEN) as usize) .clamp(MIN_SUMMARY_CHARS, MAX_SUMMARY_CHARS) } // ========================================================================= // Transcript building (shared helpers, unchanged from original) // ========================================================================= fn format_transcript_entry(message: &ChatMessage) -> String { let role = match message.role.as_str() { "assistant" => "Assistant", "tool" => "Tool", _ => message.role.as_str(), }; let name = message .tool_name .as_ref() .map(|n| format!(" ({})", n)) .unwrap_or_default(); format!("{}: {}{}", role, message.content, name) } fn build_transcript(messages: &[ChatMessage]) -> String { messages .iter() .map(Self::format_transcript_entry) .collect::>() .join("\n\n") } fn split_text_chunks(text: &str, max_chars: usize) -> Vec { if text.is_empty() { return Vec::new(); } let chunk_size = max_chars.max(1); let chars: Vec = text.chars().collect(); chars .chunks(chunk_size) .map(|chunk| chunk.iter().collect()) .collect() } fn chunk_messages_for_summary(messages: &[ChatMessage], max_chars: usize) -> Vec { if messages.is_empty() { return Vec::new(); } let chunk_limit = max_chars.max(1); let mut chunks = Vec::new(); let mut current = String::new(); for entry in messages.iter().map(Self::format_transcript_entry) { let separator = if current.is_empty() { "" } else { "\n\n" }; let candidate = format!("{}{}{}", current, separator, entry); if !current.is_empty() && char_count(&candidate) > chunk_limit { chunks.push(current); current = String::new(); } if char_count(&entry) > chunk_limit { if !current.is_empty() { chunks.push(current); current = String::new(); } chunks.extend(Self::split_text_chunks(&entry, chunk_limit)); continue; } if current.is_empty() { current = entry; } else { current.push_str("\n\n"); current.push_str(&entry); } } if !current.is_empty() { chunks.push(current); } chunks } fn build_summary_prompt(transcript: &str, target_chars: usize) -> String { format!( r#"You are a conversation compaction engine. Summarize the following conversation segment. PRESERVE: - Each user question or request in full or as a near-verbatim restatement - All identifiers (UUIDs, hashes, file paths, URLs) - Actions taken (tool calls, file operations, commands) - Key information obtained (results, data, errors) - Decisions and user preferences - Current task status OMIT: - Reproducing full tool output verbatim unless it is essential - Repeated greetings or filler Do not assume tool content was pre-trimmed. You may receive long tool outputs; keep the important results, errors, and artifacts. Be concise, aim for {} characters or less. --- {} "#, target_chars, transcript ) } // ========================================================================= // Two-segment compression: heavy (older) + light (newer) prompts // ========================================================================= /// Prompt for the older segment — aggressive compression, keep only essentials. fn build_heavy_summary_prompt(transcript: &str, target_chars: usize) -> String { format!( r#"You are a conversation compaction engine. Aggressively summarize the following OLDER conversation segment. These events happened earlier in the session and will be marked as "较早的操作记录(已压缩)". A newer portion (not shown here) follows this segment. The model can use tools to re-read files for exact data, so you do NOT need to preserve full tool outputs. === MUST PRESERVE (keep verbatim or near-verbatim) === - All file paths (absolute and relative) - All URLs - Key numeric results and conclusions (one sentence per result) - Error messages (exact text if important) - User preferences and decisions - The original user task or goal === SHOULD CONDENSE === - Each tool call → one line summary: "tool_name: 关键参数 → 结果概要" - Multiple similar operations → group: "对 ./data/ 目录下的 10 个 CSV 文件执行了统计分析" - Verbose tool output → drop entirely unless it contains a critical result or error === SHOULD DROP === - Full tool output (hundreds of lines of raw data) - Repeated attempts of the same operation - Greetings, filler text, markdown formatting Be concise, aim for {} characters or less. Output the summary in Chinese if the original conversation was in Chinese. --- OLDER SEGMENT (events from earlier in the session): {} "#, target_chars, transcript ) } /// Prompt for the newer segment — lighter compression, keep more detail. fn build_light_summary_prompt(transcript: &str, target_chars: usize) -> String { format!( r#"You are a conversation compaction engine. Lightly summarize the following RECENT conversation segment. These events happened just before the current moment and will be marked as "近期操作记录(轻度压缩,保留了更多细节)". An older segment (already summarized separately) precedes this. Keep enough detail so the model can continue the task seamlessly without re-reading files for data that appears in these recent results. === MUST PRESERVE (keep with more detail than a normal summary) === - All file paths, URLs, and identifiers - The exact sequence of recent operations (step by step) - Tool parameters (especially file paths, search queries, command strings) - Key outputs and results (shortened but keep the substance) - Error messages (complete, not truncated) - Current task status and what should happen next === SHOULD CONDENSE === - Tool outputs → truncate to the most meaningful parts (key data, conclusions, not raw output) - Long text → keep the essence but not every word - Repeated similar outputs → note the pattern but keep a representative example === SHOULD DROP === - Completely irrelevant debug output - Boilerplate text with no informational value - Trivial operations that have no bearing on the task Be concise, aim for {} characters or less. Output the summary in Chinese if the original conversation was in Chinese. --- RECENT SEGMENT (events from just before the current moment): {} "#, target_chars, transcript ) } async fn summarize_transcript( &self, provider: &dyn LLMProvider, transcript: &str, target_chars: usize, ) -> Result { let request = ChatCompletionRequest { messages: vec![ Message::system("You are a helpful assistant."), Message::user(Self::build_summary_prompt(transcript, target_chars)), ], temperature: Some(0.3), max_tokens: Some(1000), tools: None, }; let response = provider .chat(request) .await .map_err(|e| AgentError::LlmError(e.to_string()))?; Ok(response.content) } async fn summarize_chunked_transcript( &self, provider: &dyn LLMProvider, messages: &[ChatMessage], transcript: &str, ) -> Result { let target_chars = self.config.summary_max_chars.max(1); let mut layer = Self::chunk_messages_for_summary(messages, target_chars); if layer.is_empty() { layer.push(transcript.to_string()); } for _ in 0..6 { if layer.len() == 1 && char_count(&layer[0]) <= target_chars { return self .summarize_transcript(provider, &layer[0], target_chars) .await; } let per_chunk_target = (target_chars / layer.len().max(1)) .max(500) .min(target_chars); let mut summaries = Vec::with_capacity(layer.len()); for chunk in &layer { summaries.push( self.summarize_transcript(provider, chunk, per_chunk_target) .await?, ); } if summaries.len() == 1 { let summary = summaries.pop().unwrap_or_default(); if char_count(&summary) <= target_chars { return Ok(summary); } layer = Self::split_text_chunks(&summary, target_chars); continue; } let merged = summaries.join("\n\n"); if char_count(&merged) <= target_chars { return self .summarize_transcript(provider, &merged, target_chars) .await; } layer = Self::split_text_chunks(&merged, target_chars); } Ok(take_prefix_chars(transcript, target_chars)) } // ========================================================================= // Two-segment compression // ========================================================================= /// Find a safe split point in the unit list. The split ensures: /// - Accumulated tokens up to `ratio` of total are placed in the older segment. /// - The split never lands on a SystemGuard (they're always preserved). /// - The split lands on a unit boundary (ToolRound, AssistantText, or UserMessage). fn find_safe_split_point(&self, units: &[HistoryUnit], ratio: f64) -> usize { let total_tokens = estimate_tokens_from_units(units); let target = (total_tokens as f64 * ratio) as usize; let mut accumulated = 0; for (i, unit) in units.iter().enumerate() { // Never split on a SystemGuard — they must be preserved if matches!(unit, HistoryUnit::SystemGuard(_)) { continue; } accumulated += unit.estimate_tokens(); if accumulated >= target { return i + 1; } } // Fallback: put everything in the older segment units.len() } /// Summarize a transcript with a custom prompt builder function. async fn summarize_with_prompt( &self, provider: &dyn LLMProvider, transcript: &str, target_chars: usize, build_prompt: fn(&str, usize) -> String, ) -> Result { let request = ChatCompletionRequest { messages: vec![ Message::system("You are a helpful assistant."), Message::user(build_prompt(transcript, target_chars)), ], temperature: Some(0.3), max_tokens: Some(1000), tools: None, }; let response = provider .chat(request) .await .map_err(|e| AgentError::LlmError(e.to_string()))?; Ok(response.content) } /// Summarize a segment of units with chunked fallback. async fn summarize_units_segment( &self, provider: &dyn LLMProvider, messages: &[ChatMessage], transcript: &str, target_chars: usize, build_prompt: fn(&str, usize) -> String, ) -> Result { if messages.is_empty() { return Ok(String::new()); } if char_count(transcript) <= target_chars { return self .summarize_with_prompt(provider, transcript, target_chars, build_prompt) .await; } // Use chunked summarization for oversized transcripts let target = target_chars.max(1); let mut layer = Self::chunk_messages_for_summary(messages, target); if layer.is_empty() { layer.push(transcript.to_string()); } for _ in 0..6 { if layer.len() == 1 && char_count(&layer[0]) <= target { return self .summarize_with_prompt(provider, &layer[0], target, build_prompt) .await; } let per_chunk_target = (target / layer.len().max(1)).max(500).min(target); let mut summaries = Vec::with_capacity(layer.len()); for chunk in &layer { summaries.push( self.summarize_with_prompt(provider, chunk, per_chunk_target, build_prompt) .await?, ); } if summaries.len() == 1 { let summary = summaries.pop().unwrap_or_default(); if char_count(&summary) <= target { return Ok(summary); } layer = Self::split_text_chunks(&summary, target); continue; } let merged = summaries.join("\n\n"); if char_count(&merged) <= target { return self .summarize_with_prompt(provider, &merged, target, build_prompt) .await; } layer = Self::split_text_chunks(&merged, target); } Ok(take_prefix_chars(transcript, target)) } /// Main entry point for two-segment compression. /// /// Splits history into older and newer segments, then summarizes each /// with LLM using different prompts and budget allocations. The result /// contains no tool_calls or tool-result messages — only system summaries /// and the original user message — eliminating any risk of API 400 errors /// from orphaned tool call sequences. pub async fn compress_two_segment( &self, history: &[ChatMessage], provider_config: &LLMProviderConfig, ) -> Result, AgentError> { let tokens = estimate_tokens(history); if tokens <= self.threshold() { tracing::info!( tokens = tokens, threshold = self.threshold(), msg_count = history.len(), "Two-segment compression not needed (under threshold)" ); return Ok(history.to_vec()); } tracing::info!( tokens = tokens, threshold = self.threshold(), msg_count = history.len(), "Starting two-segment compression" ); // Step 1: Parse into atomic units let units = parse_to_units(history); // Step 2: Separate system guards + user messages from compressible units let mut system_guards: Vec = Vec::new(); let mut user_messages: Vec = Vec::new(); let mut compressible: Vec = Vec::new(); for unit in units { match unit { HistoryUnit::SystemGuard(msg) => system_guards.push(msg), HistoryUnit::UserMessage(msg) => user_messages.push(msg), other => compressible.push(other), } } // Keep only the latest user message in full; older ones go into compression let latest_user_msg = user_messages.pop(); // Step 3: Find split point in compressible units let total_compressible = estimate_tokens_from_units(&compressible); let split_point = if total_compressible > 0 { self.find_safe_split_point(&compressible, 0.5) } else { 0 }; let older_units: Vec<&HistoryUnit> = compressible[..split_point].iter().collect(); let newer_units: Vec<&HistoryUnit> = compressible[split_point..].iter().collect(); // Step 4: Build transcripts let older_messages: Vec = older_units .iter() .flat_map(|u| match u { HistoryUnit::ToolRound { assistant, results } => { let mut msgs = vec![assistant.clone()]; msgs.extend(results.clone()); msgs } HistoryUnit::AssistantText(msg) => vec![msg.clone()], HistoryUnit::UserMessage(msg) => { // Older user messages go into the compressible transcript vec![msg.clone()] } _ => vec![], }) .collect(); let newer_messages: Vec = newer_units .iter() .flat_map(|u| match u { HistoryUnit::ToolRound { assistant, results } => { let mut msgs = vec![assistant.clone()]; msgs.extend(results.clone()); msgs } HistoryUnit::AssistantText(msg) => vec![msg.clone()], _ => vec![], }) .collect(); // Include older user messages in the older transcript let older_user_msgs: Vec = user_messages .iter() .map(|m| { let mut msg = m.clone(); msg.role = "user".to_string(); msg }) .collect(); let all_older_messages: Vec = older_user_msgs .iter() .chain(older_messages.iter()) .cloned() .collect(); let older_transcript = Self::build_transcript(&all_older_messages); let newer_transcript = Self::build_transcript(&newer_messages); // Step 5: Budget allocation let total_budget = self.config.summary_max_chars; let older_budget = (total_budget as f64 * OLDER_BUDGET_RATIO) as usize; let newer_budget = (total_budget as f64 * NEWER_BUDGET_RATIO) as usize; // Step 6: Create provider and run both summaries (can be parallel) let runtime_config = AgentRuntimeConfig::from(provider_config.clone()); let provider = create_provider(runtime_config.provider) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; let (older_result, newer_result) = if older_units.is_empty() && newer_units.is_empty() { (Ok(String::new()), Ok(String::new())) } else if older_units.is_empty() { let result = self .summarize_units_segment( provider.as_ref(), &newer_messages, &newer_transcript, newer_budget, Self::build_light_summary_prompt, ) .await; (Ok(String::new()), result) } else if newer_units.is_empty() { let result = self .summarize_units_segment( provider.as_ref(), &all_older_messages, &older_transcript, older_budget, Self::build_heavy_summary_prompt, ) .await; (result, Ok(String::new())) } else { let older_fut = self.summarize_units_segment( provider.as_ref(), &all_older_messages, &older_transcript, older_budget, Self::build_heavy_summary_prompt, ); let newer_fut = self.summarize_units_segment( provider.as_ref(), &newer_messages, &newer_transcript, newer_budget, Self::build_light_summary_prompt, ); tokio::join!(older_fut, newer_fut) }; let older_summary = older_result?; let newer_summary = newer_result?; // Step 7: Assemble compressed history let mut compressed: Vec = Vec::with_capacity(4); // System guards first compressed.extend(system_guards); // Latest user message if let Some(user_msg) = latest_user_msg { compressed.push(user_msg); } // Heavy compression summary (older) if !older_summary.is_empty() { compressed.push(ChatMessage::system_with_context( format!("## 较早的操作记录(已压缩)\n\n{}", older_summary), Some(SYSTEM_CONTEXT_HISTORY_COMPACTION_OLDER.to_string()), )); } // Light compression summary (newer) if !newer_summary.is_empty() { compressed.push(ChatMessage::system_with_context( format!( "## 近期操作记录(轻度压缩,保留了更多细节)\n\n{}\n\n---\n以上为近期操作记录。如需精确数据,可使用工具重新读取相关文件。", newer_summary ), Some(SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER.to_string()), )); } tracing::info!( original_tokens = tokens, final_tokens = estimate_tokens(&compressed), final_msg_count = compressed.len(), older_units = older_units.len(), newer_units = newer_units.len(), "Two-segment compression completed" ); Ok(compressed) } // ========================================================================= // Legacy methods (kept for backward compatibility during migration) // ========================================================================= /// Create a new compressor with the given context window size. pub fn new(context_window: usize) -> Self { Self { config: ContextCompressionConfig::default(), context_window, threshold_ratio: DEFAULT_THRESHOLD_RATIO, } } pub fn from_provider_config(provider_config: &LLMProviderConfig) -> Self { Self::from_runtime_config(&AgentRuntimeConfig::from(provider_config.clone())) } pub fn from_runtime_config(config: &AgentRuntimeConfig) -> Self { Self::with_config( config.context_window_tokens, ContextCompressionConfig { summary_max_chars: config.context_summary_char_budget, ..ContextCompressionConfig::default() }, ) } /// Create with custom configuration. pub fn with_config(context_window: usize, config: ContextCompressionConfig) -> Self { Self { config, context_window, threshold_ratio: DEFAULT_THRESHOLD_RATIO, } } /// Get the compression threshold in tokens (70% of context window). fn threshold(&self) -> usize { (self.context_window as f64 * self.threshold_ratio) as usize } pub fn should_compress(&self, history: &[ChatMessage]) -> bool { estimate_tokens(history) > self.threshold() } fn user_turn_ranges(&self, history: &[ChatMessage]) -> Vec { let user_indices: Vec = history .iter() .enumerate() .filter(|(_, message)| message.role == "user") .map(|(index, _)| index) .collect(); user_indices .iter() .enumerate() .map(|(turn_index, start)| UserTurnRange { start: *start, end_exclusive: user_indices .get(turn_index + 1) .copied() .unwrap_or(history.len()), }) .collect() } fn should_preserve_system_message(&self, message: &ChatMessage) -> bool { message.role == "system" && (message.has_system_context(SYSTEM_CONTEXT_AGENT_PROMPT) || message.has_system_context(SYSTEM_CONTEXT_SCHEDULED_PROMPT)) } fn split_prefix_messages( &self, history: &[ChatMessage], ) -> (Vec, Vec) { let preserved_system_messages = history .iter() .filter(|message| self.should_preserve_system_message(message)) .cloned() .collect(); let summary_source = history .iter() .filter(|message| !self.should_preserve_system_message(message)) .cloned() .collect(); (preserved_system_messages, summary_source) } pub async fn build_compaction_plan( &self, history: &[ChatMessage], provider_config: &LLMProviderConfig, ) -> Result, AgentError> { if !self.should_compress(history) { return Ok(None); } let turn_ranges = self.user_turn_ranges(history); if turn_ranges.len() <= self.config.retain_last_user_turns { return Ok(None); } let preserved_turn_start = turn_ranges[turn_ranges.len() - self.config.retain_last_user_turns].start; if preserved_turn_start == 0 { return Ok(None); } let (preserved_system_messages, summary_source) = self.split_prefix_messages(&history[..preserved_turn_start]); let summary = self .summarize_segment(&summary_source, provider_config) .await?; // Sanitize preserved messages: the boundary between the summarized // and preserved sections can split an assistant tool_calls message // from its tool result messages, creating orphaned sequences that // would cause API 400 errors. let mut preserved_messages = history[preserved_turn_start..].to_vec(); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut preserved_messages); if removed > 0 { tracing::warn!( removed_count = removed, preserved_turn_start, "Compaction plan: removed incomplete tool call sequences from preserved messages" ); } Ok(Some(HistoryCompactionPlan { preserved_system_messages, summary_message: ChatMessage::system_with_context( format!("[Compressed History]\n\n{}", summary), Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()), ), preserved_messages, compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns, preserved_turns: self.config.retain_last_user_turns, })) } /// Main entry point - compresses history if over threshold. pub async fn compress_if_needed( &self, history: Vec, provider_config: &LLMProviderConfig, ) -> Result, AgentError> { let tokens = estimate_tokens(&history); if tokens <= self.threshold() { #[cfg(debug_assertions)] tracing::info!( tokens = tokens, threshold = self.threshold(), msg_count = history.len(), "Context compression not needed" ); return Ok(history); } tracing::info!( tokens = tokens, threshold = self.threshold(), msg_count = history.len(), "Starting context compression" ); let mut current_history = match self .build_compaction_plan(&history, provider_config) .await? { Some(plan) => { let mut compressed = Vec::with_capacity( plan.preserved_system_messages.len() + plan.preserved_messages.len() + 1, ); compressed.extend(plan.preserved_system_messages); compressed.push(plan.summary_message); compressed.extend(plan.preserved_messages); compressed } None => history, }; // Post-compression sanitization: compression can split an assistant // tool_calls message from its tool result messages at the boundary // between the summarized and preserved sections. This pass removes // any orphaned tool_calls or tool results to ensure the message // sequence is always valid for the LLM API. let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut current_history); if removed > 0 { tracing::warn!( removed_count = removed, remaining_messages = current_history.len(), "Post-compression sanitization removed incomplete tool call sequences" ); } tracing::info!( final_tokens = estimate_tokens(¤t_history), final_msg_count = current_history.len(), "Context compression completed" ); Ok(current_history) } /// Summarize a segment of messages using LLM. async fn summarize_segment( &self, messages: &[ChatMessage], provider_config: &LLMProviderConfig, ) -> Result { if messages.is_empty() { return Ok(String::new()); } let runtime_config = AgentRuntimeConfig::from(provider_config.clone()); let provider = create_provider(runtime_config.provider) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; let transcript = Self::build_transcript(messages); let result = if char_count(&transcript) <= self.config.summary_max_chars { self.summarize_transcript( provider.as_ref(), &transcript, self.config.summary_max_chars, ) .await } else { self.summarize_chunked_transcript(provider.as_ref(), messages, &transcript) .await }; match result { Ok(summary) => Ok(summary), Err(e) => { tracing::warn!(error = %e, "LLM summarization failed, using truncated transcript"); Ok(take_prefix_chars( &transcript, self.config.summary_max_chars, )) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_estimate_tokens() { let messages = vec![ ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ChatMessage::user("How are you?"), ]; let tokens = estimate_tokens(&messages); // Content: "Hello" (5) + "Hi there!" (9) + "How are you?" (12) = 26 chars // English: 26 / 4 = 6.5 tokens for content // JSON overhead: 3 * 50 = 150 // Total before multiplier: 156.5 // After 1.2x: ~188 tokens assert!( tokens > 100 && tokens < 300, "Expected ~150-250 tokens for English content, got {}", tokens ); } #[test] fn test_estimate_tokens_chinese_content() { let messages = vec![ ChatMessage::user("你好,这是一个中文测试"), ChatMessage::assistant("这是一个中文回复消息"), ]; let tokens = estimate_tokens(&messages); // Content: ~20 CJK chars, CJK uses 2 chars/token = ~10 tokens for content // JSON overhead: 2 * 50 = 100 // Total before multiplier: ~110 // After 1.2x: ~132 tokens assert!( tokens > 80 && tokens < 200, "Expected ~100-180 tokens for Chinese content, got {}", tokens ); } #[test] fn test_estimate_tokens_mixed_content() { let messages = vec![ ChatMessage::user("Hello 世界 this is 测试"), ]; let tokens = estimate_tokens(&messages); // Content: 18 English chars + 4 CJK chars // English: 18 / 4 = 4.5, CJK: 4 / 2 = 2, content tokens = 6.5 // JSON overhead: 1 * 50 = 50 // Total before multiplier: 56.5 // After 1.2x: ~68 tokens assert!( tokens > 40 && tokens < 120, "Expected ~50-100 tokens for mixed content, got {}", tokens ); } #[test] fn test_chinese_tokens_higher_than_english() { // Use more characters to make the content difference significant // compared to JSON overhead (50 tokens per message) let english = vec![ChatMessage::user(&"abcdefghij".repeat(20))]; // 200 English chars let chinese = vec![ChatMessage::user(&"这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20) let english_tokens = estimate_tokens(&english); let chinese_tokens = estimate_tokens(&chinese); // 200 English chars: 200/4 = 50 content tokens // 200 CJK chars: 200/2 = 100 content tokens // With same JSON overhead, Chinese should use ~1.5x tokens assert!( chinese_tokens > english_tokens * 130 / 100, // At least 1.3x "Chinese (200 chars) should use more tokens than English (200 chars): EN={} CN={}", english_tokens, chinese_tokens ); } #[test] fn test_estimate_tokens_includes_image_media_refs() { let temp_dir = tempfile::tempdir().unwrap(); let image_path = temp_dir.path().join("demo.jpg"); std::fs::write(&image_path, vec![0_u8; 12_000]).unwrap(); let plain = vec![ChatMessage::user("hello")]; let with_image = vec![ChatMessage::user_with_media( "hello", vec![image_path.to_string_lossy().to_string()], )]; assert!(estimate_tokens(&with_image) > estimate_tokens(&plain)); } #[test] fn test_should_compress() { let compressor = ContextCompressor::new(20); // Need more content to trigger compression with new weighted calculation // 200 English chars / 4 = 50 tokens, plus overhead let messages = vec![ChatMessage::user(&"x".repeat(400))]; assert!(compressor.should_compress(&messages)); } #[test] fn test_user_turn_ranges_follow_user_boundaries() { let compressor = ContextCompressor::new(100_000); let history = vec![ ChatMessage::system("system"), ChatMessage::user("u1"), ChatMessage::assistant("a1"), ChatMessage::tool("call-1", "bash", "t1"), ChatMessage::user("u2"), ChatMessage::assistant("a2"), ChatMessage::user("u3"), ]; let turns = compressor.user_turn_ranges(&history); assert_eq!( turns, vec![ UserTurnRange { start: 1, end_exclusive: 4 }, UserTurnRange { start: 4, end_exclusive: 6 }, UserTurnRange { start: 6, end_exclusive: 7 }, ] ); } #[test] fn test_split_prefix_messages_preserves_key_system_messages() { let compressor = ContextCompressor::new(50); let prefix = vec![ ChatMessage::system_with_context( "agent prompt", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), ), ChatMessage::user("u1"), ChatMessage::assistant("a1"), ChatMessage::system_with_context( "scheduled prompt", Some(SYSTEM_CONTEXT_SCHEDULED_PROMPT.to_string()), ), ]; let (preserved_system_messages, summary_source) = compressor.split_prefix_messages(&prefix); assert_eq!(preserved_system_messages.len(), 2); assert_eq!(summary_source.len(), 2); assert!(preserved_system_messages[0].has_system_context(SYSTEM_CONTEXT_AGENT_PROMPT)); assert!(preserved_system_messages[1].has_system_context(SYSTEM_CONTEXT_SCHEDULED_PROMPT)); } #[test] fn test_threshold() { let compressor = ContextCompressor::new(128_000); assert_eq!(compressor.threshold(), 89_600); // 70% of 128_000 } #[test] fn test_summary_char_budget_for_context_window_scales_and_clamps() { assert_eq!( ContextCompressor::summary_char_budget_for_context_window(4_096), 1_500 ); assert_eq!( ContextCompressor::summary_char_budget_for_context_window(65_536), 16_384 ); assert_eq!( ContextCompressor::summary_char_budget_for_context_window(128_000), 32_000 ); assert_eq!( ContextCompressor::summary_char_budget_for_context_window(400_000), 50_000 ); } #[test] fn test_chunk_messages_for_summary_keeps_message_boundaries_when_possible() { let messages = vec![ ChatMessage::user("alpha"), ChatMessage::assistant("beta"), ChatMessage::user("gamma"), ]; let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 30); assert_eq!(chunks.len(), 2); assert!(chunks.iter().all(|chunk| char_count(chunk) <= 30)); assert_eq!(chunks[0], "user: alpha\n\nAssistant: beta"); assert_eq!(chunks[1], "user: gamma"); } #[test] fn test_chunk_messages_for_summary_splits_oversized_message() { let messages = vec![ChatMessage::user(&"x".repeat(25))]; let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10); assert!(chunks.len() > 1); assert!(chunks.iter().all(|chunk| char_count(chunk) <= 10)); assert_eq!(chunks.concat(), "user: xxxxxxxxxxxxxxxxxxxxxxxxx"); } // ========================================================================= // HistoryUnit / parse_to_units tests // ========================================================================= #[test] fn test_parse_to_units_system_guards_preserved() { let messages = vec![ ChatMessage::system_with_context( "agent prompt", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), ), ChatMessage::user("hello"), ChatMessage::assistant("hi"), ]; let units = parse_to_units(&messages); // SystemGuard + UserMessage + AssistantText = 3 assert_eq!(units.len(), 3); assert!(matches!(units[0], HistoryUnit::SystemGuard(_))); assert!(matches!(units[1], HistoryUnit::UserMessage(_))); assert!(matches!(units[2], HistoryUnit::AssistantText(_))); } #[test] fn test_parse_to_units_tool_round_atomic() { let messages = vec![ ChatMessage::user("read a file"), ChatMessage::assistant_with_tool_calls( "let me read", vec![crate::domain::messages::ToolCall { id: "call_1".to_string(), name: "file_read".to_string(), arguments: serde_json::json!({"path": "/data/test.txt"}), }], ), ChatMessage::tool("call_1", "file_read", "file contents here"), ChatMessage::assistant("the file says hello"), ]; let units = parse_to_units(&messages); assert_eq!(units.len(), 3); // UserMessage + ToolRound + AssistantText assert!(matches!(units[0], HistoryUnit::UserMessage(_))); assert!(matches!(units[1], HistoryUnit::ToolRound { .. })); assert!(matches!(units[2], HistoryUnit::AssistantText(_))); // Verify ToolRound contains both assistant and results if let HistoryUnit::ToolRound { assistant, results } = &units[1] { assert_eq!(assistant.role, "assistant"); assert!(assistant.tool_calls.is_some()); assert_eq!(results.len(), 1); assert_eq!(results[0].role, "tool"); assert_eq!(results[0].tool_call_id, Some("call_1".to_string())); } } #[test] fn test_parse_to_units_orphaned_tool_result_dropped() { let messages = vec![ ChatMessage::user("hello"), // Orphaned tool result — no preceding assistant with tool_calls ChatMessage::tool("orphan_1", "bash", "some output"), ChatMessage::assistant("done"), ]; let units = parse_to_units(&messages); // Orphaned tool result should be dropped, leaving UserMessage + AssistantText assert_eq!(units.len(), 2); assert!(matches!(units[0], HistoryUnit::UserMessage(_))); assert!(matches!(units[1], HistoryUnit::AssistantText(_))); } #[test] fn test_parse_to_units_multiple_tool_results_same_round() { let messages = vec![ ChatMessage::user("do multiple things"), ChatMessage::assistant_with_tool_calls( "doing things", vec![ crate::domain::messages::ToolCall { id: "call_a".to_string(), name: "bash".to_string(), arguments: serde_json::json!({"command": "ls"}), }, crate::domain::messages::ToolCall { id: "call_b".to_string(), name: "file_read".to_string(), arguments: serde_json::json!({"path": "/data/test.txt"}), }, ], ), ChatMessage::tool("call_a", "bash", "file1.txt\nfile2.txt"), ChatMessage::tool("call_b", "file_read", "hello world"), ]; let units = parse_to_units(&messages); assert_eq!(units.len(), 2); // UserMessage + ToolRound if let HistoryUnit::ToolRound { results, .. } = &units[1] { assert_eq!(results.len(), 2); assert_eq!(results[0].tool_call_id, Some("call_a".to_string())); assert_eq!(results[1].tool_call_id, Some("call_b".to_string())); } else { panic!("Expected ToolRound"); } } #[test] fn test_parse_to_units_empty_input() { let units = parse_to_units(&[]); assert!(units.is_empty()); } #[test] fn test_find_safe_split_point() { let compressor = ContextCompressor::new(100_000); // Build a list of units with known token sizes let messages: Vec = (0..10) .map(|i| ChatMessage::assistant(&format!("message content number {}", i))) .collect(); let units = parse_to_units(&messages); // All units are AssistantText, split at 50% token ratio let split = compressor.find_safe_split_point(&units, 0.5); // Should split somewhere in the middle (not 0, not len()) assert!(split > 0 && split < units.len(), "split {} should be between 0 and {}", split, units.len()); } #[test] fn test_compress_two_segment_no_tool_calls_in_output() { // This test verifies the critical invariant: // compress_two_segment output MUST NOT contain any tool_calls or tool messages. let compressor = ContextCompressor::new(128_000); // Build a simple history that's well under the threshold // so compress_two_segment returns it unchanged (no LLM call needed). let history = vec![ ChatMessage::system_with_context( "You are a helpful assistant.", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), ), ChatMessage::user("hello"), ChatMessage::assistant("hi there"), ]; // Dummy config — won't be used because history is under threshold. let config = LLMProviderConfig { provider_type: "openai".to_string(), name: "test".to_string(), base_url: "http://localhost".to_string(), api_key: "sk-test".to_string(), extra_headers: std::collections::HashMap::new(), llm_timeout_secs: 120, memory_maintenance_timeout_secs: 300, model_id: "test-model".to_string(), temperature: None, max_tokens: None, context_window_tokens: Some(128_000), model_extra: std::collections::HashMap::new(), max_tool_iterations: 100, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2_000, max_images_in_context: 10, max_image_age_rounds: 50, }; let runtime = tokio::runtime::Runtime::new().unwrap(); let result = runtime.block_on(compressor.compress_two_segment(&history, &config)); assert!(result.is_ok()); let compressed = result.unwrap(); // Under threshold: should return unchanged (3 messages) assert_eq!(compressed.len(), 3); // Critical invariant: NO tool_calls or tool_call_id anywhere for msg in &compressed { assert!(msg.tool_calls.is_none(), "compress_two_segment output should never contain tool_calls"); assert!(msg.tool_call_id.is_none(), "compress_two_segment output should never contain tool_call_id"); } } }