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}
39
40/// Conversation history manager with compaction support
41#[derive(Debug, Clone)]
42pub struct ConversationHistory {
43    /// Full conversation turns
44    turns: Vec<ConversationTurn>,
45    /// Compressed summary of older turns (if any)
46    compressed_summary: Option<String>,
47    /// Total estimated tokens in history
48    total_tokens: usize,
49    /// Maximum tokens before triggering compaction
50    compression_threshold_tokens: usize,
51}
52
53impl Default for ConversationHistory {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl ConversationHistory {
60    pub fn new() -> Self {
61        let max_tokens = DEFAULT_MAX_CONTEXT_TOKENS;
62        Self {
63            turns: Vec::new(),
64            compressed_summary: None,
65            total_tokens: 0,
66            compression_threshold_tokens: (max_tokens as f32 * DEFAULT_COMPRESSION_THRESHOLD) as usize,
67        }
68    }
69
70    /// Create with custom compression threshold
71    pub fn with_threshold(max_context_tokens: usize, threshold_fraction: f32) -> Self {
72        Self {
73            turns: Vec::new(),
74            compressed_summary: None,
75            total_tokens: 0,
76            compression_threshold_tokens: (max_context_tokens as f32 * threshold_fraction) as usize,
77        }
78    }
79
80    /// Estimate tokens in a string
81    fn estimate_tokens(text: &str) -> usize {
82        text.len() / CHARS_PER_TOKEN
83    }
84
85    /// Add a new conversation turn
86    pub fn add_turn(&mut self, user_message: String, assistant_response: String, tool_calls: Vec<ToolCallRecord>) {
87        let turn_tokens = Self::estimate_tokens(&user_message)
88            + Self::estimate_tokens(&assistant_response)
89            + tool_calls.iter().map(|tc| {
90                Self::estimate_tokens(&tc.tool_name)
91                + Self::estimate_tokens(&tc.args_summary)
92                + Self::estimate_tokens(&tc.result_summary)
93            }).sum::<usize>();
94
95        self.turns.push(ConversationTurn {
96            user_message,
97            assistant_response,
98            tool_calls,
99            estimated_tokens: turn_tokens,
100        });
101        self.total_tokens += turn_tokens;
102    }
103
104    /// Check if compaction is needed
105    pub fn needs_compaction(&self) -> bool {
106        self.total_tokens > self.compression_threshold_tokens
107    }
108
109    /// Get current token count
110    pub fn token_count(&self) -> usize {
111        self.total_tokens
112    }
113
114    /// Get number of turns
115    pub fn turn_count(&self) -> usize {
116        self.turns.len()
117    }
118
119    /// Clear all history
120    pub fn clear(&mut self) {
121        self.turns.clear();
122        self.compressed_summary = None;
123        self.total_tokens = 0;
124    }
125
126    /// Perform compaction - summarize older turns and keep recent ones
127    /// Returns the summary that was created (for logging/display)
128    pub fn compact(&mut self) -> Option<String> {
129        if self.turns.len() < 2 {
130            return None; // Nothing to compact
131        }
132
133        // Calculate split point - keep last 30% of turns
134        let preserve_count = ((self.turns.len() as f32) * COMPRESSION_PRESERVE_FRACTION).ceil() as usize;
135        let preserve_count = preserve_count.max(1); // Keep at least 1 turn
136        let split_point = self.turns.len().saturating_sub(preserve_count);
137
138        if split_point == 0 {
139            return None; // Nothing to compress
140        }
141
142        // Create summary of older turns
143        let turns_to_compress = &self.turns[..split_point];
144        let summary = self.create_summary(turns_to_compress);
145
146        // Update compressed summary
147        let new_summary = if let Some(existing) = &self.compressed_summary {
148            format!("{}\n\n{}", existing, summary)
149        } else {
150            summary.clone()
151        };
152        self.compressed_summary = Some(new_summary);
153
154        // Keep only recent turns
155        let preserved_turns: Vec<_> = self.turns[split_point..].to_vec();
156        self.turns = preserved_turns;
157
158        // Recalculate token count
159        self.total_tokens = Self::estimate_tokens(self.compressed_summary.as_deref().unwrap_or(""))
160            + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
161
162        Some(summary)
163    }
164
165    /// Create a text summary of conversation turns
166    fn create_summary(&self, turns: &[ConversationTurn]) -> String {
167        let mut summary_parts = Vec::new();
168
169        for (i, turn) in turns.iter().enumerate() {
170            let mut turn_summary = format!(
171                "Turn {}: User asked about: {}",
172                i + 1,
173                Self::truncate_text(&turn.user_message, 100)
174            );
175
176            if !turn.tool_calls.is_empty() {
177                let tool_names: Vec<_> = turn.tool_calls.iter()
178                    .map(|tc| tc.tool_name.as_str())
179                    .collect();
180                turn_summary.push_str(&format!(". Tools used: {}", tool_names.join(", ")));
181            }
182
183            turn_summary.push_str(&format!(
184                ". Response summary: {}",
185                Self::truncate_text(&turn.assistant_response, 200)
186            ));
187
188            summary_parts.push(turn_summary);
189        }
190
191        format!(
192            "Previous conversation summary ({} turns compressed):\n{}",
193            turns.len(),
194            summary_parts.join("\n")
195        )
196    }
197
198    /// Truncate text with ellipsis
199    fn truncate_text(text: &str, max_len: usize) -> String {
200        if text.len() <= max_len {
201            text.to_string()
202        } else {
203            format!("{}...", &text[..max_len.saturating_sub(3)])
204        }
205    }
206
207    /// Convert history to Rig Message format for the agent
208    pub fn to_messages(&self) -> Vec<Message> {
209        use rig::completion::message::{Text, UserContent, AssistantContent};
210        use rig::OneOrMany;
211
212        let mut messages = Vec::new();
213
214        // Add compressed summary as initial context if present
215        if let Some(summary) = &self.compressed_summary {
216            // Add as a user message with the summary, followed by acknowledgment
217            messages.push(Message::User {
218                content: OneOrMany::one(UserContent::Text(Text {
219                    text: format!("[Previous conversation context]\n{}", summary),
220                })),
221            });
222            messages.push(Message::Assistant {
223                id: None,
224                content: OneOrMany::one(AssistantContent::Text(Text {
225                    text: "I understand the previous context. How can I help you continue?".to_string(),
226                })),
227            });
228        }
229
230        // Add recent turns
231        for turn in &self.turns {
232            // User message
233            messages.push(Message::User {
234                content: OneOrMany::one(UserContent::Text(Text {
235                    text: turn.user_message.clone(),
236                })),
237            });
238
239            // Assistant response (simplified - just the text response)
240            // Note: Tool calls are implicitly part of the response context
241            messages.push(Message::Assistant {
242                id: None,
243                content: OneOrMany::one(AssistantContent::Text(Text {
244                    text: turn.assistant_response.clone(),
245                })),
246            });
247        }
248
249        messages
250    }
251
252    /// Check if there's any history
253    pub fn is_empty(&self) -> bool {
254        self.turns.is_empty() && self.compressed_summary.is_none()
255    }
256
257    /// Get a brief status string for display
258    pub fn status(&self) -> String {
259        let compressed_info = if self.compressed_summary.is_some() {
260            " (with compressed history)"
261        } else {
262            ""
263        };
264        format!(
265            "{} turns, ~{} tokens{}",
266            self.turns.len(),
267            self.total_tokens,
268            compressed_info
269        )
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_add_turn() {
279        let mut history = ConversationHistory::new();
280        history.add_turn(
281            "Hello".to_string(),
282            "Hi there!".to_string(),
283            vec![],
284        );
285        assert_eq!(history.turn_count(), 1);
286        assert!(!history.is_empty());
287    }
288
289    #[test]
290    fn test_compaction() {
291        let mut history = ConversationHistory::with_threshold(1000, 0.1); // Low threshold
292
293        // Add many turns to trigger compaction
294        for i in 0..10 {
295            history.add_turn(
296                format!("Question {}", i),
297                format!("Answer {} with lots of detail to increase token count", i),
298                vec![ToolCallRecord {
299                    tool_name: "analyze".to_string(),
300                    args_summary: "path: .".to_string(),
301                    result_summary: "Found rust project".to_string(),
302                }],
303            );
304        }
305
306        if history.needs_compaction() {
307            let summary = history.compact();
308            assert!(summary.is_some());
309            assert!(history.turn_count() < 10);
310        }
311    }
312
313    #[test]
314    fn test_to_messages() {
315        let mut history = ConversationHistory::new();
316        history.add_turn(
317            "What is this project?".to_string(),
318            "This is a Rust CLI tool.".to_string(),
319            vec![],
320        );
321
322        let messages = history.to_messages();
323        assert_eq!(messages.len(), 2); // 1 user + 1 assistant
324    }
325
326    #[test]
327    fn test_clear() {
328        let mut history = ConversationHistory::new();
329        history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
330        history.clear();
331        assert!(history.is_empty());
332        assert_eq!(history.token_count(), 0);
333    }
334}