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 #[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 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 let summary = match self.try_structured_compact(provider, &older_text).await {
183 Some(rendered) => rendered,
184 None => {
185 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 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 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 let compactor = Compactor::new(100).keep_recent_n(5);
287 let summary_msg = compactor.compact(&provider, &transcript).await.unwrap();
288
289 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 #[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 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 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 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 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 assert!(summary_msg.content.contains("[compacted:"));
416 assert!(summary_msg
417 .content
418 .contains("Fallback after invalid structured response."));
419 }
420}