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    /// Compact the transcript: summarize older messages into a single system
151    /// message, keeping the last `keep_recent_n` messages verbatim.
152    ///
153    /// Returns the summary `Message` that should replace the older portion.
154    /// The caller is responsible for splicing it into the transcript.
155    #[tracing::instrument(skip(self, provider, transcript))]
156    pub async fn compact(
157        &self,
158        provider: &dyn LlmProvider,
159        transcript: &[Message],
160    ) -> Result<Message> {
161        let n = self.keep_recent_n.min(transcript.len().saturating_sub(1));
162        let split = transcript.len().saturating_sub(n);
163        let older = &transcript[..split];
164        let _recent = &transcript[split..];
165
166        // Build a meta-prompt asking the model to summarize the older portion.
167        let older_text: String = older
168            .iter()
169            .map(|m| {
170                let role_tag = match m.role {
171                    crate::message::Role::System => "system",
172                    crate::message::Role::User => "user",
173                    crate::message::Role::Assistant => "assistant",
174                    crate::message::Role::Tool => "tool",
175                };
176                format!("<{role_tag}>{}</{role_tag}>", m.content)
177            })
178            .collect::<Vec<_>>()
179            .join("\n");
180
181        // Try structured output first
182        let summary = match self.try_structured_compact(provider, &older_text).await {
183            Some(rendered) => rendered,
184            None => {
185                // Fall back to free-text path
186                let summary_prompt = format!(
187                    "Summarize the following conversation in ≤300 words. \
188                     Preserve: file paths modified, key technical decisions, test \
189                     outcomes, and any errors not yet resolved. Drop: file contents, \
190                     repeated tool errors, exploratory dead-ends.\n\n\
191                     Conversation to summarize:\n{older_text}"
192                );
193                let completion = provider
194                    .complete(&[Message::user(summary_prompt)], &[] as &[ToolSpec])
195                    .await?;
196                completion.content
197            }
198        };
199
200        let _older_chars: usize = older.iter().map(|m| m.content.len()).sum();
201        let summary_chars = summary.len();
202
203        let header = format!(
204            "[compacted: {} messages → {} chars]\n{}",
205            older.len(),
206            summary_chars,
207            summary
208        );
209
210        Ok(Message::system(header))
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::llm::{Completion, MockProvider};
218
219    #[tokio::test]
220    async fn compact_returns_system_message_with_summary() {
221        let provider = MockProvider::new(vec![Completion {
222            content: "Key decisions: added adder tool. Tests pass.".to_string(),
223            tool_calls: vec![],
224            finish_reason: Some("stop".to_string()),
225            usage: None,
226            reasoning_content: None,
227        }]);
228
229        let transcript = vec![
230            Message::system("You are a coding agent.".to_string()),
231            Message::user("Add an adder tool".to_string()),
232            Message::assistant("Let me create the tool.".to_string()),
233            Message::user("Done. Now test it.".to_string()),
234            Message::assistant("Tests pass.".to_string()),
235        ];
236
237        let compactor = Compactor::new(200).keep_recent_n(2);
238        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
239
240        assert_eq!(summary_msg.role, crate::message::Role::System);
241        assert!(summary_msg.content.contains("[compacted:"));
242        assert!(summary_msg.content.contains("Key decisions:"));
243        assert!(summary_msg.content.contains("Tests pass."));
244    }
245
246    #[tokio::test]
247    async fn compact_preserves_recent_messages() {
248        let provider = MockProvider::new(vec![Completion {
249            content: "Summary of older messages.".to_string(),
250            tool_calls: vec![],
251            finish_reason: Some("stop".to_string()),
252            usage: None,
253            reasoning_content: None,
254        }]);
255
256        let transcript = vec![
257            Message::system("sys".to_string()),
258            Message::user("old goal".to_string()),
259            Message::assistant("old reply".to_string()),
260            Message::user("recent goal".to_string()),
261            Message::assistant("recent reply".to_string()),
262        ];
263
264        // keep_recent_n=2 should keep the last 2 messages verbatim
265        let compactor = Compactor::new(100).keep_recent_n(2);
266        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
267
268        assert!(summary_msg.content.contains("[compacted: 3 messages →"));
269        // The summary should mention the older messages
270        assert!(summary_msg.content.contains("Summary of older messages."));
271    }
272
273    #[tokio::test]
274    async fn compact_handles_empty_older_portion() {
275        let provider = MockProvider::new(vec![Completion {
276            content: "nothing to summarize".to_string(),
277            tool_calls: vec![],
278            finish_reason: Some("stop".to_string()),
279            usage: None,
280            reasoning_content: None,
281        }]);
282
283        let transcript = vec![Message::user("only message".to_string())];
284
285        // keep_recent_n=5 means all messages are "recent", none to compact
286        let compactor = Compactor::new(100).keep_recent_n(5);
287        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
288
289        // Should still produce a summary (even if older portion is empty-ish)
290        assert_eq!(summary_msg.role, crate::message::Role::System);
291        assert!(summary_msg.content.contains("[compacted:"));
292    }
293
294    #[test]
295    fn estimate_chars_sums_content_lengths() {
296        let transcript = vec![
297            Message::user("hello".to_string()),
298            Message::assistant("world".to_string()),
299        ];
300        assert_eq!(Compactor::estimate_chars(&transcript), 10);
301    }
302
303    #[test]
304    fn default_threshold_is_max() {
305        let c = Compactor::default();
306        assert_eq!(c.threshold_chars, usize::MAX);
307        assert_eq!(c.keep_recent_n, 8);
308    }
309
310    #[test]
311    fn builder_methods_work() {
312        let c = Compactor::new(500).keep_recent_n(4);
313        assert_eq!(c.threshold_chars, 500);
314        assert_eq!(c.keep_recent_n, 4);
315    }
316
317    // ========================================================================
318    // Structured compaction tests
319    // ========================================================================
320
321    #[tokio::test]
322    async fn compactor_structured_happy_path() {
323        let json = serde_json::json!({
324            "summary": "Added adder tool and verified tests pass.",
325            "kept_facts": [
326                "goal=add_adder_tool",
327                "tool adder created successfully",
328                "tests pass"
329            ],
330            "next_steps": [
331                "Add subtractor tool",
332                "Run integration tests"
333            ]
334        });
335        let provider = MockProvider::new(vec![]).with_structured_responses(vec![Ok(json)]);
336
337        let transcript = vec![
338            Message::system("You are a coding agent.".to_string()),
339            Message::user("Add an adder tool".to_string()),
340            Message::assistant("Let me create the tool.".to_string()),
341            Message::user("Done. Now test it.".to_string()),
342            Message::assistant("Tests pass.".to_string()),
343        ];
344
345        let compactor = Compactor::new(200).keep_recent_n(2);
346        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
347
348        assert_eq!(summary_msg.role, crate::message::Role::System);
349        // Should contain the structured rendering format
350        assert!(summary_msg
351            .content
352            .contains("[Context compacted at step N]"));
353        assert!(summary_msg.content.contains("Summary: Added adder tool"));
354        assert!(summary_msg.content.contains("Key facts to remember:"));
355        assert!(summary_msg.content.contains("- goal=add_adder_tool"));
356        assert!(summary_msg
357            .content
358            .contains("- tool adder created successfully"));
359        assert!(summary_msg.content.contains("- tests pass"));
360        assert!(summary_msg.content.contains("Outstanding TODOs:"));
361        assert!(summary_msg.content.contains("- Add subtractor tool"));
362        assert!(summary_msg.content.contains("- Run integration tests"));
363    }
364
365    #[tokio::test]
366    async fn compactor_falls_back_on_structured_error() {
367        // MockProvider with no structured responses configured -> returns error
368        let provider = MockProvider::new(vec![Completion {
369            content: "Free-text fallback summary.".to_string(),
370            tool_calls: vec![],
371            finish_reason: Some("stop".to_string()),
372            usage: None,
373            reasoning_content: None,
374        }]);
375
376        let transcript = vec![
377            Message::user("goal".to_string()),
378            Message::assistant("response".to_string()),
379        ];
380
381        let compactor = Compactor::new(100).keep_recent_n(1);
382        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
383
384        assert_eq!(summary_msg.role, crate::message::Role::System);
385        // Should have fallen back to free-text format
386        assert!(summary_msg.content.contains("[compacted:"));
387        assert!(summary_msg.content.contains("Free-text fallback summary."));
388    }
389
390    #[tokio::test]
391    async fn compactor_structured_invalid_response_falls_back() {
392        // Return valid JSON but not matching the schema (missing 'summary')
393        let json = serde_json::json!({
394            "foo": "bar"
395        });
396        let provider = MockProvider::new(vec![Completion {
397            content: "Fallback after invalid structured response.".to_string(),
398            tool_calls: vec![],
399            finish_reason: Some("stop".to_string()),
400            usage: None,
401            reasoning_content: None,
402        }])
403        .with_structured_responses(vec![Ok(json)]);
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
417            .content
418            .contains("Fallback after invalid structured response."));
419    }
420}