use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CompactionConfig { pub preserve_recent_messages: usize, pub max_estimated_tokens: usize, } impl Default for CompactionConfig { fn default() -> Self { Self { preserve_recent_messages: 4, max_estimated_tokens: 10_000, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompactionResult { pub summary: String, pub compacted_session: Session, pub removed_message_count: usize, } #[must_use] pub fn estimate_session_tokens(session: &Session) -> usize { session.messages.iter().map(estimate_message_tokens).sum() } #[must_use] pub fn should_compact(session: &Session, config: CompactionConfig) -> bool { session.messages.len() > config.preserve_recent_messages && estimate_session_tokens(session) >= config.max_estimated_tokens } #[must_use] pub fn format_compact_summary(summary: &str) -> String { let without_analysis = strip_tag_block(summary, "analysis"); let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") { without_analysis.replace( &format!("{content}"), &format!("Summary:\n{}", content.trim()), ) } else { without_analysis }; collapse_blank_lines(&formatted).trim().to_string() } #[must_use] pub fn get_compact_continuation_message( summary: &str, suppress_follow_up_questions: bool, recent_messages_preserved: bool, ) -> String { let mut base = format!( "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}", format_compact_summary(summary) ); if recent_messages_preserved { base.push_str("\n\nRecent messages are preserved verbatim."); } if suppress_follow_up_questions { base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text."); } base } #[must_use] pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult { if !should_compact(session, config) { return CompactionResult { summary: String::new(), compacted_session: session.clone(), removed_message_count: 0, }; } let keep_from = session .messages .len() .saturating_sub(config.preserve_recent_messages); let removed = &session.messages[..keep_from]; let preserved = session.messages[keep_from..].to_vec(); let summary = summarize_messages(removed); let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty()); let mut compacted_messages = vec![ConversationMessage { role: MessageRole::System, blocks: vec![ContentBlock::Text { text: continuation }], usage: None, }]; compacted_messages.extend(preserved); CompactionResult { summary, compacted_session: Session { version: session.version, messages: compacted_messages, }, removed_message_count: removed.len(), } } fn summarize_messages(messages: &[ConversationMessage]) -> String { let mut lines = vec!["".to_string(), "Conversation summary:".to_string()]; for message in messages { let role = match message.role { MessageRole::System => "system", MessageRole::User => "user", MessageRole::Assistant => "assistant", MessageRole::Tool => "tool", }; let content = message .blocks .iter() .map(summarize_block) .collect::>() .join(" | "); lines.push(format!("- {role}: {content}")); } lines.push("".to_string()); lines.join("\n") } fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolResult { tool_name, output, is_error, .. } => format!( "tool_result {tool_name}: {}{output}", if *is_error { "error " } else { "" } ), }; truncate_summary(&raw, 160) } fn truncate_summary(content: &str, max_chars: usize) -> String { if content.chars().count() <= max_chars { return content.to_string(); } let mut truncated = content.chars().take(max_chars).collect::(); truncated.push('…'); truncated } fn estimate_message_tokens(message: &ConversationMessage) -> usize { message .blocks .iter() .map(|block| match block { ContentBlock::Text { text } => text.len() / 4 + 1, ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolResult { tool_name, output, .. } => (tool_name.len() + output.len()) / 4 + 1, }) .sum() } fn extract_tag_block(content: &str, tag: &str) -> Option { let start = format!("<{tag}>"); let end = format!(""); let start_index = content.find(&start)? + start.len(); let end_index = content[start_index..].find(&end)? + start_index; Some(content[start_index..end_index].to_string()) } fn strip_tag_block(content: &str, tag: &str) -> String { let start = format!("<{tag}>"); let end = format!(""); if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) { let end_index = end_index_rel + end.len(); let mut stripped = String::new(); stripped.push_str(&content[..start_index]); stripped.push_str(&content[end_index..]); stripped } else { content.to_string() } } fn collapse_blank_lines(content: &str) -> String { let mut result = String::new(); let mut last_blank = false; for line in content.lines() { let is_blank = line.trim().is_empty(); if is_blank && last_blank { continue; } result.push_str(line); result.push('\n'); last_blank = is_blank; } result } #[cfg(test)] mod tests { use super::{ compact_session, estimate_session_tokens, format_compact_summary, should_compact, CompactionConfig, }; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; #[test] fn formats_compact_summary_like_upstream() { let summary = "scratch\nKept work"; assert_eq!(format_compact_summary(summary), "Summary:\nKept work"); } #[test] fn leaves_small_sessions_unchanged() { let session = Session { version: 1, messages: vec![ConversationMessage::user_text("hello")], }; let result = compact_session(&session, CompactionConfig::default()); assert_eq!(result.removed_message_count, 0); assert_eq!(result.compacted_session, session); assert!(result.summary.is_empty()); } #[test] fn compacts_older_messages_into_a_system_summary() { let session = Session { version: 1, messages: vec![ ConversationMessage::user_text("one ".repeat(200)), ConversationMessage::assistant(vec![ContentBlock::Text { text: "two ".repeat(200), }]), ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false), ConversationMessage { role: MessageRole::Assistant, blocks: vec![ContentBlock::Text { text: "recent".to_string(), }], usage: None, }, ], }; let result = compact_session( &session, CompactionConfig { preserve_recent_messages: 2, max_estimated_tokens: 1, }, ); assert_eq!(result.removed_message_count, 2); assert_eq!( result.compacted_session.messages[0].role, MessageRole::System ); assert!(matches!( &result.compacted_session.messages[0].blocks[0], ContentBlock::Text { text } if text.contains("Summary:") )); assert!(should_compact( &session, CompactionConfig { preserve_recent_messages: 2, max_estimated_tokens: 1, } )); assert!( estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session) ); } #[test] fn truncates_long_blocks_in_summary() { let summary = super::summarize_block(&ContentBlock::Text { text: "x".repeat(400), }); assert!(summary.ends_with('…')); assert!(summary.chars().count() <= 161); } }