syncable_cli/agent/
history.rs

1//! Conversation history management with forge-style compaction support
2//!
3//! This module provides conversation history storage with intelligent compaction
4//! inspired by forge's context management approach:
5//! - Configurable thresholds (tokens, turns, messages)
6//! - Smart eviction strategy (protects tool-call/result adjacency)
7//! - Droppable message support for ephemeral content
8//! - Summary frame generation for compressed history
9
10use super::compact::{
11    CompactConfig, CompactThresholds, CompactionStrategy, ContextSummary, SummaryFrame,
12};
13use rig::completion::Message;
14use serde::{Deserialize, Serialize};
15
16/// Rough token estimate: ~4 characters per token
17const CHARS_PER_TOKEN: usize = 4;
18
19/// A conversation turn containing user input and assistant response
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22    pub user_message: String,
23    pub assistant_response: String,
24    /// Tool calls made during this turn (for context preservation)
25    pub tool_calls: Vec<ToolCallRecord>,
26    /// Estimated token count for this turn
27    pub estimated_tokens: usize,
28    /// Whether this turn can be dropped entirely (ephemeral content)
29    /// Droppable turns are typically file reads or directory listings
30    /// that can be re-fetched if needed
31    #[serde(default)]
32    pub droppable: bool,
33}
34
35/// Record of a tool call for history tracking
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ToolCallRecord {
38    pub tool_name: String,
39    pub args_summary: String,
40    pub result_summary: String,
41    /// Tool call ID for proper message pairing (optional for backwards compat)
42    #[serde(default)]
43    pub tool_id: Option<String>,
44    /// Whether this tool result is droppable (ephemeral content like file reads)
45    #[serde(default)]
46    pub droppable: bool,
47}
48
49/// Conversation history manager with forge-style compaction support
50#[derive(Debug, Clone)]
51pub struct ConversationHistory {
52    /// Full conversation turns
53    turns: Vec<ConversationTurn>,
54    /// Compressed summary using SummaryFrame (if any)
55    summary_frame: Option<SummaryFrame>,
56    /// Total estimated tokens in history
57    total_tokens: usize,
58    /// Compaction configuration
59    compact_config: CompactConfig,
60    /// Number of user turns (for threshold checking)
61    user_turn_count: usize,
62    /// Context summary for tracking file operations and decisions
63    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    /// Create with custom compaction configuration
85    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    /// Create with aggressive compaction for limited context windows
97    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    /// Create with relaxed compaction for large context windows
106    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    /// Estimate tokens in a string
115    fn estimate_tokens(text: &str) -> usize {
116        text.len() / CHARS_PER_TOKEN
117    }
118
119    /// Add a new conversation turn
120    pub fn add_turn(
121        &mut self,
122        user_message: String,
123        assistant_response: String,
124        tool_calls: Vec<ToolCallRecord>,
125    ) {
126        // Determine if this turn is droppable based on tool calls
127        // Turns that only read files or list directories are droppable
128        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    /// Add a turn with explicit droppable flag
159    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    /// Check if compaction is needed using forge-style thresholds
189    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    /// Get the reason for compaction (for logging)
205    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    /// Get current token count
211    pub fn token_count(&self) -> usize {
212        self.total_tokens
213    }
214
215    /// Get number of turns
216    pub fn turn_count(&self) -> usize {
217        self.turns.len()
218    }
219
220    /// Get number of user turns
221    pub fn user_turn_count(&self) -> usize {
222        self.user_turn_count
223    }
224
225    /// Clear all history
226    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    /// Perform forge-style compaction with smart eviction
235    /// Returns the summary that was created (for logging/display)
236    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; // Nothing to compact
244        }
245
246        // Build message metadata for strategy
247        let messages: Vec<MessageMeta> = self
248            .turns
249            .iter()
250            .enumerate()
251            .flat_map(|(turn_idx, turn)| {
252                let mut metas = vec![];
253
254                // User message
255                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                // Assistant message (may have tool calls)
266                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        // Use default strategy (evict 60% or retain 10, whichever is more conservative)
284        let strategy = CompactionStrategy::default();
285
286        // Calculate eviction range with tool-call safety
287        let range = strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
288
289        if range.is_empty() {
290            return None;
291        }
292
293        // Convert message indices to turn indices
294        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        // Build context summary from turns to evict
302        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![], // Could extract from assistant response
320            };
321            new_context.add_turn(turn_summary);
322        }
323
324        // Merge with existing context summary
325        self.context_summary.merge(new_context);
326
327        // Generate summary frame
328        let new_frame = SummaryFrame::from_summary(&self.context_summary);
329
330        // Merge with existing frame if present
331        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        // Keep only recent turns (non-evicted)
343        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        // Recalculate token count
348        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, // rough estimate of evicted tokens
359            self.total_tokens
360        ))
361    }
362
363    /// Convert history to Rig Message format for the agent
364    /// Uses structured summary frames to preserve context
365    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        // Add summary frame as initial context if present
372        if let Some(frame) = &self.summary_frame {
373            // Add as a user message with the summary, followed by acknowledgment
374            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        // Add recent turns with tool call context as text
389        for turn in &self.turns {
390            // User message
391            messages.push(Message::User {
392                content: OneOrMany::one(UserContent::Text(Text {
393                    text: turn.user_message.clone(),
394                })),
395            });
396
397            // Build assistant response that includes tool call context
398            let mut response_text = String::new();
399
400            // If there were tool calls, include them as text context
401            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            // Add the actual response
415            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    /// Check if there's any history
427    pub fn is_empty(&self) -> bool {
428        self.turns.is_empty() && self.summary_frame.is_none()
429    }
430
431    /// Get a brief status string for display
432    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    /// Get files that have been read during this session
450    pub fn files_read(&self) -> impl Iterator<Item = &str> {
451        self.context_summary.files_read.iter().map(|s| s.as_str())
452    }
453
454    /// Get files that have been written during this session
455    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
463/// Helper to truncate text with ellipsis
464fn 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        // Turn with only read_file should be droppable
489        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        // Turn with write_file should NOT be droppable
503        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        // Use aggressive config for easier testing
520        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        // Add many turns to trigger compaction
532        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); // 1 user + 1 assistant
568    }
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        // Add turns to exceed threshold
593        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}