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 (~4 characters per token)
115    /// Public so it can be used for pre-request context size estimation
116    pub fn estimate_tokens(text: &str) -> usize {
117        text.len() / CHARS_PER_TOKEN
118    }
119
120    /// Add a new conversation turn
121    pub fn add_turn(
122        &mut self,
123        user_message: String,
124        assistant_response: String,
125        tool_calls: Vec<ToolCallRecord>,
126    ) {
127        // Determine if this turn is droppable based on tool calls
128        // Turns that only read files or list directories are droppable
129        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    /// Add a turn with explicit droppable flag
160    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    /// Check if compaction is needed using forge-style thresholds
190    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    /// Get the reason for compaction (for logging)
206    pub fn compaction_reason(&self) -> Option<String> {
207        self.compact_config
208            .compaction_reason(self.total_tokens, self.user_turn_count, self.turns.len())
209    }
210
211    /// Get current token count
212    pub fn token_count(&self) -> usize {
213        self.total_tokens
214    }
215
216    /// Get number of turns
217    pub fn turn_count(&self) -> usize {
218        self.turns.len()
219    }
220
221    /// Get number of user turns
222    pub fn user_turn_count(&self) -> usize {
223        self.user_turn_count
224    }
225
226    /// Clear all history
227    pub fn clear(&mut self) {
228        self.turns.clear();
229        self.summary_frame = None;
230        self.total_tokens = 0;
231        self.user_turn_count = 0;
232        self.context_summary = ContextSummary::new();
233    }
234
235    /// Perform forge-style compaction with smart eviction
236    /// Returns the summary that was created (for logging/display)
237    pub fn compact(&mut self) -> Option<String> {
238        use super::compact::strategy::{MessageMeta, MessageRole};
239        use super::compact::summary::{
240            extract_assistant_action, extract_user_intent, ToolCallSummary, TurnSummary,
241        };
242
243        if self.turns.len() < 2 {
244            return None; // Nothing to compact
245        }
246
247        // Build message metadata for strategy
248        let messages: Vec<MessageMeta> = self
249            .turns
250            .iter()
251            .enumerate()
252            .flat_map(|(turn_idx, turn)| {
253                let mut metas = vec![];
254
255                // User message
256                metas.push(MessageMeta {
257                    index: turn_idx * 2,
258                    role: MessageRole::User,
259                    droppable: turn.droppable,
260                    has_tool_call: false,
261                    is_tool_result: false,
262                    tool_id: None,
263                    token_count: Self::estimate_tokens(&turn.user_message),
264                });
265
266                // Assistant message (may have tool calls)
267                let has_tool_call = !turn.tool_calls.is_empty();
268                let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
269
270                metas.push(MessageMeta {
271                    index: turn_idx * 2 + 1,
272                    role: MessageRole::Assistant,
273                    droppable: turn.droppable,
274                    has_tool_call,
275                    is_tool_result: false,
276                    tool_id,
277                    token_count: Self::estimate_tokens(&turn.assistant_response),
278                });
279
280                metas
281            })
282            .collect();
283
284        // Use default strategy (evict 60% or retain 10, whichever is more conservative)
285        let strategy = CompactionStrategy::default();
286
287        // Calculate eviction range with tool-call safety
288        let range = strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
289
290        if range.is_empty() {
291            return None;
292        }
293
294        // Convert message indices to turn indices
295        let start_turn = range.start / 2;
296        let end_turn = (range.end + 1) / 2;
297
298        if start_turn >= end_turn || end_turn > self.turns.len() {
299            return None;
300        }
301
302        // Build context summary from turns to evict
303        let mut new_context = ContextSummary::new();
304
305        for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
306            let turn_summary = TurnSummary {
307                turn_number: start_turn + i + 1,
308                user_intent: extract_user_intent(&turn.user_message, 80),
309                assistant_action: extract_assistant_action(&turn.assistant_response, 100),
310                tool_calls: turn
311                    .tool_calls
312                    .iter()
313                    .map(|tc| ToolCallSummary {
314                        tool_name: tc.tool_name.clone(),
315                        args_summary: tc.args_summary.clone(),
316                        result_summary: truncate_text(&tc.result_summary, 100),
317                        success: !tc.result_summary.to_lowercase().contains("error"),
318                    })
319                    .collect(),
320                key_decisions: vec![], // Could extract from assistant response
321            };
322            new_context.add_turn(turn_summary);
323        }
324
325        // Merge with existing context summary
326        self.context_summary.merge(new_context);
327
328        // Generate summary frame
329        let new_frame = SummaryFrame::from_summary(&self.context_summary);
330
331        // Merge with existing frame if present
332        if let Some(existing) = &self.summary_frame {
333            let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
334            let merged_tokens = existing.token_count + new_frame.token_count;
335            self.summary_frame = Some(SummaryFrame {
336                content: merged_content,
337                token_count: merged_tokens,
338            });
339        } else {
340            self.summary_frame = Some(new_frame);
341        }
342
343        // Keep only recent turns (non-evicted)
344        let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
345        let evicted_count = end_turn - start_turn;
346        self.turns = preserved_turns;
347
348        // Recalculate token count
349        self.total_tokens = self
350            .summary_frame
351            .as_ref()
352            .map(|f| f.token_count)
353            .unwrap_or(0)
354            + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
355
356        Some(format!(
357            "Compacted {} turns ({} → {} tokens)",
358            evicted_count,
359            self.total_tokens + evicted_count * 500, // rough estimate of evicted tokens
360            self.total_tokens
361        ))
362    }
363
364    /// Emergency compaction - more aggressive than normal
365    /// Used when "input too long" error occurs and we need to reduce context urgently.
366    /// Temporarily switches to aggressive config, compacts, then restores original.
367    pub fn emergency_compact(&mut self) -> Option<String> {
368        // Switch to aggressive config temporarily
369        let original_config = self.compact_config.clone();
370        self.compact_config = CompactConfig {
371            retention_window: 3,  // Keep only 3 most recent turns
372            eviction_window: 0.9, // Evict 90% of context
373            thresholds: CompactThresholds::aggressive(),
374        };
375
376        let result = self.compact();
377
378        // Restore original config
379        self.compact_config = original_config;
380        result
381    }
382
383    /// Convert history to Rig Message format for the agent
384    /// Uses structured summary frames to preserve context
385    pub fn to_messages(&self) -> Vec<Message> {
386        use rig::completion::message::{AssistantContent, Text, UserContent};
387        use rig::OneOrMany;
388
389        let mut messages = Vec::new();
390
391        // Add summary frame as initial context if present
392        if let Some(frame) = &self.summary_frame {
393            // Add as a user message with the summary, followed by acknowledgment
394            messages.push(Message::User {
395                content: OneOrMany::one(UserContent::Text(Text {
396                    text: format!("[Previous conversation context]\n{}", frame.content),
397                })),
398            });
399            messages.push(Message::Assistant {
400                id: None,
401                content: OneOrMany::one(AssistantContent::Text(Text {
402                    text: "I understand the previous context. I'll continue from where we left off."
403                        .to_string(),
404                })),
405            });
406        }
407
408        // Add recent turns with tool call context as text
409        for turn in &self.turns {
410            // User message
411            messages.push(Message::User {
412                content: OneOrMany::one(UserContent::Text(Text {
413                    text: turn.user_message.clone(),
414                })),
415            });
416
417            // Build assistant response that includes tool call context
418            let mut response_text = String::new();
419
420            // If there were tool calls, include them as text context
421            if !turn.tool_calls.is_empty() {
422                response_text.push_str("[Tools used in this turn:\n");
423                for tc in &turn.tool_calls {
424                    response_text.push_str(&format!(
425                        "  - {}({}) → {}\n",
426                        tc.tool_name,
427                        truncate_text(&tc.args_summary, 50),
428                        truncate_text(&tc.result_summary, 100)
429                    ));
430                }
431                response_text.push_str("]\n\n");
432            }
433
434            // Add the actual response
435            response_text.push_str(&turn.assistant_response);
436
437            messages.push(Message::Assistant {
438                id: None,
439                content: OneOrMany::one(AssistantContent::Text(Text { text: response_text })),
440            });
441        }
442
443        messages
444    }
445
446    /// Check if there's any history
447    pub fn is_empty(&self) -> bool {
448        self.turns.is_empty() && self.summary_frame.is_none()
449    }
450
451    /// Get a brief status string for display
452    pub fn status(&self) -> String {
453        let compressed_info = if self.summary_frame.is_some() {
454            format!(
455                " (+{} compacted)",
456                self.context_summary.turns_compacted
457            )
458        } else {
459            String::new()
460        };
461        format!(
462            "{} turns, ~{} tokens{}",
463            self.turns.len(),
464            self.total_tokens,
465            compressed_info
466        )
467    }
468
469    /// Get files that have been read during this session
470    pub fn files_read(&self) -> impl Iterator<Item = &str> {
471        self.context_summary.files_read.iter().map(|s| s.as_str())
472    }
473
474    /// Get files that have been written during this session
475    pub fn files_written(&self) -> impl Iterator<Item = &str> {
476        self.context_summary
477            .files_written
478            .iter()
479            .map(|s| s.as_str())
480    }
481}
482
483/// Helper to truncate text with ellipsis
484fn truncate_text(text: &str, max_len: usize) -> String {
485    if text.len() <= max_len {
486        text.to_string()
487    } else {
488        format!("{}...", &text[..max_len.saturating_sub(3)])
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_add_turn() {
498        let mut history = ConversationHistory::new();
499        history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
500        assert_eq!(history.turn_count(), 1);
501        assert!(!history.is_empty());
502    }
503
504    #[test]
505    fn test_droppable_detection() {
506        let mut history = ConversationHistory::new();
507
508        // Turn with only read_file should be droppable
509        history.add_turn(
510            "Read the file".to_string(),
511            "Here's the content".to_string(),
512            vec![ToolCallRecord {
513                tool_name: "read_file".to_string(),
514                args_summary: "src/main.rs".to_string(),
515                result_summary: "file content...".to_string(),
516                tool_id: Some("tool_1".to_string()),
517                droppable: true,
518            }],
519        );
520        assert!(history.turns[0].droppable);
521
522        // Turn with write_file should NOT be droppable
523        history.add_turn(
524            "Write the file".to_string(),
525            "Done".to_string(),
526            vec![ToolCallRecord {
527                tool_name: "write_file".to_string(),
528                args_summary: "src/new.rs".to_string(),
529                result_summary: "success".to_string(),
530                tool_id: Some("tool_2".to_string()),
531                droppable: false,
532            }],
533        );
534        assert!(!history.turns[1].droppable);
535    }
536
537    #[test]
538    fn test_compaction() {
539        // Use aggressive config for easier testing
540        let mut history = ConversationHistory::with_config(CompactConfig {
541            retention_window: 2,
542            eviction_window: 0.6,
543            thresholds: CompactThresholds {
544                token_threshold: Some(500),
545                turn_threshold: Some(5),
546                message_threshold: Some(10),
547                on_turn_end: None,
548            },
549        });
550
551        // Add many turns to trigger compaction
552        for i in 0..10 {
553            history.add_turn(
554                format!("Question {} with lots of text to increase token count", i),
555                format!(
556                    "Answer {} with lots of detail to increase token count even more",
557                    i
558                ),
559                vec![ToolCallRecord {
560                    tool_name: "analyze".to_string(),
561                    args_summary: "path: .".to_string(),
562                    result_summary: "Found rust project with many files".to_string(),
563                    tool_id: Some(format!("tool_{}", i)),
564                    droppable: false,
565                }],
566            );
567        }
568
569        if history.needs_compaction() {
570            let summary = history.compact();
571            assert!(summary.is_some());
572            assert!(history.turn_count() < 10);
573            assert!(history.summary_frame.is_some());
574        }
575    }
576
577    #[test]
578    fn test_to_messages() {
579        let mut history = ConversationHistory::new();
580        history.add_turn(
581            "What is this project?".to_string(),
582            "This is a Rust CLI tool.".to_string(),
583            vec![],
584        );
585
586        let messages = history.to_messages();
587        assert_eq!(messages.len(), 2); // 1 user + 1 assistant
588    }
589
590    #[test]
591    fn test_clear() {
592        let mut history = ConversationHistory::new();
593        history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
594        history.clear();
595        assert!(history.is_empty());
596        assert_eq!(history.token_count(), 0);
597    }
598
599    #[test]
600    fn test_compaction_reason() {
601        let mut history = ConversationHistory::with_config(CompactConfig {
602            retention_window: 2,
603            eviction_window: 0.6,
604            thresholds: CompactThresholds {
605                token_threshold: Some(100),
606                turn_threshold: Some(3),
607                message_threshold: Some(5),
608                on_turn_end: None,
609            },
610        });
611
612        // Add turns to exceed threshold
613        for i in 0..5 {
614            history.add_turn(
615                format!("Question {}", i),
616                format!("Answer {}", i),
617                vec![],
618            );
619        }
620
621        assert!(history.needs_compaction());
622        let reason = history.compaction_reason();
623        assert!(reason.is_some());
624    }
625}