Skip to main content

pawan/
compaction.rs

1//! Structured conversation compaction for context overflow handling
2//!
3//! When conversation history exceeds context limits, this module provides
4//! tools to compact the history while preserving key information like:
5//! - User's original intent and requirements
6//! - Important decisions made
7//! - Code changes and their rationale
8//! - Error messages and debugging information
9
10use crate::agent::{Message, Role};
11use serde::{Deserialize, Serialize};
12
13/// Compaction strategy for preserving different types of information
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompactionStrategy {
16    /// Keep the most recent N messages (default: 10)
17    pub keep_recent: usize,
18    /// Keep messages with specific keywords (e.g., "error", "fix", "bug")
19    pub keep_keywords: Vec<String>,
20    /// Keep tool call results (default: true)
21    pub keep_tool_results: bool,
22    /// Keep system messages (default: true)
23    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/// Compaction result with statistics
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CompactionResult {
50    /// The compacted messages
51    pub messages: Vec<Message>,
52    /// Number of messages before compaction
53    pub original_count: usize,
54    /// Number of messages after compaction
55    pub compacted_count: usize,
56    /// Estimated tokens saved
57    pub tokens_saved: usize,
58}
59
60/// Build a structured compaction prompt for the LLM
61///
62/// This prompt instructs the LLM to create a structured summary that preserves
63/// essential information while reducing token count. The output format is
64/// designed to be machine-readable and easily parsed.
65pub 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    // Add messages to the prompt with clear section markers
147    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        // Add tool call information if present
157        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        // Add tool result if present
165        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
182/// Compact messages based on the given strategy
183pub fn compact_messages(messages: Vec<Message>, strategy: &CompactionStrategy) -> CompactionResult {
184    let original_count = messages.len();
185    let mut compacted = Vec::new();
186
187    // Always keep system messages if enabled
188    if strategy.keep_system {
189        compacted.extend(
190            messages
191                .iter()
192                .filter(|m| m.role == Role::System)
193                .cloned(),
194        );
195    }
196
197    // Keep messages with keywords
198    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    // Keep tool results if enabled
212    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    // Keep the most recent messages
223    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    // Sort by original order (approximate)
236    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
254/// Estimate tokens saved by compaction (rough approximation)
255fn estimate_tokens_saved(original: usize, compacted: usize) -> usize {
256    // Assume average of 4 tokens per message
257    let avg_tokens_per_message = 4;
258    (original - compacted) * avg_tokens_per_message
259}
260
261/// Parse a structured compaction summary
262///
263/// This function parses the structured output from the LLM and extracts
264/// the different sections into a structured format.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ParsedCompactionSummary {
267    /// User's original intent
268    pub user_intent: String,
269    /// Key decisions made
270    pub key_decisions: Vec<String>,
271    /// Code changes made
272    pub code_changes: Vec<CodeChange>,
273    /// Errors encountered and their solutions
274    pub errors_and_solutions: Vec<ErrorSolution>,
275    /// Debugging steps taken
276    pub debugging_steps: Vec<String>,
277    /// Warnings and notes
278    pub warnings_and_notes: Vec<String>,
279    /// Current state of the work
280    pub current_state: String,
281    /// Next steps to take
282    pub next_steps: Vec<String>,
283}
284
285/// A code change with metadata
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct CodeChange {
288    /// File that was changed
289    pub file: String,
290    /// Description of the change
291    pub change: String,
292    /// Rationale for the change
293    pub rationale: String,
294    /// Impact of the change
295    pub impact: String,
296}
297
298/// An error and its solution
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ErrorSolution {
301    /// Error description
302    pub error: String,
303    /// Location of the error
304    pub location: String,
305    /// How it was fixed
306    pub solution: String,
307    /// How to prevent this in the future
308    pub prevention: String,
309}
310
311/// Parse a structured compaction summary from LLM output
312///
313/// This is a simple parser that extracts sections from the structured format.
314/// It's designed to be robust to minor variations in formatting.
315pub 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        // Detect section headers
336        if line.starts_with("## ") {
337            current_section = Some(line[3..].to_string());
338            continue;
339        }
340
341        // Detect subsection headers (###)
342        if line.starts_with("### ") {
343            let subsection = line[4..].to_string();
344
345            // If we were building a code change, save it
346            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 we were building an error solution, save it
353            if let Some(error) = current_error.take() {
354                if !error.error.is_empty() {
355                    parsed.errors_and_solutions.push(error);
356                }
357            }
358
359            // Start new code change or error
360            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        // Process content based on current section
380        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    // Save any remaining code change or error
436    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    // Trim whitespace
448    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
454/// Convert a parsed compaction summary back to a message
455///
456/// This function converts the structured summary back into a message
457/// that can be added to the conversation history.
458pub 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        // Use a strategy that will actually drop some messages
594        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        // With keep_recent=1, only the most recent message (Assistant) is kept
604        // System and User are dropped because keep_system=false and keep_keywords=[]
605        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}