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        }]);
227
228        let transcript = vec![
229            Message::system("You are a coding agent.".to_string()),
230            Message::user("Add an adder tool".to_string()),
231            Message::assistant("Let me create the tool.".to_string()),
232            Message::user("Done. Now test it.".to_string()),
233            Message::assistant("Tests pass.".to_string()),
234        ];
235
236        let compactor = Compactor::new(200).keep_recent_n(2);
237        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
238
239        assert_eq!(summary_msg.role, crate::message::Role::System);
240        assert!(summary_msg.content.contains("[compacted:"));
241        assert!(summary_msg.content.contains("Key decisions:"));
242        assert!(summary_msg.content.contains("Tests pass."));
243    }
244
245    #[tokio::test]
246    async fn compact_preserves_recent_messages() {
247        let provider = MockProvider::new(vec![Completion {
248            content: "Summary of older messages.".to_string(),
249            tool_calls: vec![],
250            finish_reason: Some("stop".to_string()),
251            usage: None,
252        }]);
253
254        let transcript = vec![
255            Message::system("sys".to_string()),
256            Message::user("old goal".to_string()),
257            Message::assistant("old reply".to_string()),
258            Message::user("recent goal".to_string()),
259            Message::assistant("recent reply".to_string()),
260        ];
261
262        // keep_recent_n=2 should keep the last 2 messages verbatim
263        let compactor = Compactor::new(100).keep_recent_n(2);
264        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
265
266        assert!(summary_msg.content.contains("[compacted: 3 messages →"));
267        // The summary should mention the older messages
268        assert!(summary_msg.content.contains("Summary of older messages."));
269    }
270
271    #[tokio::test]
272    async fn compact_handles_empty_older_portion() {
273        let provider = MockProvider::new(vec![Completion {
274            content: "nothing to summarize".to_string(),
275            tool_calls: vec![],
276            finish_reason: Some("stop".to_string()),
277            usage: None,
278        }]);
279
280        let transcript = vec![Message::user("only message".to_string())];
281
282        // keep_recent_n=5 means all messages are "recent", none to compact
283        let compactor = Compactor::new(100).keep_recent_n(5);
284        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
285
286        // Should still produce a summary (even if older portion is empty-ish)
287        assert_eq!(summary_msg.role, crate::message::Role::System);
288        assert!(summary_msg.content.contains("[compacted:"));
289    }
290
291    #[test]
292    fn estimate_chars_sums_content_lengths() {
293        let transcript = vec![
294            Message::user("hello".to_string()),
295            Message::assistant("world".to_string()),
296        ];
297        assert_eq!(Compactor::estimate_chars(&transcript), 10);
298    }
299
300    #[test]
301    fn default_threshold_is_max() {
302        let c = Compactor::default();
303        assert_eq!(c.threshold_chars, usize::MAX);
304        assert_eq!(c.keep_recent_n, 8);
305    }
306
307    #[test]
308    fn builder_methods_work() {
309        let c = Compactor::new(500).keep_recent_n(4);
310        assert_eq!(c.threshold_chars, 500);
311        assert_eq!(c.keep_recent_n, 4);
312    }
313
314    // ========================================================================
315    // Structured compaction tests
316    // ========================================================================
317
318    #[tokio::test]
319    async fn compactor_structured_happy_path() {
320        let json = serde_json::json!({
321            "summary": "Added adder tool and verified tests pass.",
322            "kept_facts": [
323                "goal=add_adder_tool",
324                "tool adder created successfully",
325                "tests pass"
326            ],
327            "next_steps": [
328                "Add subtractor tool",
329                "Run integration tests"
330            ]
331        });
332        let provider = MockProvider::new(vec![]).with_structured_responses(vec![Ok(json)]);
333
334        let transcript = vec![
335            Message::system("You are a coding agent.".to_string()),
336            Message::user("Add an adder tool".to_string()),
337            Message::assistant("Let me create the tool.".to_string()),
338            Message::user("Done. Now test it.".to_string()),
339            Message::assistant("Tests pass.".to_string()),
340        ];
341
342        let compactor = Compactor::new(200).keep_recent_n(2);
343        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
344
345        assert_eq!(summary_msg.role, crate::message::Role::System);
346        // Should contain the structured rendering format
347        assert!(summary_msg
348            .content
349            .contains("[Context compacted at step N]"));
350        assert!(summary_msg.content.contains("Summary: Added adder tool"));
351        assert!(summary_msg.content.contains("Key facts to remember:"));
352        assert!(summary_msg.content.contains("- goal=add_adder_tool"));
353        assert!(summary_msg
354            .content
355            .contains("- tool adder created successfully"));
356        assert!(summary_msg.content.contains("- tests pass"));
357        assert!(summary_msg.content.contains("Outstanding TODOs:"));
358        assert!(summary_msg.content.contains("- Add subtractor tool"));
359        assert!(summary_msg.content.contains("- Run integration tests"));
360    }
361
362    #[tokio::test]
363    async fn compactor_falls_back_on_structured_error() {
364        // MockProvider with no structured responses configured -> returns error
365        let provider = MockProvider::new(vec![Completion {
366            content: "Free-text fallback summary.".to_string(),
367            tool_calls: vec![],
368            finish_reason: Some("stop".to_string()),
369            usage: None,
370        }]);
371
372        let transcript = vec![
373            Message::user("goal".to_string()),
374            Message::assistant("response".to_string()),
375        ];
376
377        let compactor = Compactor::new(100).keep_recent_n(1);
378        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
379
380        assert_eq!(summary_msg.role, crate::message::Role::System);
381        // Should have fallen back to free-text format
382        assert!(summary_msg.content.contains("[compacted:"));
383        assert!(summary_msg.content.contains("Free-text fallback summary."));
384    }
385
386    #[tokio::test]
387    async fn compactor_structured_invalid_response_falls_back() {
388        // Return valid JSON but not matching the schema (missing 'summary')
389        let json = serde_json::json!({
390            "foo": "bar"
391        });
392        let provider = MockProvider::new(vec![Completion {
393            content: "Fallback after invalid structured response.".to_string(),
394            tool_calls: vec![],
395            finish_reason: Some("stop".to_string()),
396            usage: None,
397        }])
398        .with_structured_responses(vec![Ok(json)]);
399
400        let transcript = vec![
401            Message::user("goal".to_string()),
402            Message::assistant("response".to_string()),
403        ];
404
405        let compactor = Compactor::new(100).keep_recent_n(1);
406        let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
407
408        assert_eq!(summary_msg.role, crate::message::Role::System);
409        // Should have fallen back to free-text format
410        assert!(summary_msg.content.contains("[compacted:"));
411        assert!(summary_msg
412            .content
413            .contains("Fallback after invalid structured response."));
414    }
415}