1use tracing::{debug, warn};
15
16use crate::client::{ApiContentBlock, ApiMessage, CreateMessageRequest};
17use crate::error::Result;
18use crate::provider::LlmProvider;
19
20pub const DEFAULT_COMPACTION_MODEL: &str = "claude-haiku-4-5";
22
23pub const DEFAULT_MIN_KEEP_MESSAGES: usize = 4;
25
26pub const DEFAULT_SUMMARY_MAX_TOKENS: u32 = 4096;
28
29pub fn should_compact(input_tokens: u64, context_budget: u64) -> bool {
31 input_tokens > context_budget
32}
33
34pub fn find_split_point(conversation: &[ApiMessage], min_keep: usize) -> usize {
42 if conversation.len() <= min_keep {
43 return 0;
44 }
45
46 let mut split = conversation.len() - min_keep;
48
49 while split > 0 {
53 if split < conversation.len() {
58 let msg = &conversation[split];
59 if msg.role == "user" && has_tool_results(&msg.content) {
60 if split > 0 {
62 let prev = &conversation[split - 1];
63 if prev.role == "assistant" && has_tool_uses(&prev.content) {
64 split -= 1;
66 continue;
67 }
68 }
69 }
70 }
71 break;
72 }
73
74 split
75}
76
77pub fn build_summary_prompt(old_messages: &[ApiMessage]) -> String {
79 let mut rendered = String::new();
80
81 for msg in old_messages {
82 rendered.push_str(&format!("[{}]\n", msg.role));
83 for block in &msg.content {
84 match block {
85 ApiContentBlock::Text { text, .. } => {
86 rendered.push_str(text);
87 rendered.push('\n');
88 }
89 ApiContentBlock::ToolUse { name, input, .. } => {
90 rendered.push_str(&format!("Tool call: {} input: {}\n", name, input));
91 }
92 ApiContentBlock::ToolResult {
93 content, is_error, ..
94 } => {
95 let label = if *is_error == Some(true) {
96 "error"
97 } else {
98 "result"
99 };
100 let content_str = content.to_string();
102 if content_str.len() > 500 {
103 let mut end = 500;
104 while end > 0 && !content_str.is_char_boundary(end) {
105 end -= 1;
106 }
107 rendered.push_str(&format!("Tool {}: {}...\n", label, &content_str[..end]));
108 } else {
109 rendered.push_str(&format!("Tool {}: {}\n", label, content_str));
110 }
111 }
112 ApiContentBlock::Thinking { thinking } => {
113 if thinking.len() <= 200 {
115 rendered.push_str(&format!("(thinking: {})\n", thinking));
116 }
117 }
118 ApiContentBlock::Image { .. } => {
119 rendered.push_str("[image]\n");
120 }
121 }
122 }
123 rendered.push('\n');
124 }
125
126 format!(
127 "Summarize the following conversation segment concisely. Preserve:\n\
128 - Key decisions made\n\
129 - Important facts and context established\n\
130 - File paths and code references mentioned\n\
131 - Tool results and their outcomes\n\
132 - Any commitments or action items\n\n\
133 Format as a structured summary with sections.\n\n\
134 <conversation>\n{rendered}</conversation>"
135 )
136}
137
138pub async fn call_summarizer(
143 provider: &dyn LlmProvider,
144 summary_prompt: &str,
145 compaction_model: &str,
146 fallback_provider: Option<&dyn LlmProvider>,
147 fallback_model: &str,
148 summary_max_tokens: u32,
149) -> Result<String> {
150 let request = CreateMessageRequest {
151 model: compaction_model.to_string(),
152 max_tokens: summary_max_tokens,
153 messages: vec![ApiMessage {
154 role: "user".to_string(),
155 content: vec![ApiContentBlock::Text {
156 text: summary_prompt.to_string(),
157 cache_control: None,
158 }],
159 }],
160 system: None,
161 tools: None,
162 stream: false,
163 metadata: None,
164 thinking: None,
165 };
166
167 match provider.create_message(&request).await {
168 Ok(resp) => extract_text(&resp.content),
169 Err(e) => {
170 warn!(
171 model = compaction_model,
172 error = %e,
173 "Compaction model failed, falling back to primary model"
174 );
175 let mut fallback_req = request;
177 fallback_req.model = fallback_model.to_string();
178 let fb = fallback_provider.unwrap_or(provider);
179 let resp = fb.create_message(&fallback_req).await?;
180 extract_text(&resp.content)
181 }
182 }
183}
184
185pub fn splice_conversation(conversation: &mut Vec<ApiMessage>, split_point: usize, summary: &str) {
187 conversation.drain(..split_point);
189
190 conversation.insert(
192 0,
193 ApiMessage {
194 role: "user".to_string(),
195 content: vec![ApiContentBlock::Text {
196 text: format!(
197 "[Previous conversation summary]\n{summary}\n[End of summary — conversation continues below]"
198 ),
199 cache_control: None,
200 }],
201 },
202 );
203}
204
205#[derive(Debug)]
207pub struct CompactResult {
208 pub pre_tokens: u64,
209 pub summary: String,
210 pub messages_compacted: usize,
211}
212
213pub const DEFAULT_PRUNE_TOOL_RESULT_MAX_CHARS: usize = 2_000;
217
218pub const DEFAULT_PRUNE_THRESHOLD_PCT: u8 = 70;
220
221pub fn should_prune(input_tokens: u64, context_budget: u64, threshold_pct: u8) -> bool {
226 let threshold = context_budget * threshold_pct as u64 / 100;
227 input_tokens > threshold
228}
229
230pub fn prune_tool_results(
238 conversation: &mut [ApiMessage],
239 max_chars: usize,
240 preserve_tail: usize,
241) -> usize {
242 let len = conversation.len();
243 let end = len.saturating_sub(preserve_tail);
244 let mut total_removed = 0;
245
246 for msg in conversation[..end].iter_mut() {
247 for block in msg.content.iter_mut() {
248 if let ApiContentBlock::ToolResult { content, .. } = block {
249 let text = content.to_string();
250 if text.len() <= max_chars {
251 continue;
252 }
253
254 let original_len = text.len();
255
256 let head_end = char_boundary(&text, 500);
258 let tail_start = char_boundary_rev(&text, 200);
259
260 let pruned = format!(
261 "{}\n\n[...{} chars pruned...]\n\n{}",
262 &text[..head_end],
263 original_len - head_end - (original_len - tail_start),
264 &text[tail_start..]
265 );
266
267 let removed = original_len - pruned.len();
268 total_removed += removed;
269 *content = serde_json::json!(pruned);
270
271 debug!(
272 original = original_len,
273 pruned = pruned.len(),
274 saved = removed,
275 "Pruned tool result"
276 );
277 }
278 }
279 }
280
281 if total_removed > 0 {
282 debug!(
283 total_chars_removed = total_removed,
284 "Tool result pruning complete"
285 );
286 }
287
288 total_removed
289}
290
291fn char_boundary(s: &str, target: usize) -> usize {
293 let target = target.min(s.len());
294 let mut pos = target;
295 while pos > 0 && !s.is_char_boundary(pos) {
296 pos -= 1;
297 }
298 pos
299}
300
301fn char_boundary_rev(s: &str, distance: usize) -> usize {
303 if distance >= s.len() {
304 return 0;
305 }
306 let mut pos = s.len() - distance;
307 while pos < s.len() && !s.is_char_boundary(pos) {
308 pos += 1;
309 }
310 pos
311}
312
313fn has_tool_uses(blocks: &[ApiContentBlock]) -> bool {
316 blocks
317 .iter()
318 .any(|b| matches!(b, ApiContentBlock::ToolUse { .. }))
319}
320
321fn has_tool_results(blocks: &[ApiContentBlock]) -> bool {
322 blocks
323 .iter()
324 .any(|b| matches!(b, ApiContentBlock::ToolResult { .. }))
325}
326
327fn extract_text(content: &[ApiContentBlock]) -> Result<String> {
328 let text: String = content
329 .iter()
330 .filter_map(|b| match b {
331 ApiContentBlock::Text { text, .. } => Some(text.as_str()),
332 _ => None,
333 })
334 .collect::<Vec<_>>()
335 .join("");
336
337 if text.is_empty() {
338 Err(crate::error::AgentError::Api(
339 "Compaction response contained no text".into(),
340 ))
341 } else {
342 debug!(summary_len = text.len(), "Generated compaction summary");
343 Ok(text)
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn text_msg(role: &str, text: &str) -> ApiMessage {
352 ApiMessage {
353 role: role.to_string(),
354 content: vec![ApiContentBlock::Text {
355 text: text.to_string(),
356 cache_control: None,
357 }],
358 }
359 }
360
361 fn tool_use_msg() -> ApiMessage {
362 ApiMessage {
363 role: "assistant".to_string(),
364 content: vec![
365 ApiContentBlock::Text {
366 text: "Let me check.".to_string(),
367 cache_control: None,
368 },
369 ApiContentBlock::ToolUse {
370 id: "tu_1".to_string(),
371 name: "Bash".to_string(),
372 input: serde_json::json!({"command": "ls"}),
373 },
374 ],
375 }
376 }
377
378 fn tool_result_msg() -> ApiMessage {
379 ApiMessage {
380 role: "user".to_string(),
381 content: vec![ApiContentBlock::ToolResult {
382 tool_use_id: "tu_1".to_string(),
383 content: serde_json::json!("file1.rs\nfile2.rs"),
384 is_error: None,
385 cache_control: None,
386 name: None,
387 }],
388 }
389 }
390
391 #[test]
392 fn test_should_compact_threshold() {
393 assert!(!should_compact(150_000, 160_000));
394 assert!(should_compact(170_000, 160_000));
395 assert!(should_compact(160_001, 160_000));
396 assert!(!should_compact(160_000, 160_000));
397 }
398
399 #[test]
400 fn test_find_split_point_preserves_recent() {
401 let conv: Vec<ApiMessage> = (0..10)
402 .map(|i| {
403 let role = if i % 2 == 0 { "user" } else { "assistant" };
404 text_msg(role, &format!("message {i}"))
405 })
406 .collect();
407
408 let split = find_split_point(&conv, DEFAULT_MIN_KEEP_MESSAGES);
409 assert!(conv.len() - split >= DEFAULT_MIN_KEEP_MESSAGES);
411 assert_eq!(split, 6); }
413
414 #[test]
415 fn test_find_split_point_respects_tool_boundaries() {
416 let conv = vec![
422 text_msg("user", "hello"), text_msg("assistant", "hi"), text_msg("user", "do something"), tool_use_msg(), tool_result_msg(), text_msg("assistant", "done"), text_msg("user", "thanks"), ];
430
431 let split = find_split_point(&conv, DEFAULT_MIN_KEEP_MESSAGES);
432 assert_eq!(split, 3);
436 }
437
438 #[test]
439 fn test_find_split_point_moves_back_when_splitting_tool_cycle() {
440 let conv = vec![
445 text_msg("user", "start"), tool_use_msg(), tool_result_msg(), text_msg("assistant", "ok"), text_msg("user", "next"), ];
451 let split = find_split_point(&conv, DEFAULT_MIN_KEEP_MESSAGES);
452 assert_eq!(split, 1);
453
454 let conv2 = vec![
456 text_msg("user", "start"), text_msg("assistant", "ack"), tool_result_msg(), text_msg("assistant", "done"), text_msg("user", "q1"), text_msg("assistant", "a1"), ];
463 let split2 = find_split_point(&conv2, DEFAULT_MIN_KEEP_MESSAGES);
464 assert_eq!(split2, 2);
467 }
468
469 #[test]
470 fn test_find_split_point_too_short() {
471 let conv = vec![
472 text_msg("user", "hi"),
473 text_msg("assistant", "hello"),
474 text_msg("user", "bye"),
475 ];
476 assert_eq!(find_split_point(&conv, DEFAULT_MIN_KEEP_MESSAGES), 0);
477 }
478
479 #[test]
480 fn test_splice_conversation() {
481 let mut conv: Vec<ApiMessage> = (0..10)
482 .map(|i| {
483 let role = if i % 2 == 0 { "user" } else { "assistant" };
484 text_msg(role, &format!("msg {i}"))
485 })
486 .collect();
487
488 splice_conversation(&mut conv, 6, "Summary of messages 0-5");
489
490 assert_eq!(conv.len(), 5);
492 match &conv[0].content[0] {
494 ApiContentBlock::Text { text, .. } => {
495 assert!(text.contains("Summary of messages 0-5"));
496 assert!(text.contains("[Previous conversation summary]"));
497 }
498 _ => panic!("Expected text block"),
499 }
500 match &conv[1].content[0] {
502 ApiContentBlock::Text { text, .. } => assert_eq!(text, "msg 6"),
503 _ => panic!("Expected text block"),
504 }
505 }
506
507 #[test]
508 fn test_find_split_point_custom_min_keep() {
509 let conv: Vec<ApiMessage> = (0..10)
510 .map(|i| {
511 let role = if i % 2 == 0 { "user" } else { "assistant" };
512 text_msg(role, &format!("message {i}"))
513 })
514 .collect();
515
516 assert_eq!(find_split_point(&conv, 2), 8);
518
519 assert_eq!(find_split_point(&conv, 6), 4);
521
522 assert_eq!(find_split_point(&conv, 1), 9);
524 }
525
526 #[test]
527 fn test_build_summary_prompt_format() {
528 let msgs = vec![
529 text_msg("user", "Tell me about Rust"),
530 text_msg("assistant", "Rust is a systems language."),
531 ];
532
533 let prompt = build_summary_prompt(&msgs);
534 assert!(prompt.contains("Summarize the following"));
535 assert!(prompt.contains("[user]"));
536 assert!(prompt.contains("Tell me about Rust"));
537 assert!(prompt.contains("[assistant]"));
538 assert!(prompt.contains("Rust is a systems language."));
539 assert!(prompt.contains("<conversation>"));
540 }
541
542 #[test]
545 fn test_should_prune_threshold() {
546 assert!(!should_prune(100_000, 160_000, 70));
548 assert!(should_prune(120_000, 160_000, 70));
549 assert!(!should_prune(112_000, 160_000, 70));
550 assert!(should_prune(112_001, 160_000, 70));
551 }
552
553 fn large_tool_result_msg(size: usize) -> ApiMessage {
556 ApiMessage {
557 role: "user".to_string(),
558 content: vec![ApiContentBlock::ToolResult {
559 tool_use_id: "tu_big".to_string(),
560 content: serde_json::json!("x".repeat(size)),
561 is_error: None,
562 cache_control: None,
563 name: None,
564 }],
565 }
566 }
567
568 #[test]
569 fn prune_truncates_large_tool_results() {
570 let mut conv = vec![
571 text_msg("user", "start"),
572 tool_use_msg(),
573 large_tool_result_msg(5000),
574 text_msg("assistant", "ok"),
575 text_msg("user", "next"),
576 ];
577
578 let removed = prune_tool_results(&mut conv, 2000, 2);
579 assert!(removed > 0, "Should have pruned chars");
580
581 if let ApiContentBlock::ToolResult { content, .. } = &conv[2].content[0] {
583 let text = content.as_str().unwrap();
584 assert!(text.contains("[..."), "Should contain prune marker");
585 assert!(text.len() < 5000, "Should be smaller than original");
586 } else {
587 panic!("Expected tool result");
588 }
589 }
590
591 #[test]
592 fn prune_preserves_small_tool_results() {
593 let mut conv = vec![
594 text_msg("user", "start"),
595 tool_use_msg(),
596 tool_result_msg(), text_msg("assistant", "ok"),
598 text_msg("user", "next"),
599 ];
600
601 let removed = prune_tool_results(&mut conv, 2000, 2);
602 assert_eq!(removed, 0, "Small results should not be pruned");
603 }
604
605 #[test]
606 fn prune_skips_tail_messages() {
607 let mut conv = vec![
608 text_msg("user", "old"),
609 text_msg("assistant", "old reply"),
610 tool_use_msg(),
611 large_tool_result_msg(5000), text_msg("assistant", "done"),
613 ];
614
615 let removed = prune_tool_results(&mut conv, 2000, 2);
617 assert_eq!(removed, 0, "Tail messages should not be pruned");
618 }
619
620 #[test]
621 fn prune_handles_empty_conversation() {
622 let mut conv: Vec<ApiMessage> = vec![];
623 let removed = prune_tool_results(&mut conv, 2000, 2);
624 assert_eq!(removed, 0);
625 }
626
627 #[test]
628 fn prune_multiple_large_tool_results() {
629 let mut conv = vec![
630 text_msg("user", "q1"),
631 tool_use_msg(),
632 large_tool_result_msg(5000), text_msg("assistant", "a1"),
634 text_msg("user", "q2"),
635 tool_use_msg(),
636 large_tool_result_msg(8000), text_msg("assistant", "a2"),
638 text_msg("user", "latest"), text_msg("assistant", "done"), ];
641
642 let removed = prune_tool_results(&mut conv, 2000, 2);
643 assert!(removed > 0, "Should have pruned chars");
644
645 for idx in [2, 6] {
647 if let ApiContentBlock::ToolResult { content, .. } = &conv[idx].content[0] {
648 let text = content.as_str().unwrap();
649 assert!(text.contains("[..."), "Index {} should be pruned", idx);
650 }
651 }
652 }
653
654 #[test]
655 fn prune_all_messages_in_tail() {
656 let mut conv = vec![
657 text_msg("user", "hello"),
658 tool_use_msg(),
659 large_tool_result_msg(5000),
660 ];
661
662 let removed = prune_tool_results(&mut conv, 2000, 10);
664 assert_eq!(removed, 0, "All messages in tail — nothing to prune");
665 }
666}