Skip to main content

cascade_agent/memory/
compaction.rs

1use std::sync::{Arc, Mutex};
2
3use llm_cascade::{Conversation, Message, MessageRole};
4
5use crate::config::MemorySettings;
6use crate::error::{AgentError, Result};
7use crate::memory::state::ConversationStateAccess;
8use crate::memory::tokenizer::TokenCounter;
9use crate::memory::types::CompactionReport;
10
11/// Context compactor that summarizes older messages to stay within token limits.
12///
13/// Strategy:
14/// 1. Always keep the system prompt (first message).
15/// 2. Keep the most recent N messages that fit within `token_limit * target_ratio`.
16/// 3. Summarize all messages in between into a single system message.
17/// 4. If summarization fails, the original messages are preserved (no data loss).
18pub struct Compactor {
19    cascade_name: String,
20    cascade_config: Arc<llm_cascade::AppConfig>,
21    db_conn: Mutex<rusqlite::Connection>,
22    target_ratio: f64,
23}
24
25impl Compactor {
26    /// Create a new compactor.
27    ///
28    /// # Arguments
29    /// * `settings` - Memory settings containing the summarization cascade name and target ratio
30    /// * `cascade_config` - The llm-cascade `AppConfig` for LLM inference
31    /// * `db_conn` - SQLite connection for llm-cascade's cooldown/logging
32    pub fn new(
33        settings: &MemorySettings,
34        cascade_config: Arc<llm_cascade::AppConfig>,
35        db_conn: rusqlite::Connection,
36    ) -> Self {
37        Self {
38            cascade_name: settings.summarization_cascade.clone(),
39            cascade_config,
40            db_conn: Mutex::new(db_conn),
41            target_ratio: settings.compaction_target_ratio,
42        }
43    }
44
45    /// Compact conversation state if it exceeds the token limit.
46    ///
47    /// Returns a `CompactionReport` with before/after statistics.
48    /// If compaction is not needed, returns a report with no changes.
49    /// If summarization fails, returns an error and leaves messages unchanged.
50    pub async fn compact(
51        &self,
52        state: &mut impl ConversationStateAccess,
53        token_counter: &TokenCounter,
54        token_limit: usize,
55    ) -> Result<CompactionReport> {
56        let messages = state.messages();
57        let messages_before = messages.len();
58        let tokens_before = token_counter.count_messages(messages);
59
60        let target_tokens = (token_limit as f64 * self.target_ratio) as usize;
61
62        // If we're already under target, no compaction needed
63        if tokens_before <= target_tokens {
64            return Ok(CompactionReport {
65                messages_before,
66                messages_after: messages_before,
67                tokens_before,
68                tokens_after: tokens_before,
69            });
70        }
71
72        // Need at least 3 messages to compact: system prompt + at least 1 middle + at least 1 recent
73        if messages.len() < 3 {
74            return Ok(CompactionReport {
75                messages_before,
76                messages_after: messages_before,
77                tokens_before,
78                tokens_after: tokens_before,
79            });
80        }
81
82        // Strategy: keep system prompt (index 0), find how many recent messages fit,
83        // summarize everything in between.
84        let system_prompt = state.system_prompt().to_string();
85        // Estimate tokens for system prompt: content + role overhead ("system: " = 2) + separator (1) + framing (3)
86        let system_tokens = token_counter.count_text(&system_prompt) + 2 + 1 + 3;
87
88        // Start from the end and count backwards to find how many recent messages we can keep
89        let available_for_recent = target_tokens.saturating_sub(system_tokens);
90        let mut recent_count = 0;
91        let mut recent_tokens = 0;
92
93        for msg in messages.iter().rev() {
94            let msg_tokens = token_counter.count_text(&msg.content) + 1; // +1 for separator
95            if recent_tokens + msg_tokens > available_for_recent && recent_count > 0 {
96                break;
97            }
98            recent_tokens += msg_tokens;
99            recent_count += 1;
100        }
101
102        // Ensure we keep at least 1 recent message
103        if recent_count == 0 {
104            recent_count = 1;
105        }
106
107        // Messages to summarize: everything after system prompt up to the recent tail
108        let summarize_start = 1; // skip system prompt
109        let summarize_end = messages.len().saturating_sub(recent_count);
110
111        if summarize_start >= summarize_end {
112            // Nothing to summarize
113            return Ok(CompactionReport {
114                messages_before,
115                messages_after: messages_before,
116                tokens_before,
117                tokens_after: tokens_before,
118            });
119        }
120
121        let messages_to_summarize = &messages[summarize_start..summarize_end];
122
123        // Build the summarization prompt
124        let conversation_text = format_messages_for_summarization(messages_to_summarize);
125
126        let summarization_prompt = Message::system(format!(
127            "Summarize the following conversation excerpt concisely. \
128             Preserve:\n\
129             - Key facts and information discussed\n\
130             - Decisions that were made\n\
131             - User preferences and requirements\n\
132             - Any critical context that would be needed to continue the conversation\n\
133             - File paths, code snippets, or tool results that are referenced\n\n\
134             Write the summary in a clear, structured format. Be concise but complete.\n\n\
135             [Conversation to summarize]:\n\
136             {}",
137            conversation_text
138        ));
139
140        // Call the LLM to summarize
141        let summary = self.call_summarization(summarization_prompt).await?;
142
143        let compacted_message = Message {
144            role: MessageRole::System,
145            content: format!(
146                "[Compacted context from earlier in the conversation]:\n{}",
147                summary
148            ),
149            tool_call_id: None,
150        };
151
152        // Rebuild messages: system prompt + summary + recent messages
153        let mut new_messages = Vec::with_capacity(2 + recent_count);
154        new_messages.push(messages[0].clone()); // original system prompt
155        new_messages.push(compacted_message);
156        new_messages.extend(messages[summarize_end..].iter().cloned());
157
158        // Calculate tokens after compaction
159        let tokens_after = token_counter.count_messages(&new_messages);
160
161        // Replace messages in state
162        *state.messages_mut() = new_messages;
163
164        tracing::info!(
165            "Compacted conversation: {} messages -> {}, {} tokens -> {}",
166            messages_before,
167            state.messages().len(),
168            tokens_before,
169            tokens_after,
170        );
171
172        Ok(CompactionReport {
173            messages_before,
174            messages_after: state.messages().len(),
175            tokens_before,
176            tokens_after,
177        })
178    }
179
180    /// Call the summarization cascade via llm-cascade.
181    #[allow(clippy::await_holding_lock)]
182    async fn call_summarization(&self, prompt: Message) -> Result<String> {
183        let conversation = Conversation::new(vec![prompt]);
184        let cascade_name = self.cascade_name.clone();
185        let config = Arc::clone(&self.cascade_config);
186
187        let conn_guard = self.db_conn.lock().map_err(|e| {
188            AgentError::InferenceFailed(format!("Failed to acquire db lock for compaction: {}", e))
189        })?;
190
191        // Note: std::sync::Mutex is held across the await because run_cascade is async
192        // but takes &Connection (sync). The lock duration is the HTTP call time.
193        // This is acceptable because compaction is infrequent and we don't want to
194        // move the Connection between threads (SQLite is not Send).
195        let result =
196            llm_cascade::run_cascade(&cascade_name, &conversation, &config, &conn_guard).await;
197
198        drop(conn_guard);
199
200        match result {
201            Ok(response) => Ok(response.text_only()),
202            Err(e) => Err(AgentError::InferenceFailed(format!(
203                "Summarization cascade '{}' failed: {}",
204                cascade_name, e
205            ))),
206        }
207    }
208}
209
210/// Format messages into a readable text representation for summarization.
211fn format_messages_for_summarization(messages: &[Message]) -> String {
212    let mut output = String::new();
213    for msg in messages {
214        let role_str = match msg.role {
215            MessageRole::System => "System",
216            MessageRole::User => "User",
217            MessageRole::Assistant => "Assistant",
218            MessageRole::Tool => "Tool",
219        };
220        output.push_str(&format!("--- {} ---\n{}\n\n", role_str, msg.content));
221    }
222    output
223}