cascade_agent/memory/
compaction.rs1use 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
11pub 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 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 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 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 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 let system_prompt = state.system_prompt().to_string();
85 let system_tokens = token_counter.count_text(&system_prompt) + 2 + 1 + 3;
87
88 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; 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 if recent_count == 0 {
104 recent_count = 1;
105 }
106
107 let summarize_start = 1; let summarize_end = messages.len().saturating_sub(recent_count);
110
111 if summarize_start >= summarize_end {
112 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 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 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 let mut new_messages = Vec::with_capacity(2 + recent_count);
154 new_messages.push(messages[0].clone()); new_messages.push(compacted_message);
156 new_messages.extend(messages[summarize_end..].iter().cloned());
157
158 let tokens_after = token_counter.count_messages(&new_messages);
160
161 *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 #[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 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
210fn 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}