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)]
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 { "" } else { "s" }
143        ));
144
145        // Tool usage summary
146        if !summary.tool_usage.is_empty() {
147            content.push_str("Tools used: ");
148            let tools: Vec<String> = summary
149                .tool_usage
150                .iter()
151                .map(|(name, count)| format!("{}({}x)", name, count))
152                .collect();
153            content.push_str(&tools.join(", "));
154            content.push('\n');
155        }
156        content.push_str("</overview>\n\n");
157
158        // Turn summaries (condensed)
159        content.push_str("<turns>\n");
160        for turn in &summary.turn_summaries {
161            content.push_str(&format!(
162                "Turn {}: {} → {}\n",
163                turn.turn_number,
164                truncate(&turn.user_intent, 80),
165                truncate(&turn.assistant_action, 100)
166            ));
167
168            // Include important tool calls
169            let important_tools: Vec<_> = turn
170                .tool_calls
171                .iter()
172                .filter(|tc| {
173                    matches!(
174                        tc.tool_name.as_str(),
175                        "write_file" | "write_files" | "shell" | "analyze_project"
176                    ) || !tc.success
177                })
178                .collect();
179
180            for tc in important_tools.iter().take(3) {
181                let status = if tc.success { "✓" } else { "✗" };
182                content.push_str(&format!(
183                    "  {} {}({})\n",
184                    status,
185                    tc.tool_name,
186                    truncate(&tc.args_summary, 40)
187                ));
188            }
189
190            if important_tools.len() > 3 {
191                content.push_str(&format!(
192                    "  ... +{} more tool calls\n",
193                    important_tools.len() - 3
194                ));
195            }
196        }
197        content.push_str("</turns>\n\n");
198
199        // Files context
200        if !summary.files_read.is_empty() || !summary.files_written.is_empty() {
201            content.push_str("<files_context>\n");
202
203            if !summary.files_written.is_empty() {
204                content.push_str("Files created/modified:\n");
205                for file in summary.files_written.iter().take(20) {
206                    content.push_str(&format!("  - {}\n", file));
207                }
208                if summary.files_written.len() > 20 {
209                    content.push_str(&format!(
210                        "  ... +{} more files\n",
211                        summary.files_written.len() - 20
212                    ));
213                }
214            }
215
216            if !summary.files_read.is_empty() {
217                content.push_str("Files read (content was available):\n");
218                for file in summary.files_read.iter().take(15) {
219                    content.push_str(&format!("  - {}\n", file));
220                }
221                if summary.files_read.len() > 15 {
222                    content.push_str(&format!(
223                        "  ... +{} more files\n",
224                        summary.files_read.len() - 15
225                    ));
226                }
227            }
228
229            content.push_str("</files_context>\n\n");
230        }
231
232        // Key decisions
233        if !summary.key_decisions.is_empty() {
234            content.push_str("<key_decisions>\n");
235            for decision in summary.key_decisions.iter().take(10) {
236                content.push_str(&format!("- {}\n", decision));
237            }
238            content.push_str("</key_decisions>\n\n");
239        }
240
241        // Errors (important to preserve)
242        if !summary.errors_encountered.is_empty() {
243            content.push_str("<errors_encountered>\n");
244            for error in summary.errors_encountered.iter().take(5) {
245                content.push_str(&format!("- {}\n", error));
246            }
247            content.push_str("</errors_encountered>\n\n");
248        }
249
250        content.push_str("</conversation_summary>");
251
252        // Estimate tokens
253        let token_count = content.len() / 4;
254
255        Self {
256            content,
257            token_count,
258        }
259    }
260
261    /// Create a minimal summary frame (for very aggressive compaction)
262    pub fn minimal(turns: usize, files_written: &[String]) -> Self {
263        let mut content = format!(
264            "<conversation_summary turns=\"{}\" minimal=\"true\">\n",
265            turns
266        );
267
268        if !files_written.is_empty() {
269            content.push_str("Files created: ");
270            content.push_str(&files_written.join(", "));
271            content.push('\n');
272        }
273
274        content.push_str("</conversation_summary>");
275
276        let token_count = content.len() / 4;
277        Self { content, token_count }
278    }
279}
280
281/// Helper to truncate text with ellipsis
282fn truncate(text: &str, max_len: usize) -> String {
283    let text = text.trim();
284    if text.len() <= max_len {
285        text.to_string()
286    } else {
287        format!("{}...", &text[..max_len.saturating_sub(3)])
288    }
289}
290
291/// Extract a brief intent from user message
292pub fn extract_user_intent(message: &str, max_len: usize) -> String {
293    let message = message.trim();
294
295    // Remove common prefixes
296    let cleaned = message
297        .strip_prefix("please ")
298        .or_else(|| message.strip_prefix("can you "))
299        .or_else(|| message.strip_prefix("could you "))
300        .unwrap_or(message);
301
302    truncate(cleaned, max_len)
303}
304
305/// Extract action summary from assistant response
306pub fn extract_assistant_action(response: &str, max_len: usize) -> String {
307    let response = response.trim();
308
309    // Take first sentence or line
310    let first_part = response
311        .split(|c| c == '.' || c == '\n')
312        .next()
313        .unwrap_or(response);
314
315    truncate(first_part, max_len)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_context_summary() {
324        let mut summary = ContextSummary::new();
325
326        summary.add_turn(TurnSummary {
327            turn_number: 1,
328            user_intent: "Analyze the project".to_string(),
329            assistant_action: "I analyzed the project structure".to_string(),
330            tool_calls: vec![
331                ToolCallSummary {
332                    tool_name: "analyze_project".to_string(),
333                    args_summary: ".".to_string(),
334                    result_summary: "Found Rust project".to_string(),
335                    success: true,
336                },
337                ToolCallSummary {
338                    tool_name: "read_file".to_string(),
339                    args_summary: "Cargo.toml".to_string(),
340                    result_summary: "Read 50 lines".to_string(),
341                    success: true,
342                },
343            ],
344            key_decisions: vec!["This is a Rust CLI project".to_string()],
345        });
346
347        assert_eq!(summary.turns_compacted, 1);
348        assert!(summary.files_read.contains("Cargo.toml"));
349        assert_eq!(summary.tool_usage.get("read_file"), Some(&1));
350    }
351
352    #[test]
353    fn test_summary_frame_generation() {
354        let mut summary = ContextSummary::new();
355        summary.files_written.insert("Dockerfile".to_string());
356        summary.turns_compacted = 3;
357
358        let frame = SummaryFrame::from_summary(&summary);
359
360        assert!(frame.content.contains("conversation_summary"));
361        assert!(frame.content.contains("Dockerfile"));
362        assert!(frame.token_count > 0);
363    }
364
365    #[test]
366    fn test_extract_user_intent() {
367        assert_eq!(
368            extract_user_intent("please analyze the codebase", 50),
369            "analyze the codebase"
370        );
371        assert_eq!(
372            extract_user_intent("can you create a Dockerfile", 50),
373            "create a Dockerfile"
374        );
375    }
376
377    #[test]
378    fn test_truncate() {
379        assert_eq!(truncate("short", 10), "short");
380        assert_eq!(truncate("this is a longer text", 10), "this is...");
381    }
382}