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 fn estimate_tokens(text: &str) -> usize {
116 text.len() / CHARS_PER_TOKEN
117 }
118
119 pub fn add_turn(
121 &mut self,
122 user_message: String,
123 assistant_response: String,
124 tool_calls: Vec<ToolCallRecord>,
125 ) {
126 let droppable = !tool_calls.is_empty()
129 && tool_calls.iter().all(|tc| {
130 matches!(
131 tc.tool_name.as_str(),
132 "read_file" | "list_directory" | "analyze_project"
133 )
134 });
135
136 let turn_tokens = Self::estimate_tokens(&user_message)
137 + Self::estimate_tokens(&assistant_response)
138 + tool_calls
139 .iter()
140 .map(|tc| {
141 Self::estimate_tokens(&tc.tool_name)
142 + Self::estimate_tokens(&tc.args_summary)
143 + Self::estimate_tokens(&tc.result_summary)
144 })
145 .sum::<usize>();
146
147 self.turns.push(ConversationTurn {
148 user_message,
149 assistant_response,
150 tool_calls,
151 estimated_tokens: turn_tokens,
152 droppable,
153 });
154 self.total_tokens += turn_tokens;
155 self.user_turn_count += 1;
156 }
157
158 pub fn add_turn_droppable(
160 &mut self,
161 user_message: String,
162 assistant_response: String,
163 tool_calls: Vec<ToolCallRecord>,
164 droppable: bool,
165 ) {
166 let turn_tokens = Self::estimate_tokens(&user_message)
167 + Self::estimate_tokens(&assistant_response)
168 + tool_calls
169 .iter()
170 .map(|tc| {
171 Self::estimate_tokens(&tc.tool_name)
172 + Self::estimate_tokens(&tc.args_summary)
173 + Self::estimate_tokens(&tc.result_summary)
174 })
175 .sum::<usize>();
176
177 self.turns.push(ConversationTurn {
178 user_message,
179 assistant_response,
180 tool_calls,
181 estimated_tokens: turn_tokens,
182 droppable,
183 });
184 self.total_tokens += turn_tokens;
185 self.user_turn_count += 1;
186 }
187
188 pub fn needs_compaction(&self) -> bool {
190 let last_is_user = self
191 .turns
192 .last()
193 .map(|t| !t.user_message.is_empty())
194 .unwrap_or(false);
195
196 self.compact_config.should_compact(
197 self.total_tokens,
198 self.user_turn_count,
199 self.turns.len(),
200 last_is_user,
201 )
202 }
203
204 pub fn compaction_reason(&self) -> Option<String> {
206 self.compact_config
207 .compaction_reason(self.total_tokens, self.user_turn_count, self.turns.len())
208 }
209
210 pub fn token_count(&self) -> usize {
212 self.total_tokens
213 }
214
215 pub fn turn_count(&self) -> usize {
217 self.turns.len()
218 }
219
220 pub fn user_turn_count(&self) -> usize {
222 self.user_turn_count
223 }
224
225 pub fn clear(&mut self) {
227 self.turns.clear();
228 self.summary_frame = None;
229 self.total_tokens = 0;
230 self.user_turn_count = 0;
231 self.context_summary = ContextSummary::new();
232 }
233
234 pub fn compact(&mut self) -> Option<String> {
237 use super::compact::strategy::{MessageMeta, MessageRole};
238 use super::compact::summary::{
239 extract_assistant_action, extract_user_intent, ToolCallSummary, TurnSummary,
240 };
241
242 if self.turns.len() < 2 {
243 return None; }
245
246 let messages: Vec<MessageMeta> = self
248 .turns
249 .iter()
250 .enumerate()
251 .flat_map(|(turn_idx, turn)| {
252 let mut metas = vec![];
253
254 metas.push(MessageMeta {
256 index: turn_idx * 2,
257 role: MessageRole::User,
258 droppable: turn.droppable,
259 has_tool_call: false,
260 is_tool_result: false,
261 tool_id: None,
262 token_count: Self::estimate_tokens(&turn.user_message),
263 });
264
265 let has_tool_call = !turn.tool_calls.is_empty();
267 let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
268
269 metas.push(MessageMeta {
270 index: turn_idx * 2 + 1,
271 role: MessageRole::Assistant,
272 droppable: turn.droppable,
273 has_tool_call,
274 is_tool_result: false,
275 tool_id,
276 token_count: Self::estimate_tokens(&turn.assistant_response),
277 });
278
279 metas
280 })
281 .collect();
282
283 let strategy = CompactionStrategy::default();
285
286 let range = strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
288
289 if range.is_empty() {
290 return None;
291 }
292
293 let start_turn = range.start / 2;
295 let end_turn = (range.end + 1) / 2;
296
297 if start_turn >= end_turn || end_turn > self.turns.len() {
298 return None;
299 }
300
301 let mut new_context = ContextSummary::new();
303
304 for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
305 let turn_summary = TurnSummary {
306 turn_number: start_turn + i + 1,
307 user_intent: extract_user_intent(&turn.user_message, 80),
308 assistant_action: extract_assistant_action(&turn.assistant_response, 100),
309 tool_calls: turn
310 .tool_calls
311 .iter()
312 .map(|tc| ToolCallSummary {
313 tool_name: tc.tool_name.clone(),
314 args_summary: tc.args_summary.clone(),
315 result_summary: truncate_text(&tc.result_summary, 100),
316 success: !tc.result_summary.to_lowercase().contains("error"),
317 })
318 .collect(),
319 key_decisions: vec![], };
321 new_context.add_turn(turn_summary);
322 }
323
324 self.context_summary.merge(new_context);
326
327 let new_frame = SummaryFrame::from_summary(&self.context_summary);
329
330 if let Some(existing) = &self.summary_frame {
332 let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
333 let merged_tokens = existing.token_count + new_frame.token_count;
334 self.summary_frame = Some(SummaryFrame {
335 content: merged_content,
336 token_count: merged_tokens,
337 });
338 } else {
339 self.summary_frame = Some(new_frame);
340 }
341
342 let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
344 let evicted_count = end_turn - start_turn;
345 self.turns = preserved_turns;
346
347 self.total_tokens = self
349 .summary_frame
350 .as_ref()
351 .map(|f| f.token_count)
352 .unwrap_or(0)
353 + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
354
355 Some(format!(
356 "Compacted {} turns ({} → {} tokens)",
357 evicted_count,
358 self.total_tokens + evicted_count * 500, self.total_tokens
360 ))
361 }
362
363 pub fn to_messages(&self) -> Vec<Message> {
366 use rig::completion::message::{AssistantContent, Text, UserContent};
367 use rig::OneOrMany;
368
369 let mut messages = Vec::new();
370
371 if let Some(frame) = &self.summary_frame {
373 messages.push(Message::User {
375 content: OneOrMany::one(UserContent::Text(Text {
376 text: format!("[Previous conversation context]\n{}", frame.content),
377 })),
378 });
379 messages.push(Message::Assistant {
380 id: None,
381 content: OneOrMany::one(AssistantContent::Text(Text {
382 text: "I understand the previous context. I'll continue from where we left off."
383 .to_string(),
384 })),
385 });
386 }
387
388 for turn in &self.turns {
390 messages.push(Message::User {
392 content: OneOrMany::one(UserContent::Text(Text {
393 text: turn.user_message.clone(),
394 })),
395 });
396
397 let mut response_text = String::new();
399
400 if !turn.tool_calls.is_empty() {
402 response_text.push_str("[Tools used in this turn:\n");
403 for tc in &turn.tool_calls {
404 response_text.push_str(&format!(
405 " - {}({}) → {}\n",
406 tc.tool_name,
407 truncate_text(&tc.args_summary, 50),
408 truncate_text(&tc.result_summary, 100)
409 ));
410 }
411 response_text.push_str("]\n\n");
412 }
413
414 response_text.push_str(&turn.assistant_response);
416
417 messages.push(Message::Assistant {
418 id: None,
419 content: OneOrMany::one(AssistantContent::Text(Text { text: response_text })),
420 });
421 }
422
423 messages
424 }
425
426 pub fn is_empty(&self) -> bool {
428 self.turns.is_empty() && self.summary_frame.is_none()
429 }
430
431 pub fn status(&self) -> String {
433 let compressed_info = if self.summary_frame.is_some() {
434 format!(
435 " (+{} compacted)",
436 self.context_summary.turns_compacted
437 )
438 } else {
439 String::new()
440 };
441 format!(
442 "{} turns, ~{} tokens{}",
443 self.turns.len(),
444 self.total_tokens,
445 compressed_info
446 )
447 }
448
449 pub fn files_read(&self) -> impl Iterator<Item = &str> {
451 self.context_summary.files_read.iter().map(|s| s.as_str())
452 }
453
454 pub fn files_written(&self) -> impl Iterator<Item = &str> {
456 self.context_summary
457 .files_written
458 .iter()
459 .map(|s| s.as_str())
460 }
461}
462
463fn truncate_text(text: &str, max_len: usize) -> String {
465 if text.len() <= max_len {
466 text.to_string()
467 } else {
468 format!("{}...", &text[..max_len.saturating_sub(3)])
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn test_add_turn() {
478 let mut history = ConversationHistory::new();
479 history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
480 assert_eq!(history.turn_count(), 1);
481 assert!(!history.is_empty());
482 }
483
484 #[test]
485 fn test_droppable_detection() {
486 let mut history = ConversationHistory::new();
487
488 history.add_turn(
490 "Read the file".to_string(),
491 "Here's the content".to_string(),
492 vec![ToolCallRecord {
493 tool_name: "read_file".to_string(),
494 args_summary: "src/main.rs".to_string(),
495 result_summary: "file content...".to_string(),
496 tool_id: Some("tool_1".to_string()),
497 droppable: true,
498 }],
499 );
500 assert!(history.turns[0].droppable);
501
502 history.add_turn(
504 "Write the file".to_string(),
505 "Done".to_string(),
506 vec![ToolCallRecord {
507 tool_name: "write_file".to_string(),
508 args_summary: "src/new.rs".to_string(),
509 result_summary: "success".to_string(),
510 tool_id: Some("tool_2".to_string()),
511 droppable: false,
512 }],
513 );
514 assert!(!history.turns[1].droppable);
515 }
516
517 #[test]
518 fn test_compaction() {
519 let mut history = ConversationHistory::with_config(CompactConfig {
521 retention_window: 2,
522 eviction_window: 0.6,
523 thresholds: CompactThresholds {
524 token_threshold: Some(500),
525 turn_threshold: Some(5),
526 message_threshold: Some(10),
527 on_turn_end: None,
528 },
529 });
530
531 for i in 0..10 {
533 history.add_turn(
534 format!("Question {} with lots of text to increase token count", i),
535 format!(
536 "Answer {} with lots of detail to increase token count even more",
537 i
538 ),
539 vec![ToolCallRecord {
540 tool_name: "analyze".to_string(),
541 args_summary: "path: .".to_string(),
542 result_summary: "Found rust project with many files".to_string(),
543 tool_id: Some(format!("tool_{}", i)),
544 droppable: false,
545 }],
546 );
547 }
548
549 if history.needs_compaction() {
550 let summary = history.compact();
551 assert!(summary.is_some());
552 assert!(history.turn_count() < 10);
553 assert!(history.summary_frame.is_some());
554 }
555 }
556
557 #[test]
558 fn test_to_messages() {
559 let mut history = ConversationHistory::new();
560 history.add_turn(
561 "What is this project?".to_string(),
562 "This is a Rust CLI tool.".to_string(),
563 vec![],
564 );
565
566 let messages = history.to_messages();
567 assert_eq!(messages.len(), 2); }
569
570 #[test]
571 fn test_clear() {
572 let mut history = ConversationHistory::new();
573 history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
574 history.clear();
575 assert!(history.is_empty());
576 assert_eq!(history.token_count(), 0);
577 }
578
579 #[test]
580 fn test_compaction_reason() {
581 let mut history = ConversationHistory::with_config(CompactConfig {
582 retention_window: 2,
583 eviction_window: 0.6,
584 thresholds: CompactThresholds {
585 token_threshold: Some(100),
586 turn_threshold: Some(3),
587 message_threshold: Some(5),
588 on_turn_end: None,
589 },
590 });
591
592 for i in 0..5 {
594 history.add_turn(
595 format!("Question {}", i),
596 format!("Answer {}", i),
597 vec![],
598 );
599 }
600
601 assert!(history.needs_compaction());
602 let reason = history.compaction_reason();
603 assert!(reason.is_some());
604 }
605}