1use crate::llm::{vm_call_llm_full, vm_value_to_json};
4use crate::value::{VmDictExt, VmError, VmValue};
5use serde::{Deserialize, Serialize};
6
7mod tool_output;
8use crate::vm::AsyncBuiltinCtx;
9pub use tool_output::{
10 microcompact_tool_output, microcompact_tool_output_result, MicrocompactedToolOutput,
11};
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum CompactStrategy {
15 Llm,
16 Truncate,
17 Custom,
18 ObservationMask,
19}
20
21pub fn parse_compact_strategy(value: &str) -> Result<CompactStrategy, VmError> {
22 match value {
23 "llm" => Ok(CompactStrategy::Llm),
24 "truncate" => Ok(CompactStrategy::Truncate),
25 "custom" => Ok(CompactStrategy::Custom),
26 "observation_mask" => Ok(CompactStrategy::ObservationMask),
27 other => Err(VmError::Runtime(format!(
28 "unknown compact_strategy '{other}' (expected 'llm', 'truncate', 'custom', or 'observation_mask')"
29 ))),
30 }
31}
32
33pub fn compact_strategy_name(strategy: &CompactStrategy) -> &'static str {
34 match strategy {
35 CompactStrategy::Llm => "llm",
36 CompactStrategy::Truncate => "truncate",
37 CompactStrategy::Custom => "custom",
38 CompactStrategy::ObservationMask => "observation_mask",
39 }
40}
41
42const COMPACTION_POLICY_KEYS: &[&str] = &[
43 "instructions",
44 "mode",
45 "scope",
46 "preserve",
47 "drop",
48 "extend_default_instructions",
49 "author",
50];
51
52#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(default)]
54pub struct CompactionPolicy {
55 pub instructions: Option<String>,
56 pub mode: Option<String>,
57 pub scope: Option<String>,
58 pub preserve: Vec<String>,
59 #[serde(rename = "drop")]
60 pub drop_items: Vec<String>,
61 pub extend_default_instructions: Option<bool>,
62 pub author: Option<String>,
63}
64
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(default)]
67pub struct CompactionRequest {
68 pub mode: Option<String>,
69 pub policy: CompactionPolicy,
70}
71
72impl CompactionPolicy {
73 pub fn has_metadata(&self) -> bool {
74 self.instructions.is_some()
75 || self.mode.is_some()
76 || self.scope.is_some()
77 || !self.preserve.is_empty()
78 || !self.drop_items.is_empty()
79 || self.extend_default_instructions.is_some()
80 || self.author.is_some()
81 }
82
83 fn has_prompt_directives(&self) -> bool {
84 self.instructions
85 .as_deref()
86 .is_some_and(|value| !value.trim().is_empty())
87 || !self.preserve.is_empty()
88 || !self.drop_items.is_empty()
89 }
90
91 pub fn instruction_mode(&self) -> &'static str {
92 if !self.has_prompt_directives() {
93 "default"
94 } else if self.extend_default_instructions == Some(false) {
95 "replace"
96 } else {
97 "extend"
98 }
99 }
100
101 pub fn instruction_source(&self) -> Option<&str> {
102 self.author
103 .as_deref()
104 .filter(|author| !author.trim().is_empty())
105 }
106
107 pub fn metadata_json(&self) -> Option<serde_json::Value> {
108 if !self.has_metadata() {
109 return None;
110 }
111 let mut map = serde_json::Map::new();
112 if let Some(instructions) = self.instructions.as_ref() {
113 map.insert(
114 "instructions".to_string(),
115 serde_json::Value::String(instructions.clone()),
116 );
117 }
118 if let Some(mode) = self.mode.as_ref() {
119 map.insert("mode".to_string(), serde_json::Value::String(mode.clone()));
120 }
121 if let Some(scope) = self.scope.as_ref() {
122 map.insert(
123 "scope".to_string(),
124 serde_json::Value::String(scope.clone()),
125 );
126 }
127 if !self.preserve.is_empty() {
128 map.insert(
129 "preserve".to_string(),
130 serde_json::to_value(&self.preserve).unwrap_or_default(),
131 );
132 }
133 if !self.drop_items.is_empty() {
134 map.insert(
135 "drop".to_string(),
136 serde_json::to_value(&self.drop_items).unwrap_or_default(),
137 );
138 }
139 if let Some(extend_default_instructions) = self.extend_default_instructions {
140 map.insert(
141 "extend_default_instructions".to_string(),
142 serde_json::Value::Bool(extend_default_instructions),
143 );
144 }
145 if let Some(author) = self.author.as_ref() {
146 map.insert(
147 "author".to_string(),
148 serde_json::Value::String(author.clone()),
149 );
150 }
151 map.insert(
152 "instruction_mode".to_string(),
153 serde_json::Value::String(self.instruction_mode().to_string()),
154 );
155 if let Some(source) = self.instruction_source() {
156 map.insert(
157 "instruction_source".to_string(),
158 serde_json::Value::String(source.to_string()),
159 );
160 }
161 Some(serde_json::Value::Object(map))
162 }
163
164 fn prompt_directives(&self) -> Option<String> {
165 if !self.has_prompt_directives() {
166 return None;
167 }
168 let mut parts = Vec::new();
169 if let Some(instructions) = self
170 .instructions
171 .as_deref()
172 .map(str::trim)
173 .filter(|value| !value.is_empty())
174 {
175 parts.push(instructions.to_string());
176 }
177 if !self.preserve.is_empty() {
178 parts.push(format!("Preserve: {}.", self.preserve.join("; ")));
179 }
180 if !self.drop_items.is_empty() {
181 parts.push(format!("Drop: {}.", self.drop_items.join("; ")));
182 }
183 Some(parts.join("\n"))
184 }
185
186 fn is_model_visible_scope(&self) -> bool {
187 matches!(
188 self.scope.as_deref(),
189 Some("model_visible" | "summary" | "transcript")
190 )
191 }
192}
193
194pub fn compaction_policy_option_keys() -> &'static [&'static str] {
195 COMPACTION_POLICY_KEYS
196}
197
198pub fn compaction_policy_to_vm_value(policy: &CompactionPolicy) -> VmValue {
199 let mut map = crate::value::DictMap::new();
200 if let Some(instructions) = policy.instructions.as_ref() {
201 map.put_str("instructions", instructions.clone());
202 }
203 if let Some(mode) = policy.mode.as_ref() {
204 map.put_str("mode", mode.clone());
205 }
206 if let Some(scope) = policy.scope.as_ref() {
207 map.put_str("scope", scope.clone());
208 }
209 map.insert(
210 crate::value::intern_key("preserve"),
211 VmValue::List(std::sync::Arc::new(
212 policy
213 .preserve
214 .iter()
215 .map(|item| VmValue::String(arcstr::ArcStr::from(item.clone())))
216 .collect(),
217 )),
218 );
219 map.insert(
220 crate::value::intern_key("drop"),
221 VmValue::List(std::sync::Arc::new(
222 policy
223 .drop_items
224 .iter()
225 .map(|item| VmValue::String(arcstr::ArcStr::from(item.clone())))
226 .collect(),
227 )),
228 );
229 if let Some(extend_default_instructions) = policy.extend_default_instructions {
230 map.insert(
231 crate::value::intern_key("extend_default_instructions"),
232 VmValue::Bool(extend_default_instructions),
233 );
234 }
235 if let Some(author) = policy.author.as_ref() {
236 map.put_str("author", author.clone());
237 }
238 VmValue::dict(map)
239}
240
241pub fn parse_compaction_policy_options(
242 options: Option<&crate::value::DictMap>,
243 builtin: &str,
244) -> Result<CompactionPolicy, VmError> {
245 let mut policy = options
246 .and_then(|map| {
247 map.get("policy")
248 .or_else(|| map.get("compaction_policy"))
249 .or_else(|| map.get("compaction_request"))
250 })
251 .map(|value| parse_compaction_policy_value(value, builtin))
252 .transpose()?
253 .unwrap_or_default();
254 if let Some(options) = options {
255 apply_compaction_policy_fields(&mut policy, options, builtin)?;
256 }
257 Ok(policy)
258}
259
260fn parse_compaction_policy_value(
261 value: &VmValue,
262 builtin: &str,
263) -> Result<CompactionPolicy, VmError> {
264 match value {
265 VmValue::Nil => Ok(CompactionPolicy::default()),
266 VmValue::Dict(map) => {
267 if let Some(nested) = map
268 .get("policy")
269 .or_else(|| map.get("compaction_policy"))
270 .or_else(|| map.get("compaction_request"))
271 {
272 let mut policy = parse_compaction_policy_value(nested, builtin)?;
273 apply_compaction_policy_fields(&mut policy, map, builtin)?;
274 Ok(policy)
275 } else {
276 let mut policy = CompactionPolicy::default();
277 apply_compaction_policy_fields(&mut policy, map, builtin)?;
278 Ok(policy)
279 }
280 }
281 other => Err(VmError::Runtime(format!(
282 "{builtin}: compaction policy must be a dict or nil, got {}",
283 other.type_name()
284 ))),
285 }
286}
287
288fn apply_compaction_policy_fields(
289 policy: &mut CompactionPolicy,
290 map: &crate::value::DictMap,
291 builtin: &str,
292) -> Result<(), VmError> {
293 if let Some(value) = optional_policy_string(map, "instructions", builtin)? {
294 policy.instructions = Some(value);
295 }
296 if let Some(value) = optional_policy_string(map, "mode", builtin)? {
297 policy.mode = Some(value);
298 }
299 if let Some(value) = optional_policy_string(map, "scope", builtin)? {
300 policy.scope = Some(value);
301 }
302 if map.contains_key("preserve") {
303 policy.preserve = policy_string_list(map.get("preserve"), builtin, "preserve")?;
304 }
305 if map.contains_key("drop") {
306 policy.drop_items = policy_string_list(map.get("drop"), builtin, "drop")?;
307 }
308 if let Some(value) = optional_policy_bool(map, "extend_default_instructions", builtin)? {
309 policy.extend_default_instructions = Some(value);
310 }
311 if let Some(value) = optional_policy_string(map, "author", builtin)? {
312 policy.author = Some(value);
313 }
314 Ok(())
315}
316
317fn optional_policy_string(
318 map: &crate::value::DictMap,
319 key: &str,
320 builtin: &str,
321) -> Result<Option<String>, VmError> {
322 match map.get(key) {
323 None | Some(VmValue::Nil) => Ok(None),
324 Some(VmValue::String(text)) => {
325 let trimmed = text.trim();
326 if trimmed.is_empty() {
327 Ok(None)
328 } else {
329 Ok(Some(trimmed.to_string()))
330 }
331 }
332 Some(other) => Err(VmError::Runtime(format!(
333 "{builtin}: compaction policy `{key}` must be a string, got {}",
334 other.type_name()
335 ))),
336 }
337}
338
339fn optional_policy_bool(
340 map: &crate::value::DictMap,
341 key: &str,
342 builtin: &str,
343) -> Result<Option<bool>, VmError> {
344 match map.get(key) {
345 None | Some(VmValue::Nil) => Ok(None),
346 Some(VmValue::Bool(value)) => Ok(Some(*value)),
347 Some(other) => Err(VmError::Runtime(format!(
348 "{builtin}: compaction policy `{key}` must be a bool, got {}",
349 other.type_name()
350 ))),
351 }
352}
353
354fn policy_string_list(
355 value: Option<&VmValue>,
356 builtin: &str,
357 key: &str,
358) -> Result<Vec<String>, VmError> {
359 match value {
360 None | Some(VmValue::Nil) => Ok(Vec::new()),
361 Some(VmValue::String(text)) => {
362 let trimmed = text.trim();
363 if trimmed.is_empty() {
364 Ok(Vec::new())
365 } else {
366 Ok(vec![trimmed.to_string()])
367 }
368 }
369 Some(VmValue::List(items)) => items
370 .iter()
371 .map(|item| match item {
372 VmValue::String(text) => Ok(text.trim().to_string()),
373 other => Err(VmError::Runtime(format!(
374 "{builtin}: compaction policy `{key}` entries must be strings, got {}",
375 other.type_name()
376 ))),
377 })
378 .filter_map(|result| match result {
379 Ok(value) if value.is_empty() => None,
380 other => Some(other),
381 })
382 .collect(),
383 Some(other) => Err(VmError::Runtime(format!(
384 "{builtin}: compaction policy `{key}` must be a string or list, got {}",
385 other.type_name()
386 ))),
387 }
388}
389
390pub fn compaction_policy_metadata_fields(
391 policy: &CompactionPolicy,
392) -> Vec<(&'static str, serde_json::Value)> {
393 let mut fields = vec![(
394 "instruction_mode",
395 serde_json::Value::String(policy.instruction_mode().to_string()),
396 )];
397 if let Some(source) = policy.instruction_source() {
398 fields.push((
399 "instruction_source",
400 serde_json::Value::String(source.to_string()),
401 ));
402 }
403 if let Some(policy_json) = policy.metadata_json() {
404 fields.push(("compaction_policy", policy_json));
405 }
406 fields
407}
408
409#[derive(Clone, Debug)]
419pub struct AutoCompactConfig {
420 pub keep_first: usize,
424 pub token_threshold: usize,
426 pub tool_output_max_chars: usize,
428 pub keep_last: usize,
430 pub compact_strategy: CompactStrategy,
432 pub hard_limit_tokens: Option<usize>,
436 pub hard_limit_strategy: CompactStrategy,
438 pub custom_compactor: Option<VmValue>,
440 pub custom_compactor_reminders: Vec<VmValue>,
444 pub mask_callback: Option<VmValue>,
451 pub compress_callback: Option<VmValue>,
457 pub summarize_prompt: Option<String>,
461 pub policy_strategy: String,
465 pub fallback_strategy: Option<CompactStrategy>,
469 pub policy: CompactionPolicy,
473 pub recap_budget_bytes: usize,
481}
482
483impl Default for AutoCompactConfig {
484 fn default() -> Self {
485 Self {
486 keep_first: 0,
487 token_threshold: 48_000,
488 tool_output_max_chars: 16_000,
489 keep_last: 12,
490 compact_strategy: CompactStrategy::ObservationMask,
491 hard_limit_tokens: None,
492 hard_limit_strategy: CompactStrategy::Llm,
493 custom_compactor: None,
494 custom_compactor_reminders: Vec::new(),
495 mask_callback: None,
496 compress_callback: None,
497 summarize_prompt: None,
498 policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
499 fallback_strategy: None,
500 policy: CompactionPolicy::default(),
501 recap_budget_bytes: DEFAULT_RECAP_BUDGET_BYTES,
502 }
503 }
504}
505
506pub const DEFAULT_RECAP_BUDGET_BYTES: usize = 16_000;
512
513#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
516#[serde(default)]
517pub struct RecapMetrics {
518 pub recap_bytes: usize,
519 pub budget_bytes: usize,
520 pub kept_results_count: usize,
521 pub dropped_count: usize,
522 pub carried_prior_recap: bool,
523}
524
525impl RecapMetrics {
526 pub fn to_json(self) -> serde_json::Value {
527 serde_json::json!({
528 "recap_bytes": self.recap_bytes,
529 "budget_bytes": self.budget_bytes,
530 "kept_results_count": self.kept_results_count,
531 "dropped_count": self.dropped_count,
532 "carried_prior_recap": self.carried_prior_recap,
533 })
534 }
535}
536
537pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
539 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
540}
541
542fn estimate_message_chars(message: &serde_json::Value) -> usize {
543 let mut total = message
544 .get("content")
545 .map(estimate_content_chars)
546 .unwrap_or_default();
547 if let Some(reasoning) = message.get("reasoning") {
548 total += estimate_content_chars(reasoning);
549 }
550 if let Some(tool_calls) = message.get("tool_calls") {
551 total += estimate_content_chars(tool_calls);
552 }
553 total
554}
555
556fn estimate_content_chars(value: &serde_json::Value) -> usize {
557 match value {
558 serde_json::Value::String(text) => text.len(),
559 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
560 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
561 serde_json::Value::Null => 0,
562 other => other.to_string().len(),
563 }
564}
565
566fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
567 let role = message
568 .get("role")
569 .and_then(|value| value.as_str())
570 .unwrap_or_default();
571 role == "tool"
572 || message.get("tool_calls").is_some()
573 || message
574 .get("reasoning")
575 .map(|value| !value.is_null())
576 .unwrap_or(false)
577}
578
579fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
580 (0..=start)
581 .rev()
582 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
583}
584
585fn is_tool_result_message(message: &serde_json::Value) -> bool {
591 match message.get("role").and_then(|role| role.as_str()) {
592 Some("tool") | Some("tool_result") => true,
593 Some("user") => message
594 .get("content")
595 .and_then(|content| content.as_array())
596 .is_some_and(|blocks| {
597 blocks.iter().any(|block| {
598 block.get("type").and_then(|value| value.as_str()) == Some("tool_result")
599 })
600 }),
601 _ => false,
602 }
603}
604
605fn snap_split_off_tool_results(
615 messages: &[serde_json::Value],
616 split_at: usize,
617 compact_start: usize,
618) -> usize {
619 if split_at >= messages.len() || !is_tool_result_message(&messages[split_at]) {
620 return split_at;
621 }
622 let mut backward = split_at;
623 while backward > compact_start && is_tool_result_message(&messages[backward]) {
624 backward -= 1;
625 }
626 if backward > compact_start {
627 return backward;
628 }
629 let mut forward = split_at;
630 while forward < messages.len() && is_tool_result_message(&messages[forward]) {
631 forward += 1;
632 }
633 forward
634}
635
636fn line_has_file_line_prefix(trimmed: &str) -> bool {
640 let bytes = trimmed.as_bytes();
641 let mut i = 0;
642 while i < bytes.len() && bytes[i] != b':' {
643 i += 1;
644 }
645 i < bytes.len() && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
646}
647
648pub(super) fn is_failure_signal_line(line: &str) -> bool {
662 let trimmed = line.trim();
663 if trimmed.is_empty() {
664 return false;
665 }
666 let lower = trimmed.to_lowercase();
667
668 let has_file_line = line_has_file_line_prefix(trimmed);
669 let has_strong_keyword =
670 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
671 let has_weak_keyword = trimmed.contains("error")
672 || trimmed.contains("undefined")
673 || trimmed.contains("expected")
674 || trimmed.contains("got")
675 || lower.contains("cannot find")
676 || lower.contains("not found")
677 || lower.contains("no such")
678 || lower.contains("unresolved")
679 || lower.contains("missing")
680 || lower.contains("declared but not used")
681 || lower.contains("unused")
682 || lower.contains("mismatch");
683 let positional = lower.contains(" error ")
684 || lower.starts_with("error:")
685 || lower.starts_with("warning:")
686 || lower.starts_with("note:")
687 || lower.contains("panic:");
688
689 let assertion_value = lower.starts_with("left:")
690 || lower.starts_with("right:")
691 || lower.starts_with("expected:")
692 || lower.starts_with("actual:")
693 || lower.starts_with("got:")
694 || lower.starts_with("want:")
695 || lower.starts_with("got ")
696 || lower.starts_with("want ")
697 || lower.starts_with("assertion")
698 || lower.contains("assertionerror");
699
700 let rustc_continuation = trimmed.starts_with("-->")
703 || trimmed.starts_with("= help:")
704 || trimmed.starts_with("= note:")
705 || trimmed.contains('^')
706 || {
707 let mut chars = trimmed.chars();
709 let mut saw_digit = false;
710 let mut rest = trimmed;
711 while let Some(c) = chars.clone().next() {
712 if c.is_ascii_digit() {
713 saw_digit = true;
714 chars.next();
715 rest = chars.as_str();
716 } else {
717 break;
718 }
719 }
720 saw_digit && rest.trim_start().starts_with('|')
721 };
722
723 let failing_line_marker = {
724 if let Some(rest) = trimmed.strip_prefix('L') {
725 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
726 !digits.is_empty() && rest[digits.len()..].starts_with(':')
727 } else {
728 false
729 }
730 };
731
732 has_strong_keyword
733 || (has_file_line && has_weak_keyword)
734 || positional
735 || assertion_value
736 || rustc_continuation
737 || failing_line_marker
738}
739
740fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
744 if max_bytes >= s.len() {
745 return s;
746 }
747 let search_end = s.floor_char_boundary(max_bytes);
748 match s[..search_end].rfind('\n') {
749 Some(pos) => &s[..pos + 1],
750 None => &s[..search_end], }
752}
753
754fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
758 if start_byte == 0 {
759 return s;
760 }
761 let search_start = s.ceil_char_boundary(start_byte);
762 if search_start >= s.len() {
763 return "";
764 }
765 match s[search_start..].find('\n') {
766 Some(pos) => {
767 let line_start = search_start + pos + 1;
768 if line_start < s.len() {
769 &s[line_start..]
770 } else {
771 &s[search_start..]
772 }
773 }
774 None => &s[search_start..], }
776}
777
778fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
779 messages
780 .iter()
781 .map(|msg| {
782 let role = msg
783 .get("role")
784 .and_then(|v| v.as_str())
785 .unwrap_or("user")
786 .to_uppercase();
787 let content = msg
788 .get("content")
789 .and_then(|v| v.as_str())
790 .unwrap_or_default();
791 format!("{role}: {content}")
792 })
793 .collect::<Vec<_>>()
794 .join("\n")
795}
796
797fn truncate_compaction_summary(
798 old_messages: &[serde_json::Value],
799 archived_count: usize,
800) -> String {
801 truncate_compaction_summary_with_context(old_messages, archived_count, false)
802}
803
804fn truncate_compaction_summary_with_context(
805 old_messages: &[serde_json::Value],
806 archived_count: usize,
807 is_llm_fallback: bool,
808) -> String {
809 let per_msg_limit = 500_usize;
810 let summary_parts: Vec<String> = old_messages
811 .iter()
812 .filter_map(|m| {
813 let role = m.get("role")?.as_str()?;
814 let content = m.get("content")?.as_str()?;
815 if content.is_empty() {
816 return None;
817 }
818 let truncated = if content.len() > per_msg_limit {
819 format!(
820 "{}... [truncated from {} chars]",
821 &content[..content.floor_char_boundary(per_msg_limit)],
822 content.len()
823 )
824 } else {
825 content.to_string()
826 };
827 Some(format!("[{role}] {truncated}"))
828 })
829 .take(15)
830 .collect();
831 let header = if is_llm_fallback {
832 format!(
833 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
834 )
835 } else {
836 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
837 };
838 format!(
839 "{header}\n{}{}",
840 summary_parts.join("\n"),
841 if archived_count > 15 {
842 format!("\n... and {} more", archived_count - 15)
843 } else {
844 String::new()
845 }
846 )
847}
848
849fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
850 if let Some(map) = value.as_dict() {
851 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
852 return Ok(summary.display());
853 }
854 }
855 match value {
856 VmValue::String(text) => Ok(text.to_string()),
857 VmValue::Nil => Ok(String::new()),
858 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
859 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
860 }
861}
862
863async fn llm_compaction_summary(
864 old_messages: &[serde_json::Value],
865 archived_count: usize,
866 llm_opts: &crate::llm::api::LlmCallOptions,
867 summarize_prompt: Option<&str>,
868 policy: &CompactionPolicy,
869) -> Result<String, VmError> {
870 let mut compact_opts = llm_opts.clone();
871 let formatted = format_compaction_messages(old_messages);
872 compact_opts.system = None;
873 compact_opts.transcript_summary = None;
874 compact_opts.native_tools = None;
875 compact_opts.tool_choice = None;
876 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
877 compact_opts.response_format = None;
878 compact_opts.json_schema = None;
879 compact_opts.output_schema = None;
880 let prompt =
881 render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
882 compact_opts.messages = vec![serde_json::json!({
883 "role": "user",
884 "content": prompt,
885 })];
886 let manifest = &mut compact_opts.context_manifest;
887 manifest.record_system_transform("compaction", "stdlib:compaction", "removed system", None);
888 compact_opts.set_call_role("compaction");
889 let result = vm_call_llm_full(&compact_opts).await?;
890 let summary = result.text.trim();
891 if summary.is_empty() {
892 Ok(truncate_compaction_summary_with_context(
893 old_messages,
894 archived_count,
895 true,
896 ))
897 } else {
898 Ok(format!(
899 "[auto-compacted {archived_count} older messages]\n{summary}"
900 ))
901 }
902}
903
904fn render_llm_compaction_prompt(
905 summarize_prompt: Option<&str>,
906 formatted: &str,
907 archived_count: usize,
908 policy: &CompactionPolicy,
909) -> Result<String, VmError> {
910 if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
911 return render_replacement_compaction_prompt(policy, formatted, archived_count);
912 }
913 let mut bindings = crate::value::DictMap::new();
914 bindings.put_str("formatted_messages", formatted);
915 bindings.insert(
916 crate::value::intern_key("archived_count"),
917 VmValue::Int(archived_count as i64),
918 );
919 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
920 let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
921 "orchestration/prompts/compaction_summary.harn.prompt",
922 Some(&bindings),
923 )?;
924 return Ok(extend_compaction_prompt(prompt, policy));
925 };
926
927 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
928 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
929 let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
930 .map_err(VmError::from)?;
931 Ok(extend_compaction_prompt(prompt, policy))
932}
933
934fn render_replacement_compaction_prompt(
935 policy: &CompactionPolicy,
936 formatted: &str,
937 archived_count: usize,
938) -> Result<String, VmError> {
939 let directives = policy.prompt_directives().unwrap_or_default();
940 let mut bindings = crate::value::DictMap::new();
941 bindings.put_str("directives", directives);
942 bindings.put_str("formatted_messages", formatted);
943 bindings.insert(
944 crate::value::intern_key("archived_count"),
945 VmValue::Int(archived_count as i64),
946 );
947 crate::stdlib::template::render_stdlib_prompt_asset(
948 "orchestration/prompts/compaction_policy_replacement.harn.prompt",
949 Some(&bindings),
950 )
951}
952
953fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
954 let Some(directives) = policy.prompt_directives() else {
955 return prompt;
956 };
957 prompt.push_str(
958 "\n\nAdditional compaction instructions: use these directives to shape the summary, but do not quote this section unless it explicitly requests a model-visible note.\n",
959 );
960 prompt.push_str(&directives);
961 prompt
962}
963
964async fn custom_compaction_summary(
965 ctx: Option<&AsyncBuiltinCtx>,
966 old_messages: &[serde_json::Value],
967 archived_count: usize,
968 callback: &VmValue,
969 reminders: &[VmValue],
970 policy: &CompactionPolicy,
971) -> Result<String, VmError> {
972 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
973 return Err(VmError::Runtime(
974 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
975 ));
976 };
977 let Some(ctx) = ctx else {
978 return Err(VmError::Runtime(
979 "custom transcript compaction requires an async builtin VM context".to_string(),
980 ));
981 };
982 let mut vm = ctx.child_vm();
983 let messages_vm = VmValue::List(std::sync::Arc::new(
984 old_messages
985 .iter()
986 .map(crate::stdlib::json_to_vm_value)
987 .collect(),
988 ));
989 let result = if policy.has_metadata()
990 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
991 {
992 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
993 let policy_vm = compaction_policy_to_vm_value(policy);
994 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
995 .await
996 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
997 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
998 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
999 .await
1000 } else {
1001 vm.call_closure_pub(&closure, &[messages_vm]).await
1002 };
1003 let summary = compact_summary_text_from_value(&result?)?;
1004 ctx.forward_output(&vm.take_output());
1005 if summary.trim().is_empty() {
1006 Ok(truncate_compaction_summary(old_messages, archived_count))
1007 } else {
1008 Ok(format!(
1009 "[auto-compacted {archived_count} older messages]\n{summary}"
1010 ))
1011 }
1012}
1013
1014pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
1022
1023pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
1032
1033fn is_pinned_content(content: &str) -> bool {
1035 content.contains(NO_COMPACT_MARKER)
1036}
1037
1038fn latest_pinned_indices<'a, F>(
1043 messages: impl Iterator<Item = &'a serde_json::Value>,
1044 content_of: F,
1045) -> std::collections::HashSet<usize>
1046where
1047 F: Fn(&serde_json::Value) -> Option<&str>,
1048{
1049 let pinned: Vec<usize> = messages
1051 .enumerate()
1052 .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
1053 .map(|(idx, _)| idx)
1054 .collect();
1055 pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
1056}
1057
1058fn content_should_preserve(content: &str) -> bool {
1064 content.len() < 500
1065}
1066
1067fn default_mask_tool_result(role: &str, content: &str) -> String {
1077 let first_line = content.lines().next().unwrap_or(content);
1078 let line_count = content.lines().count();
1079 let char_count = content.len();
1080 if line_count <= 3 {
1081 return format!("[{role}] {content}");
1082 }
1083 let preview = &first_line[..first_line.len().min(120)];
1084 let kept: Vec<&str> = content
1087 .lines()
1088 .skip(1)
1089 .filter(|line| is_failure_signal_line(line))
1090 .take(32)
1091 .collect();
1092 if kept.is_empty() {
1093 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
1094 } else {
1095 format!(
1096 "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
1097 failure lines preserved]\n{}",
1098 kept.join("\n")
1099 )
1100 }
1101}
1102
1103pub(crate) const RECAP_HEADER_SENTINEL: &str = "via observation masking]";
1110
1111const PRIOR_RECAP_CARRY_CAP: usize = 6_000;
1115
1116const ASSISTANT_PREVIEW_CHARS: usize = 240;
1121
1122fn is_prior_recap(content: &str) -> bool {
1124 content.contains(RECAP_HEADER_SENTINEL)
1125}
1126
1127fn assistant_preview(content: &str) -> String {
1131 if content.len() <= ASSISTANT_PREVIEW_CHARS {
1132 return format!("[assistant] {content}");
1133 }
1134 let head = snap_to_line_end(content, ASSISTANT_PREVIEW_CHARS);
1135 let dropped = content.len().saturating_sub(head.len());
1136 format!("[assistant] {head}... [assistant turn truncated, {dropped} chars masked]")
1137}
1138
1139fn collapse_repeats(lines: Vec<String>) -> Vec<String> {
1143 let mut out: Vec<String> = Vec::with_capacity(lines.len());
1144 let mut run = 0usize;
1145 for line in lines {
1146 if out
1147 .last()
1148 .is_some_and(|prev| strip_repeat_suffix(prev) == line)
1149 {
1150 run += 1;
1151 let base =
1152 strip_repeat_suffix(out.last().expect("run implies a last line")).to_string();
1153 *out.last_mut().expect("run implies a last line") = format!("{base} (x{})", run + 1);
1154 } else {
1155 run = 0;
1156 out.push(line);
1157 }
1158 }
1159 out
1160}
1161
1162fn strip_repeat_suffix(line: &str) -> &str {
1163 line.rfind(" (x")
1164 .filter(|_| line.ends_with(')'))
1165 .map(|idx| &line[..idx])
1166 .unwrap_or(line)
1167}
1168
1169#[cfg(test)]
1171pub(crate) fn observation_mask_compaction(
1172 old_messages: &[serde_json::Value],
1173 archived_count: usize,
1174) -> String {
1175 observation_mask_compaction_with_callback(
1176 old_messages,
1177 archived_count,
1178 None,
1179 DEFAULT_RECAP_BUDGET_BYTES,
1180 )
1181 .0
1182}
1183
1184#[cfg(test)]
1187pub(crate) fn observation_mask_compaction_for_test(
1188 old_messages: &[serde_json::Value],
1189 archived_count: usize,
1190 budget_bytes: usize,
1191) -> (String, RecapMetrics) {
1192 observation_mask_compaction_with_callback(old_messages, archived_count, None, budget_bytes)
1193}
1194
1195fn observation_mask_compaction_with_callback(
1211 old_messages: &[serde_json::Value],
1212 archived_count: usize,
1213 mask_results: Option<&[Option<String>]>,
1214 budget_bytes: usize,
1215) -> (String, RecapMetrics) {
1216 let header =
1217 format!("[auto-compacted {archived_count} older messages via observation masking]");
1218 let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
1219 msg.get("content").and_then(|v| v.as_str())
1220 });
1221 let prior_recap_idx = old_messages
1224 .iter()
1225 .enumerate()
1226 .rev()
1227 .find(|(_, msg)| {
1228 msg.get("content")
1229 .and_then(|v| v.as_str())
1230 .is_some_and(is_prior_recap)
1231 })
1232 .map(|(idx, _)| idx);
1233
1234 let mut metrics = RecapMetrics {
1235 budget_bytes,
1236 ..RecapMetrics::default()
1237 };
1238 let mut rendered_rev: Vec<String> = Vec::new();
1241 let mut used = header.len();
1242
1243 for (idx, msg) in old_messages.iter().enumerate().rev() {
1244 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
1245 let content = msg
1246 .get("content")
1247 .and_then(|v| v.as_str())
1248 .unwrap_or_default();
1249 if content.is_empty() {
1250 continue;
1251 }
1252
1253 if Some(idx) == prior_recap_idx {
1254 let carried = snap_to_line_end(content, PRIOR_RECAP_CARRY_CAP);
1255 rendered_rev.push(format!("[prior recap] {carried}"));
1256 metrics.carried_prior_recap = true;
1257 continue;
1258 }
1259
1260 if pinned.contains(&idx) {
1262 rendered_rev.push(format!("[{role}] {content}"));
1263 continue;
1264 }
1265
1266 let (line, is_result) = if role == "assistant" {
1267 (assistant_preview(content), false)
1268 } else if content_should_preserve(content) {
1269 (format!("[{role}] {content}"), true)
1270 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
1271 (custom.clone(), true)
1272 } else {
1273 (default_mask_tool_result(role, content), true)
1274 };
1275
1276 if used + line.len() + 1 > budget_bytes {
1277 metrics.dropped_count += 1;
1278 continue;
1279 }
1280 used += line.len() + 1;
1281 if is_result {
1282 metrics.kept_results_count += 1;
1283 }
1284 rendered_rev.push(line);
1285 }
1286
1287 let mut body: Vec<String> = rendered_rev.into_iter().rev().collect();
1288 body = collapse_repeats(body);
1289 let mut parts = vec![header];
1290 parts.append(&mut body);
1291 if metrics.dropped_count > 0 {
1292 parts.push(format!(
1293 "[{} older message(s) dropped to fit recap budget]",
1294 metrics.dropped_count
1295 ));
1296 }
1297 let summary = parts.join("\n");
1298 metrics.recap_bytes = summary.len();
1299 (summary, metrics)
1300}
1301
1302async fn invoke_mask_callback(
1304 ctx: Option<&AsyncBuiltinCtx>,
1305 callback: &VmValue,
1306 old_messages: &[serde_json::Value],
1307) -> Result<Vec<Option<String>>, VmError> {
1308 let VmValue::Closure(closure) = callback.clone() else {
1309 return Err(VmError::Runtime(
1310 "mask_callback must be a closure".to_string(),
1311 ));
1312 };
1313 let Some(ctx) = ctx else {
1314 return Err(VmError::Runtime(
1315 "mask_callback requires an async builtin VM context".to_string(),
1316 ));
1317 };
1318 let mut vm = ctx.child_vm();
1319 let messages_vm = VmValue::List(std::sync::Arc::new(
1320 old_messages
1321 .iter()
1322 .map(crate::stdlib::json_to_vm_value)
1323 .collect(),
1324 ));
1325 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
1326 ctx.forward_output(&vm.take_output());
1327 let list = match result {
1328 VmValue::List(items) => items,
1329 _ => return Ok(vec![None; old_messages.len()]),
1330 };
1331 Ok(list
1332 .iter()
1333 .map(|v| match v {
1334 VmValue::String(s) => Some(s.to_string()),
1335 VmValue::Nil => None,
1336 _ => None,
1337 })
1338 .collect())
1339}
1340
1341async fn clamp_tool_outputs(
1348 ctx: Option<&AsyncBuiltinCtx>,
1349 messages: &mut [serde_json::Value],
1350 config: &AutoCompactConfig,
1351) -> Result<(), VmError> {
1352 if config.tool_output_max_chars == 0 {
1353 return Ok(());
1354 }
1355 let pinned = latest_pinned_indices(messages.iter(), |msg| {
1360 if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
1361 msg.get("content").and_then(|content| content.as_str())
1362 } else {
1363 None
1364 }
1365 });
1366 for (idx, message) in messages.iter_mut().enumerate() {
1367 if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
1368 continue;
1369 }
1370 let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
1371 continue;
1372 };
1373 if content.len() <= config.tool_output_max_chars {
1374 continue;
1375 }
1376 if pinned.contains(&idx) {
1377 continue;
1378 }
1379 let content = content.to_string();
1380 let replacement = match (config.compress_callback.as_ref(), ctx) {
1381 (Some(callback), Some(ctx)) => {
1382 invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
1383 .await?
1384 }
1385 _ => microcompact_tool_output(&content, config.tool_output_max_chars),
1386 };
1387 message["content"] = serde_json::Value::String(replacement);
1388 }
1389 Ok(())
1390}
1391
1392async fn invoke_compress_callback(
1396 ctx: &AsyncBuiltinCtx,
1397 callback: &VmValue,
1398 content: &str,
1399 max_chars: usize,
1400) -> Result<String, VmError> {
1401 let VmValue::Closure(closure) = callback.clone() else {
1402 return Err(VmError::Runtime(
1403 "compress_callback must be a closure".to_string(),
1404 ));
1405 };
1406 let mut vm = ctx.child_vm();
1407 let args = [
1408 VmValue::String(arcstr::ArcStr::from(content)),
1409 VmValue::Int(max_chars as i64),
1410 ];
1411 let result = vm.call_closure_pub(&closure, &args).await?;
1412 ctx.forward_output(&vm.take_output());
1413 match result {
1414 VmValue::String(text) => Ok(text.to_string()),
1415 _ => Ok(microcompact_tool_output(content, max_chars)),
1416 }
1417}
1418
1419#[derive(Clone, Copy)]
1420struct CompactionStrategyInputs<'a> {
1421 ctx: Option<&'a AsyncBuiltinCtx>,
1422 strategy: &'a CompactStrategy,
1423 old_messages: &'a [serde_json::Value],
1424 archived_count: usize,
1425 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
1426 custom_compactor: Option<&'a VmValue>,
1427 custom_compactor_reminders: &'a [VmValue],
1428 mask_callback: Option<&'a VmValue>,
1429 summarize_prompt: Option<&'a str>,
1430 policy: &'a CompactionPolicy,
1431 recap_budget_bytes: usize,
1432}
1433
1434async fn apply_compaction_strategy(
1439 input: CompactionStrategyInputs<'_>,
1440) -> Result<(String, Option<RecapMetrics>), VmError> {
1441 let CompactionStrategyInputs {
1442 strategy,
1443 old_messages,
1444 archived_count,
1445 llm_opts,
1446 custom_compactor,
1447 custom_compactor_reminders,
1448 mask_callback,
1449 summarize_prompt,
1450 policy,
1451 recap_budget_bytes,
1452 ctx,
1453 } = input;
1454 match strategy {
1455 CompactStrategy::Truncate => Ok((
1456 truncate_compaction_summary(old_messages, archived_count),
1457 None,
1458 )),
1459 CompactStrategy::Llm => llm_compaction_summary(
1460 old_messages,
1461 archived_count,
1462 llm_opts.ok_or_else(|| {
1463 VmError::Runtime(
1464 "LLM transcript compaction requires active LLM call options".to_string(),
1465 )
1466 })?,
1467 summarize_prompt,
1468 policy,
1469 )
1470 .await
1471 .map(|summary| (summary, None)),
1472 CompactStrategy::Custom => custom_compaction_summary(
1473 ctx,
1474 old_messages,
1475 archived_count,
1476 custom_compactor.ok_or_else(|| {
1477 VmError::Runtime(
1478 "compact_callback is required when compact_strategy is 'custom'".to_string(),
1479 )
1480 })?,
1481 custom_compactor_reminders,
1482 policy,
1483 )
1484 .await
1485 .map(|summary| (summary, None)),
1486 CompactStrategy::ObservationMask => {
1487 let mask_results = if let Some(cb) = mask_callback {
1488 Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1489 } else {
1490 None
1491 };
1492 let (summary, metrics) = observation_mask_compaction_with_callback(
1493 old_messages,
1494 archived_count,
1495 mask_results.as_deref(),
1496 recap_budget_bytes,
1497 );
1498 Ok((summary, Some(metrics)))
1499 }
1500 }
1501}
1502
1503async fn apply_compaction_strategy_with_fallback(
1504 input: CompactionStrategyInputs<'_>,
1505 fallback_strategy: Option<&CompactStrategy>,
1506) -> Result<(String, CompactStrategy, Option<RecapMetrics>), VmError> {
1507 match apply_compaction_strategy(input).await {
1508 Ok((summary, metrics)) => Ok((summary, input.strategy.clone(), metrics)),
1509 Err(primary_error) => {
1510 let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1511 else {
1512 return Err(primary_error);
1513 };
1514 let fallback_input = CompactionStrategyInputs {
1515 strategy: fallback,
1516 ..input
1517 };
1518 apply_compaction_strategy(fallback_input)
1519 .await
1520 .map(|(summary, metrics)| (summary, fallback.clone(), metrics))
1521 }
1522 }
1523}
1524
1525pub(crate) struct AutoCompactResult {
1526 pub summary: String,
1527 pub strategy: CompactStrategy,
1528 pub recap_metrics: Option<RecapMetrics>,
1529}
1530
1531#[cfg(test)]
1533pub(crate) async fn auto_compact_messages_with_result(
1534 messages: &mut Vec<serde_json::Value>,
1535 config: &AutoCompactConfig,
1536 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1537) -> Result<Option<AutoCompactResult>, VmError> {
1538 auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1539}
1540
1541pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1542 ctx: Option<&AsyncBuiltinCtx>,
1543 messages: &mut Vec<serde_json::Value>,
1544 config: &AutoCompactConfig,
1545 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1546) -> Result<Option<AutoCompactResult>, VmError> {
1547 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1548 return Ok(None);
1549 }
1550 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1551 return Ok(None);
1552 }
1553 let compact_start = config.keep_first.min(messages.len());
1554 let original_split = messages.len().saturating_sub(config.keep_last);
1555 let mut split_at = original_split;
1556 while split_at > compact_start
1560 && split_at < messages.len()
1561 && messages[split_at]
1562 .get("role")
1563 .and_then(|r| r.as_str())
1564 .is_none_or(|r| r != "user")
1565 {
1566 split_at -= 1;
1567 }
1568 if split_at == compact_start {
1571 split_at = original_split;
1572 }
1573 if let Some(volatile_start) = messages[split_at..]
1574 .iter()
1575 .position(is_reasoning_or_tool_turn_message)
1576 .map(|offset| split_at + offset)
1577 {
1578 if let Some(boundary) = volatile_start
1579 .checked_sub(1)
1580 .and_then(|idx| find_prev_user_boundary(messages, idx))
1581 .filter(|boundary| *boundary > compact_start)
1582 {
1583 split_at = boundary;
1584 }
1585 }
1586 split_at = snap_split_off_tool_results(messages, split_at, compact_start);
1591 if split_at <= compact_start {
1592 return Ok(None);
1593 }
1594 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1595 let archived_count = old_messages.len();
1596
1597 clamp_tool_outputs(ctx, messages, config).await?;
1605
1606 let (mut summary, mut strategy, mut recap_metrics) = apply_compaction_strategy_with_fallback(
1607 CompactionStrategyInputs {
1608 ctx,
1609 strategy: &config.compact_strategy,
1610 old_messages: &old_messages,
1611 archived_count,
1612 llm_opts,
1613 custom_compactor: config.custom_compactor.as_ref(),
1614 custom_compactor_reminders: &config.custom_compactor_reminders,
1615 mask_callback: config.mask_callback.as_ref(),
1616 summarize_prompt: config.summarize_prompt.as_deref(),
1617 policy: &config.policy,
1618 recap_budget_bytes: config.recap_budget_bytes,
1619 },
1620 config.fallback_strategy.as_ref(),
1621 )
1622 .await?;
1623
1624 if let Some(hard_limit) = config.hard_limit_tokens {
1625 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1626 let mut estimate_msgs = vec![summary_msg];
1627 estimate_msgs.extend_from_slice(messages.as_slice());
1628 let estimated = estimate_message_tokens(&estimate_msgs);
1629 if estimated > hard_limit {
1630 let tier1_as_messages = vec![serde_json::json!({
1631 "role": "user",
1632 "content": summary,
1633 })];
1634 let (hard_limit_summary, hard_limit_strategy, hard_limit_metrics) =
1635 apply_compaction_strategy_with_fallback(
1636 CompactionStrategyInputs {
1637 ctx,
1638 strategy: &config.hard_limit_strategy,
1639 old_messages: &tier1_as_messages,
1640 archived_count,
1641 llm_opts,
1642 custom_compactor: config.custom_compactor.as_ref(),
1643 custom_compactor_reminders: &config.custom_compactor_reminders,
1644 mask_callback: None,
1645 summarize_prompt: config.summarize_prompt.as_deref(),
1646 policy: &config.policy,
1647 recap_budget_bytes: config.recap_budget_bytes,
1648 },
1649 config.fallback_strategy.as_ref(),
1650 )
1651 .await?;
1652 summary = hard_limit_summary;
1653 strategy = hard_limit_strategy;
1654 recap_metrics = hard_limit_metrics.or(recap_metrics);
1657 }
1658 }
1659
1660 summary = super::repair_ledger::append_repair_ledger_to_summary(
1661 apply_model_visible_policy(summary, &config.policy),
1662 &old_messages,
1663 );
1664
1665 messages.insert(
1666 compact_start,
1667 serde_json::json!({
1668 "role": "user",
1669 "content": summary,
1670 }),
1671 );
1672 Ok(Some(AutoCompactResult {
1673 summary,
1674 strategy,
1675 recap_metrics,
1676 }))
1677}
1678
1679#[cfg(test)]
1681pub(crate) async fn auto_compact_messages(
1682 messages: &mut Vec<serde_json::Value>,
1683 config: &AutoCompactConfig,
1684 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1685) -> Result<Option<String>, VmError> {
1686 Ok(
1687 auto_compact_messages_with_result(messages, config, llm_opts)
1688 .await?
1689 .map(|result| result.summary),
1690 )
1691}
1692
1693fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1694 if !policy.is_model_visible_scope() {
1695 return summary;
1696 }
1697 let Some(directives) = policy.prompt_directives() else {
1698 return summary;
1699 };
1700 summary.push_str("\n\n[compaction instructions]\n");
1701 summary.push_str(&directives);
1702 summary
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707 use super::*;
1708
1709 #[test]
1710 fn microcompact_short_output_unchanged() {
1711 let output = "line1\nline2\nline3\n";
1712 assert_eq!(microcompact_tool_output(output, 1000), output);
1713 }
1714
1715 #[test]
1716 fn microcompact_snaps_to_line_boundaries() {
1717 let lines: Vec<String> = (0..20)
1718 .map(|i| format!("line {i:02} content here"))
1719 .collect();
1720 let output = lines.join("\n");
1721 let result = microcompact_tool_output(&output, 200);
1722 assert!(result.contains("[... "), "should have snip marker");
1723 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1724 assert!(parts.len() >= 2, "should split at marker");
1725 let head = parts[0];
1726 for line in head.lines() {
1727 assert!(
1728 line.starts_with("line "),
1729 "head line should be complete: {line}"
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1736 let mut lines = Vec::new();
1737 for i in 0..50 {
1738 lines.push(format!("verbose output line {i}"));
1739 }
1740 lines.push("src/main.rs:42: error: cannot find value".to_string());
1741 for i in 50..100 {
1742 lines.push(format!("verbose output line {i}"));
1743 }
1744 let output = lines.join("\n");
1745 let result = microcompact_tool_output(&output, 600);
1746 assert!(result.contains("cannot find value"), "diagnostic preserved");
1747 assert!(
1748 result.contains("[diagnostic lines preserved]"),
1749 "has diagnostic marker"
1750 );
1751 }
1752
1753 #[test]
1757 fn failure_signal_filter_keeps_structured_failure_lines() {
1758 for keep in [
1759 "left: 3",
1760 "right: 4",
1761 "expected: foo",
1762 "actual: bar",
1763 " --> src/main.rs:4:9",
1764 "= help: add `use std::fmt;`",
1765 "12 | let x = bad();",
1766 " | ^^^^^^^ not found",
1767 "L42: assertion failed",
1768 "src/main.rs:42: error: cannot find value",
1769 "FAIL: TestThing",
1770 "panic: index out of range",
1771 ] {
1772 assert!(
1773 is_failure_signal_line(keep),
1774 "should keep failure-signal line: {keep:?}"
1775 );
1776 }
1777 for drop in [
1778 "verbose output line 7",
1779 "compiling crate foo",
1780 " let y = ok();",
1781 "",
1782 ] {
1783 assert!(
1784 !is_failure_signal_line(drop),
1785 "should drop ordinary line: {drop:?}"
1786 );
1787 }
1788 }
1789
1790 #[test]
1794 fn default_mask_preserves_failure_detail() {
1795 let mut lines = vec!["running 1 test".to_string()];
1796 for i in 0..40 {
1797 lines.push(format!("noise line {i}"));
1798 }
1799 lines.push("assertion `left == right` failed".to_string());
1800 lines.push(" left: 3".to_string());
1801 lines.push(" right: 4".to_string());
1802 lines.push(" --> src/lib.rs:10:5".to_string());
1803 for i in 40..80 {
1804 lines.push(format!("more noise {i}"));
1805 }
1806 let content = lines.join("\n");
1807 let masked = default_mask_tool_result("tool", &content);
1808 assert!(
1809 masked.contains("masked"),
1810 "still reports it masked: {masked}"
1811 );
1812 assert!(
1813 masked.contains("failure lines preserved"),
1814 "should flag preserved lines: {masked}"
1815 );
1816 assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1817 assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1818 assert!(
1819 masked.contains("--> src/lib.rs:10:5"),
1820 "keeps rustc location: {masked}"
1821 );
1822 assert!(
1823 !masked.contains("noise line 7"),
1824 "drops ordinary noise: {masked}"
1825 );
1826 }
1827
1828 #[test]
1830 fn default_mask_without_failure_lines_stays_terse() {
1831 let lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1832 let content = lines.join("\n");
1833 let masked = default_mask_tool_result("tool", &content);
1834 assert!(masked.contains("masked]"), "should mask: {masked}");
1835 assert!(
1836 !masked.contains("failure lines preserved"),
1837 "no failure lines to preserve: {masked}"
1838 );
1839 }
1840
1841 #[test]
1842 fn token_estimate_counts_structured_message_content() {
1843 let text = "x".repeat(400);
1844 let messages = vec![serde_json::json!({
1845 "role": "user",
1846 "content": [
1847 {"type": "text", "text": text},
1848 {"type": "input_text", "text": "tail"},
1849 ],
1850 "reasoning": {"text": "scratch"},
1851 "tool_calls": [{
1852 "id": "call_1",
1853 "type": "function",
1854 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1855 }],
1856 })];
1857
1858 assert!(
1859 estimate_message_tokens(&messages) >= 100,
1860 "structured content must not count as zero"
1861 );
1862 }
1863
1864 #[test]
1865 fn compaction_policy_instructions_extend_by_default() {
1866 let policy = CompactionPolicy {
1867 instructions: Some("Keep the failing test names.".to_string()),
1868 ..Default::default()
1869 };
1870 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1871 .expect("prompt renders");
1872
1873 assert_eq!(policy.instruction_mode(), "extend");
1874 assert!(prompt.contains("Preserve goals, constraints"));
1875 assert!(prompt.contains("Additional compaction instructions"));
1876 assert!(prompt.contains("Keep the failing test names."));
1877 }
1878
1879 #[test]
1880 fn compaction_policy_can_replace_default_instructions() {
1881 let policy = CompactionPolicy {
1882 instructions: Some("Only keep repro steps.".to_string()),
1883 extend_default_instructions: Some(false),
1884 ..Default::default()
1885 };
1886 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1887 .expect("prompt renders");
1888
1889 assert_eq!(policy.instruction_mode(), "replace");
1890 assert!(prompt.contains("according to these instructions"));
1891 assert!(prompt.contains("Only keep repro steps."));
1892 assert!(!prompt.contains("Preserve goals, constraints"));
1893 }
1894
1895 #[test]
1896 fn snap_to_line_end_finds_newline() {
1897 let s = "line1\nline2\nline3\nline4\n";
1898 let head = snap_to_line_end(s, 12);
1899 assert!(head.ends_with('\n'), "should end at newline");
1900 assert!(head.contains("line1"));
1901 }
1902
1903 #[test]
1904 fn snap_to_line_start_finds_newline() {
1905 let s = "line1\nline2\nline3\nline4\n";
1906 let tail = snap_to_line_start(s, 12);
1907 assert!(
1908 tail.starts_with("line"),
1909 "should start at line boundary: {tail}"
1910 );
1911 }
1912
1913 #[test]
1914 fn auto_compact_preserves_reasoning_tool_suffix() {
1915 let mut messages = vec![
1916 serde_json::json!({"role": "user", "content": "old task"}),
1917 serde_json::json!({"role": "assistant", "content": "old reply"}),
1918 serde_json::json!({"role": "user", "content": "new task"}),
1919 serde_json::json!({
1920 "role": "assistant",
1921 "content": "",
1922 "reasoning": "think first",
1923 "tool_calls": [{
1924 "id": "call_1",
1925 "type": "function",
1926 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1927 }],
1928 }),
1929 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1930 ];
1931 let config = AutoCompactConfig {
1932 token_threshold: 1,
1933 keep_last: 2,
1934 ..Default::default()
1935 };
1936
1937 let runtime = tokio::runtime::Builder::new_current_thread()
1938 .enable_all()
1939 .build()
1940 .expect("runtime");
1941 let summary = runtime
1942 .block_on(auto_compact_messages(&mut messages, &config, None))
1943 .expect("compaction succeeds");
1944
1945 assert!(summary.is_some());
1946 assert_eq!(messages[1]["role"], "user");
1947 assert_eq!(messages[2]["role"], "assistant");
1948 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1949 assert_eq!(messages[3]["role"], "tool");
1950 assert_eq!(messages[3]["tool_call_id"], "call_1");
1951 }
1952
1953 #[test]
1960 fn auto_compact_never_splits_assistant_tool_use_from_its_result() {
1961 let tool_call = |id: &str| {
1962 serde_json::json!({
1963 "id": id,
1964 "type": "function",
1965 "function": {"name": "run", "arguments": "{}"}
1966 })
1967 };
1968 let mut messages = vec![
1969 serde_json::json!({"role": "user", "content": "task"}),
1970 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c0")]}),
1971 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1972 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c1")]}),
1973 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1974 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c2")]}),
1975 serde_json::json!({"role": "tool", "tool_call_id": "c2", "content": "r2"}),
1976 ];
1977 let config = AutoCompactConfig {
1980 token_threshold: 1,
1981 keep_first: 0,
1982 keep_last: 3,
1983 ..Default::default()
1984 };
1985
1986 let runtime = tokio::runtime::Builder::new_current_thread()
1987 .enable_all()
1988 .build()
1989 .expect("runtime");
1990 let summary = runtime
1991 .block_on(auto_compact_messages(&mut messages, &config, None))
1992 .expect("compaction succeeds");
1993 assert!(summary.is_some(), "compaction should trigger");
1994
1995 assert_eq!(messages[0]["role"], "user", "summary head");
1997 assert_eq!(messages[1]["role"], "assistant");
1998 assert_eq!(messages[1]["tool_calls"][0]["id"], "c1");
1999 assert_eq!(messages[2]["role"], "tool");
2000 assert_eq!(messages[2]["tool_call_id"], "c1");
2001 assert_eq!(messages[3]["tool_calls"][0]["id"], "c2");
2002 assert_eq!(messages[4]["tool_call_id"], "c2");
2003 for (idx, message) in messages.iter().enumerate() {
2005 if message["role"] == "tool" {
2006 let id = message["tool_call_id"].as_str().expect("tool_call_id");
2007 let paired = messages[..idx].iter().any(|prev| {
2008 prev["tool_calls"]
2009 .as_array()
2010 .is_some_and(|calls| calls.iter().any(|call| call["id"] == id))
2011 });
2012 assert!(paired, "tool_result {id} orphaned in kept window");
2013 }
2014 }
2015 }
2016
2017 #[test]
2018 fn snap_split_off_tool_results_handles_all_result_shapes() {
2019 let openai = vec![
2023 serde_json::json!({"role": "user", "content": "task"}),
2024 serde_json::json!({"role": "assistant", "content": "", "tool_calls": []}),
2025 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2026 ];
2027 assert_eq!(snap_split_off_tool_results(&openai, 2, 0), 1);
2028 let anthropic = vec![
2030 serde_json::json!({"role": "user", "content": "task"}),
2031 serde_json::json!({"role": "assistant", "content": ""}),
2032 serde_json::json!({"role": "tool_result", "tool_use_id": "c0", "content": "r0"}),
2033 ];
2034 assert_eq!(snap_split_off_tool_results(&anthropic, 2, 0), 1);
2035 let user_blocks = vec![
2037 serde_json::json!({"role": "user", "content": "task"}),
2038 serde_json::json!({"role": "assistant", "content": ""}),
2039 serde_json::json!({
2040 "role": "user",
2041 "content": [{"type": "tool_result", "tool_use_id": "c0", "content": "r0"}],
2042 }),
2043 ];
2044 assert_eq!(snap_split_off_tool_results(&user_blocks, 2, 0), 1);
2045 let text = vec![
2047 serde_json::json!({"role": "assistant", "content": ""}),
2048 serde_json::json!({"role": "user", "content": "plain"}),
2049 ];
2050 assert_eq!(snap_split_off_tool_results(&text, 1, 0), 1);
2051 let pinned = vec![
2055 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2056 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
2057 serde_json::json!({"role": "assistant", "content": "done"}),
2058 ];
2059 assert_eq!(snap_split_off_tool_results(&pinned, 1, 0), 2);
2060 }
2061
2062 #[test]
2063 fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
2064 let big = "x".repeat(4000);
2067 let big_len = big.len();
2068 let mut messages = vec![
2069 serde_json::json!({"role": "user", "content": "old task"}),
2070 serde_json::json!({"role": "assistant", "content": "old reply"}),
2071 serde_json::json!({"role": "user", "content": "new task"}),
2072 serde_json::json!({"role": "assistant", "content": "calling tool"}),
2073 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2074 ];
2075 let config = AutoCompactConfig {
2076 token_threshold: 1,
2077 keep_last: 2,
2078 tool_output_max_chars: 500,
2079 ..Default::default()
2080 };
2081
2082 let runtime = tokio::runtime::Builder::new_current_thread()
2083 .enable_all()
2084 .build()
2085 .expect("runtime");
2086 let result = runtime
2087 .block_on(auto_compact_messages(&mut messages, &config, None))
2088 .expect("compaction succeeds");
2089 assert!(result.is_some(), "compaction should trigger");
2090
2091 let tool_msg = messages
2092 .iter()
2093 .find(|message| message["role"] == "tool")
2094 .expect("tool message kept in window");
2095 assert_eq!(tool_msg["tool_call_id"], "call_1");
2097 let content = tool_msg["content"].as_str().expect("string content");
2099 assert!(
2100 content.len() < big_len,
2101 "tool output should be clamped: {} vs {}",
2102 content.len(),
2103 big_len
2104 );
2105 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2106 }
2107
2108 #[test]
2111 fn observation_mask_preserves_pinned_live_file_view() {
2112 let pinned_body = format!(
2113 "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
2114 NO_COMPACT_MARKER,
2115 (0..40)
2116 .map(|i| format!(" {i} let x = compute({i});"))
2117 .collect::<Vec<_>>()
2118 .join("\n")
2119 );
2120 let verbose_unpinned = (0..60)
2121 .map(|i| format!("verbose scan output line {i}"))
2122 .collect::<Vec<_>>()
2123 .join("\n");
2124 let archived = vec![
2126 serde_json::json!({"role": "user", "content": verbose_unpinned}),
2127 serde_json::json!({"role": "user", "content": pinned_body}),
2128 ];
2129 let summary = observation_mask_compaction(&archived, archived.len());
2130 assert!(
2132 summary.contains("Edited region now reads"),
2133 "pinned heading survived: {summary}"
2134 );
2135 assert!(
2136 summary.contains("let x = compute(39);"),
2137 "pinned body survived verbatim"
2138 );
2139 assert!(summary.contains("masked]"), "unpinned output was masked");
2141 assert!(!summary.contains("verbose scan output line 30"));
2142 }
2143
2144 #[test]
2147 fn clamp_exempts_pinned_tool_output() {
2148 let pinned_big = format!(
2149 "## Exact current file text {}\n{}",
2150 NO_COMPACT_MARKER,
2151 "x".repeat(4000)
2152 );
2153 let pinned_len = pinned_big.len();
2154 let unpinned_big = "y".repeat(4000);
2155 let unpinned_len = unpinned_big.len();
2156 let mut messages = vec![
2157 serde_json::json!({"role": "user", "content": "old task"}),
2158 serde_json::json!({"role": "assistant", "content": "reply"}),
2159 serde_json::json!({"role": "user", "content": "new task"}),
2160 serde_json::json!({"role": "assistant", "content": "calling tools"}),
2161 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
2162 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
2163 serde_json::json!({"role": "user", "content": "continue"}),
2164 ];
2165 let config = AutoCompactConfig {
2166 token_threshold: 1,
2167 keep_last: 4,
2168 tool_output_max_chars: 500,
2169 ..Default::default()
2170 };
2171 let runtime = tokio::runtime::Builder::new_current_thread()
2172 .enable_all()
2173 .build()
2174 .expect("runtime");
2175 runtime
2176 .block_on(auto_compact_messages(&mut messages, &config, None))
2177 .expect("compaction succeeds");
2178
2179 let pinned_msg = messages
2180 .iter()
2181 .find(|m| m["tool_call_id"] == "c1")
2182 .expect("pinned tool message kept");
2183 assert_eq!(
2184 pinned_msg["content"].as_str().map(str::len),
2185 Some(pinned_len),
2186 "pinned output must be intact (unclamped)"
2187 );
2188 let unpinned_msg = messages
2189 .iter()
2190 .find(|m| m["tool_call_id"] == "c0")
2191 .expect("unpinned tool message kept");
2192 assert!(
2193 unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
2194 "unpinned output of the same size must be clamped"
2195 );
2196 }
2197
2198 #[test]
2203 fn pin_bound_keeps_only_latest_segments() {
2204 let make = |gen: usize| {
2208 let body = (0..40)
2209 .map(|i| format!("marker-gen-{gen} body line {i}"))
2210 .collect::<Vec<_>>()
2211 .join("\n");
2212 serde_json::json!({
2213 "role": "user",
2214 "content": format!(
2215 "## Edited region now reads (gen {gen}) {}\n{}",
2216 NO_COMPACT_MARKER, body
2217 ),
2218 })
2219 };
2220 let archived: Vec<_> = (0..6).map(make).collect();
2221
2222 let pinned = latest_pinned_indices(archived.iter(), |m| {
2224 m.get("content").and_then(|c| c.as_str())
2225 });
2226 assert_eq!(
2227 pinned.len(),
2228 MAX_PINNED_SEGMENTS,
2229 "only the latest MAX_PINNED_SEGMENTS are pinned"
2230 );
2231 assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
2232 assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
2233
2234 let summary = observation_mask_compaction(&archived, archived.len());
2238 assert!(
2239 summary.contains("marker-gen-5")
2240 && summary.contains("marker-gen-4")
2241 && summary.contains("marker-gen-3"),
2242 "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
2243 );
2244 assert!(
2245 !summary.contains("marker-gen-0")
2246 && !summary.contains("marker-gen-1")
2247 && !summary.contains("marker-gen-2"),
2248 "older pinned snapshots are masked (bound enforced)"
2249 );
2250 assert!(summary.contains("masked]"), "older snapshots were masked");
2251 }
2252
2253 #[test]
2255 fn no_pins_preserves_prior_clamp_behavior() {
2256 let big = "x".repeat(4000);
2257 let big_len = big.len();
2258 let mut messages = vec![
2259 serde_json::json!({"role": "user", "content": "old task"}),
2260 serde_json::json!({"role": "assistant", "content": "old reply"}),
2261 serde_json::json!({"role": "user", "content": "new task"}),
2262 serde_json::json!({"role": "assistant", "content": "calling tool"}),
2263 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2264 ];
2265 let config = AutoCompactConfig {
2266 token_threshold: 1,
2267 keep_last: 2,
2268 tool_output_max_chars: 500,
2269 ..Default::default()
2270 };
2271 let runtime = tokio::runtime::Builder::new_current_thread()
2272 .enable_all()
2273 .build()
2274 .expect("runtime");
2275 let result = runtime
2276 .block_on(auto_compact_messages(&mut messages, &config, None))
2277 .expect("compaction succeeds");
2278 assert!(result.is_some());
2279 let tool_msg = messages
2280 .iter()
2281 .find(|m| m["role"] == "tool")
2282 .expect("tool kept");
2283 let content = tool_msg["content"].as_str().expect("string content");
2284 assert!(content.len() < big_len, "unpinned output clamped as before");
2285 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2286 }
2287}