1use crate::agent::{Message, Role};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompactionStrategy {
16 pub keep_recent: usize,
18 pub keep_keywords: Vec<String>,
20 pub keep_tool_results: bool,
22 pub keep_system: bool,
24}
25
26impl Default for CompactionStrategy {
27 fn default() -> Self {
28 Self {
29 keep_recent: 10,
30 keep_keywords: vec![
31 "error".to_string(),
32 "fix".to_string(),
33 "bug".to_string(),
34 "issue".to_string(),
35 "problem".to_string(),
36 "solution".to_string(),
37 "important".to_string(),
38 "note".to_string(),
39 "warning".to_string(),
40 ],
41 keep_tool_results: true,
42 keep_system: true,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CompactionResult {
50 pub messages: Vec<Message>,
52 pub original_count: usize,
54 pub compacted_count: usize,
56 pub tokens_saved: usize,
58}
59
60pub fn build_compaction_prompt(messages: &[Message], _strategy: &CompactionStrategy) -> String {
66 let mut prompt = String::from(
67 r#"# Structured Conversation Compaction
68
69You are tasked with compacting a conversation history while preserving all essential information.
70
71## Your Goal
72
73Create a concise, structured summary that captures:
741. **User's Original Intent** - What the user wanted to accomplish
752. **Important Decisions** - Key decisions made during the conversation
763. **Code Changes** - What was changed and why
774. **Error Messages** - Any errors encountered and their solutions
785. **Debugging Information** - Important debugging steps and findings
796. **Warnings and Notes** - Any warnings or important notes
80
81## Output Format
82
83Your response MUST follow this exact structure:
84
85```
86# Conversation Summary
87
88## User Intent
89[Describe what the user wanted to accomplish in 1-2 sentences]
90
91## Key Decisions
92- [Decision 1]
93- [Decision 2]
94- [Decision 3]
95
96## Code Changes
97### File: [filename]
98- **Change**: [description of change]
99- **Rationale**: [why this change was made]
100- **Impact**: [what this affects]
101
102### File: [filename]
103- **Change**: [description of change]
104- **Rationale**: [why this change was made]
105- **Impact**: [what this affects]
106
107## Errors and Solutions
108### Error: [error description]
109- **Location**: [where the error occurred]
110- **Solution**: [how it was fixed]
111- **Prevention**: [how to prevent this in the future]
112
113## Debugging Steps
1141. [Step 1]
1152. [Step 2]
1163. [Step 3]
117
118## Warnings and Notes
119- [Warning or note 1]
120- [Warning or note 2]
121
122## Current State
123[Describe the current state of the work in 1-2 sentences]
124
125## Next Steps
1261. [Next step 1]
1272. [Next step 2]
1283. [Next step 3]
129```
130
131## Guidelines
132
133- Be concise but complete
134- Preserve all technical details (function names, file paths, error messages)
135- Use bullet points for lists
136- Keep each section focused and clear
137- If a section has no relevant information, write "None"
138- Maintain chronological order where relevant
139- Include specific values (numbers, strings, paths) when important
140
141## Original Conversation
142
143"#,
144 );
145
146 for (i, msg) in messages.iter().enumerate() {
148 let role = match msg.role {
149 Role::System => "SYSTEM",
150 Role::User => "USER",
151 Role::Assistant => "ASSISTANT",
152 Role::Tool => "TOOL",
153 };
154 prompt.push_str(&format!("\n### Message {} [{}]\n\n{}\n", i + 1, role, msg.content));
155
156 if !msg.tool_calls.is_empty() {
158 prompt.push_str("\n**Tool Calls:**\n");
159 for tc in &msg.tool_calls {
160 prompt.push_str(&format!("- `{}`: {}\n", tc.name, tc.arguments));
161 }
162 }
163
164 if let Some(ref result) = msg.tool_result {
166 prompt.push_str(&format!("\n**Tool Result:**\n{}\n", result.content));
167 }
168 }
169
170 prompt.push_str(
171 r#"
172
173--- End of Original Conversation ---
174
175Please provide a structured summary following the exact format specified above.
176"#,
177 );
178
179 prompt
180}
181
182pub fn compact_messages(messages: Vec<Message>, strategy: &CompactionStrategy) -> CompactionResult {
184 let original_count = messages.len();
185 let mut compacted = Vec::new();
186
187 if strategy.keep_system {
189 compacted.extend(
190 messages
191 .iter()
192 .filter(|m| m.role == Role::System)
193 .cloned(),
194 );
195 }
196
197 for msg in &messages {
199 let content_lower = msg.content.to_lowercase();
200 if strategy
201 .keep_keywords
202 .iter()
203 .any(|kw| content_lower.contains(&kw.to_lowercase()))
204 {
205 if !compacted.iter().any(|m| m.content == msg.content) {
206 compacted.push(msg.clone());
207 }
208 }
209 }
210
211 if strategy.keep_tool_results {
213 for msg in &messages {
214 if msg.tool_result.is_some() && !msg.tool_calls.is_empty() {
215 if !compacted.iter().any(|m| m.content == msg.content) {
216 compacted.push(msg.clone());
217 }
218 }
219 }
220 }
221
222 let recent_start = if messages.len() > strategy.keep_recent {
224 messages.len() - strategy.keep_recent
225 } else {
226 0
227 };
228
229 for msg in &messages[recent_start..] {
230 if !compacted.iter().any(|m| m.content == msg.content) {
231 compacted.push(msg.clone());
232 }
233 }
234
235 compacted.sort_by_key(|m| {
237 messages
238 .iter()
239 .position(|orig| orig.content == m.content)
240 .unwrap_or(usize::MAX)
241 });
242
243 let compacted_count = compacted.len();
244 let tokens_saved = estimate_tokens_saved(original_count, compacted_count);
245
246 CompactionResult {
247 messages: compacted,
248 original_count,
249 compacted_count,
250 tokens_saved,
251 }
252}
253
254fn estimate_tokens_saved(original: usize, compacted: usize) -> usize {
256 let avg_tokens_per_message = 4;
258 (original - compacted) * avg_tokens_per_message
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ParsedCompactionSummary {
267 pub user_intent: String,
269 pub key_decisions: Vec<String>,
271 pub code_changes: Vec<CodeChange>,
273 pub errors_and_solutions: Vec<ErrorSolution>,
275 pub debugging_steps: Vec<String>,
277 pub warnings_and_notes: Vec<String>,
279 pub current_state: String,
281 pub next_steps: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct CodeChange {
288 pub file: String,
290 pub change: String,
292 pub rationale: String,
294 pub impact: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ErrorSolution {
301 pub error: String,
303 pub location: String,
305 pub solution: String,
307 pub prevention: String,
309}
310
311pub fn parse_compaction_summary(summary: &str) -> Result<ParsedCompactionSummary, String> {
316 let mut parsed = ParsedCompactionSummary {
317 user_intent: String::new(),
318 key_decisions: Vec::new(),
319 code_changes: Vec::new(),
320 errors_and_solutions: Vec::new(),
321 debugging_steps: Vec::new(),
322 warnings_and_notes: Vec::new(),
323 current_state: String::new(),
324 next_steps: Vec::new(),
325 };
326
327 let lines: Vec<&str> = summary.lines().collect();
328 let mut current_section: Option<String> = None;
329 let mut current_code_change: Option<CodeChange> = None;
330 let mut current_error: Option<ErrorSolution> = None;
331
332 for line in lines {
333 let line = line.trim();
334
335 if line.starts_with("## ") {
337 current_section = Some(line[3..].to_string());
338 continue;
339 }
340
341 if line.starts_with("### ") {
343 let subsection = line[4..].to_string();
344
345 if let Some(code_change) = current_code_change.take() {
347 if !code_change.file.is_empty() {
348 parsed.code_changes.push(code_change);
349 }
350 }
351
352 if let Some(error) = current_error.take() {
354 if !error.error.is_empty() {
355 parsed.errors_and_solutions.push(error);
356 }
357 }
358
359 if subsection.starts_with("File: ") {
361 current_code_change = Some(CodeChange {
362 file: subsection[6..].to_string(),
363 change: String::new(),
364 rationale: String::new(),
365 impact: String::new(),
366 });
367 } else if subsection.starts_with("Error: ") {
368 current_error = Some(ErrorSolution {
369 error: subsection[7..].to_string(),
370 location: String::new(),
371 solution: String::new(),
372 prevention: String::new(),
373 });
374 }
375
376 continue;
377 }
378
379 match current_section.as_deref() {
381 Some("User Intent") => {
382 parsed.user_intent.push_str(line);
383 parsed.user_intent.push(' ');
384 }
385 Some("Key Decisions") => {
386 if line.starts_with("- ") {
387 parsed.key_decisions.push(line[2..].to_string());
388 }
389 }
390 Some("Code Changes") => {
391 if let Some(ref mut code_change) = current_code_change {
392 if line.starts_with("- **Change**: ") {
393 code_change.change = line[12..].to_string();
394 } else if line.starts_with("- **Rationale**: ") {
395 code_change.rationale = line[14..].to_string();
396 } else if line.starts_with("- **Impact**: ") {
397 code_change.impact = line[11..].to_string();
398 }
399 }
400 }
401 Some("Errors and Solutions") => {
402 if let Some(ref mut error) = current_error {
403 if line.starts_with("- **Location**: ") {
404 error.location = line[14..].to_string();
405 } else if line.starts_with("- **Solution**: ") {
406 error.solution = line[14..].to_string();
407 } else if line.starts_with("- **Prevention**: ") {
408 error.prevention = line[15..].to_string();
409 }
410 }
411 }
412 Some("Debugging Steps") => {
413 if line.starts_with("1. ") || line.starts_with("2. ") || line.starts_with("3. ") {
414 parsed.debugging_steps.push(line[3..].to_string());
415 }
416 }
417 Some("Warnings and Notes") => {
418 if line.starts_with("- ") {
419 parsed.warnings_and_notes.push(line[2..].to_string());
420 }
421 }
422 Some("Current State") => {
423 parsed.current_state.push_str(line);
424 parsed.current_state.push(' ');
425 }
426 Some("Next Steps") => {
427 if line.starts_with("1. ") || line.starts_with("2. ") || line.starts_with("3. ") {
428 parsed.next_steps.push(line[3..].to_string());
429 }
430 }
431 _ => {}
432 }
433 }
434
435 if let Some(code_change) = current_code_change {
437 if !code_change.file.is_empty() {
438 parsed.code_changes.push(code_change);
439 }
440 }
441 if let Some(error) = current_error {
442 if !error.error.is_empty() {
443 parsed.errors_and_solutions.push(error);
444 }
445 }
446
447 parsed.user_intent = parsed.user_intent.trim().to_string();
449 parsed.current_state = parsed.current_state.trim().to_string();
450
451 Ok(parsed)
452}
453
454pub fn summary_to_message(summary: &ParsedCompactionSummary) -> Message {
459 let mut content = String::from("# Conversation Summary\n\n");
460
461 content.push_str("## User Intent\n");
462 content.push_str(&summary.user_intent);
463 content.push_str("\n\n");
464
465 if !summary.key_decisions.is_empty() {
466 content.push_str("## Key Decisions\n");
467 for decision in &summary.key_decisions {
468 content.push_str("- ");
469 content.push_str(decision);
470 content.push_str("\n");
471 }
472 content.push_str("\n");
473 }
474
475 if !summary.code_changes.is_empty() {
476 content.push_str("## Code Changes\n");
477 for change in &summary.code_changes {
478 content.push_str(&format!("### File: {}\n", change.file));
479 content.push_str(&format!("- **Change**: {}\n", change.change));
480 content.push_str(&format!("- **Rationale**: {}\n", change.rationale));
481 content.push_str(&format!("- **Impact**: {}\n", change.impact));
482 content.push_str("\n");
483 }
484 }
485
486 if !summary.errors_and_solutions.is_empty() {
487 content.push_str("## Errors and Solutions\n");
488 for error in &summary.errors_and_solutions {
489 content.push_str(&format!("### Error: {}\n", error.error));
490 content.push_str(&format!("- **Location**: {}\n", error.location));
491 content.push_str(&format!("- **Solution**: {}\n", error.solution));
492 content.push_str(&format!("- **Prevention**: {}\n", error.prevention));
493 content.push_str("\n");
494 }
495 }
496
497 if !summary.debugging_steps.is_empty() {
498 content.push_str("## Debugging Steps\n");
499 for (i, step) in summary.debugging_steps.iter().enumerate() {
500 content.push_str(&format!("{}. {}\n", i + 1, step));
501 }
502 content.push_str("\n");
503 }
504
505 if !summary.warnings_and_notes.is_empty() {
506 content.push_str("## Warnings and Notes\n");
507 for warning in &summary.warnings_and_notes {
508 content.push_str("- ");
509 content.push_str(warning);
510 content.push_str("\n");
511 }
512 content.push_str("\n");
513 }
514
515 content.push_str("## Current State\n");
516 content.push_str(&summary.current_state);
517 content.push_str("\n\n");
518
519 if !summary.next_steps.is_empty() {
520 content.push_str("## Next Steps\n");
521 for (i, step) in summary.next_steps.iter().enumerate() {
522 content.push_str(&format!("{}. {}\n", i + 1, step));
523 }
524 }
525
526 Message {
527 role: Role::System,
528 content,
529 tool_calls: vec![],
530 tool_result: None,
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn test_compaction_strategy_default() {
540 let strategy = CompactionStrategy::default();
541 assert_eq!(strategy.keep_recent, 10);
542 assert!(strategy.keep_keywords.contains(&"error".to_string()));
543 assert!(strategy.keep_tool_results);
544 assert!(strategy.keep_system);
545 }
546
547 #[test]
548 fn test_build_compaction_prompt() {
549 let messages = vec![
550 Message {
551 role: Role::User,
552 content: "Fix the bug in main.rs".to_string(),
553 tool_calls: vec![],
554 tool_result: None,
555 },
556 Message {
557 role: Role::Assistant,
558 content: "I'll read the file first.".to_string(),
559 tool_calls: vec![],
560 tool_result: None,
561 },
562 ];
563
564 let prompt = build_compaction_prompt(&messages, &CompactionStrategy::default());
565 assert!(prompt.contains("Fix the bug in main.rs"));
566 assert!(prompt.contains("I'll read the file first."));
567 assert!(prompt.contains("Original Conversation"));
568 }
569
570 #[test]
571 fn test_compact_messages() {
572 let messages = vec![
573 Message {
574 role: Role::System,
575 content: "You are a coding agent.".to_string(),
576 tool_calls: vec![],
577 tool_result: None,
578 },
579 Message {
580 role: Role::User,
581 content: "Fix the error".to_string(),
582 tool_calls: vec![],
583 tool_result: None,
584 },
585 Message {
586 role: Role::Assistant,
587 content: "I'll help.".to_string(),
588 tool_calls: vec![],
589 tool_result: None,
590 },
591 ];
592
593 let strategy = CompactionStrategy {
595 keep_recent: 1,
596 keep_keywords: vec![],
597 keep_tool_results: false,
598 keep_system: false,
599 };
600 let result = compact_messages(messages, &strategy);
601
602 assert_eq!(result.original_count, 3);
603 assert!(result.compacted_count > 0);
606 assert!(result.tokens_saved > 0);
607 }
608
609 #[test]
610 fn test_compaction_preserves_system_messages() {
611 let messages = vec![
612 Message {
613 role: Role::System,
614 content: "System prompt".to_string(),
615 tool_calls: vec![],
616 tool_result: None,
617 },
618 Message {
619 role: Role::User,
620 content: "User message".to_string(),
621 tool_calls: vec![],
622 tool_result: None,
623 },
624 ];
625
626 let strategy = CompactionStrategy {
627 keep_system: true,
628 ..Default::default()
629 };
630
631 let result = compact_messages(messages, &strategy);
632 assert!(result
633 .messages
634 .iter()
635 .any(|m| m.role == Role::System && m.content == "System prompt"));
636 }
637
638 #[test]
639 fn test_compaction_preserves_keyword_messages() {
640 let messages = vec![
641 Message {
642 role: Role::User,
643 content: "Fix the error".to_string(),
644 tool_calls: vec![],
645 tool_result: None,
646 },
647 Message {
648 role: Role::User,
649 content: "Regular message".to_string(),
650 tool_calls: vec![],
651 tool_result: None,
652 },
653 ];
654
655 let strategy = CompactionStrategy {
656 keep_keywords: vec!["error".to_string()],
657 ..Default::default()
658 };
659
660 let result = compact_messages(messages, &strategy);
661 assert!(result
662 .messages
663 .iter()
664 .any(|m| m.content == "Fix the error"));
665 }
666}