syncable_cli/agent/compact/
summary.rs

1//! Summary frame generation for compacted context
2//!
3//! Creates structured summaries that preserve important information
4//! while being optimized for model consumption.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9/// A tool call summary for inclusion in context summary
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ToolCallSummary {
12    pub tool_name: String,
13    pub args_summary: String,
14    pub result_summary: String,
15    pub success: bool,
16}
17
18/// Summary of a conversation turn
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TurnSummary {
21    pub turn_number: usize,
22    pub user_intent: String,
23    pub assistant_action: String,
24    pub tool_calls: Vec<ToolCallSummary>,
25    pub key_decisions: Vec<String>,
26}
27
28/// Aggregated context from compacted messages
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct ContextSummary {
31    /// Number of turns that were compacted
32    pub turns_compacted: usize,
33
34    /// Summaries of individual turns
35    pub turn_summaries: Vec<TurnSummary>,
36
37    /// Files that were read during compacted turns
38    pub files_read: HashSet<String>,
39
40    /// Files that were written during compacted turns
41    pub files_written: HashSet<String>,
42
43    /// Directories that were listed
44    pub directories_listed: HashSet<String>,
45
46    /// Key decisions or constraints established
47    pub key_decisions: Vec<String>,
48
49    /// Errors or issues encountered
50    pub errors_encountered: Vec<String>,
51
52    /// Tools used with their counts
53    pub tool_usage: HashMap<String, usize>,
54}
55
56impl ContextSummary {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Add a turn summary
62    pub fn add_turn(&mut self, turn: TurnSummary) {
63        // Extract file operations
64        for tc in &turn.tool_calls {
65            *self.tool_usage.entry(tc.tool_name.clone()).or_insert(0) += 1;
66
67            match tc.tool_name.as_str() {
68                "read_file" => {
69                    self.files_read.insert(tc.args_summary.clone());
70                }
71                "write_file" | "write_files" => {
72                    self.files_written.insert(tc.args_summary.clone());
73                }
74                "list_directory" => {
75                    self.directories_listed.insert(tc.args_summary.clone());
76                }
77                _ => {}
78            }
79
80            if !tc.success && !tc.result_summary.is_empty() {
81                self.errors_encountered.push(format!(
82                    "{}: {}",
83                    tc.tool_name,
84                    truncate(&tc.result_summary, 100)
85                ));
86            }
87        }
88
89        // Add key decisions
90        self.key_decisions.extend(turn.key_decisions.clone());
91
92        self.turn_summaries.push(turn);
93        self.turns_compacted += 1;
94    }
95
96    /// Merge another summary into this one
97    pub fn merge(&mut self, other: ContextSummary) {
98        self.turns_compacted += other.turns_compacted;
99        self.turn_summaries.extend(other.turn_summaries);
100        self.files_read.extend(other.files_read);
101        self.files_written.extend(other.files_written);
102        self.directories_listed.extend(other.directories_listed);
103        self.key_decisions.extend(other.key_decisions);
104        self.errors_encountered.extend(other.errors_encountered);
105
106        for (tool, count) in other.tool_usage {
107            *self.tool_usage.entry(tool).or_insert(0) += count;
108        }
109    }
110}
111
112/// A summary frame ready to be inserted into context
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SummaryFrame {
115    /// The rendered summary text
116    pub content: String,
117    /// Estimated token count
118    pub token_count: usize,
119}
120
121impl SummaryFrame {
122    /// Generate a summary frame from a ContextSummary
123    ///
124    /// The format is designed for model consumption:
125    /// - Structured XML-like sections
126    /// - Hierarchical information
127    /// - Key context preserved
128    pub fn from_summary(summary: &ContextSummary) -> Self {
129        let mut content = String::new();
130
131        // Header
132        content.push_str(&format!(
133            "<conversation_summary turns=\"{}\">\n",
134            summary.turns_compacted
135        ));
136
137        // High-level overview
138        content.push_str("<overview>\n");
139        content.push_str(&format!(
140            "This summary covers {} conversation turn{}.\n",
141            summary.turns_compacted,
142            if summary.turns_compacted == 1 {
143                ""
144            } else {
145                "s"
146            }
147        ));
148
149        // Tool usage summary
150        if !summary.tool_usage.is_empty() {
151            content.push_str("Tools used: ");
152            let tools: Vec<String> = summary
153                .tool_usage
154                .iter()
155                .map(|(name, count)| format!("{}({}x)", name, count))
156                .collect();
157            content.push_str(&tools.join(", "));
158            content.push('\n');
159        }
160        content.push_str("</overview>\n\n");
161
162        // Turn summaries (condensed)
163        content.push_str("<turns>\n");
164        for turn in &summary.turn_summaries {
165            content.push_str(&format!(
166                "Turn {}: {} → {}\n",
167                turn.turn_number,
168                truncate(&turn.user_intent, 80),
169                truncate(&turn.assistant_action, 100)
170            ));
171
172            // Include important tool calls
173            let important_tools: Vec<_> = turn
174                .tool_calls
175                .iter()
176                .filter(|tc| {
177                    matches!(
178                        tc.tool_name.as_str(),
179                        "write_file" | "write_files" | "shell" | "analyze_project"
180                    ) || !tc.success
181                })
182                .collect();
183
184            for tc in important_tools.iter().take(3) {
185                let status = if tc.success { "✓" } else { "✗" };
186                content.push_str(&format!(
187                    "  {} {}({})\n",
188                    status,
189                    tc.tool_name,
190                    truncate(&tc.args_summary, 40)
191                ));
192            }
193
194            if important_tools.len() > 3 {
195                content.push_str(&format!(
196                    "  ... +{} more tool calls\n",
197                    important_tools.len() - 3
198                ));
199            }
200        }
201        content.push_str("</turns>\n\n");
202
203        // Files context
204        if !summary.files_read.is_empty() || !summary.files_written.is_empty() {
205            content.push_str("<files_context>\n");
206
207            if !summary.files_written.is_empty() {
208                content.push_str("Files created/modified:\n");
209                for file in summary.files_written.iter().take(20) {
210                    content.push_str(&format!("  - {}\n", file));
211                }
212                if summary.files_written.len() > 20 {
213                    content.push_str(&format!(
214                        "  ... +{} more files\n",
215                        summary.files_written.len() - 20
216                    ));
217                }
218            }
219
220            if !summary.files_read.is_empty() {
221                content.push_str("Files read (content was available):\n");
222                for file in summary.files_read.iter().take(15) {
223                    content.push_str(&format!("  - {}\n", file));
224                }
225                if summary.files_read.len() > 15 {
226                    content.push_str(&format!(
227                        "  ... +{} more files\n",
228                        summary.files_read.len() - 15
229                    ));
230                }
231            }
232
233            content.push_str("</files_context>\n\n");
234        }
235
236        // Key decisions
237        if !summary.key_decisions.is_empty() {
238            content.push_str("<key_decisions>\n");
239            for decision in summary.key_decisions.iter().take(10) {
240                content.push_str(&format!("- {}\n", decision));
241            }
242            content.push_str("</key_decisions>\n\n");
243        }
244
245        // Errors (important to preserve)
246        if !summary.errors_encountered.is_empty() {
247            content.push_str("<errors_encountered>\n");
248            for error in summary.errors_encountered.iter().take(5) {
249                content.push_str(&format!("- {}\n", error));
250            }
251            content.push_str("</errors_encountered>\n\n");
252        }
253
254        content.push_str("</conversation_summary>");
255
256        // Estimate tokens
257        let token_count = content.len() / 4;
258
259        Self {
260            content,
261            token_count,
262        }
263    }
264
265    /// Create a minimal summary frame (for very aggressive compaction)
266    pub fn minimal(turns: usize, files_written: &[String]) -> Self {
267        let mut content = format!(
268            "<conversation_summary turns=\"{}\" minimal=\"true\">\n",
269            turns
270        );
271
272        if !files_written.is_empty() {
273            content.push_str("Files created: ");
274            content.push_str(&files_written.join(", "));
275            content.push('\n');
276        }
277
278        content.push_str("</conversation_summary>");
279
280        let token_count = content.len() / 4;
281        Self {
282            content,
283            token_count,
284        }
285    }
286}
287
288/// Helper to truncate text with ellipsis
289fn truncate(text: &str, max_len: usize) -> String {
290    let text = text.trim();
291    if text.len() <= max_len {
292        text.to_string()
293    } else {
294        format!("{}...", &text[..max_len.saturating_sub(3)])
295    }
296}
297
298/// Extract a brief intent from user message
299pub fn extract_user_intent(message: &str, max_len: usize) -> String {
300    let message = message.trim();
301
302    // Remove common prefixes
303    let cleaned = message
304        .strip_prefix("please ")
305        .or_else(|| message.strip_prefix("can you "))
306        .or_else(|| message.strip_prefix("could you "))
307        .unwrap_or(message);
308
309    truncate(cleaned, max_len)
310}
311
312/// Extract action summary from assistant response
313pub fn extract_assistant_action(response: &str, max_len: usize) -> String {
314    let response = response.trim();
315
316    // Take first sentence or line
317    let first_part = response.split(['.', '\n']).next().unwrap_or(response);
318
319    truncate(first_part, max_len)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_context_summary() {
328        let mut summary = ContextSummary::new();
329
330        summary.add_turn(TurnSummary {
331            turn_number: 1,
332            user_intent: "Analyze the project".to_string(),
333            assistant_action: "I analyzed the project structure".to_string(),
334            tool_calls: vec![
335                ToolCallSummary {
336                    tool_name: "analyze_project".to_string(),
337                    args_summary: ".".to_string(),
338                    result_summary: "Found Rust project".to_string(),
339                    success: true,
340                },
341                ToolCallSummary {
342                    tool_name: "read_file".to_string(),
343                    args_summary: "Cargo.toml".to_string(),
344                    result_summary: "Read 50 lines".to_string(),
345                    success: true,
346                },
347            ],
348            key_decisions: vec!["This is a Rust CLI project".to_string()],
349        });
350
351        assert_eq!(summary.turns_compacted, 1);
352        assert!(summary.files_read.contains("Cargo.toml"));
353        assert_eq!(summary.tool_usage.get("read_file"), Some(&1));
354    }
355
356    #[test]
357    fn test_summary_frame_generation() {
358        let mut summary = ContextSummary::new();
359        summary.files_written.insert("Dockerfile".to_string());
360        summary.turns_compacted = 3;
361
362        let frame = SummaryFrame::from_summary(&summary);
363
364        assert!(frame.content.contains("conversation_summary"));
365        assert!(frame.content.contains("Dockerfile"));
366        assert!(frame.token_count > 0);
367    }
368
369    #[test]
370    fn test_extract_user_intent() {
371        assert_eq!(
372            extract_user_intent("please analyze the codebase", 50),
373            "analyze the codebase"
374        );
375        assert_eq!(
376            extract_user_intent("can you create a Dockerfile", 50),
377            "create a Dockerfile"
378        );
379    }
380
381    #[test]
382    fn test_truncate() {
383        assert_eq!(truncate("short", 10), "short");
384        assert_eq!(truncate("this is a longer text", 10), "this is...");
385    }
386}