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, Serialize, Deserialize)]
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 {
143 ""
144 } else {
145 "s"
146 }
147 ));
148
149 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 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 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 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 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 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 let token_count = content.len() / 4;
258
259 Self {
260 content,
261 token_count,
262 }
263 }
264
265 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
288fn 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
298pub fn extract_user_intent(message: &str, max_len: usize) -> String {
300 let message = message.trim();
301
302 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
312pub fn extract_assistant_action(response: &str, max_len: usize) -> String {
314 let response = response.trim();
315
316 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}