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.compaction_reason(
208 self.total_tokens,
209 self.user_turn_count,
210 self.turns.len(),
211 )
212 }
213
214 pub fn token_count(&self) -> usize {
216 self.total_tokens
217 }
218
219 pub fn turn_count(&self) -> usize {
221 self.turns.len()
222 }
223
224 pub fn user_turn_count(&self) -> usize {
226 self.user_turn_count
227 }
228
229 pub fn clear(&mut self) {
231 self.turns.clear();
232 self.summary_frame = None;
233 self.total_tokens = 0;
234 self.user_turn_count = 0;
235 self.context_summary = ContextSummary::new();
236 }
237
238 pub fn compact(&mut self) -> Option<String> {
241 use super::compact::strategy::{MessageMeta, MessageRole};
242 use super::compact::summary::{
243 ToolCallSummary, TurnSummary, extract_assistant_action, extract_user_intent,
244 };
245
246 if self.turns.len() < 2 {
247 return None; }
249
250 let messages: Vec<MessageMeta> = self
252 .turns
253 .iter()
254 .enumerate()
255 .flat_map(|(turn_idx, turn)| {
256 let mut metas = vec![];
257
258 metas.push(MessageMeta {
260 index: turn_idx * 2,
261 role: MessageRole::User,
262 droppable: turn.droppable,
263 has_tool_call: false,
264 is_tool_result: false,
265 tool_id: None,
266 token_count: Self::estimate_tokens(&turn.user_message),
267 });
268
269 let has_tool_call = !turn.tool_calls.is_empty();
271 let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
272
273 metas.push(MessageMeta {
274 index: turn_idx * 2 + 1,
275 role: MessageRole::Assistant,
276 droppable: turn.droppable,
277 has_tool_call,
278 is_tool_result: false,
279 tool_id,
280 token_count: Self::estimate_tokens(&turn.assistant_response),
281 });
282
283 metas
284 })
285 .collect();
286
287 let strategy = CompactionStrategy::default();
289
290 let range =
292 strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
293
294 if range.is_empty() {
295 return None;
296 }
297
298 let start_turn = range.start / 2;
300 let end_turn = range.end.div_ceil(2);
301
302 if start_turn >= end_turn || end_turn > self.turns.len() {
303 return None;
304 }
305
306 let mut new_context = ContextSummary::new();
308
309 for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
310 let turn_summary = TurnSummary {
311 turn_number: start_turn + i + 1,
312 user_intent: extract_user_intent(&turn.user_message, 80),
313 assistant_action: extract_assistant_action(&turn.assistant_response, 100),
314 tool_calls: turn
315 .tool_calls
316 .iter()
317 .map(|tc| ToolCallSummary {
318 tool_name: tc.tool_name.clone(),
319 args_summary: tc.args_summary.clone(),
320 result_summary: truncate_text(&tc.result_summary, 100),
321 success: !tc.result_summary.to_lowercase().contains("error"),
322 })
323 .collect(),
324 key_decisions: vec![], };
326 new_context.add_turn(turn_summary);
327 }
328
329 self.context_summary.merge(new_context);
331
332 let new_frame = SummaryFrame::from_summary(&self.context_summary);
334
335 if let Some(existing) = &self.summary_frame {
337 let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
338 let merged_tokens = existing.token_count + new_frame.token_count;
339 self.summary_frame = Some(SummaryFrame {
340 content: merged_content,
341 token_count: merged_tokens,
342 });
343 } else {
344 self.summary_frame = Some(new_frame);
345 }
346
347 let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
349 let evicted_count = end_turn - start_turn;
350 self.turns = preserved_turns;
351
352 self.total_tokens = self
354 .summary_frame
355 .as_ref()
356 .map(|f| f.token_count)
357 .unwrap_or(0)
358 + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
359
360 Some(format!(
361 "Compacted {} turns ({} → {} tokens)",
362 evicted_count,
363 self.total_tokens + evicted_count * 500, self.total_tokens
365 ))
366 }
367
368 pub fn emergency_compact(&mut self) -> Option<String> {
372 let original_config = self.compact_config.clone();
374 self.compact_config = CompactConfig {
375 retention_window: 3, eviction_window: 0.9, thresholds: CompactThresholds::aggressive(),
378 };
379
380 let result = self.compact();
381
382 self.compact_config = original_config;
384 result
385 }
386
387 pub fn to_messages(&self) -> Vec<Message> {
390 use rig::OneOrMany;
391 use rig::completion::message::{AssistantContent, Text, UserContent};
392
393 let mut messages = Vec::new();
394
395 if let Some(frame) = &self.summary_frame {
397 messages.push(Message::User {
399 content: OneOrMany::one(UserContent::Text(Text {
400 text: format!("[Previous conversation context]\n{}", frame.content),
401 })),
402 });
403 messages.push(Message::Assistant {
404 id: None,
405 content: OneOrMany::one(AssistantContent::Text(Text {
406 text:
407 "I understand the previous context. I'll continue from where we left off."
408 .to_string(),
409 })),
410 });
411 }
412
413 for turn in &self.turns {
415 messages.push(Message::User {
417 content: OneOrMany::one(UserContent::Text(Text {
418 text: turn.user_message.clone(),
419 })),
420 });
421
422 let mut response_text = String::new();
424
425 if !turn.tool_calls.is_empty() {
427 response_text.push_str("[Tools used in this turn:\n");
428 for tc in &turn.tool_calls {
429 response_text.push_str(&format!(
430 " - {}({}) → {}\n",
431 tc.tool_name,
432 truncate_text(&tc.args_summary, 50),
433 truncate_text(&tc.result_summary, 100)
434 ));
435 }
436 response_text.push_str("]\n\n");
437 }
438
439 response_text.push_str(&turn.assistant_response);
441
442 messages.push(Message::Assistant {
443 id: None,
444 content: OneOrMany::one(AssistantContent::Text(Text {
445 text: response_text,
446 })),
447 });
448 }
449
450 messages
451 }
452
453 pub fn is_empty(&self) -> bool {
455 self.turns.is_empty() && self.summary_frame.is_none()
456 }
457
458 pub fn status(&self) -> String {
460 let compressed_info = if self.summary_frame.is_some() {
461 format!(" (+{} compacted)", self.context_summary.turns_compacted)
462 } else {
463 String::new()
464 };
465 format!(
466 "{} turns, ~{} tokens{}",
467 self.turns.len(),
468 self.total_tokens,
469 compressed_info
470 )
471 }
472
473 pub fn files_read(&self) -> impl Iterator<Item = &str> {
475 self.context_summary.files_read.iter().map(|s| s.as_str())
476 }
477
478 pub fn files_written(&self) -> impl Iterator<Item = &str> {
480 self.context_summary
481 .files_written
482 .iter()
483 .map(|s| s.as_str())
484 }
485}
486
487fn truncate_text(text: &str, max_len: usize) -> String {
489 if text.len() <= max_len {
490 text.to_string()
491 } else {
492 format!("{}...", &text[..max_len.saturating_sub(3)])
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn test_add_turn() {
502 let mut history = ConversationHistory::new();
503 history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
504 assert_eq!(history.turn_count(), 1);
505 assert!(!history.is_empty());
506 }
507
508 #[test]
509 fn test_droppable_detection() {
510 let mut history = ConversationHistory::new();
511
512 history.add_turn(
514 "Read the file".to_string(),
515 "Here's the content".to_string(),
516 vec![ToolCallRecord {
517 tool_name: "read_file".to_string(),
518 args_summary: "src/main.rs".to_string(),
519 result_summary: "file content...".to_string(),
520 tool_id: Some("tool_1".to_string()),
521 droppable: true,
522 }],
523 );
524 assert!(history.turns[0].droppable);
525
526 history.add_turn(
528 "Write the file".to_string(),
529 "Done".to_string(),
530 vec![ToolCallRecord {
531 tool_name: "write_file".to_string(),
532 args_summary: "src/new.rs".to_string(),
533 result_summary: "success".to_string(),
534 tool_id: Some("tool_2".to_string()),
535 droppable: false,
536 }],
537 );
538 assert!(!history.turns[1].droppable);
539 }
540
541 #[test]
542 fn test_compaction() {
543 let mut history = ConversationHistory::with_config(CompactConfig {
545 retention_window: 2,
546 eviction_window: 0.6,
547 thresholds: CompactThresholds {
548 token_threshold: Some(500),
549 turn_threshold: Some(5),
550 message_threshold: Some(10),
551 on_turn_end: None,
552 },
553 });
554
555 for i in 0..10 {
557 history.add_turn(
558 format!("Question {} with lots of text to increase token count", i),
559 format!(
560 "Answer {} with lots of detail to increase token count even more",
561 i
562 ),
563 vec![ToolCallRecord {
564 tool_name: "analyze".to_string(),
565 args_summary: "path: .".to_string(),
566 result_summary: "Found rust project with many files".to_string(),
567 tool_id: Some(format!("tool_{}", i)),
568 droppable: false,
569 }],
570 );
571 }
572
573 if history.needs_compaction() {
574 let summary = history.compact();
575 assert!(summary.is_some());
576 assert!(history.turn_count() < 10);
577 assert!(history.summary_frame.is_some());
578 }
579 }
580
581 #[test]
582 fn test_to_messages() {
583 let mut history = ConversationHistory::new();
584 history.add_turn(
585 "What is this project?".to_string(),
586 "This is a Rust CLI tool.".to_string(),
587 vec![],
588 );
589
590 let messages = history.to_messages();
591 assert_eq!(messages.len(), 2); }
593
594 #[test]
595 fn test_clear() {
596 let mut history = ConversationHistory::new();
597 history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
598 history.clear();
599 assert!(history.is_empty());
600 assert_eq!(history.token_count(), 0);
601 }
602
603 #[test]
604 fn test_compaction_reason() {
605 let mut history = ConversationHistory::with_config(CompactConfig {
606 retention_window: 2,
607 eviction_window: 0.6,
608 thresholds: CompactThresholds {
609 token_threshold: Some(100),
610 turn_threshold: Some(3),
611 message_threshold: Some(5),
612 on_turn_end: None,
613 },
614 });
615
616 for i in 0..5 {
618 history.add_turn(format!("Question {}", i), format!("Answer {}", i), vec![]);
619 }
620
621 assert!(history.needs_compaction());
622 let reason = history.compaction_reason();
623 assert!(reason.is_some());
624 }
625}