Skip to main content

recursive/
compact.rs

1//! LLM-driven context compaction.
2//!
3//! When the transcript grows large, `Compactor::compact` asks the model to
4//! summarize the older portion into a single system message, preserving key
5//! decisions, paths, and outcomes. The agent then continues with the summary
6//! plus recent messages, staying within the context window.
7//!
8//! Compaction is **disabled by default** (threshold = `usize::MAX`). Enable
9//! it via `AgentBuilder::compactor(...)`.
10
11use crate::error::Result;
12use crate::llm::{LlmProvider, StructuredRequest, ToolSpec};
13use crate::message::Message;
14
15/// Configuration for LLM-driven transcript compaction.
16#[derive(Debug, Clone)]
17pub struct Compactor {
18    /// Character-count threshold above which compaction is triggered.
19    /// Defaults to `usize::MAX` (disabled).
20    pub threshold_chars: usize,
21    /// Number of most-recent messages to keep verbatim during compaction.
22    pub keep_recent_n: usize,
23}
24
25impl Default for Compactor {
26    fn default() -> Self {
27        Self {
28            threshold_chars: usize::MAX,
29            keep_recent_n: 8,
30        }
31    }
32}
33
34impl Compactor {
35    /// Create a new compactor with the given threshold and default `keep_recent_n` (8).
36    pub fn new(threshold_chars: usize) -> Self {
37        Self {
38            threshold_chars,
39            keep_recent_n: 8,
40        }
41    }
42
43    /// Set the number of recent messages to preserve verbatim.
44    pub fn keep_recent_n(mut self, n: usize) -> Self {
45        self.keep_recent_n = n;
46        self
47    }
48
49    /// Estimate the prompt character count of a transcript.
50    ///
51    /// This is a rough proxy for token count. The agent uses this to decide
52    /// whether compaction is needed before the next LLM call.
53    pub fn estimate_chars(transcript: &[Message]) -> usize {
54        transcript.iter().map(|m| m.content.len()).sum()
55    }
56
57    /// JSON schema for structured compaction output.
58    const COMPACT_SCHEMA: &'static str = r#"{"type":"object","properties":{"summary":{"type":"string","description":"1-3 paragraph summary of the conversation so far, preserving key decisions, file paths touched, and outcomes."},"kept_facts":{"type":"array","items":{"type":"string"},"description":"Discrete facts worth remembering across compaction (e.g. 'goal=add_X_to_Y', 'compaction happened at step N', 'tool X failed 3 times')."},"next_steps":{"type":"array","items":{"type":"string"},"description":"Outstanding TODOs the agent identified before compaction (each one a single-sentence imperative)."}},"required":["summary","kept_facts"]}"#;
59
60    /// Render a structured compaction result into the message format.
61    fn render_structured(summary: &str, kept_facts: &[String], next_steps: &[String]) -> String {
62        let mut rendered = format!(
63            "[Context compacted at step N]\n\nSummary: {summary}\n\nKey facts to remember:\n"
64        );
65        for fact in kept_facts {
66            rendered.push_str(&format!("- {fact}\n"));
67        }
68        if !next_steps.is_empty() {
69            rendered.push_str("\nOutstanding TODOs:\n");
70            for step in next_steps {
71                rendered.push_str(&format!("- {step}\n"));
72            }
73        }
74        rendered
75    }
76
77    /// Try structured compaction, returning the rendered string on success.
78    /// Returns None if the provider doesn't support it or the response is invalid.
79    async fn try_structured_compact(
80        &self,
81        provider: &dyn LlmProvider,
82        older_text: &str,
83    ) -> Option<String> {
84        let structured_prompt = format!(
85            "Summarize the following conversation. \
86             Preserve: file paths modified, key technical decisions, test \
87             outcomes, and any errors not yet resolved. Drop: file contents, \
88             repeated tool errors, exploratory dead-ends.\n\n\
89             Conversation to summarize:\n{older_text}"
90        );
91
92        let structured_req = StructuredRequest {
93            messages: vec![Message::user(structured_prompt)],
94            schema: serde_json::from_str(Self::COMPACT_SCHEMA)
95                .expect("COMPACT_SCHEMA is valid JSON"),
96            schema_name: "compaction_result".to_string(),
97        };
98
99        let json_val = match provider.complete_structured(structured_req).await {
100            Ok(v) => v,
101            Err(e) => {
102                tracing::info!(error = %e, "structured compaction not available, falling back to free-text");
103                return None;
104            }
105        };
106
107        let obj = match json_val.as_object() {
108            Some(o) => o,
109            None => {
110                tracing::warn!(
111                    "structured compaction returned non-object, falling back to free-text"
112                );
113                return None;
114            }
115        };
116
117        let summary = match obj.get("summary").and_then(|v| v.as_str()) {
118            Some(s) => s.to_string(),
119            None => {
120                tracing::warn!(
121                    "structured compaction missing 'summary' field, falling back to free-text"
122                );
123                return None;
124            }
125        };
126
127        let kept_facts: Vec<String> = obj
128            .get("kept_facts")
129            .and_then(|v| v.as_array())
130            .map(|arr| {
131                arr.iter()
132                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
133                    .collect()
134            })
135            .unwrap_or_default();
136
137        let next_steps: Vec<String> = obj
138            .get("next_steps")
139            .and_then(|v| v.as_array())
140            .map(|arr| {
141                arr.iter()
142                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
143                    .collect()
144            })
145            .unwrap_or_default();
146
147        Some(Self::render_structured(&summary, &kept_facts, &next_steps))
148    }
149
150    /// Apply compaction in-place to `transcript`, returning `(removed, summary_chars)`.
151    ///
152    /// Finds the correct split point (never splits inside a tool-call pair),
153    /// calls `compact()` to get the summary message, splices the transcript,
154    /// and returns how many messages were removed and the summary char count.
155    ///
156    /// Returns `None` when the transcript is too short to compact
157    /// (`< keep_recent_n + 2` messages).
158    pub async fn apply_to_transcript(
159        &self,
160        provider: &dyn LlmProvider,
161        transcript: &mut Vec<Message>,
162    ) -> Result<Option<(usize, usize)>> {
163        if transcript.len() < self.keep_recent_n + 2 {
164            return Ok(None);
165        }
166        let summary_msg = self.compact(provider, transcript).await?;
167        let summary_chars = summary_msg.content.len();
168        let keep = self.keep_recent_n;
169        let mut split = transcript.len().saturating_sub(keep);
170        while split > 0 && matches!(transcript[split].role, crate::message::Role::Tool) {
171            split -= 1;
172        }
173        let removed = split;
174        transcript.drain(..split);
175        transcript.insert(0, summary_msg);
176        Ok(Some((removed, summary_chars)))
177    }
178
179    /// Compact the transcript: summarize older messages into a single system
180    /// message, keeping the last `keep_recent_n` messages verbatim.
181    ///
182    /// Returns the summary `Message` that should replace the older portion.
183    /// The caller is responsible for splicing it into the transcript.
184    #[tracing::instrument(skip(self, provider, transcript))]
185    pub async fn compact(
186        &self,
187        provider: &dyn LlmProvider,
188        transcript: &[Message],
189    ) -> Result<Message> {
190        let n = self.keep_recent_n.min(transcript.len().saturating_sub(1));
191        let split = transcript.len().saturating_sub(n);
192        let older = &transcript[..split];
193        let _recent = &transcript[split..];
194
195        // Build a meta-prompt asking the model to summarize the older portion.
196        let older_text: String = older
197            .iter()
198            .map(|m| {
199                let role_tag = match m.role {
200                    crate::message::Role::System => "system",
201                    crate::message::Role::User => "user",
202                    crate::message::Role::Assistant => "assistant",
203                    crate::message::Role::Tool => "tool",
204                };
205                format!("<{role_tag}>{}</{role_tag}>", m.content)
206            })
207            .collect::<Vec<_>>()
208            .join("\n");
209
210        // Try structured output first
211        let summary = match self.try_structured_compact(provider, &older_text).await {
212            Some(rendered) => rendered,
213            None => {
214                // Fall back to free-text path
215                let summary_prompt = format!(
216                    "Summarize the following conversation in ≤300 words. \
217                     Preserve: file paths modified, key technical decisions, test \
218                     outcomes, and any errors not yet resolved. Drop: file contents, \
219                     repeated tool errors, exploratory dead-ends.\n\n\
220                     Conversation to summarize:\n{older_text}"
221                );
222                let completion = provider
223                    .complete(&[Message::user(summary_prompt)], &[] as &[ToolSpec])
224                    .await?;
225                completion.content
226            }
227        };
228
229        let _older_chars: usize = older.iter().map(|m| m.content.len()).sum();
230        let summary_chars = summary.len();
231
232        let header = format!(
233            "[compacted: {} messages → {} chars]\n{}",
234            older.len(),
235            summary_chars,
236            summary
237        );
238
239        Ok(Message::system(header))
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::llm::{Completion, MockProvider};
247
248    #[tokio::test]
249    async fn compact_returns_system_message_with_summary() {
250        let provider = MockProvider::new(vec![Completion {
251            content: "Key decisions: added adder tool. Tests pass.".to_string(),
252            tool_calls: vec![],
253            finish_reason: Some("stop".to_string()),
254            usage: None,
255            reasoning_content: None,
256        }]);
257
258        let transcript = vec![
259            Message::system("You are a coding agent.".to_string()),
260            Message::user("Add an adder tool".to_string()),
261            Message::assistant("Let me create the tool.".to_string()),
262            Message::user("Done. Now test it.".to_string()),
263            Message::assistant("Tests pass.".to_string()),
264        ];
265
266        let compactor = Compactor::new(200).keep_recent_n(2);
267        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
268
269        assert_eq!(summary_msg.role, crate::message::Role::System);
270        assert!(summary_msg.content.contains("[compacted:"));
271        assert!(summary_msg.content.contains("Key decisions:"));
272        assert!(summary_msg.content.contains("Tests pass."));
273    }
274
275    #[tokio::test]
276    async fn compact_preserves_recent_messages() {
277        let provider = MockProvider::new(vec![Completion {
278            content: "Summary of older messages.".to_string(),
279            tool_calls: vec![],
280            finish_reason: Some("stop".to_string()),
281            usage: None,
282            reasoning_content: None,
283        }]);
284
285        let transcript = vec![
286            Message::system("sys".to_string()),
287            Message::user("old goal".to_string()),
288            Message::assistant("old reply".to_string()),
289            Message::user("recent goal".to_string()),
290            Message::assistant("recent reply".to_string()),
291        ];
292
293        // keep_recent_n=2 should keep the last 2 messages verbatim
294        let compactor = Compactor::new(100).keep_recent_n(2);
295        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
296
297        assert!(summary_msg.content.contains("[compacted: 3 messages →"));
298        // The summary should mention the older messages
299        assert!(summary_msg.content.contains("Summary of older messages."));
300    }
301
302    #[tokio::test]
303    async fn compact_handles_empty_older_portion() {
304        let provider = MockProvider::new(vec![Completion {
305            content: "nothing to summarize".to_string(),
306            tool_calls: vec![],
307            finish_reason: Some("stop".to_string()),
308            usage: None,
309            reasoning_content: None,
310        }]);
311
312        let transcript = vec![Message::user("only message".to_string())];
313
314        // keep_recent_n=5 means all messages are "recent", none to compact
315        let compactor = Compactor::new(100).keep_recent_n(5);
316        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
317
318        // Should still produce a summary (even if older portion is empty-ish)
319        assert_eq!(summary_msg.role, crate::message::Role::System);
320        assert!(summary_msg.content.contains("[compacted:"));
321    }
322
323    #[test]
324    fn estimate_chars_sums_content_lengths() {
325        let transcript = vec![
326            Message::user("hello".to_string()),
327            Message::assistant("world".to_string()),
328        ];
329        assert_eq!(Compactor::estimate_chars(&transcript), 10);
330    }
331
332    #[test]
333    fn default_threshold_is_max() {
334        let c = Compactor::default();
335        assert_eq!(c.threshold_chars, usize::MAX);
336        assert_eq!(c.keep_recent_n, 8);
337    }
338
339    #[test]
340    fn builder_methods_work() {
341        let c = Compactor::new(500).keep_recent_n(4);
342        assert_eq!(c.threshold_chars, 500);
343        assert_eq!(c.keep_recent_n, 4);
344    }
345
346    // ========================================================================
347    // Structured compaction tests
348    // ========================================================================
349
350    #[tokio::test]
351    async fn compactor_structured_happy_path() {
352        let json = serde_json::json!({
353            "summary": "Added adder tool and verified tests pass.",
354            "kept_facts": [
355                "goal=add_adder_tool",
356                "tool adder created successfully",
357                "tests pass"
358            ],
359            "next_steps": [
360                "Add subtractor tool",
361                "Run integration tests"
362            ]
363        });
364        let provider = MockProvider::new(vec![]).with_structured_responses(vec![Ok(json)]);
365
366        let transcript = vec![
367            Message::system("You are a coding agent.".to_string()),
368            Message::user("Add an adder tool".to_string()),
369            Message::assistant("Let me create the tool.".to_string()),
370            Message::user("Done. Now test it.".to_string()),
371            Message::assistant("Tests pass.".to_string()),
372        ];
373
374        let compactor = Compactor::new(200).keep_recent_n(2);
375        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
376
377        assert_eq!(summary_msg.role, crate::message::Role::System);
378        // Should contain the structured rendering format
379        assert!(summary_msg
380            .content
381            .contains("[Context compacted at step N]"));
382        assert!(summary_msg.content.contains("Summary: Added adder tool"));
383        assert!(summary_msg.content.contains("Key facts to remember:"));
384        assert!(summary_msg.content.contains("- goal=add_adder_tool"));
385        assert!(summary_msg
386            .content
387            .contains("- tool adder created successfully"));
388        assert!(summary_msg.content.contains("- tests pass"));
389        assert!(summary_msg.content.contains("Outstanding TODOs:"));
390        assert!(summary_msg.content.contains("- Add subtractor tool"));
391        assert!(summary_msg.content.contains("- Run integration tests"));
392    }
393
394    #[tokio::test]
395    async fn compactor_falls_back_on_structured_error() {
396        // MockProvider with no structured responses configured -> returns error
397        let provider = MockProvider::new(vec![Completion {
398            content: "Free-text fallback summary.".to_string(),
399            tool_calls: vec![],
400            finish_reason: Some("stop".to_string()),
401            usage: None,
402            reasoning_content: None,
403        }]);
404
405        let transcript = vec![
406            Message::user("goal".to_string()),
407            Message::assistant("response".to_string()),
408        ];
409
410        let compactor = Compactor::new(100).keep_recent_n(1);
411        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
412
413        assert_eq!(summary_msg.role, crate::message::Role::System);
414        // Should have fallen back to free-text format
415        assert!(summary_msg.content.contains("[compacted:"));
416        assert!(summary_msg.content.contains("Free-text fallback summary."));
417    }
418
419    #[tokio::test]
420    async fn compactor_structured_invalid_response_falls_back() {
421        // Return valid JSON but not matching the schema (missing 'summary')
422        let json = serde_json::json!({
423            "foo": "bar"
424        });
425        let provider = MockProvider::new(vec![Completion {
426            content: "Fallback after invalid structured response.".to_string(),
427            tool_calls: vec![],
428            finish_reason: Some("stop".to_string()),
429            usage: None,
430            reasoning_content: None,
431        }])
432        .with_structured_responses(vec![Ok(json)]);
433
434        let transcript = vec![
435            Message::user("goal".to_string()),
436            Message::assistant("response".to_string()),
437        ];
438
439        let compactor = Compactor::new(100).keep_recent_n(1);
440        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
441
442        assert_eq!(summary_msg.role, crate::message::Role::System);
443        // Should have fallen back to free-text format
444        assert!(summary_msg.content.contains("[compacted:"));
445        assert!(summary_msg
446            .content
447            .contains("Fallback after invalid structured response."));
448    }
449}