1use std::rc::Rc;
4
5use crate::llm::{vm_call_llm_full, vm_value_to_json};
6use crate::value::{VmError, VmValue};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum CompactStrategy {
10 Llm,
11 Truncate,
12 Custom,
13 ObservationMask,
14}
15
16pub fn parse_compact_strategy(value: &str) -> Result<CompactStrategy, VmError> {
17 match value {
18 "llm" => Ok(CompactStrategy::Llm),
19 "truncate" => Ok(CompactStrategy::Truncate),
20 "custom" => Ok(CompactStrategy::Custom),
21 "observation_mask" => Ok(CompactStrategy::ObservationMask),
22 other => Err(VmError::Runtime(format!(
23 "unknown compact_strategy '{other}' (expected 'llm', 'truncate', 'custom', or 'observation_mask')"
24 ))),
25 }
26}
27
28#[derive(Clone, Debug)]
38pub struct AutoCompactConfig {
39 pub token_threshold: usize,
41 pub tool_output_max_chars: usize,
43 pub keep_last: usize,
45 pub compact_strategy: CompactStrategy,
47 pub hard_limit_tokens: Option<usize>,
51 pub hard_limit_strategy: CompactStrategy,
53 pub custom_compactor: Option<VmValue>,
55 pub mask_callback: Option<VmValue>,
62 pub compress_callback: Option<VmValue>,
68}
69
70impl Default for AutoCompactConfig {
71 fn default() -> Self {
72 Self {
73 token_threshold: 48_000,
74 tool_output_max_chars: 16_000,
75 keep_last: 12,
76 compact_strategy: CompactStrategy::ObservationMask,
77 hard_limit_tokens: None,
78 hard_limit_strategy: CompactStrategy::Llm,
79 custom_compactor: None,
80 mask_callback: None,
81 compress_callback: None,
82 }
83 }
84}
85
86pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
88 messages
89 .iter()
90 .map(|m| {
91 m.get("content")
92 .and_then(|c| c.as_str())
93 .map(|s| s.len())
94 .unwrap_or(0)
95 })
96 .sum::<usize>()
97 / 4
98}
99
100fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
101 let role = message
102 .get("role")
103 .and_then(|value| value.as_str())
104 .unwrap_or_default();
105 role == "tool"
106 || message.get("tool_calls").is_some()
107 || message
108 .get("reasoning")
109 .map(|value| !value.is_null())
110 .unwrap_or(false)
111}
112
113fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
114 (0..=start)
115 .rev()
116 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
117}
118
119pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
122 if output.len() <= max_chars || max_chars < 200 {
123 return output.to_string();
124 }
125 let diagnostic_lines = output
126 .lines()
127 .filter(|line| {
128 let trimmed = line.trim();
129 let lower = trimmed.to_lowercase();
130 let has_file_line = {
131 let bytes = trimmed.as_bytes();
132 let mut i = 0;
133 let mut found_colon = false;
134 while i < bytes.len() {
135 if bytes[i] == b':' {
136 found_colon = true;
137 break;
138 }
139 i += 1;
140 }
141 found_colon && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
142 };
143 let has_strong_keyword =
144 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
145 let has_weak_keyword = trimmed.contains("error")
146 || trimmed.contains("undefined")
147 || trimmed.contains("expected")
148 || trimmed.contains("got")
149 || lower.contains("cannot find")
150 || lower.contains("not found")
151 || lower.contains("no such")
152 || lower.contains("unresolved")
153 || lower.contains("missing")
154 || lower.contains("declared but not used")
155 || lower.contains("unused")
156 || lower.contains("mismatch");
157 let positional = lower.contains(" error ")
158 || lower.starts_with("error:")
159 || lower.starts_with("warning:")
160 || lower.starts_with("note:")
161 || lower.contains("panic:");
162 has_strong_keyword || (has_file_line && has_weak_keyword) || positional
163 })
164 .take(32)
165 .collect::<Vec<_>>();
166 if !diagnostic_lines.is_empty() {
167 let diagnostics = diagnostic_lines.join("\n");
168 let budget = max_chars.saturating_sub(diagnostics.len() + 64);
169 let keep = budget / 2;
170 if keep >= 80 && output.len() > keep * 2 {
171 let head = snap_to_line_end(output, keep);
172 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
173 return format!(
174 "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
175 );
176 }
177 }
178 let keep = max_chars / 2;
179 let head = snap_to_line_end(output, keep);
180 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
181 let snipped = output.len().saturating_sub(head.len() + tail.len());
182 format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
183}
184
185pub(crate) async fn invoke_compress_callback(
189 callback: &VmValue,
190 tool_name: &str,
191 output: &str,
192 max_chars: usize,
193) -> String {
194 let VmValue::Closure(closure) = callback.clone() else {
195 return microcompact_tool_output(output, max_chars);
196 };
197 let mut vm = match crate::vm::clone_async_builtin_child_vm() {
198 Some(vm) => vm,
199 None => return microcompact_tool_output(output, max_chars),
200 };
201 let args_dict = VmValue::Dict(Rc::new({
202 let mut dict = std::collections::BTreeMap::new();
203 dict.insert(
204 "tool_name".to_string(),
205 VmValue::String(Rc::from(tool_name)),
206 );
207 dict.insert("output".to_string(), VmValue::String(Rc::from(output)));
208 dict.insert("max_chars".to_string(), VmValue::Int(max_chars as i64));
209 dict
210 }));
211 match vm.call_closure_pub(&closure, &[args_dict], &[]).await {
212 Ok(VmValue::String(s)) if !s.is_empty() => s.to_string(),
213 _ => microcompact_tool_output(output, max_chars),
214 }
215}
216
217fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
221 if max_bytes >= s.len() {
222 return s;
223 }
224 let search_end = s.floor_char_boundary(max_bytes);
225 match s[..search_end].rfind('\n') {
226 Some(pos) => &s[..pos + 1],
227 None => &s[..search_end], }
229}
230
231fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
235 if start_byte == 0 {
236 return s;
237 }
238 let search_start = s.ceil_char_boundary(start_byte);
239 if search_start >= s.len() {
240 return "";
241 }
242 match s[search_start..].find('\n') {
243 Some(pos) => {
244 let line_start = search_start + pos + 1;
245 if line_start < s.len() {
246 &s[line_start..]
247 } else {
248 &s[search_start..]
249 }
250 }
251 None => &s[search_start..], }
253}
254
255fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
256 messages
257 .iter()
258 .map(|msg| {
259 let role = msg
260 .get("role")
261 .and_then(|v| v.as_str())
262 .unwrap_or("user")
263 .to_uppercase();
264 let content = msg
265 .get("content")
266 .and_then(|v| v.as_str())
267 .unwrap_or_default();
268 format!("{role}: {content}")
269 })
270 .collect::<Vec<_>>()
271 .join("\n")
272}
273
274fn truncate_compaction_summary(
275 old_messages: &[serde_json::Value],
276 archived_count: usize,
277) -> String {
278 truncate_compaction_summary_with_context(old_messages, archived_count, false)
279}
280
281fn truncate_compaction_summary_with_context(
282 old_messages: &[serde_json::Value],
283 archived_count: usize,
284 is_llm_fallback: bool,
285) -> String {
286 let per_msg_limit = 500_usize;
287 let summary_parts: Vec<String> = old_messages
288 .iter()
289 .filter_map(|m| {
290 let role = m.get("role")?.as_str()?;
291 let content = m.get("content")?.as_str()?;
292 if content.is_empty() {
293 return None;
294 }
295 let truncated = if content.len() > per_msg_limit {
296 format!(
297 "{}... [truncated from {} chars]",
298 &content[..content.floor_char_boundary(per_msg_limit)],
299 content.len()
300 )
301 } else {
302 content.to_string()
303 };
304 Some(format!("[{role}] {truncated}"))
305 })
306 .take(15)
307 .collect();
308 let header = if is_llm_fallback {
309 format!(
310 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
311 )
312 } else {
313 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
314 };
315 format!(
316 "{header}\n{}{}",
317 summary_parts.join("\n"),
318 if archived_count > 15 {
319 format!("\n... and {} more", archived_count - 15)
320 } else {
321 String::new()
322 }
323 )
324}
325
326fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
327 if let Some(map) = value.as_dict() {
328 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
329 return Ok(summary.display());
330 }
331 }
332 match value {
333 VmValue::String(text) => Ok(text.to_string()),
334 VmValue::Nil => Ok(String::new()),
335 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
336 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
337 }
338}
339
340async fn llm_compaction_summary(
341 old_messages: &[serde_json::Value],
342 archived_count: usize,
343 llm_opts: &crate::llm::api::LlmCallOptions,
344) -> Result<String, VmError> {
345 let mut compact_opts = llm_opts.clone();
346 let formatted = format_compaction_messages(old_messages);
347 compact_opts.system = None;
348 compact_opts.transcript_id = None;
349 compact_opts.transcript_summary = None;
350 compact_opts.transcript_metadata = None;
351 compact_opts.native_tools = None;
352 compact_opts.tool_choice = None;
353 compact_opts.response_format = None;
354 compact_opts.json_schema = None;
355 compact_opts.messages = vec![serde_json::json!({
356 "role": "user",
357 "content": format!(
358 "Summarize these archived conversation messages for a follow-on coding agent. Preserve goals, constraints, decisions, completed tool work, unresolved issues, and next actions. Output only the summary text.\n\nArchived message count: {archived_count}\n\nConversation:\n{formatted}"
359 ),
360 })];
361 let result = vm_call_llm_full(&compact_opts).await?;
362 let summary = result.text.trim();
363 if summary.is_empty() {
364 Ok(truncate_compaction_summary_with_context(
365 old_messages,
366 archived_count,
367 true,
368 ))
369 } else {
370 Ok(format!(
371 "[auto-compacted {archived_count} older messages]\n{summary}"
372 ))
373 }
374}
375
376async fn custom_compaction_summary(
377 old_messages: &[serde_json::Value],
378 archived_count: usize,
379 callback: &VmValue,
380) -> Result<String, VmError> {
381 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
382 return Err(VmError::Runtime(
383 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
384 ));
385 };
386 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
387 VmError::Runtime(
388 "custom transcript compaction requires an async builtin VM context".to_string(),
389 )
390 })?;
391 let messages_vm = VmValue::List(Rc::new(
392 old_messages
393 .iter()
394 .map(crate::stdlib::json_to_vm_value)
395 .collect(),
396 ));
397 let result = vm.call_closure_pub(&closure, &[messages_vm], &[]).await;
398 let summary = compact_summary_text_from_value(&result?)?;
399 if summary.trim().is_empty() {
400 Ok(truncate_compaction_summary(old_messages, archived_count))
401 } else {
402 Ok(format!(
403 "[auto-compacted {archived_count} older messages]\n{summary}"
404 ))
405 }
406}
407
408fn content_should_preserve(content: &str) -> bool {
414 content.len() < 500
415}
416
417fn default_mask_tool_result(role: &str, content: &str) -> String {
419 let first_line = content.lines().next().unwrap_or(content);
420 let line_count = content.lines().count();
421 let char_count = content.len();
422 if line_count <= 3 {
423 format!("[{role}] {content}")
424 } else {
425 let preview = &first_line[..first_line.len().min(120)];
426 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
427 }
428}
429
430#[cfg(test)]
432pub(crate) fn observation_mask_compaction(
433 old_messages: &[serde_json::Value],
434 archived_count: usize,
435) -> String {
436 observation_mask_compaction_with_callback(old_messages, archived_count, None)
437}
438
439fn observation_mask_compaction_with_callback(
440 old_messages: &[serde_json::Value],
441 archived_count: usize,
442 mask_results: Option<&[Option<String>]>,
443) -> String {
444 let mut parts = Vec::new();
445 parts.push(format!(
446 "[auto-compacted {archived_count} older messages via observation masking]"
447 ));
448 for (idx, msg) in old_messages.iter().enumerate() {
449 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
450 let content = msg
451 .get("content")
452 .and_then(|v| v.as_str())
453 .unwrap_or_default();
454 if content.is_empty() {
455 continue;
456 }
457 if role == "assistant" {
458 parts.push(format!("[assistant] {content}"));
459 continue;
460 }
461 if content_should_preserve(content) {
462 parts.push(format!("[{role}] {content}"));
463 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
464 parts.push(custom.clone());
465 } else {
466 parts.push(default_mask_tool_result(role, content));
467 }
468 }
469 parts.join("\n")
470}
471
472async fn invoke_mask_callback(
474 callback: &VmValue,
475 old_messages: &[serde_json::Value],
476) -> Result<Vec<Option<String>>, VmError> {
477 let VmValue::Closure(closure) = callback.clone() else {
478 return Err(VmError::Runtime(
479 "mask_callback must be a closure".to_string(),
480 ));
481 };
482 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
483 VmError::Runtime("mask_callback requires an async builtin VM context".to_string())
484 })?;
485 let messages_vm = VmValue::List(Rc::new(
486 old_messages
487 .iter()
488 .map(crate::stdlib::json_to_vm_value)
489 .collect(),
490 ));
491 let result = vm.call_closure_pub(&closure, &[messages_vm], &[]).await?;
492 let list = match result {
493 VmValue::List(items) => items,
494 _ => return Ok(vec![None; old_messages.len()]),
495 };
496 Ok(list
497 .iter()
498 .map(|v| match v {
499 VmValue::String(s) => Some(s.to_string()),
500 VmValue::Nil => None,
501 _ => None,
502 })
503 .collect())
504}
505
506async fn apply_compaction_strategy(
508 strategy: &CompactStrategy,
509 old_messages: &[serde_json::Value],
510 archived_count: usize,
511 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
512 custom_compactor: Option<&VmValue>,
513 mask_callback: Option<&VmValue>,
514) -> Result<String, VmError> {
515 match strategy {
516 CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
517 CompactStrategy::Llm => {
518 llm_compaction_summary(
519 old_messages,
520 archived_count,
521 llm_opts.ok_or_else(|| {
522 VmError::Runtime(
523 "LLM transcript compaction requires active LLM call options".to_string(),
524 )
525 })?,
526 )
527 .await
528 }
529 CompactStrategy::Custom => {
530 custom_compaction_summary(
531 old_messages,
532 archived_count,
533 custom_compactor.ok_or_else(|| {
534 VmError::Runtime(
535 "compact_callback is required when compact_strategy is 'custom'"
536 .to_string(),
537 )
538 })?,
539 )
540 .await
541 }
542 CompactStrategy::ObservationMask => {
543 let mask_results = if let Some(cb) = mask_callback {
544 Some(invoke_mask_callback(cb, old_messages).await?)
545 } else {
546 None
547 };
548 Ok(observation_mask_compaction_with_callback(
549 old_messages,
550 archived_count,
551 mask_results.as_deref(),
552 ))
553 }
554 }
555}
556
557pub(crate) async fn auto_compact_messages(
559 messages: &mut Vec<serde_json::Value>,
560 config: &AutoCompactConfig,
561 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
562) -> Result<Option<String>, VmError> {
563 if messages.len() <= config.keep_last {
564 return Ok(None);
565 }
566 let original_split = messages.len().saturating_sub(config.keep_last);
567 let mut split_at = original_split;
568 while split_at > 0
572 && messages[split_at]
573 .get("role")
574 .and_then(|r| r.as_str())
575 .is_none_or(|r| r != "user")
576 {
577 split_at -= 1;
578 }
579 if split_at == 0 {
582 split_at = original_split;
583 }
584 if let Some(volatile_start) = messages[split_at..]
585 .iter()
586 .position(is_reasoning_or_tool_turn_message)
587 .map(|offset| split_at + offset)
588 {
589 if let Some(boundary) = volatile_start
590 .checked_sub(1)
591 .and_then(|idx| find_prev_user_boundary(messages, idx))
592 .filter(|boundary| *boundary > 0)
593 {
594 split_at = boundary;
595 }
596 }
597 if split_at == 0 {
598 return Ok(None);
599 }
600 let old_messages: Vec<_> = messages.drain(..split_at).collect();
601 let archived_count = old_messages.len();
602
603 let mut summary = apply_compaction_strategy(
604 &config.compact_strategy,
605 &old_messages,
606 archived_count,
607 llm_opts,
608 config.custom_compactor.as_ref(),
609 config.mask_callback.as_ref(),
610 )
611 .await?;
612
613 if let Some(hard_limit) = config.hard_limit_tokens {
614 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
615 let mut estimate_msgs = vec![summary_msg];
616 estimate_msgs.extend_from_slice(messages.as_slice());
617 let estimated = estimate_message_tokens(&estimate_msgs);
618 if estimated > hard_limit {
619 let tier1_as_messages = vec![serde_json::json!({
620 "role": "user",
621 "content": summary,
622 })];
623 summary = apply_compaction_strategy(
624 &config.hard_limit_strategy,
625 &tier1_as_messages,
626 archived_count,
627 llm_opts,
628 config.custom_compactor.as_ref(),
629 None,
630 )
631 .await?;
632 }
633 }
634
635 messages.insert(
636 0,
637 serde_json::json!({
638 "role": "user",
639 "content": summary,
640 }),
641 );
642 Ok(Some(summary))
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn microcompact_short_output_unchanged() {
651 let output = "line1\nline2\nline3\n";
652 assert_eq!(microcompact_tool_output(output, 1000), output);
653 }
654
655 #[test]
656 fn microcompact_snaps_to_line_boundaries() {
657 let lines: Vec<String> = (0..20)
658 .map(|i| format!("line {:02} content here", i))
659 .collect();
660 let output = lines.join("\n");
661 let result = microcompact_tool_output(&output, 200);
662 assert!(result.contains("[... "), "should have snip marker");
663 let parts: Vec<&str> = result.split("\n\n[... ").collect();
664 assert!(parts.len() >= 2, "should split at marker");
665 let head = parts[0];
666 for line in head.lines() {
667 assert!(
668 line.starts_with("line "),
669 "head line should be complete: {line}"
670 );
671 }
672 }
673
674 #[test]
675 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
676 let mut lines = Vec::new();
677 for i in 0..50 {
678 lines.push(format!("verbose output line {i}"));
679 }
680 lines.push("src/main.rs:42: error: cannot find value".to_string());
681 for i in 50..100 {
682 lines.push(format!("verbose output line {i}"));
683 }
684 let output = lines.join("\n");
685 let result = microcompact_tool_output(&output, 600);
686 assert!(result.contains("cannot find value"), "diagnostic preserved");
687 assert!(
688 result.contains("[diagnostic lines preserved]"),
689 "has diagnostic marker"
690 );
691 }
692
693 #[test]
694 fn snap_to_line_end_finds_newline() {
695 let s = "line1\nline2\nline3\nline4\n";
696 let head = snap_to_line_end(s, 12);
697 assert!(head.ends_with('\n'), "should end at newline");
698 assert!(head.contains("line1"));
699 }
700
701 #[test]
702 fn snap_to_line_start_finds_newline() {
703 let s = "line1\nline2\nline3\nline4\n";
704 let tail = snap_to_line_start(s, 12);
705 assert!(
706 tail.starts_with("line"),
707 "should start at line boundary: {tail}"
708 );
709 }
710
711 #[test]
712 fn auto_compact_preserves_reasoning_tool_suffix() {
713 let mut messages = vec![
714 serde_json::json!({"role": "user", "content": "old task"}),
715 serde_json::json!({"role": "assistant", "content": "old reply"}),
716 serde_json::json!({"role": "user", "content": "new task"}),
717 serde_json::json!({
718 "role": "assistant",
719 "content": "",
720 "reasoning": "think first",
721 "tool_calls": [{
722 "id": "call_1",
723 "type": "function",
724 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
725 }],
726 }),
727 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
728 ];
729 let config = AutoCompactConfig {
730 keep_last: 2,
731 ..Default::default()
732 };
733
734 let runtime = tokio::runtime::Builder::new_current_thread()
735 .enable_all()
736 .build()
737 .expect("runtime");
738 let summary = runtime
739 .block_on(auto_compact_messages(&mut messages, &config, None))
740 .expect("compaction succeeds");
741
742 assert!(summary.is_some());
743 assert_eq!(messages[1]["role"], "user");
744 assert_eq!(messages[2]["role"], "assistant");
745 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
746 assert_eq!(messages[3]["role"], "tool");
747 assert_eq!(messages[3]["tool_call_id"], "call_1");
748 }
749}