| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- 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!("<summary>{content}</summary>"),
- &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!["<summary>".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::<Vec<_>>()
- .join(" | ");
- lines.push(format!("- {role}: {content}"));
- }
- lines.push("</summary>".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::<String>();
- 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<String> {
- let start = format!("<{tag}>");
- let end = format!("</{tag}>");
- 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!("</{tag}>");
- 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 = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
- 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);
- }
- }
|