syncable_cli/agent/
history.rs

1//! Conversation history management with compaction support
2//!
3//! This module provides conversation history storage and automatic compaction
4//! when the token count exceeds a configurable threshold, similar to gemini-cli.
5
6use rig::completion::Message;
7use serde::{Deserialize, Serialize};
8
9/// Default threshold for compression as a fraction of context window (85%)
10pub const DEFAULT_COMPRESSION_THRESHOLD: f32 = 0.85;
11
12/// Fraction of history to preserve after compression (keep last 30%)
13pub const COMPRESSION_PRESERVE_FRACTION: f32 = 0.3;
14
15/// Rough token estimate: ~4 characters per token
16const CHARS_PER_TOKEN: usize = 4;
17
18/// Maximum context window tokens (conservative estimate for most models)
19const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128_000;
20
21/// A conversation turn containing user input and assistant response
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConversationTurn {
24    pub user_message: String,
25    pub assistant_response: String,
26    /// Tool calls made during this turn (for context preservation)
27    pub tool_calls: Vec<ToolCallRecord>,
28    /// Estimated token count for this turn
29    pub estimated_tokens: usize,
30}
31
32/// Record of a tool call for history tracking
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ToolCallRecord {
35    pub tool_name: String,
36    pub args_summary: String,
37    pub result_summary: String,
38    /// Tool call ID for proper message pairing (optional for backwards compat)
39    #[serde(default)]
40    pub tool_id: Option<String>,
41}
42
43/// Conversation history manager with compaction support
44#[derive(Debug, Clone)]
45pub struct ConversationHistory {
46    /// Full conversation turns
47    turns: Vec<ConversationTurn>,
48    /// Compressed summary of older turns (if any)
49    compressed_summary: Option<String>,
50    /// Total estimated tokens in history
51    total_tokens: usize,
52    /// Maximum tokens before triggering compaction
53    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    /// Create with custom compression threshold
74    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    /// Estimate tokens in a string
84    fn estimate_tokens(text: &str) -> usize {
85        text.len() / CHARS_PER_TOKEN
86    }
87
88    /// Add a new conversation turn
89    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    /// Check if compaction is needed
108    pub fn needs_compaction(&self) -> bool {
109        self.total_tokens > self.compression_threshold_tokens
110    }
111
112    /// Get current token count
113    pub fn token_count(&self) -> usize {
114        self.total_tokens
115    }
116
117    /// Get number of turns
118    pub fn turn_count(&self) -> usize {
119        self.turns.len()
120    }
121
122    /// Clear all history
123    pub fn clear(&mut self) {
124        self.turns.clear();
125        self.compressed_summary = None;
126        self.total_tokens = 0;
127    }
128
129    /// Perform compaction - summarize older turns and keep recent ones
130    /// Returns the summary that was created (for logging/display)
131    pub fn compact(&mut self) -> Option<String> {
132        if self.turns.len() < 2 {
133            return None; // Nothing to compact
134        }
135
136        // Calculate split point - keep last 30% of turns
137        let preserve_count = ((self.turns.len() as f32) * COMPRESSION_PRESERVE_FRACTION).ceil() as usize;
138        let preserve_count = preserve_count.max(1); // Keep at least 1 turn
139        let split_point = self.turns.len().saturating_sub(preserve_count);
140
141        if split_point == 0 {
142            return None; // Nothing to compress
143        }
144
145        // Create summary of older turns
146        let turns_to_compress = &self.turns[..split_point];
147        let summary = self.create_summary(turns_to_compress);
148
149        // Update compressed summary
150        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        // Keep only recent turns
158        let preserved_turns: Vec<_> = self.turns[split_point..].to_vec();
159        self.turns = preserved_turns;
160
161        // Recalculate token count
162        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    /// Create a text summary of conversation turns
169    /// Includes detailed tool call information to preserve context
170    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                // Group tool calls by type for better summary
186                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        // Add a cumulative context section
235        let mut context = format!(
236            "=== Conversation Summary ({} turns compressed) ===\n\n{}",
237            turns.len(),
238            summary_parts.join("\n\n")
239        );
240
241        // Add cumulative file context
242        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    /// Truncate text with ellipsis
263    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    /// Convert history to Rig Message format for the agent
272    /// Uses text summaries to preserve context without breaking Rig's internal tool call tracking
273    /// Note: We can't use proper ToolCall/ToolResult messages because Rig expects real API tool IDs
274    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        // Add compressed summary as initial context if present
281        if let Some(summary) = &self.compressed_summary {
282            // Add as a user message with the summary, followed by acknowledgment
283            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        // Add recent turns with tool call context as text (not as actual ToolCall messages)
297        for turn in &self.turns {
298            // User message
299            messages.push(Message::User {
300                content: OneOrMany::one(UserContent::Text(Text {
301                    text: turn.user_message.clone(),
302                })),
303            });
304
305            // Build assistant response that includes tool call context
306            let mut response_text = String::new();
307
308            // If there were tool calls, include them as text context
309            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            // Add the actual response
323            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    /// Check if there's any history
337    pub fn is_empty(&self) -> bool {
338        self.turns.is_empty() && self.compressed_summary.is_none()
339    }
340
341    /// Get a brief status string for display
342    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); // Low threshold
376
377        // Add many turns to trigger compaction
378        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); // 1 user + 1 assistant
409    }
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}