1use super::compact::{
11 CompactConfig, CompactThresholds, CompactionStrategy, ContextSummary, SummaryFrame,
12};
13use rig::completion::Message;
14use serde::{Deserialize, Serialize};
15
16const CHARS_PER_TOKEN: usize = 4;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22 pub user_message: String,
23 pub assistant_response: String,
24 pub tool_calls: Vec<ToolCallRecord>,
26 pub estimated_tokens: usize,
28 #[serde(default)]
32 pub droppable: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ToolCallRecord {
38 pub tool_name: String,
39 pub args_summary: String,
40 pub result_summary: String,
41 #[serde(default)]
43 pub tool_id: Option<String>,
44 #[serde(default)]
46 pub droppable: bool,
47}
48
49#[derive(Debug, Clone)]
51pub struct ConversationHistory {
52 turns: Vec<ConversationTurn>,
54 summary_frame: Option<SummaryFrame>,
56 total_tokens: usize,
58 compact_config: CompactConfig,
60 user_turn_count: usize,
62 context_summary: ContextSummary,
64}
65
66impl Default for ConversationHistory {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl ConversationHistory {
73 pub fn new() -> Self {
74 Self {
75 turns: Vec::new(),
76 summary_frame: None,
77 total_tokens: 0,
78 compact_config: CompactConfig::default(),
79 user_turn_count: 0,
80 context_summary: ContextSummary::new(),
81 }
82 }
83
84 pub fn with_config(config: CompactConfig) -> Self {
86 Self {
87 turns: Vec::new(),
88 summary_frame: None,
89 total_tokens: 0,
90 compact_config: config,
91 user_turn_count: 0,
92 context_summary: ContextSummary::new(),
93 }
94 }
95
96 pub fn aggressive() -> Self {
98 Self::with_config(CompactConfig {
99 retention_window: 5,
100 eviction_window: 0.7,
101 thresholds: CompactThresholds::aggressive(),
102 })
103 }
104
105 pub fn relaxed() -> Self {
107 Self::with_config(CompactConfig {
108 retention_window: 20,
109 eviction_window: 0.5,
110 thresholds: CompactThresholds::relaxed(),
111 })
112 }
113
114 pub fn estimate_tokens(text: &str) -> usize {
117 text.len() / CHARS_PER_TOKEN
118 }
119
120 pub fn add_turn(
122 &mut self,
123 user_message: String,
124 assistant_response: String,
125 tool_calls: Vec<ToolCallRecord>,
126 ) {
127 let droppable = !tool_calls.is_empty()
130 && tool_calls.iter().all(|tc| {
131 matches!(
132 tc.tool_name.as_str(),
133 "read_file" | "list_directory" | "analyze_project"
134 )
135 });
136
137 let turn_tokens = Self::estimate_tokens(&user_message)
138 + Self::estimate_tokens(&assistant_response)
139 + tool_calls
140 .iter()
141 .map(|tc| {
142 Self::estimate_tokens(&tc.tool_name)
143 + Self::estimate_tokens(&tc.args_summary)
144 + Self::estimate_tokens(&tc.result_summary)
145 })
146 .sum::<usize>();
147
148 self.turns.push(ConversationTurn {
149 user_message,
150 assistant_response,
151 tool_calls,
152 estimated_tokens: turn_tokens,
153 droppable,
154 });
155 self.total_tokens += turn_tokens;
156 self.user_turn_count += 1;
157 }
158
159 pub fn add_turn_droppable(
161 &mut self,
162 user_message: String,
163 assistant_response: String,
164 tool_calls: Vec<ToolCallRecord>,
165 droppable: bool,
166 ) {
167 let turn_tokens = Self::estimate_tokens(&user_message)
168 + Self::estimate_tokens(&assistant_response)
169 + tool_calls
170 .iter()
171 .map(|tc| {
172 Self::estimate_tokens(&tc.tool_name)
173 + Self::estimate_tokens(&tc.args_summary)
174 + Self::estimate_tokens(&tc.result_summary)
175 })
176 .sum::<usize>();
177
178 self.turns.push(ConversationTurn {
179 user_message,
180 assistant_response,
181 tool_calls,
182 estimated_tokens: turn_tokens,
183 droppable,
184 });
185 self.total_tokens += turn_tokens;
186 self.user_turn_count += 1;
187 }
188
189 pub fn needs_compaction(&self) -> bool {
191 let last_is_user = self
192 .turns
193 .last()
194 .map(|t| !t.user_message.is_empty())
195 .unwrap_or(false);
196
197 self.compact_config.should_compact(
198 self.total_tokens,
199 self.user_turn_count,
200 self.turns.len(),
201 last_is_user,
202 )
203 }
204
205 pub fn compaction_reason(&self) -> Option<String> {
207 self.compact_config
208 .compaction_reason(self.total_tokens, self.user_turn_count, self.turns.len())
209 }
210
211 pub fn token_count(&self) -> usize {
213 self.total_tokens
214 }
215
216 pub fn turn_count(&self) -> usize {
218 self.turns.len()
219 }
220
221 pub fn user_turn_count(&self) -> usize {
223 self.user_turn_count
224 }
225
226 pub fn clear(&mut self) {
228 self.turns.clear();
229 self.summary_frame = None;
230 self.total_tokens = 0;
231 self.user_turn_count = 0;
232 self.context_summary = ContextSummary::new();
233 }
234
235 pub fn compact(&mut self) -> Option<String> {
238 use super::compact::strategy::{MessageMeta, MessageRole};
239 use super::compact::summary::{
240 extract_assistant_action, extract_user_intent, ToolCallSummary, TurnSummary,
241 };
242
243 if self.turns.len() < 2 {
244 return None; }
246
247 let messages: Vec<MessageMeta> = self
249 .turns
250 .iter()
251 .enumerate()
252 .flat_map(|(turn_idx, turn)| {
253 let mut metas = vec![];
254
255 metas.push(MessageMeta {
257 index: turn_idx * 2,
258 role: MessageRole::User,
259 droppable: turn.droppable,
260 has_tool_call: false,
261 is_tool_result: false,
262 tool_id: None,
263 token_count: Self::estimate_tokens(&turn.user_message),
264 });
265
266 let has_tool_call = !turn.tool_calls.is_empty();
268 let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
269
270 metas.push(MessageMeta {
271 index: turn_idx * 2 + 1,
272 role: MessageRole::Assistant,
273 droppable: turn.droppable,
274 has_tool_call,
275 is_tool_result: false,
276 tool_id,
277 token_count: Self::estimate_tokens(&turn.assistant_response),
278 });
279
280 metas
281 })
282 .collect();
283
284 let strategy = CompactionStrategy::default();
286
287 let range = strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
289
290 if range.is_empty() {
291 return None;
292 }
293
294 let start_turn = range.start / 2;
296 let end_turn = (range.end + 1) / 2;
297
298 if start_turn >= end_turn || end_turn > self.turns.len() {
299 return None;
300 }
301
302 let mut new_context = ContextSummary::new();
304
305 for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
306 let turn_summary = TurnSummary {
307 turn_number: start_turn + i + 1,
308 user_intent: extract_user_intent(&turn.user_message, 80),
309 assistant_action: extract_assistant_action(&turn.assistant_response, 100),
310 tool_calls: turn
311 .tool_calls
312 .iter()
313 .map(|tc| ToolCallSummary {
314 tool_name: tc.tool_name.clone(),
315 args_summary: tc.args_summary.clone(),
316 result_summary: truncate_text(&tc.result_summary, 100),
317 success: !tc.result_summary.to_lowercase().contains("error"),
318 })
319 .collect(),
320 key_decisions: vec![], };
322 new_context.add_turn(turn_summary);
323 }
324
325 self.context_summary.merge(new_context);
327
328 let new_frame = SummaryFrame::from_summary(&self.context_summary);
330
331 if let Some(existing) = &self.summary_frame {
333 let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
334 let merged_tokens = existing.token_count + new_frame.token_count;
335 self.summary_frame = Some(SummaryFrame {
336 content: merged_content,
337 token_count: merged_tokens,
338 });
339 } else {
340 self.summary_frame = Some(new_frame);
341 }
342
343 let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
345 let evicted_count = end_turn - start_turn;
346 self.turns = preserved_turns;
347
348 self.total_tokens = self
350 .summary_frame
351 .as_ref()
352 .map(|f| f.token_count)
353 .unwrap_or(0)
354 + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
355
356 Some(format!(
357 "Compacted {} turns ({} → {} tokens)",
358 evicted_count,
359 self.total_tokens + evicted_count * 500, self.total_tokens
361 ))
362 }
363
364 pub fn emergency_compact(&mut self) -> Option<String> {
368 let original_config = self.compact_config.clone();
370 self.compact_config = CompactConfig {
371 retention_window: 3, eviction_window: 0.9, thresholds: CompactThresholds::aggressive(),
374 };
375
376 let result = self.compact();
377
378 self.compact_config = original_config;
380 result
381 }
382
383 pub fn to_messages(&self) -> Vec<Message> {
386 use rig::completion::message::{AssistantContent, Text, UserContent};
387 use rig::OneOrMany;
388
389 let mut messages = Vec::new();
390
391 if let Some(frame) = &self.summary_frame {
393 messages.push(Message::User {
395 content: OneOrMany::one(UserContent::Text(Text {
396 text: format!("[Previous conversation context]\n{}", frame.content),
397 })),
398 });
399 messages.push(Message::Assistant {
400 id: None,
401 content: OneOrMany::one(AssistantContent::Text(Text {
402 text: "I understand the previous context. I'll continue from where we left off."
403 .to_string(),
404 })),
405 });
406 }
407
408 for turn in &self.turns {
410 messages.push(Message::User {
412 content: OneOrMany::one(UserContent::Text(Text {
413 text: turn.user_message.clone(),
414 })),
415 });
416
417 let mut response_text = String::new();
419
420 if !turn.tool_calls.is_empty() {
422 response_text.push_str("[Tools used in this turn:\n");
423 for tc in &turn.tool_calls {
424 response_text.push_str(&format!(
425 " - {}({}) → {}\n",
426 tc.tool_name,
427 truncate_text(&tc.args_summary, 50),
428 truncate_text(&tc.result_summary, 100)
429 ));
430 }
431 response_text.push_str("]\n\n");
432 }
433
434 response_text.push_str(&turn.assistant_response);
436
437 messages.push(Message::Assistant {
438 id: None,
439 content: OneOrMany::one(AssistantContent::Text(Text { text: response_text })),
440 });
441 }
442
443 messages
444 }
445
446 pub fn is_empty(&self) -> bool {
448 self.turns.is_empty() && self.summary_frame.is_none()
449 }
450
451 pub fn status(&self) -> String {
453 let compressed_info = if self.summary_frame.is_some() {
454 format!(
455 " (+{} compacted)",
456 self.context_summary.turns_compacted
457 )
458 } else {
459 String::new()
460 };
461 format!(
462 "{} turns, ~{} tokens{}",
463 self.turns.len(),
464 self.total_tokens,
465 compressed_info
466 )
467 }
468
469 pub fn files_read(&self) -> impl Iterator<Item = &str> {
471 self.context_summary.files_read.iter().map(|s| s.as_str())
472 }
473
474 pub fn files_written(&self) -> impl Iterator<Item = &str> {
476 self.context_summary
477 .files_written
478 .iter()
479 .map(|s| s.as_str())
480 }
481}
482
483fn truncate_text(text: &str, max_len: usize) -> String {
485 if text.len() <= max_len {
486 text.to_string()
487 } else {
488 format!("{}...", &text[..max_len.saturating_sub(3)])
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_add_turn() {
498 let mut history = ConversationHistory::new();
499 history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
500 assert_eq!(history.turn_count(), 1);
501 assert!(!history.is_empty());
502 }
503
504 #[test]
505 fn test_droppable_detection() {
506 let mut history = ConversationHistory::new();
507
508 history.add_turn(
510 "Read the file".to_string(),
511 "Here's the content".to_string(),
512 vec![ToolCallRecord {
513 tool_name: "read_file".to_string(),
514 args_summary: "src/main.rs".to_string(),
515 result_summary: "file content...".to_string(),
516 tool_id: Some("tool_1".to_string()),
517 droppable: true,
518 }],
519 );
520 assert!(history.turns[0].droppable);
521
522 history.add_turn(
524 "Write the file".to_string(),
525 "Done".to_string(),
526 vec![ToolCallRecord {
527 tool_name: "write_file".to_string(),
528 args_summary: "src/new.rs".to_string(),
529 result_summary: "success".to_string(),
530 tool_id: Some("tool_2".to_string()),
531 droppable: false,
532 }],
533 );
534 assert!(!history.turns[1].droppable);
535 }
536
537 #[test]
538 fn test_compaction() {
539 let mut history = ConversationHistory::with_config(CompactConfig {
541 retention_window: 2,
542 eviction_window: 0.6,
543 thresholds: CompactThresholds {
544 token_threshold: Some(500),
545 turn_threshold: Some(5),
546 message_threshold: Some(10),
547 on_turn_end: None,
548 },
549 });
550
551 for i in 0..10 {
553 history.add_turn(
554 format!("Question {} with lots of text to increase token count", i),
555 format!(
556 "Answer {} with lots of detail to increase token count even more",
557 i
558 ),
559 vec![ToolCallRecord {
560 tool_name: "analyze".to_string(),
561 args_summary: "path: .".to_string(),
562 result_summary: "Found rust project with many files".to_string(),
563 tool_id: Some(format!("tool_{}", i)),
564 droppable: false,
565 }],
566 );
567 }
568
569 if history.needs_compaction() {
570 let summary = history.compact();
571 assert!(summary.is_some());
572 assert!(history.turn_count() < 10);
573 assert!(history.summary_frame.is_some());
574 }
575 }
576
577 #[test]
578 fn test_to_messages() {
579 let mut history = ConversationHistory::new();
580 history.add_turn(
581 "What is this project?".to_string(),
582 "This is a Rust CLI tool.".to_string(),
583 vec![],
584 );
585
586 let messages = history.to_messages();
587 assert_eq!(messages.len(), 2); }
589
590 #[test]
591 fn test_clear() {
592 let mut history = ConversationHistory::new();
593 history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
594 history.clear();
595 assert!(history.is_empty());
596 assert_eq!(history.token_count(), 0);
597 }
598
599 #[test]
600 fn test_compaction_reason() {
601 let mut history = ConversationHistory::with_config(CompactConfig {
602 retention_window: 2,
603 eviction_window: 0.6,
604 thresholds: CompactThresholds {
605 token_threshold: Some(100),
606 turn_threshold: Some(3),
607 message_threshold: Some(5),
608 on_turn_end: None,
609 },
610 });
611
612 for i in 0..5 {
614 history.add_turn(
615 format!("Question {}", i),
616 format!("Answer {}", i),
617 vec![],
618 );
619 }
620
621 assert!(history.needs_compaction());
622 let reason = history.compaction_reason();
623 assert!(reason.is_some());
624 }
625}