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