1use crate::error::Result;
12use crate::llm::{LlmProvider, StructuredRequest, ToolSpec};
13use crate::message::Message;
14
15#[derive(Debug, Clone)]
17pub struct Compactor {
18 pub threshold_chars: usize,
21 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 pub fn new(threshold_chars: usize) -> Self {
37 Self {
38 threshold_chars,
39 keep_recent_n: 8,
40 }
41 }
42
43 pub fn keep_recent_n(mut self, n: usize) -> Self {
45 self.keep_recent_n = n;
46 self
47 }
48
49 pub fn estimate_chars(transcript: &[Message]) -> usize {
54 transcript.iter().map(|m| m.content.len()).sum()
55 }
56
57 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 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 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 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 #[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 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 let summary = match self.try_structured_compact(provider, &older_text).await {
212 Some(rendered) => rendered,
213 None => {
214 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 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 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 let compactor = Compactor::new(100).keep_recent_n(5);
316 let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
317
318 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 #[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 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 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 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 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 assert!(summary_msg.content.contains("[compacted:"));
445 assert!(summary_msg
446 .content
447 .contains("Fallback after invalid structured response."));
448 }
449}