1use rig::completion::Message;
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_COMPRESSION_THRESHOLD: f32 = 0.85;
11
12pub const COMPRESSION_PRESERVE_FRACTION: f32 = 0.3;
14
15const CHARS_PER_TOKEN: usize = 4;
17
18const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128_000;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConversationTurn {
24 pub user_message: String,
25 pub assistant_response: String,
26 pub tool_calls: Vec<ToolCallRecord>,
28 pub estimated_tokens: usize,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ToolCallRecord {
35 pub tool_name: String,
36 pub args_summary: String,
37 pub result_summary: String,
38 #[serde(default)]
40 pub tool_id: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct ConversationHistory {
46 turns: Vec<ConversationTurn>,
48 compressed_summary: Option<String>,
50 total_tokens: usize,
52 compression_threshold_tokens: usize,
54}
55
56impl Default for ConversationHistory {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl ConversationHistory {
63 pub fn new() -> Self {
64 let max_tokens = DEFAULT_MAX_CONTEXT_TOKENS;
65 Self {
66 turns: Vec::new(),
67 compressed_summary: None,
68 total_tokens: 0,
69 compression_threshold_tokens: (max_tokens as f32 * DEFAULT_COMPRESSION_THRESHOLD) as usize,
70 }
71 }
72
73 pub fn with_threshold(max_context_tokens: usize, threshold_fraction: f32) -> Self {
75 Self {
76 turns: Vec::new(),
77 compressed_summary: None,
78 total_tokens: 0,
79 compression_threshold_tokens: (max_context_tokens as f32 * threshold_fraction) as usize,
80 }
81 }
82
83 fn estimate_tokens(text: &str) -> usize {
85 text.len() / CHARS_PER_TOKEN
86 }
87
88 pub fn add_turn(&mut self, user_message: String, assistant_response: String, tool_calls: Vec<ToolCallRecord>) {
90 let turn_tokens = Self::estimate_tokens(&user_message)
91 + Self::estimate_tokens(&assistant_response)
92 + tool_calls.iter().map(|tc| {
93 Self::estimate_tokens(&tc.tool_name)
94 + Self::estimate_tokens(&tc.args_summary)
95 + Self::estimate_tokens(&tc.result_summary)
96 }).sum::<usize>();
97
98 self.turns.push(ConversationTurn {
99 user_message,
100 assistant_response,
101 tool_calls,
102 estimated_tokens: turn_tokens,
103 });
104 self.total_tokens += turn_tokens;
105 }
106
107 pub fn needs_compaction(&self) -> bool {
109 self.total_tokens > self.compression_threshold_tokens
110 }
111
112 pub fn token_count(&self) -> usize {
114 self.total_tokens
115 }
116
117 pub fn turn_count(&self) -> usize {
119 self.turns.len()
120 }
121
122 pub fn clear(&mut self) {
124 self.turns.clear();
125 self.compressed_summary = None;
126 self.total_tokens = 0;
127 }
128
129 pub fn compact(&mut self) -> Option<String> {
132 if self.turns.len() < 2 {
133 return None; }
135
136 let preserve_count = ((self.turns.len() as f32) * COMPRESSION_PRESERVE_FRACTION).ceil() as usize;
138 let preserve_count = preserve_count.max(1); let split_point = self.turns.len().saturating_sub(preserve_count);
140
141 if split_point == 0 {
142 return None; }
144
145 let turns_to_compress = &self.turns[..split_point];
147 let summary = self.create_summary(turns_to_compress);
148
149 let new_summary = if let Some(existing) = &self.compressed_summary {
151 format!("{}\n\n{}", existing, summary)
152 } else {
153 summary.clone()
154 };
155 self.compressed_summary = Some(new_summary);
156
157 let preserved_turns: Vec<_> = self.turns[split_point..].to_vec();
159 self.turns = preserved_turns;
160
161 self.total_tokens = Self::estimate_tokens(self.compressed_summary.as_deref().unwrap_or(""))
163 + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
164
165 Some(summary)
166 }
167
168 fn create_summary(&self, turns: &[ConversationTurn]) -> String {
171 use std::collections::HashSet;
172
173 let mut summary_parts = Vec::new();
174 let mut all_files_read: HashSet<String> = HashSet::new();
175 let mut all_files_written: HashSet<String> = HashSet::new();
176
177 for (i, turn) in turns.iter().enumerate() {
178 let mut turn_summary = format!(
179 "Turn {}: User: {}",
180 i + 1,
181 Self::truncate_text(&turn.user_message, 150)
182 );
183
184 if !turn.tool_calls.is_empty() {
185 let mut reads = Vec::new();
187 let mut writes = Vec::new();
188 let mut other = Vec::new();
189
190 for tc in &turn.tool_calls {
191 match tc.tool_name.as_str() {
192 "read_file" => {
193 reads.push(tc.args_summary.clone());
194 all_files_read.insert(tc.args_summary.clone());
195 }
196 "write_file" | "write_files" => {
197 writes.push(tc.args_summary.clone());
198 all_files_written.insert(tc.args_summary.clone());
199 }
200 "list_directory" => {
201 other.push(format!("listed {}", tc.args_summary));
202 }
203 _ => {
204 other.push(format!("{}({})", tc.tool_name, Self::truncate_text(&tc.args_summary, 30)));
205 }
206 }
207 }
208
209 if !reads.is_empty() {
210 turn_summary.push_str(&format!("\n - Read {} files: {}",
211 reads.len(),
212 reads.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
213 ));
214 if reads.len() > 5 {
215 turn_summary.push_str(&format!(" (+{} more)", reads.len() - 5));
216 }
217 }
218 if !writes.is_empty() {
219 turn_summary.push_str(&format!("\n - Wrote: {}", writes.join(", ")));
220 }
221 if !other.is_empty() {
222 turn_summary.push_str(&format!("\n - Other: {}", other.join(", ")));
223 }
224 }
225
226 turn_summary.push_str(&format!(
227 "\n - Response: {}",
228 Self::truncate_text(&turn.assistant_response, 300)
229 ));
230
231 summary_parts.push(turn_summary);
232 }
233
234 let mut context = format!(
236 "=== Conversation Summary ({} turns compressed) ===\n\n{}",
237 turns.len(),
238 summary_parts.join("\n\n")
239 );
240
241 if !all_files_read.is_empty() {
243 context.push_str("\n\n=== Files Previously Read (content is in context) ===\n");
244 for file in all_files_read.iter().take(30) {
245 context.push_str(&format!(" - {}\n", file));
246 }
247 if all_files_read.len() > 30 {
248 context.push_str(&format!(" ... and {} more files\n", all_files_read.len() - 30));
249 }
250 }
251
252 if !all_files_written.is_empty() {
253 context.push_str("\n=== Files Previously Written ===\n");
254 for file in &all_files_written {
255 context.push_str(&format!(" - {}\n", file));
256 }
257 }
258
259 context
260 }
261
262 fn truncate_text(text: &str, max_len: usize) -> String {
264 if text.len() <= max_len {
265 text.to_string()
266 } else {
267 format!("{}...", &text[..max_len.saturating_sub(3)])
268 }
269 }
270
271 pub fn to_messages(&self) -> Vec<Message> {
275 use rig::completion::message::{Text, UserContent, AssistantContent};
276 use rig::OneOrMany;
277
278 let mut messages = Vec::new();
279
280 if let Some(summary) = &self.compressed_summary {
282 messages.push(Message::User {
284 content: OneOrMany::one(UserContent::Text(Text {
285 text: format!("[Previous conversation context]\n{}", summary),
286 })),
287 });
288 messages.push(Message::Assistant {
289 id: None,
290 content: OneOrMany::one(AssistantContent::Text(Text {
291 text: "I understand the previous context. How can I help you continue?".to_string(),
292 })),
293 });
294 }
295
296 for turn in &self.turns {
298 messages.push(Message::User {
300 content: OneOrMany::one(UserContent::Text(Text {
301 text: turn.user_message.clone(),
302 })),
303 });
304
305 let mut response_text = String::new();
307
308 if !turn.tool_calls.is_empty() {
310 response_text.push_str("[Tools used in this turn:\n");
311 for tc in &turn.tool_calls {
312 response_text.push_str(&format!(
313 " - {}({}) → {}\n",
314 tc.tool_name,
315 Self::truncate_text(&tc.args_summary, 50),
316 Self::truncate_text(&tc.result_summary, 100)
317 ));
318 }
319 response_text.push_str("]\n\n");
320 }
321
322 response_text.push_str(&turn.assistant_response);
324
325 messages.push(Message::Assistant {
326 id: None,
327 content: OneOrMany::one(AssistantContent::Text(Text {
328 text: response_text,
329 })),
330 });
331 }
332
333 messages
334 }
335
336 pub fn is_empty(&self) -> bool {
338 self.turns.is_empty() && self.compressed_summary.is_none()
339 }
340
341 pub fn status(&self) -> String {
343 let compressed_info = if self.compressed_summary.is_some() {
344 " (with compressed history)"
345 } else {
346 ""
347 };
348 format!(
349 "{} turns, ~{} tokens{}",
350 self.turns.len(),
351 self.total_tokens,
352 compressed_info
353 )
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_add_turn() {
363 let mut history = ConversationHistory::new();
364 history.add_turn(
365 "Hello".to_string(),
366 "Hi there!".to_string(),
367 vec![],
368 );
369 assert_eq!(history.turn_count(), 1);
370 assert!(!history.is_empty());
371 }
372
373 #[test]
374 fn test_compaction() {
375 let mut history = ConversationHistory::with_threshold(1000, 0.1); for i in 0..10 {
379 history.add_turn(
380 format!("Question {}", i),
381 format!("Answer {} with lots of detail to increase token count", i),
382 vec![ToolCallRecord {
383 tool_name: "analyze".to_string(),
384 args_summary: "path: .".to_string(),
385 result_summary: "Found rust project".to_string(),
386 tool_id: Some(format!("tool_{}", i)),
387 }],
388 );
389 }
390
391 if history.needs_compaction() {
392 let summary = history.compact();
393 assert!(summary.is_some());
394 assert!(history.turn_count() < 10);
395 }
396 }
397
398 #[test]
399 fn test_to_messages() {
400 let mut history = ConversationHistory::new();
401 history.add_turn(
402 "What is this project?".to_string(),
403 "This is a Rust CLI tool.".to_string(),
404 vec![],
405 );
406
407 let messages = history.to_messages();
408 assert_eq!(messages.len(), 2); }
410
411 #[test]
412 fn test_clear() {
413 let mut history = ConversationHistory::new();
414 history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
415 history.clear();
416 assert!(history.is_empty());
417 assert_eq!(history.token_count(), 0);
418 }
419}