1use crate::llm::{vm_call_llm_full, vm_value_to_json};
4use crate::value::{VmError, VmValue};
5
6mod config;
7mod policy;
8mod prompt;
9mod tool_output;
10use crate::vm::AsyncBuiltinCtx;
11pub use config::{
12 compact_strategy_name, parse_compact_strategy, AutoCompactConfig, CompactStrategy,
13 CompactionRequestProvenance, CompactionThresholdSource, DEFAULT_RECAP_BUDGET_BYTES,
14};
15pub use policy::{
16 compaction_policy_metadata_fields, compaction_policy_option_keys,
17 compaction_policy_to_vm_value, parse_compaction_policy_options, CompactionPolicy,
18 CompactionRequest,
19};
20use prompt::render_llm_compaction_prompt;
21pub use tool_output::{
22 microcompact_tool_output, microcompact_tool_output_result, MicrocompactedToolOutput,
23};
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct RecapMetrics {
30 pub recap_bytes: usize,
31 pub budget_bytes: usize,
32 pub kept_results_count: usize,
33 pub dropped_count: usize,
34 pub carried_prior_recap: bool,
35}
36
37impl RecapMetrics {
38 pub fn to_json(self) -> serde_json::Value {
39 serde_json::json!({
40 "recap_bytes": self.recap_bytes,
41 "budget_bytes": self.budget_bytes,
42 "kept_results_count": self.kept_results_count,
43 "dropped_count": self.dropped_count,
44 "carried_prior_recap": self.carried_prior_recap,
45 })
46 }
47}
48
49pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
51 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
52}
53
54fn estimate_message_chars(message: &serde_json::Value) -> usize {
55 let mut total = message
56 .get("content")
57 .map(estimate_content_chars)
58 .unwrap_or_default();
59 if let Some(reasoning) = message.get("reasoning") {
60 total += estimate_content_chars(reasoning);
61 }
62 if let Some(tool_calls) = message.get("tool_calls") {
63 total += estimate_content_chars(tool_calls);
64 }
65 total
66}
67
68fn estimate_content_chars(value: &serde_json::Value) -> usize {
69 match value {
70 serde_json::Value::String(text) => text.len(),
71 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
72 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
73 serde_json::Value::Null => 0,
74 other => other.to_string().len(),
75 }
76}
77
78fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
79 let role = message
80 .get("role")
81 .and_then(|value| value.as_str())
82 .unwrap_or_default();
83 role == "tool"
84 || message.get("tool_calls").is_some()
85 || message
86 .get("reasoning")
87 .map(|value| !value.is_null())
88 .unwrap_or(false)
89}
90
91fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
92 (0..=start)
93 .rev()
94 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
95}
96
97fn is_tool_result_message(message: &serde_json::Value) -> bool {
103 match message.get("role").and_then(|role| role.as_str()) {
104 Some("tool") | Some("tool_result") => true,
105 Some("user") => message
106 .get("content")
107 .and_then(|content| content.as_array())
108 .is_some_and(|blocks| {
109 blocks.iter().any(|block| {
110 block.get("type").and_then(|value| value.as_str()) == Some("tool_result")
111 })
112 }),
113 _ => false,
114 }
115}
116
117fn snap_split_off_tool_results(
127 messages: &[serde_json::Value],
128 split_at: usize,
129 compact_start: usize,
130) -> usize {
131 if split_at >= messages.len() || !is_tool_result_message(&messages[split_at]) {
132 return split_at;
133 }
134 let mut backward = split_at;
135 while backward > compact_start && is_tool_result_message(&messages[backward]) {
136 backward -= 1;
137 }
138 if backward > compact_start {
139 return backward;
140 }
141 let mut forward = split_at;
142 while forward < messages.len() && is_tool_result_message(&messages[forward]) {
143 forward += 1;
144 }
145 forward
146}
147
148fn line_has_file_line_prefix(trimmed: &str) -> bool {
152 let bytes = trimmed.as_bytes();
153 let mut i = 0;
154 while i < bytes.len() && bytes[i] != b':' {
155 i += 1;
156 }
157 i < bytes.len() && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
158}
159
160pub(super) fn is_failure_signal_line(line: &str) -> bool {
174 let trimmed = line.trim();
175 if trimmed.is_empty() {
176 return false;
177 }
178 let lower = trimmed.to_lowercase();
179
180 let has_file_line = line_has_file_line_prefix(trimmed);
181 let has_strong_keyword =
182 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
183 let has_weak_keyword = trimmed.contains("error")
184 || trimmed.contains("undefined")
185 || trimmed.contains("expected")
186 || trimmed.contains("got")
187 || lower.contains("cannot find")
188 || lower.contains("not found")
189 || lower.contains("no such")
190 || lower.contains("unresolved")
191 || lower.contains("missing")
192 || lower.contains("declared but not used")
193 || lower.contains("unused")
194 || lower.contains("mismatch");
195 let positional = lower.contains(" error ")
196 || lower.starts_with("error:")
197 || lower.starts_with("warning:")
198 || lower.starts_with("note:")
199 || lower.contains("panic:");
200
201 let assertion_value = lower.starts_with("left:")
202 || lower.starts_with("right:")
203 || lower.starts_with("expected:")
204 || lower.starts_with("actual:")
205 || lower.starts_with("got:")
206 || lower.starts_with("want:")
207 || lower.starts_with("got ")
208 || lower.starts_with("want ")
209 || lower.starts_with("assertion")
210 || lower.contains("assertionerror");
211
212 let rustc_continuation = trimmed.starts_with("-->")
215 || trimmed.starts_with("= help:")
216 || trimmed.starts_with("= note:")
217 || trimmed.contains('^')
218 || {
219 let mut chars = trimmed.chars();
221 let mut saw_digit = false;
222 let mut rest = trimmed;
223 while let Some(c) = chars.clone().next() {
224 if c.is_ascii_digit() {
225 saw_digit = true;
226 chars.next();
227 rest = chars.as_str();
228 } else {
229 break;
230 }
231 }
232 saw_digit && rest.trim_start().starts_with('|')
233 };
234
235 #[expect(
236 clippy::string_slice,
237 reason = "digits is an ASCII-digit prefix of rest, so its len is a boundary"
238 )]
239 let failing_line_marker = {
240 if let Some(rest) = trimmed.strip_prefix('L') {
241 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
242 !digits.is_empty() && rest[digits.len()..].starts_with(':')
243 } else {
244 false
245 }
246 };
247
248 has_strong_keyword
249 || (has_file_line && has_weak_keyword)
250 || positional
251 || assertion_value
252 || rustc_continuation
253 || failing_line_marker
254}
255
256#[expect(
260 clippy::string_slice,
261 reason = "search_end is a floor_char_boundary; pos indexes an ASCII newline"
262)]
263fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
264 if max_bytes >= s.len() {
265 return s;
266 }
267 let search_end = s.floor_char_boundary(max_bytes);
268 match s[..search_end].rfind('\n') {
269 Some(pos) => &s[..pos + 1],
270 None => &s[..search_end], }
272}
273
274#[expect(
278 clippy::string_slice,
279 reason = "search_start is a ceil_char_boundary; line_start follows an ASCII newline"
280)]
281fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
282 if start_byte == 0 {
283 return s;
284 }
285 let search_start = s.ceil_char_boundary(start_byte);
286 if search_start >= s.len() {
287 return "";
288 }
289 match s[search_start..].find('\n') {
290 Some(pos) => {
291 let line_start = search_start + pos + 1;
292 if line_start < s.len() {
293 &s[line_start..]
294 } else {
295 &s[search_start..]
296 }
297 }
298 None => &s[search_start..], }
300}
301
302#[derive(Debug)]
312pub(crate) struct CompactionSummary {
313 pub text: String,
314 pub scaffold_bytes: usize,
315}
316
317impl CompactionSummary {
318 fn new(text: String, scaffold_bytes: usize) -> Self {
319 let scaffold_bytes = scaffold_bytes.min(text.len());
320 Self {
321 text,
322 scaffold_bytes,
323 }
324 }
325
326 pub(crate) fn carried_source_bytes(&self) -> usize {
327 self.text.len().saturating_sub(self.scaffold_bytes)
328 }
329}
330
331#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
339#[serde(default)]
340pub struct CompactionSourceMeasurement {
341 pub source_message_count: Option<usize>,
343 pub source_bytes: Option<usize>,
346 pub summary_bytes: Option<usize>,
348 pub carried_source_bytes: Option<usize>,
351}
352
353impl CompactionSourceMeasurement {
354 pub fn discarded_source_context(&self) -> bool {
357 matches!(
358 (self.source_bytes, self.carried_source_bytes),
359 (Some(source), Some(0)) if source > 0
360 )
361 }
362}
363
364fn message_content_bytes(message: &serde_json::Value) -> usize {
367 message
368 .get("content")
369 .map(|content| super::repair_ledger::value_text(content).len())
370 .unwrap_or(0)
371}
372
373fn truncate_compaction_summary(
374 old_messages: &[serde_json::Value],
375 archived_count: usize,
376) -> CompactionSummary {
377 truncate_compaction_summary_with_context(old_messages, archived_count, false)
378}
379
380fn truncate_compaction_summary_with_context(
381 old_messages: &[serde_json::Value],
382 archived_count: usize,
383 is_llm_fallback: bool,
384) -> CompactionSummary {
385 let per_msg_limit = 500_usize;
386 let mut carried = 0_usize;
391 let summary_parts: Vec<String> = old_messages
392 .iter()
393 .filter_map(|m| {
394 let role = m.get("role")?.as_str()?;
395 let content = super::repair_ledger::value_text(m.get("content")?);
396 if content.is_empty() {
397 return None;
398 }
399 #[expect(
400 clippy::string_slice,
401 reason = "floor_char_boundary returns a char boundary"
402 )]
403 let truncated = if content.len() > per_msg_limit {
404 format!(
405 "{}... [truncated from {} chars]",
406 &content[..content.floor_char_boundary(per_msg_limit)],
407 content.len()
408 )
409 } else {
410 content
411 };
412 carried += truncated.len();
413 Some(format!("[{role}] {truncated}"))
414 })
415 .take(15)
416 .collect();
417 let header = if is_llm_fallback {
418 format!(
419 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
420 )
421 } else {
422 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
423 };
424 let text = format!(
425 "{header}\n{}{}",
426 summary_parts.join("\n"),
427 if archived_count > 15 {
428 format!("\n... and {} more", archived_count - 15)
429 } else {
430 String::new()
431 }
432 );
433 let carried = carried.min(text.len());
436 let scaffold_bytes = text.len() - carried;
437 CompactionSummary::new(text, scaffold_bytes)
438}
439
440fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
441 if let Some(map) = value.as_dict() {
442 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
443 return Ok(summary.display());
444 }
445 }
446 match value {
447 VmValue::String(text) => Ok(text.to_string()),
448 VmValue::Nil => Ok(String::new()),
449 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
450 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
451 }
452}
453
454async fn llm_compaction_summary(
455 old_messages: &[serde_json::Value],
456 retained_messages: &[serde_json::Value],
457 archived_count: usize,
458 llm_opts: &crate::llm::api::LlmCallOptions,
459 summarize_prompt: Option<&str>,
460 policy: &CompactionPolicy,
461) -> Result<CompactionSummary, VmError> {
462 let mut compact_opts = llm_opts.clone();
463 compact_opts.system = None;
464 compact_opts.transcript_summary = None;
465 compact_opts.native_tools = None;
466 compact_opts.tool_choice = None;
467 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
468 compact_opts.output_schema = None;
469 let prompt = render_llm_compaction_prompt(
470 summarize_prompt,
471 old_messages,
472 retained_messages,
473 archived_count,
474 policy,
475 )?;
476 compact_opts.messages = vec![serde_json::json!({
477 "role": "user",
478 "content": prompt,
479 })];
480 let manifest = &mut compact_opts.context_manifest;
481 manifest.record_system_transform("compaction", "stdlib:compaction", "removed system", None);
482 compact_opts.set_call_attribution("compaction", "compact");
483 let result = vm_call_llm_full(&compact_opts).await?;
484 let summary = result.text.trim();
485 if summary.is_empty() {
486 Ok(truncate_compaction_summary_with_context(
489 old_messages,
490 archived_count,
491 true,
492 ))
493 } else {
494 let header = format!("[auto-compacted {archived_count} older messages]\n");
495 let scaffold_bytes = header.len();
496 Ok(CompactionSummary::new(
497 format!("{header}{summary}"),
498 scaffold_bytes,
499 ))
500 }
501}
502
503async fn custom_compaction_summary(
504 ctx: Option<&AsyncBuiltinCtx>,
505 old_messages: &[serde_json::Value],
506 archived_count: usize,
507 callback: &VmValue,
508 reminders: &[VmValue],
509 policy: &CompactionPolicy,
510) -> Result<CompactionSummary, VmError> {
511 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
512 return Err(VmError::Runtime(
513 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
514 ));
515 };
516 let Some(ctx) = ctx else {
517 return Err(VmError::Runtime(
518 "custom transcript compaction requires an async builtin VM context".to_string(),
519 ));
520 };
521 let mut vm = ctx.child_vm();
522 let messages_vm = VmValue::List(std::sync::Arc::new(
523 old_messages
524 .iter()
525 .map(crate::stdlib::json_to_vm_value)
526 .collect(),
527 ));
528 let result = if policy.has_metadata()
529 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
530 {
531 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
532 let policy_vm = compaction_policy_to_vm_value(policy);
533 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
534 .await
535 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
536 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
537 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
538 .await
539 } else {
540 vm.call_closure_pub(&closure, &[messages_vm]).await
541 };
542 let summary = compact_summary_text_from_value(&result?)?;
543 ctx.forward_output(&vm.take_output());
544 if summary.trim().is_empty() {
545 Ok(truncate_compaction_summary(old_messages, archived_count))
547 } else {
548 let header = format!("[auto-compacted {archived_count} older messages]\n");
549 let scaffold_bytes = header.len();
550 Ok(CompactionSummary::new(
551 format!("{header}{summary}"),
552 scaffold_bytes,
553 ))
554 }
555}
556
557pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
565
566pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
575
576fn is_pinned_content(content: &str) -> bool {
578 content.contains(NO_COMPACT_MARKER)
579}
580
581fn latest_pinned_indices<'a, F>(
586 messages: impl Iterator<Item = &'a serde_json::Value>,
587 content_of: F,
588) -> std::collections::HashSet<usize>
589where
590 F: Fn(&serde_json::Value) -> Option<&str>,
591{
592 let pinned: Vec<usize> = messages
594 .enumerate()
595 .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
596 .map(|(idx, _)| idx)
597 .collect();
598 pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
599}
600
601fn content_should_preserve(content: &str) -> bool {
607 content.len() < 500
608}
609
610fn default_mask_tool_result(role: &str, content: &str) -> String {
620 let first_line = content.lines().next().unwrap_or(content);
621 let line_count = content.lines().count();
622 let char_count = content.len();
623 if line_count <= 3 {
624 return format!("[{role}] {content}");
625 }
626 #[expect(
627 clippy::string_slice,
628 reason = "floor_char_boundary returns a char boundary"
629 )]
630 let preview = &first_line[..first_line.floor_char_boundary(120)];
631 let kept: Vec<&str> = content
634 .lines()
635 .skip(1)
636 .filter(|line| is_failure_signal_line(line))
637 .take(32)
638 .collect();
639 if kept.is_empty() {
640 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
641 } else {
642 format!(
643 "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
644 failure lines preserved]\n{}",
645 kept.join("\n")
646 )
647 }
648}
649
650pub(crate) const RECAP_HEADER_SENTINEL: &str = "via observation masking]";
657
658const PRIOR_RECAP_CARRY_CAP: usize = 6_000;
662
663const ASSISTANT_PREVIEW_CHARS: usize = 240;
668
669fn is_prior_recap(content: &str) -> bool {
671 content.contains(RECAP_HEADER_SENTINEL)
672}
673
674fn assistant_preview(content: &str) -> String {
678 if content.len() <= ASSISTANT_PREVIEW_CHARS {
679 return format!("[assistant] {content}");
680 }
681 let head = snap_to_line_end(content, ASSISTANT_PREVIEW_CHARS);
682 let dropped = content.len().saturating_sub(head.len());
683 format!("[assistant] {head}... [assistant turn truncated, {dropped} chars masked]")
684}
685
686fn collapse_repeats(lines: Vec<String>) -> Vec<String> {
690 let mut out: Vec<String> = Vec::with_capacity(lines.len());
691 let mut run = 0usize;
692 for line in lines {
693 if out
694 .last()
695 .is_some_and(|prev| strip_repeat_suffix(prev) == line)
696 {
697 run += 1;
698 let base =
699 strip_repeat_suffix(out.last().expect("run implies a last line")).to_string();
700 *out.last_mut().expect("run implies a last line") = format!("{base} (x{})", run + 1);
701 } else {
702 run = 0;
703 out.push(line);
704 }
705 }
706 out
707}
708
709#[expect(
710 clippy::string_slice,
711 reason = "idx is an rfind offset on the same line"
712)]
713fn strip_repeat_suffix(line: &str) -> &str {
714 line.rfind(" (x")
715 .filter(|_| line.ends_with(')'))
716 .map(|idx| &line[..idx])
717 .unwrap_or(line)
718}
719
720#[cfg(test)]
722pub(crate) fn observation_mask_compaction(
723 old_messages: &[serde_json::Value],
724 archived_count: usize,
725) -> String {
726 observation_mask_compaction_with_callback(
727 old_messages,
728 archived_count,
729 None,
730 DEFAULT_RECAP_BUDGET_BYTES,
731 )
732 .0
733 .text
734}
735
736#[cfg(test)]
739pub(crate) fn observation_mask_compaction_for_test(
740 old_messages: &[serde_json::Value],
741 archived_count: usize,
742 budget_bytes: usize,
743) -> (String, RecapMetrics) {
744 let (summary, metrics) =
745 observation_mask_compaction_with_callback(old_messages, archived_count, None, budget_bytes);
746 (summary.text, metrics)
747}
748
749fn observation_mask_compaction_with_callback(
765 old_messages: &[serde_json::Value],
766 archived_count: usize,
767 mask_results: Option<&[Option<String>]>,
768 budget_bytes: usize,
769) -> (CompactionSummary, RecapMetrics) {
770 let header =
771 format!("[auto-compacted {archived_count} older messages via observation masking]");
772 let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
773 msg.get("content").and_then(|v| v.as_str())
774 });
775 let prior_recap_idx = old_messages
778 .iter()
779 .enumerate()
780 .rev()
781 .find(|(_, msg)| {
782 msg.get("content")
783 .and_then(|v| v.as_str())
784 .is_some_and(is_prior_recap)
785 })
786 .map(|(idx, _)| idx);
787
788 let mut metrics = RecapMetrics {
789 budget_bytes,
790 ..RecapMetrics::default()
791 };
792 let mut rendered_rev: Vec<String> = Vec::new();
795 let mut used = header.len();
796
797 for (idx, msg) in old_messages.iter().enumerate().rev() {
798 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
799 let content = msg
800 .get("content")
801 .and_then(|v| v.as_str())
802 .unwrap_or_default();
803 if content.is_empty() {
804 continue;
805 }
806
807 if Some(idx) == prior_recap_idx {
808 let carried = snap_to_line_end(content, PRIOR_RECAP_CARRY_CAP);
809 rendered_rev.push(format!("[prior recap] {carried}"));
810 metrics.carried_prior_recap = true;
811 continue;
812 }
813
814 if pinned.contains(&idx) {
816 rendered_rev.push(format!("[{role}] {content}"));
817 continue;
818 }
819
820 let (line, is_result) = if role == "assistant" {
821 (assistant_preview(content), false)
822 } else if content_should_preserve(content) {
823 (format!("[{role}] {content}"), true)
824 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
825 (custom.clone(), true)
826 } else {
827 (default_mask_tool_result(role, content), true)
828 };
829
830 if used + line.len() + 1 > budget_bytes {
831 metrics.dropped_count += 1;
832 continue;
833 }
834 used += line.len() + 1;
835 if is_result {
836 metrics.kept_results_count += 1;
837 }
838 rendered_rev.push(line);
839 }
840
841 let mut body: Vec<String> = rendered_rev.into_iter().rev().collect();
842 body = collapse_repeats(body);
843 let carried: usize = body.iter().map(String::len).sum();
844 let mut parts = vec![header];
845 parts.append(&mut body);
846 if metrics.dropped_count > 0 {
847 parts.push(format!(
848 "[{} older message(s) dropped to fit recap budget]",
849 metrics.dropped_count
850 ));
851 }
852 let summary = parts.join("\n");
853 metrics.recap_bytes = summary.len();
854 let carried = carried.min(summary.len());
855 let scaffold_bytes = summary.len() - carried;
856 (CompactionSummary::new(summary, scaffold_bytes), metrics)
857}
858
859async fn invoke_mask_callback(
861 ctx: Option<&AsyncBuiltinCtx>,
862 callback: &VmValue,
863 old_messages: &[serde_json::Value],
864) -> Result<Vec<Option<String>>, VmError> {
865 let VmValue::Closure(closure) = callback.clone() else {
866 return Err(VmError::Runtime(
867 "mask_callback must be a closure".to_string(),
868 ));
869 };
870 let Some(ctx) = ctx else {
871 return Err(VmError::Runtime(
872 "mask_callback requires an async builtin VM context".to_string(),
873 ));
874 };
875 let mut vm = ctx.child_vm();
876 let messages_vm = VmValue::List(std::sync::Arc::new(
877 old_messages
878 .iter()
879 .map(crate::stdlib::json_to_vm_value)
880 .collect(),
881 ));
882 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
883 ctx.forward_output(&vm.take_output());
884 let list = match result {
885 VmValue::List(items) => items,
886 _ => return Ok(vec![None; old_messages.len()]),
887 };
888 Ok(list
889 .iter()
890 .map(|v| match v {
891 VmValue::String(s) => Some(s.to_string()),
892 VmValue::Nil => None,
893 _ => None,
894 })
895 .collect())
896}
897
898async fn clamp_tool_outputs(
905 ctx: Option<&AsyncBuiltinCtx>,
906 messages: &mut [serde_json::Value],
907 config: &AutoCompactConfig,
908) -> Result<(), VmError> {
909 if config.tool_output_max_chars == 0 {
910 return Ok(());
911 }
912 let pinned = latest_pinned_indices(messages.iter(), |msg| {
917 if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
918 msg.get("content").and_then(|content| content.as_str())
919 } else {
920 None
921 }
922 });
923 for (idx, message) in messages.iter_mut().enumerate() {
924 if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
925 continue;
926 }
927 let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
928 continue;
929 };
930 if content.len() <= config.tool_output_max_chars {
931 continue;
932 }
933 if pinned.contains(&idx) {
934 continue;
935 }
936 let content = content.to_string();
937 let replacement = match (config.compress_callback.as_ref(), ctx) {
938 (Some(callback), Some(ctx)) => {
939 invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
940 .await?
941 }
942 _ => microcompact_tool_output(&content, config.tool_output_max_chars),
943 };
944 message["content"] = serde_json::Value::String(replacement);
945 }
946 Ok(())
947}
948
949async fn invoke_compress_callback(
953 ctx: &AsyncBuiltinCtx,
954 callback: &VmValue,
955 content: &str,
956 max_chars: usize,
957) -> Result<String, VmError> {
958 let VmValue::Closure(closure) = callback.clone() else {
959 return Err(VmError::Runtime(
960 "compress_callback must be a closure".to_string(),
961 ));
962 };
963 let mut vm = ctx.child_vm();
964 let args = [
965 VmValue::String(arcstr::ArcStr::from(content)),
966 VmValue::Int(max_chars as i64),
967 ];
968 let result = vm.call_closure_pub(&closure, &args).await?;
969 ctx.forward_output(&vm.take_output());
970 match result {
971 VmValue::String(text) => Ok(text.to_string()),
972 _ => Ok(microcompact_tool_output(content, max_chars)),
973 }
974}
975
976#[derive(Clone, Copy)]
977struct CompactionStrategyInputs<'a> {
978 ctx: Option<&'a AsyncBuiltinCtx>,
979 strategy: &'a CompactStrategy,
980 old_messages: &'a [serde_json::Value],
981 retained_messages: &'a [serde_json::Value],
982 archived_count: usize,
983 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
984 custom_compactor: Option<&'a VmValue>,
985 custom_compactor_reminders: &'a [VmValue],
986 mask_callback: Option<&'a VmValue>,
987 summarize_prompt: Option<&'a str>,
988 policy: &'a CompactionPolicy,
989 recap_budget_bytes: usize,
990}
991
992async fn apply_compaction_strategy(
997 input: CompactionStrategyInputs<'_>,
998) -> Result<(CompactionSummary, Option<RecapMetrics>), VmError> {
999 let CompactionStrategyInputs {
1000 strategy,
1001 old_messages,
1002 retained_messages,
1003 archived_count,
1004 llm_opts,
1005 custom_compactor,
1006 custom_compactor_reminders,
1007 mask_callback,
1008 summarize_prompt,
1009 policy,
1010 recap_budget_bytes,
1011 ctx,
1012 } = input;
1013 match strategy {
1014 CompactStrategy::Truncate => Ok((
1015 truncate_compaction_summary(old_messages, archived_count),
1016 None,
1017 )),
1018 CompactStrategy::Llm => llm_compaction_summary(
1019 old_messages,
1020 retained_messages,
1021 archived_count,
1022 llm_opts.ok_or_else(|| {
1023 VmError::Runtime(
1024 "LLM transcript compaction requires active LLM call options".to_string(),
1025 )
1026 })?,
1027 summarize_prompt,
1028 policy,
1029 )
1030 .await
1031 .map(|summary| (summary, None)),
1032 CompactStrategy::Custom => custom_compaction_summary(
1033 ctx,
1034 old_messages,
1035 archived_count,
1036 custom_compactor.ok_or_else(|| {
1037 VmError::Runtime(
1038 "compact_callback is required when compact_strategy is 'custom'".to_string(),
1039 )
1040 })?,
1041 custom_compactor_reminders,
1042 policy,
1043 )
1044 .await
1045 .map(|summary| (summary, None)),
1046 CompactStrategy::ObservationMask => {
1047 let mask_results = if let Some(cb) = mask_callback {
1048 Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1049 } else {
1050 None
1051 };
1052 let (summary, metrics) = observation_mask_compaction_with_callback(
1053 old_messages,
1054 archived_count,
1055 mask_results.as_deref(),
1056 recap_budget_bytes,
1057 );
1058 Ok((summary, Some(metrics)))
1059 }
1060 }
1061}
1062
1063async fn apply_compaction_strategy_with_fallback(
1064 input: CompactionStrategyInputs<'_>,
1065 fallback_strategy: Option<&CompactStrategy>,
1066) -> Result<(CompactionSummary, CompactStrategy, Option<RecapMetrics>), VmError> {
1067 match apply_compaction_strategy(input).await {
1068 Ok((summary, metrics)) => Ok((summary, input.strategy.clone(), metrics)),
1069 Err(primary_error) => {
1070 let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1071 else {
1072 return Err(primary_error);
1073 };
1074 let fallback_input = CompactionStrategyInputs {
1075 strategy: fallback,
1076 ..input
1077 };
1078 apply_compaction_strategy(fallback_input)
1079 .await
1080 .map(|(summary, metrics)| (summary, fallback.clone(), metrics))
1081 }
1082 }
1083}
1084
1085#[derive(Debug)]
1086pub(crate) struct AutoCompactResult {
1087 pub summary: String,
1088 pub strategy: CompactStrategy,
1089 pub recap_metrics: Option<RecapMetrics>,
1090 pub measurement: CompactionSourceMeasurement,
1094}
1095
1096#[cfg(test)]
1098pub(crate) async fn auto_compact_messages_with_result(
1099 messages: &mut Vec<serde_json::Value>,
1100 config: &AutoCompactConfig,
1101 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1102) -> Result<Option<AutoCompactResult>, VmError> {
1103 auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1104}
1105
1106pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1107 ctx: Option<&AsyncBuiltinCtx>,
1108 messages: &mut Vec<serde_json::Value>,
1109 config: &AutoCompactConfig,
1110 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1111) -> Result<Option<AutoCompactResult>, VmError> {
1112 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1113 return Ok(None);
1114 }
1115 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1116 return Ok(None);
1117 }
1118 let compact_start = config.keep_first.min(messages.len());
1119 let original_split = messages.len().saturating_sub(config.keep_last);
1120 let mut split_at = original_split;
1121 while split_at > compact_start
1125 && split_at < messages.len()
1126 && messages[split_at]
1127 .get("role")
1128 .and_then(|r| r.as_str())
1129 .is_none_or(|r| r != "user")
1130 {
1131 split_at -= 1;
1132 }
1133 if split_at == compact_start {
1136 split_at = original_split;
1137 }
1138 if let Some(volatile_start) = messages[split_at..]
1139 .iter()
1140 .position(is_reasoning_or_tool_turn_message)
1141 .map(|offset| split_at + offset)
1142 {
1143 if let Some(boundary) = volatile_start
1144 .checked_sub(1)
1145 .and_then(|idx| find_prev_user_boundary(messages, idx))
1146 .filter(|boundary| *boundary > compact_start)
1147 {
1148 split_at = boundary;
1149 }
1150 }
1151 split_at = snap_split_off_tool_results(messages, split_at, compact_start);
1156 if split_at <= compact_start {
1157 return Ok(None);
1158 }
1159 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1160 let archived_count = old_messages.len();
1161
1162 clamp_tool_outputs(ctx, messages, config).await?;
1170
1171 let (mut summary, mut strategy, mut recap_metrics) = apply_compaction_strategy_with_fallback(
1172 CompactionStrategyInputs {
1173 ctx,
1174 strategy: &config.compact_strategy,
1175 old_messages: &old_messages,
1176 retained_messages: messages.as_slice(),
1177 archived_count,
1178 llm_opts,
1179 custom_compactor: config.custom_compactor.as_ref(),
1180 custom_compactor_reminders: &config.custom_compactor_reminders,
1181 mask_callback: config.mask_callback.as_ref(),
1182 summarize_prompt: config.summarize_prompt.as_deref(),
1183 policy: &config.policy,
1184 recap_budget_bytes: config.recap_budget_bytes,
1185 },
1186 config.fallback_strategy.as_ref(),
1187 )
1188 .await?;
1189
1190 if let Some(hard_limit) = config.hard_limit_tokens {
1191 let summary_msg = serde_json::json!({"role": "user", "content": &summary.text});
1192 let mut estimate_msgs = vec![summary_msg];
1193 estimate_msgs.extend_from_slice(messages.as_slice());
1194 let estimated = estimate_message_tokens(&estimate_msgs);
1195 if estimated > hard_limit {
1196 let tier1_as_messages = vec![serde_json::json!({
1197 "role": "user",
1198 "content": summary.text,
1199 })];
1200 let (hard_limit_summary, hard_limit_strategy, hard_limit_metrics) =
1201 apply_compaction_strategy_with_fallback(
1202 CompactionStrategyInputs {
1203 ctx,
1204 strategy: &config.hard_limit_strategy,
1205 old_messages: &tier1_as_messages,
1206 retained_messages: messages.as_slice(),
1207 archived_count,
1208 llm_opts,
1209 custom_compactor: config.custom_compactor.as_ref(),
1210 custom_compactor_reminders: &config.custom_compactor_reminders,
1211 mask_callback: None,
1212 summarize_prompt: config.summarize_prompt.as_deref(),
1213 policy: &config.policy,
1214 recap_budget_bytes: config.recap_budget_bytes,
1215 },
1216 config.fallback_strategy.as_ref(),
1217 )
1218 .await?;
1219 summary = hard_limit_summary;
1220 strategy = hard_limit_strategy;
1221 recap_metrics = hard_limit_metrics.or(recap_metrics);
1224 }
1225 }
1226
1227 let source_bytes: usize = old_messages.iter().map(message_content_bytes).sum();
1232 let measurement = CompactionSourceMeasurement {
1233 source_message_count: Some(archived_count),
1234 source_bytes: Some(source_bytes),
1235 summary_bytes: Some(summary.text.len()),
1236 carried_source_bytes: Some(summary.carried_source_bytes()),
1237 };
1238
1239 if measurement.discarded_source_context() {
1245 let restored = old_messages;
1246 messages.splice(compact_start..compact_start, restored);
1247 return Err(VmError::Runtime(format!(
1248 "transcript compaction refused: the summary carried 0 of {source_bytes} source \
1249 bytes across {archived_count} archived messages (summary was {} bytes, all \
1250 generated scaffold); the source context was preserved",
1251 summary.text.len()
1252 )));
1253 }
1254
1255 let summary = super::repair_ledger::append_repair_ledger_to_summary(
1256 apply_model_visible_policy(summary.text, &config.policy),
1257 &old_messages,
1258 );
1259
1260 messages.insert(
1261 compact_start,
1262 serde_json::json!({
1263 "role": "user",
1264 "content": summary,
1265 }),
1266 );
1267 Ok(Some(AutoCompactResult {
1268 summary,
1269 strategy,
1270 recap_metrics,
1271 measurement,
1272 }))
1273}
1274
1275#[cfg(test)]
1277pub(crate) async fn auto_compact_messages(
1278 messages: &mut Vec<serde_json::Value>,
1279 config: &AutoCompactConfig,
1280 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1281) -> Result<Option<String>, VmError> {
1282 Ok(
1283 auto_compact_messages_with_result(messages, config, llm_opts)
1284 .await?
1285 .map(|result| result.summary),
1286 )
1287}
1288
1289fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1290 if !policy.is_model_visible_scope() {
1291 return summary;
1292 }
1293 let Some(directives) = policy.prompt_directives() else {
1294 return summary;
1295 };
1296 summary.push_str("\n\n[compaction instructions]\n");
1297 summary.push_str(&directives);
1298 summary
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303 use super::*;
1304
1305 #[test]
1306 fn microcompact_short_output_unchanged() {
1307 let output = "line1\nline2\nline3\n";
1308 assert_eq!(microcompact_tool_output(output, 1000), output);
1309 }
1310
1311 #[test]
1312 fn microcompact_snaps_to_line_boundaries() {
1313 let lines: Vec<String> = (0..20)
1314 .map(|i| format!("line {i:02} content here"))
1315 .collect();
1316 let output = lines.join("\n");
1317 let result = microcompact_tool_output(&output, 200);
1318 assert!(result.contains("[... "), "should have snip marker");
1319 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1320 assert!(parts.len() >= 2, "should split at marker");
1321 let head = parts[0];
1322 for line in head.lines() {
1323 assert!(
1324 line.starts_with("line "),
1325 "head line should be complete: {line}"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1332 let mut lines = Vec::new();
1333 for i in 0..50 {
1334 lines.push(format!("verbose output line {i}"));
1335 }
1336 lines.push("src/main.rs:42: error: cannot find value".to_string());
1337 for i in 50..100 {
1338 lines.push(format!("verbose output line {i}"));
1339 }
1340 let output = lines.join("\n");
1341 let result = microcompact_tool_output(&output, 600);
1342 assert!(result.contains("cannot find value"), "diagnostic preserved");
1343 assert!(
1344 result.contains("[diagnostic lines preserved]"),
1345 "has diagnostic marker"
1346 );
1347 }
1348
1349 #[test]
1353 fn failure_signal_filter_keeps_structured_failure_lines() {
1354 for keep in [
1355 "left: 3",
1356 "right: 4",
1357 "expected: foo",
1358 "actual: bar",
1359 " --> src/main.rs:4:9",
1360 "= help: add `use std::fmt;`",
1361 "12 | let x = bad();",
1362 " | ^^^^^^^ not found",
1363 "L42: assertion failed",
1364 "src/main.rs:42: error: cannot find value",
1365 "FAIL: TestThing",
1366 "panic: index out of range",
1367 ] {
1368 assert!(
1369 is_failure_signal_line(keep),
1370 "should keep failure-signal line: {keep:?}"
1371 );
1372 }
1373 for drop in [
1374 "verbose output line 7",
1375 "compiling crate foo",
1376 " let y = ok();",
1377 "",
1378 ] {
1379 assert!(
1380 !is_failure_signal_line(drop),
1381 "should drop ordinary line: {drop:?}"
1382 );
1383 }
1384 }
1385
1386 #[test]
1390 fn default_mask_preserves_failure_detail() {
1391 let mut lines = vec!["running 1 test".to_string()];
1392 for i in 0..40 {
1393 lines.push(format!("noise line {i}"));
1394 }
1395 lines.push("assertion `left == right` failed".to_string());
1396 lines.push(" left: 3".to_string());
1397 lines.push(" right: 4".to_string());
1398 lines.push(" --> src/lib.rs:10:5".to_string());
1399 for i in 40..80 {
1400 lines.push(format!("more noise {i}"));
1401 }
1402 let content = lines.join("\n");
1403 let masked = default_mask_tool_result("tool", &content);
1404 assert!(
1405 masked.contains("masked"),
1406 "still reports it masked: {masked}"
1407 );
1408 assert!(
1409 masked.contains("failure lines preserved"),
1410 "should flag preserved lines: {masked}"
1411 );
1412 assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1413 assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1414 assert!(
1415 masked.contains("--> src/lib.rs:10:5"),
1416 "keeps rustc location: {masked}"
1417 );
1418 assert!(
1419 !masked.contains("noise line 7"),
1420 "drops ordinary noise: {masked}"
1421 );
1422 }
1423
1424 #[test]
1426 fn default_mask_without_failure_lines_stays_terse() {
1427 let mut lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1428 lines[0] = format!("{}日本語テキスト", "x".repeat(118));
1429 let masked = default_mask_tool_result("tool", &lines.join("\n"));
1430 assert!(masked.contains("masked]"), "should mask: {masked}");
1431 assert!(
1432 !masked.contains("failure lines preserved"),
1433 "no failure lines to preserve: {masked}"
1434 );
1435 }
1436
1437 #[test]
1438 fn token_estimate_counts_structured_message_content() {
1439 let text = "x".repeat(400);
1440 let messages = vec![serde_json::json!({
1441 "role": "user",
1442 "content": [
1443 {"type": "text", "text": text},
1444 {"type": "input_text", "text": "tail"},
1445 ],
1446 "reasoning": {"text": "scratch"},
1447 "tool_calls": [{
1448 "id": "call_1",
1449 "type": "function",
1450 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1451 }],
1452 })];
1453
1454 assert!(
1455 estimate_message_tokens(&messages) >= 100,
1456 "structured content must not count as zero"
1457 );
1458 }
1459
1460 #[test]
1461 fn compaction_policy_instructions_extend_by_default() {
1462 let policy = CompactionPolicy {
1463 instructions: Some("Keep the failing test names.".to_string()),
1464 ..Default::default()
1465 };
1466 let archived = [serde_json::json!({"role": "user", "content": "old context"})];
1467 let retained = [serde_json::json!({"role": "tool", "content": "new evidence"})];
1468 let prompt = render_llm_compaction_prompt(None, &archived, &retained, 1, &policy)
1469 .expect("prompt renders");
1470
1471 assert_eq!(policy.instruction_mode(), "extend");
1472 assert!(prompt.contains("Preserve goals, constraints"));
1473 assert!(prompt.contains("Additional compaction instructions"));
1474 assert!(prompt.contains("Keep the failing test names."));
1475 assert!(prompt.contains("TOOL: new evidence"));
1476 }
1477
1478 #[test]
1479 fn compaction_policy_can_replace_default_instructions() {
1480 let policy = CompactionPolicy {
1481 instructions: Some("Only keep repro steps.".to_string()),
1482 extend_default_instructions: Some(false),
1483 ..Default::default()
1484 };
1485 let archived = [serde_json::json!({"role": "user", "content": "old context"})];
1486 let retained = [serde_json::json!({"role": "tool", "content": "new evidence"})];
1487 let prompt = render_llm_compaction_prompt(None, &archived, &retained, 1, &policy)
1488 .expect("prompt renders");
1489
1490 assert_eq!(policy.instruction_mode(), "replace");
1491 assert!(prompt.contains("according to these instructions"));
1492 assert!(prompt.contains("Only keep repro steps."));
1493 assert!(!prompt.contains("Preserve goals, constraints"));
1494 assert!(prompt.contains("newer than every archived message"));
1495 assert!(prompt.contains("TOOL: new evidence"));
1496 }
1497
1498 #[test]
1499 fn snap_to_line_end_finds_newline() {
1500 let s = "line1\nline2\nline3\nline4\n";
1501 let head = snap_to_line_end(s, 12);
1502 assert!(head.ends_with('\n'), "should end at newline");
1503 assert!(head.contains("line1"));
1504 }
1505
1506 #[test]
1507 fn snap_to_line_start_finds_newline() {
1508 let s = "line1\nline2\nline3\nline4\n";
1509 let tail = snap_to_line_start(s, 12);
1510 assert!(
1511 tail.starts_with("line"),
1512 "should start at line boundary: {tail}"
1513 );
1514 }
1515
1516 #[test]
1517 fn auto_compact_preserves_reasoning_tool_suffix() {
1518 let mut messages = vec![
1519 serde_json::json!({"role": "user", "content": "old task"}),
1520 serde_json::json!({"role": "assistant", "content": "old reply"}),
1521 serde_json::json!({"role": "user", "content": "new task"}),
1522 serde_json::json!({
1523 "role": "assistant",
1524 "content": "",
1525 "reasoning": "think first",
1526 "tool_calls": [{
1527 "id": "call_1",
1528 "type": "function",
1529 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1530 }],
1531 }),
1532 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1533 ];
1534 let config = AutoCompactConfig {
1535 token_threshold: 1,
1536 keep_last: 2,
1537 ..Default::default()
1538 };
1539
1540 let runtime = tokio::runtime::Builder::new_current_thread()
1541 .enable_all()
1542 .build()
1543 .expect("runtime");
1544 let summary = runtime
1545 .block_on(auto_compact_messages(&mut messages, &config, None))
1546 .expect("compaction succeeds");
1547
1548 assert!(summary.is_some());
1549 assert_eq!(messages[1]["role"], "user");
1550 assert_eq!(messages[2]["role"], "assistant");
1551 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1552 assert_eq!(messages[3]["role"], "tool");
1553 assert_eq!(messages[3]["tool_call_id"], "call_1");
1554 }
1555
1556 #[test]
1563 fn auto_compact_never_splits_assistant_tool_use_from_its_result() {
1564 let tool_call = |id: &str| {
1565 serde_json::json!({
1566 "id": id,
1567 "type": "function",
1568 "function": {"name": "run", "arguments": "{}"}
1569 })
1570 };
1571 let mut messages = vec![
1572 serde_json::json!({"role": "user", "content": "task"}),
1573 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c0")]}),
1574 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1575 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c1")]}),
1576 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1577 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c2")]}),
1578 serde_json::json!({"role": "tool", "tool_call_id": "c2", "content": "r2"}),
1579 ];
1580 let config = AutoCompactConfig {
1583 token_threshold: 1,
1584 keep_first: 0,
1585 keep_last: 3,
1586 ..Default::default()
1587 };
1588
1589 let runtime = tokio::runtime::Builder::new_current_thread()
1590 .enable_all()
1591 .build()
1592 .expect("runtime");
1593 let summary = runtime
1594 .block_on(auto_compact_messages(&mut messages, &config, None))
1595 .expect("compaction succeeds");
1596 assert!(summary.is_some(), "compaction should trigger");
1597
1598 assert_eq!(messages[0]["role"], "user", "summary head");
1600 assert_eq!(messages[1]["role"], "assistant");
1601 assert_eq!(messages[1]["tool_calls"][0]["id"], "c1");
1602 assert_eq!(messages[2]["role"], "tool");
1603 assert_eq!(messages[2]["tool_call_id"], "c1");
1604 assert_eq!(messages[3]["tool_calls"][0]["id"], "c2");
1605 assert_eq!(messages[4]["tool_call_id"], "c2");
1606 for (idx, message) in messages.iter().enumerate() {
1608 if message["role"] == "tool" {
1609 let id = message["tool_call_id"].as_str().expect("tool_call_id");
1610 let paired = messages[..idx].iter().any(|prev| {
1611 prev["tool_calls"]
1612 .as_array()
1613 .is_some_and(|calls| calls.iter().any(|call| call["id"] == id))
1614 });
1615 assert!(paired, "tool_result {id} orphaned in kept window");
1616 }
1617 }
1618 }
1619
1620 #[test]
1621 fn snap_split_off_tool_results_handles_all_result_shapes() {
1622 let openai = vec![
1626 serde_json::json!({"role": "user", "content": "task"}),
1627 serde_json::json!({"role": "assistant", "content": "", "tool_calls": []}),
1628 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1629 ];
1630 assert_eq!(snap_split_off_tool_results(&openai, 2, 0), 1);
1631 let anthropic = vec![
1633 serde_json::json!({"role": "user", "content": "task"}),
1634 serde_json::json!({"role": "assistant", "content": ""}),
1635 serde_json::json!({"role": "tool_result", "tool_use_id": "c0", "content": "r0"}),
1636 ];
1637 assert_eq!(snap_split_off_tool_results(&anthropic, 2, 0), 1);
1638 let user_blocks = vec![
1640 serde_json::json!({"role": "user", "content": "task"}),
1641 serde_json::json!({"role": "assistant", "content": ""}),
1642 serde_json::json!({
1643 "role": "user",
1644 "content": [{"type": "tool_result", "tool_use_id": "c0", "content": "r0"}],
1645 }),
1646 ];
1647 assert_eq!(snap_split_off_tool_results(&user_blocks, 2, 0), 1);
1648 let text = vec![
1650 serde_json::json!({"role": "assistant", "content": ""}),
1651 serde_json::json!({"role": "user", "content": "plain"}),
1652 ];
1653 assert_eq!(snap_split_off_tool_results(&text, 1, 0), 1);
1654 let pinned = vec![
1658 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1659 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1660 serde_json::json!({"role": "assistant", "content": "done"}),
1661 ];
1662 assert_eq!(snap_split_off_tool_results(&pinned, 1, 0), 2);
1663 }
1664
1665 #[test]
1666 fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
1667 let big = "x".repeat(4000);
1670 let big_len = big.len();
1671 let mut messages = vec![
1672 serde_json::json!({"role": "user", "content": "old task"}),
1673 serde_json::json!({"role": "assistant", "content": "old reply"}),
1674 serde_json::json!({"role": "user", "content": "new task"}),
1675 serde_json::json!({"role": "assistant", "content": "calling tool"}),
1676 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
1677 ];
1678 let config = AutoCompactConfig {
1679 token_threshold: 1,
1680 keep_last: 2,
1681 tool_output_max_chars: 500,
1682 ..Default::default()
1683 };
1684
1685 let runtime = tokio::runtime::Builder::new_current_thread()
1686 .enable_all()
1687 .build()
1688 .expect("runtime");
1689 let result = runtime
1690 .block_on(auto_compact_messages(&mut messages, &config, None))
1691 .expect("compaction succeeds");
1692 assert!(result.is_some(), "compaction should trigger");
1693
1694 let tool_msg = messages
1695 .iter()
1696 .find(|message| message["role"] == "tool")
1697 .expect("tool message kept in window");
1698 assert_eq!(tool_msg["tool_call_id"], "call_1");
1700 let content = tool_msg["content"].as_str().expect("string content");
1702 assert!(
1703 content.len() < big_len,
1704 "tool output should be clamped: {} vs {}",
1705 content.len(),
1706 big_len
1707 );
1708 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
1709 }
1710
1711 #[test]
1714 fn observation_mask_preserves_pinned_live_file_view() {
1715 let pinned_body = format!(
1716 "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
1717 NO_COMPACT_MARKER,
1718 (0..40)
1719 .map(|i| format!(" {i} let x = compute({i});"))
1720 .collect::<Vec<_>>()
1721 .join("\n")
1722 );
1723 let verbose_unpinned = (0..60)
1724 .map(|i| format!("verbose scan output line {i}"))
1725 .collect::<Vec<_>>()
1726 .join("\n");
1727 let archived = vec![
1729 serde_json::json!({"role": "user", "content": verbose_unpinned}),
1730 serde_json::json!({"role": "user", "content": pinned_body}),
1731 ];
1732 let summary = observation_mask_compaction(&archived, archived.len());
1733 assert!(
1735 summary.contains("Edited region now reads"),
1736 "pinned heading survived: {summary}"
1737 );
1738 assert!(
1739 summary.contains("let x = compute(39);"),
1740 "pinned body survived verbatim"
1741 );
1742 assert!(summary.contains("masked]"), "unpinned output was masked");
1744 assert!(!summary.contains("verbose scan output line 30"));
1745 }
1746
1747 #[test]
1750 fn clamp_exempts_pinned_tool_output() {
1751 let pinned_big = format!(
1752 "## Exact current file text {}\n{}",
1753 NO_COMPACT_MARKER,
1754 "x".repeat(4000)
1755 );
1756 let pinned_len = pinned_big.len();
1757 let unpinned_big = "y".repeat(4000);
1758 let unpinned_len = unpinned_big.len();
1759 let mut messages = vec![
1760 serde_json::json!({"role": "user", "content": "old task"}),
1761 serde_json::json!({"role": "assistant", "content": "reply"}),
1762 serde_json::json!({"role": "user", "content": "new task"}),
1763 serde_json::json!({"role": "assistant", "content": "calling tools"}),
1764 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
1765 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
1766 serde_json::json!({"role": "user", "content": "continue"}),
1767 ];
1768 let config = AutoCompactConfig {
1769 token_threshold: 1,
1770 keep_last: 4,
1771 tool_output_max_chars: 500,
1772 ..Default::default()
1773 };
1774 let runtime = tokio::runtime::Builder::new_current_thread()
1775 .enable_all()
1776 .build()
1777 .expect("runtime");
1778 runtime
1779 .block_on(auto_compact_messages(&mut messages, &config, None))
1780 .expect("compaction succeeds");
1781
1782 let pinned_msg = messages
1783 .iter()
1784 .find(|m| m["tool_call_id"] == "c1")
1785 .expect("pinned tool message kept");
1786 assert_eq!(
1787 pinned_msg["content"].as_str().map(str::len),
1788 Some(pinned_len),
1789 "pinned output must be intact (unclamped)"
1790 );
1791 let unpinned_msg = messages
1792 .iter()
1793 .find(|m| m["tool_call_id"] == "c0")
1794 .expect("unpinned tool message kept");
1795 assert!(
1796 unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
1797 "unpinned output of the same size must be clamped"
1798 );
1799 }
1800
1801 #[test]
1806 fn pin_bound_keeps_only_latest_segments() {
1807 let make = |gen: usize| {
1811 let body = (0..40)
1812 .map(|i| format!("marker-gen-{gen} body line {i}"))
1813 .collect::<Vec<_>>()
1814 .join("\n");
1815 serde_json::json!({
1816 "role": "user",
1817 "content": format!(
1818 "## Edited region now reads (gen {gen}) {}\n{}",
1819 NO_COMPACT_MARKER, body
1820 ),
1821 })
1822 };
1823 let archived: Vec<_> = (0..6).map(make).collect();
1824
1825 let pinned = latest_pinned_indices(archived.iter(), |m| {
1827 m.get("content").and_then(|c| c.as_str())
1828 });
1829 assert_eq!(
1830 pinned.len(),
1831 MAX_PINNED_SEGMENTS,
1832 "only the latest MAX_PINNED_SEGMENTS are pinned"
1833 );
1834 assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
1835 assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
1836
1837 let summary = observation_mask_compaction(&archived, archived.len());
1841 assert!(
1842 summary.contains("marker-gen-5")
1843 && summary.contains("marker-gen-4")
1844 && summary.contains("marker-gen-3"),
1845 "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
1846 );
1847 assert!(
1848 !summary.contains("marker-gen-0")
1849 && !summary.contains("marker-gen-1")
1850 && !summary.contains("marker-gen-2"),
1851 "older pinned snapshots are masked (bound enforced)"
1852 );
1853 assert!(summary.contains("masked]"), "older snapshots were masked");
1854 }
1855
1856 #[test]
1858 fn no_pins_preserves_prior_clamp_behavior() {
1859 let big = "x".repeat(4000);
1860 let big_len = big.len();
1861 let mut messages = vec![
1862 serde_json::json!({"role": "user", "content": "old task"}),
1863 serde_json::json!({"role": "assistant", "content": "old reply"}),
1864 serde_json::json!({"role": "user", "content": "new task"}),
1865 serde_json::json!({"role": "assistant", "content": "calling tool"}),
1866 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
1867 ];
1868 let config = AutoCompactConfig {
1869 token_threshold: 1,
1870 keep_last: 2,
1871 tool_output_max_chars: 500,
1872 ..Default::default()
1873 };
1874 let runtime = tokio::runtime::Builder::new_current_thread()
1875 .enable_all()
1876 .build()
1877 .expect("runtime");
1878 let result = runtime
1879 .block_on(auto_compact_messages(&mut messages, &config, None))
1880 .expect("compaction succeeds");
1881 assert!(result.is_some());
1882 let tool_msg = messages
1883 .iter()
1884 .find(|m| m["role"] == "tool")
1885 .expect("tool kept");
1886 let content = tool_msg["content"].as_str().expect("string content");
1887 assert!(content.len() < big_len, "unpinned output clamped as before");
1888 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
1889 }
1890}