syncable_cli/agent/compact/
summary.rs1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct ContextSummary {
31 pub turns_compacted: usize,
33
34 pub turn_summaries: Vec<TurnSummary>,
36
37 pub files_read: HashSet<String>,
39
40 pub files_written: HashSet<String>,
42
43 pub directories_listed: HashSet<String>,
45
46 pub key_decisions: Vec<String>,
48
49 pub errors_encountered: Vec<String>,
51
52 pub tool_usage: HashMap<String, usize>,
54}
55
56impl ContextSummary {
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn add_turn(&mut self, turn: TurnSummary) {
63 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 self.key_decisions.extend(turn.key_decisions.clone());
91
92 self.turn_summaries.push(turn);
93 self.turns_compacted += 1;
94 }
95
96 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#[derive(Debug, Clone)]
114pub struct SummaryFrame {
115 pub content: String,
117 pub token_count: usize,
119}
120
121impl SummaryFrame {
122 pub fn from_summary(summary: &ContextSummary) -> Self {
129 let mut content = String::new();
130
131 content.push_str(&format!(
133 "<conversation_summary turns=\"{}\">\n",
134 summary.turns_compacted
135 ));
136
137 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 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 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 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 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 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 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 let token_count = content.len() / 4;
254
255 Self {
256 content,
257 token_count,
258 }
259 }
260
261 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
281fn 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
291pub fn extract_user_intent(message: &str, max_len: usize) -> String {
293 let message = message.trim();
294
295 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
305pub fn extract_assistant_action(response: &str, max_len: usize) -> String {
307 let response = response.trim();
308
309 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}