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 #[expect(
724 clippy::string_slice,
725 reason = "digits is an ASCII-digit prefix of rest, so its len is a boundary"
726 )]
727 let failing_line_marker = {
728 if let Some(rest) = trimmed.strip_prefix('L') {
729 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
730 !digits.is_empty() && rest[digits.len()..].starts_with(':')
731 } else {
732 false
733 }
734 };
735
736 has_strong_keyword
737 || (has_file_line && has_weak_keyword)
738 || positional
739 || assertion_value
740 || rustc_continuation
741 || failing_line_marker
742}
743
744#[expect(
748 clippy::string_slice,
749 reason = "search_end is a floor_char_boundary; pos indexes an ASCII newline"
750)]
751fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
752 if max_bytes >= s.len() {
753 return s;
754 }
755 let search_end = s.floor_char_boundary(max_bytes);
756 match s[..search_end].rfind('\n') {
757 Some(pos) => &s[..pos + 1],
758 None => &s[..search_end], }
760}
761
762#[expect(
766 clippy::string_slice,
767 reason = "search_start is a ceil_char_boundary; line_start follows an ASCII newline"
768)]
769fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
770 if start_byte == 0 {
771 return s;
772 }
773 let search_start = s.ceil_char_boundary(start_byte);
774 if search_start >= s.len() {
775 return "";
776 }
777 match s[search_start..].find('\n') {
778 Some(pos) => {
779 let line_start = search_start + pos + 1;
780 if line_start < s.len() {
781 &s[line_start..]
782 } else {
783 &s[search_start..]
784 }
785 }
786 None => &s[search_start..], }
788}
789
790fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
791 messages
792 .iter()
793 .map(|msg| {
794 let role = msg
795 .get("role")
796 .and_then(|v| v.as_str())
797 .unwrap_or("user")
798 .to_uppercase();
799 let content = msg
800 .get("content")
801 .and_then(|v| v.as_str())
802 .unwrap_or_default();
803 format!("{role}: {content}")
804 })
805 .collect::<Vec<_>>()
806 .join("\n")
807}
808
809fn truncate_compaction_summary(
810 old_messages: &[serde_json::Value],
811 archived_count: usize,
812) -> String {
813 truncate_compaction_summary_with_context(old_messages, archived_count, false)
814}
815
816fn truncate_compaction_summary_with_context(
817 old_messages: &[serde_json::Value],
818 archived_count: usize,
819 is_llm_fallback: bool,
820) -> String {
821 let per_msg_limit = 500_usize;
822 let summary_parts: Vec<String> = old_messages
823 .iter()
824 .filter_map(|m| {
825 let role = m.get("role")?.as_str()?;
826 let content = m.get("content")?.as_str()?;
827 if content.is_empty() {
828 return None;
829 }
830 #[expect(
831 clippy::string_slice,
832 reason = "floor_char_boundary returns a char boundary"
833 )]
834 let truncated = if content.len() > per_msg_limit {
835 format!(
836 "{}... [truncated from {} chars]",
837 &content[..content.floor_char_boundary(per_msg_limit)],
838 content.len()
839 )
840 } else {
841 content.to_string()
842 };
843 Some(format!("[{role}] {truncated}"))
844 })
845 .take(15)
846 .collect();
847 let header = if is_llm_fallback {
848 format!(
849 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
850 )
851 } else {
852 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
853 };
854 format!(
855 "{header}\n{}{}",
856 summary_parts.join("\n"),
857 if archived_count > 15 {
858 format!("\n... and {} more", archived_count - 15)
859 } else {
860 String::new()
861 }
862 )
863}
864
865fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
866 if let Some(map) = value.as_dict() {
867 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
868 return Ok(summary.display());
869 }
870 }
871 match value {
872 VmValue::String(text) => Ok(text.to_string()),
873 VmValue::Nil => Ok(String::new()),
874 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
875 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
876 }
877}
878
879async fn llm_compaction_summary(
880 old_messages: &[serde_json::Value],
881 archived_count: usize,
882 llm_opts: &crate::llm::api::LlmCallOptions,
883 summarize_prompt: Option<&str>,
884 policy: &CompactionPolicy,
885) -> Result<String, VmError> {
886 let mut compact_opts = llm_opts.clone();
887 let formatted = format_compaction_messages(old_messages);
888 compact_opts.system = None;
889 compact_opts.transcript_summary = None;
890 compact_opts.native_tools = None;
891 compact_opts.tool_choice = None;
892 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
893 compact_opts.output_schema = None;
894 let prompt =
895 render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
896 compact_opts.messages = vec![serde_json::json!({
897 "role": "user",
898 "content": prompt,
899 })];
900 let manifest = &mut compact_opts.context_manifest;
901 manifest.record_system_transform("compaction", "stdlib:compaction", "removed system", None);
902 compact_opts.set_call_role("compaction");
903 let result = vm_call_llm_full(&compact_opts).await?;
904 let summary = result.text.trim();
905 if summary.is_empty() {
906 Ok(truncate_compaction_summary_with_context(
907 old_messages,
908 archived_count,
909 true,
910 ))
911 } else {
912 Ok(format!(
913 "[auto-compacted {archived_count} older messages]\n{summary}"
914 ))
915 }
916}
917
918fn render_llm_compaction_prompt(
919 summarize_prompt: Option<&str>,
920 formatted: &str,
921 archived_count: usize,
922 policy: &CompactionPolicy,
923) -> Result<String, VmError> {
924 if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
925 return render_replacement_compaction_prompt(policy, formatted, archived_count);
926 }
927 let mut bindings = crate::value::DictMap::new();
928 bindings.put_str("formatted_messages", formatted);
929 bindings.insert(
930 crate::value::intern_key("archived_count"),
931 VmValue::Int(archived_count as i64),
932 );
933 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
934 let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
935 "orchestration/prompts/compaction_summary.harn.prompt",
936 Some(&bindings),
937 )?;
938 return Ok(extend_compaction_prompt(prompt, policy));
939 };
940
941 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
942 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
943 let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
944 .map_err(VmError::from)?;
945 Ok(extend_compaction_prompt(prompt, policy))
946}
947
948fn render_replacement_compaction_prompt(
949 policy: &CompactionPolicy,
950 formatted: &str,
951 archived_count: usize,
952) -> Result<String, VmError> {
953 let directives = policy.prompt_directives().unwrap_or_default();
954 let mut bindings = crate::value::DictMap::new();
955 bindings.put_str("directives", directives);
956 bindings.put_str("formatted_messages", formatted);
957 bindings.insert(
958 crate::value::intern_key("archived_count"),
959 VmValue::Int(archived_count as i64),
960 );
961 crate::stdlib::template::render_stdlib_prompt_asset(
962 "orchestration/prompts/compaction_policy_replacement.harn.prompt",
963 Some(&bindings),
964 )
965}
966
967fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
968 let Some(directives) = policy.prompt_directives() else {
969 return prompt;
970 };
971 prompt.push_str(
972 "\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",
973 );
974 prompt.push_str(&directives);
975 prompt
976}
977
978async fn custom_compaction_summary(
979 ctx: Option<&AsyncBuiltinCtx>,
980 old_messages: &[serde_json::Value],
981 archived_count: usize,
982 callback: &VmValue,
983 reminders: &[VmValue],
984 policy: &CompactionPolicy,
985) -> Result<String, VmError> {
986 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
987 return Err(VmError::Runtime(
988 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
989 ));
990 };
991 let Some(ctx) = ctx else {
992 return Err(VmError::Runtime(
993 "custom transcript compaction requires an async builtin VM context".to_string(),
994 ));
995 };
996 let mut vm = ctx.child_vm();
997 let messages_vm = VmValue::List(std::sync::Arc::new(
998 old_messages
999 .iter()
1000 .map(crate::stdlib::json_to_vm_value)
1001 .collect(),
1002 ));
1003 let result = if policy.has_metadata()
1004 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
1005 {
1006 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
1007 let policy_vm = compaction_policy_to_vm_value(policy);
1008 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
1009 .await
1010 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
1011 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
1012 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
1013 .await
1014 } else {
1015 vm.call_closure_pub(&closure, &[messages_vm]).await
1016 };
1017 let summary = compact_summary_text_from_value(&result?)?;
1018 ctx.forward_output(&vm.take_output());
1019 if summary.trim().is_empty() {
1020 Ok(truncate_compaction_summary(old_messages, archived_count))
1021 } else {
1022 Ok(format!(
1023 "[auto-compacted {archived_count} older messages]\n{summary}"
1024 ))
1025 }
1026}
1027
1028pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
1036
1037pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
1046
1047fn is_pinned_content(content: &str) -> bool {
1049 content.contains(NO_COMPACT_MARKER)
1050}
1051
1052fn latest_pinned_indices<'a, F>(
1057 messages: impl Iterator<Item = &'a serde_json::Value>,
1058 content_of: F,
1059) -> std::collections::HashSet<usize>
1060where
1061 F: Fn(&serde_json::Value) -> Option<&str>,
1062{
1063 let pinned: Vec<usize> = messages
1065 .enumerate()
1066 .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
1067 .map(|(idx, _)| idx)
1068 .collect();
1069 pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
1070}
1071
1072fn content_should_preserve(content: &str) -> bool {
1078 content.len() < 500
1079}
1080
1081fn default_mask_tool_result(role: &str, content: &str) -> String {
1091 let first_line = content.lines().next().unwrap_or(content);
1092 let line_count = content.lines().count();
1093 let char_count = content.len();
1094 if line_count <= 3 {
1095 return format!("[{role}] {content}");
1096 }
1097 #[expect(
1098 clippy::string_slice,
1099 reason = "floor_char_boundary returns a char boundary"
1100 )]
1101 let preview = &first_line[..first_line.floor_char_boundary(120)];
1102 let kept: Vec<&str> = content
1105 .lines()
1106 .skip(1)
1107 .filter(|line| is_failure_signal_line(line))
1108 .take(32)
1109 .collect();
1110 if kept.is_empty() {
1111 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
1112 } else {
1113 format!(
1114 "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
1115 failure lines preserved]\n{}",
1116 kept.join("\n")
1117 )
1118 }
1119}
1120
1121pub(crate) const RECAP_HEADER_SENTINEL: &str = "via observation masking]";
1128
1129const PRIOR_RECAP_CARRY_CAP: usize = 6_000;
1133
1134const ASSISTANT_PREVIEW_CHARS: usize = 240;
1139
1140fn is_prior_recap(content: &str) -> bool {
1142 content.contains(RECAP_HEADER_SENTINEL)
1143}
1144
1145fn assistant_preview(content: &str) -> String {
1149 if content.len() <= ASSISTANT_PREVIEW_CHARS {
1150 return format!("[assistant] {content}");
1151 }
1152 let head = snap_to_line_end(content, ASSISTANT_PREVIEW_CHARS);
1153 let dropped = content.len().saturating_sub(head.len());
1154 format!("[assistant] {head}... [assistant turn truncated, {dropped} chars masked]")
1155}
1156
1157fn collapse_repeats(lines: Vec<String>) -> Vec<String> {
1161 let mut out: Vec<String> = Vec::with_capacity(lines.len());
1162 let mut run = 0usize;
1163 for line in lines {
1164 if out
1165 .last()
1166 .is_some_and(|prev| strip_repeat_suffix(prev) == line)
1167 {
1168 run += 1;
1169 let base =
1170 strip_repeat_suffix(out.last().expect("run implies a last line")).to_string();
1171 *out.last_mut().expect("run implies a last line") = format!("{base} (x{})", run + 1);
1172 } else {
1173 run = 0;
1174 out.push(line);
1175 }
1176 }
1177 out
1178}
1179
1180#[expect(
1181 clippy::string_slice,
1182 reason = "idx is an rfind offset on the same line"
1183)]
1184fn strip_repeat_suffix(line: &str) -> &str {
1185 line.rfind(" (x")
1186 .filter(|_| line.ends_with(')'))
1187 .map(|idx| &line[..idx])
1188 .unwrap_or(line)
1189}
1190
1191#[cfg(test)]
1193pub(crate) fn observation_mask_compaction(
1194 old_messages: &[serde_json::Value],
1195 archived_count: usize,
1196) -> String {
1197 observation_mask_compaction_with_callback(
1198 old_messages,
1199 archived_count,
1200 None,
1201 DEFAULT_RECAP_BUDGET_BYTES,
1202 )
1203 .0
1204}
1205
1206#[cfg(test)]
1209pub(crate) fn observation_mask_compaction_for_test(
1210 old_messages: &[serde_json::Value],
1211 archived_count: usize,
1212 budget_bytes: usize,
1213) -> (String, RecapMetrics) {
1214 observation_mask_compaction_with_callback(old_messages, archived_count, None, budget_bytes)
1215}
1216
1217fn observation_mask_compaction_with_callback(
1233 old_messages: &[serde_json::Value],
1234 archived_count: usize,
1235 mask_results: Option<&[Option<String>]>,
1236 budget_bytes: usize,
1237) -> (String, RecapMetrics) {
1238 let header =
1239 format!("[auto-compacted {archived_count} older messages via observation masking]");
1240 let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
1241 msg.get("content").and_then(|v| v.as_str())
1242 });
1243 let prior_recap_idx = old_messages
1246 .iter()
1247 .enumerate()
1248 .rev()
1249 .find(|(_, msg)| {
1250 msg.get("content")
1251 .and_then(|v| v.as_str())
1252 .is_some_and(is_prior_recap)
1253 })
1254 .map(|(idx, _)| idx);
1255
1256 let mut metrics = RecapMetrics {
1257 budget_bytes,
1258 ..RecapMetrics::default()
1259 };
1260 let mut rendered_rev: Vec<String> = Vec::new();
1263 let mut used = header.len();
1264
1265 for (idx, msg) in old_messages.iter().enumerate().rev() {
1266 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
1267 let content = msg
1268 .get("content")
1269 .and_then(|v| v.as_str())
1270 .unwrap_or_default();
1271 if content.is_empty() {
1272 continue;
1273 }
1274
1275 if Some(idx) == prior_recap_idx {
1276 let carried = snap_to_line_end(content, PRIOR_RECAP_CARRY_CAP);
1277 rendered_rev.push(format!("[prior recap] {carried}"));
1278 metrics.carried_prior_recap = true;
1279 continue;
1280 }
1281
1282 if pinned.contains(&idx) {
1284 rendered_rev.push(format!("[{role}] {content}"));
1285 continue;
1286 }
1287
1288 let (line, is_result) = if role == "assistant" {
1289 (assistant_preview(content), false)
1290 } else if content_should_preserve(content) {
1291 (format!("[{role}] {content}"), true)
1292 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
1293 (custom.clone(), true)
1294 } else {
1295 (default_mask_tool_result(role, content), true)
1296 };
1297
1298 if used + line.len() + 1 > budget_bytes {
1299 metrics.dropped_count += 1;
1300 continue;
1301 }
1302 used += line.len() + 1;
1303 if is_result {
1304 metrics.kept_results_count += 1;
1305 }
1306 rendered_rev.push(line);
1307 }
1308
1309 let mut body: Vec<String> = rendered_rev.into_iter().rev().collect();
1310 body = collapse_repeats(body);
1311 let mut parts = vec![header];
1312 parts.append(&mut body);
1313 if metrics.dropped_count > 0 {
1314 parts.push(format!(
1315 "[{} older message(s) dropped to fit recap budget]",
1316 metrics.dropped_count
1317 ));
1318 }
1319 let summary = parts.join("\n");
1320 metrics.recap_bytes = summary.len();
1321 (summary, metrics)
1322}
1323
1324async fn invoke_mask_callback(
1326 ctx: Option<&AsyncBuiltinCtx>,
1327 callback: &VmValue,
1328 old_messages: &[serde_json::Value],
1329) -> Result<Vec<Option<String>>, VmError> {
1330 let VmValue::Closure(closure) = callback.clone() else {
1331 return Err(VmError::Runtime(
1332 "mask_callback must be a closure".to_string(),
1333 ));
1334 };
1335 let Some(ctx) = ctx else {
1336 return Err(VmError::Runtime(
1337 "mask_callback requires an async builtin VM context".to_string(),
1338 ));
1339 };
1340 let mut vm = ctx.child_vm();
1341 let messages_vm = VmValue::List(std::sync::Arc::new(
1342 old_messages
1343 .iter()
1344 .map(crate::stdlib::json_to_vm_value)
1345 .collect(),
1346 ));
1347 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
1348 ctx.forward_output(&vm.take_output());
1349 let list = match result {
1350 VmValue::List(items) => items,
1351 _ => return Ok(vec![None; old_messages.len()]),
1352 };
1353 Ok(list
1354 .iter()
1355 .map(|v| match v {
1356 VmValue::String(s) => Some(s.to_string()),
1357 VmValue::Nil => None,
1358 _ => None,
1359 })
1360 .collect())
1361}
1362
1363async fn clamp_tool_outputs(
1370 ctx: Option<&AsyncBuiltinCtx>,
1371 messages: &mut [serde_json::Value],
1372 config: &AutoCompactConfig,
1373) -> Result<(), VmError> {
1374 if config.tool_output_max_chars == 0 {
1375 return Ok(());
1376 }
1377 let pinned = latest_pinned_indices(messages.iter(), |msg| {
1382 if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
1383 msg.get("content").and_then(|content| content.as_str())
1384 } else {
1385 None
1386 }
1387 });
1388 for (idx, message) in messages.iter_mut().enumerate() {
1389 if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
1390 continue;
1391 }
1392 let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
1393 continue;
1394 };
1395 if content.len() <= config.tool_output_max_chars {
1396 continue;
1397 }
1398 if pinned.contains(&idx) {
1399 continue;
1400 }
1401 let content = content.to_string();
1402 let replacement = match (config.compress_callback.as_ref(), ctx) {
1403 (Some(callback), Some(ctx)) => {
1404 invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
1405 .await?
1406 }
1407 _ => microcompact_tool_output(&content, config.tool_output_max_chars),
1408 };
1409 message["content"] = serde_json::Value::String(replacement);
1410 }
1411 Ok(())
1412}
1413
1414async fn invoke_compress_callback(
1418 ctx: &AsyncBuiltinCtx,
1419 callback: &VmValue,
1420 content: &str,
1421 max_chars: usize,
1422) -> Result<String, VmError> {
1423 let VmValue::Closure(closure) = callback.clone() else {
1424 return Err(VmError::Runtime(
1425 "compress_callback must be a closure".to_string(),
1426 ));
1427 };
1428 let mut vm = ctx.child_vm();
1429 let args = [
1430 VmValue::String(arcstr::ArcStr::from(content)),
1431 VmValue::Int(max_chars as i64),
1432 ];
1433 let result = vm.call_closure_pub(&closure, &args).await?;
1434 ctx.forward_output(&vm.take_output());
1435 match result {
1436 VmValue::String(text) => Ok(text.to_string()),
1437 _ => Ok(microcompact_tool_output(content, max_chars)),
1438 }
1439}
1440
1441#[derive(Clone, Copy)]
1442struct CompactionStrategyInputs<'a> {
1443 ctx: Option<&'a AsyncBuiltinCtx>,
1444 strategy: &'a CompactStrategy,
1445 old_messages: &'a [serde_json::Value],
1446 archived_count: usize,
1447 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
1448 custom_compactor: Option<&'a VmValue>,
1449 custom_compactor_reminders: &'a [VmValue],
1450 mask_callback: Option<&'a VmValue>,
1451 summarize_prompt: Option<&'a str>,
1452 policy: &'a CompactionPolicy,
1453 recap_budget_bytes: usize,
1454}
1455
1456async fn apply_compaction_strategy(
1461 input: CompactionStrategyInputs<'_>,
1462) -> Result<(String, Option<RecapMetrics>), VmError> {
1463 let CompactionStrategyInputs {
1464 strategy,
1465 old_messages,
1466 archived_count,
1467 llm_opts,
1468 custom_compactor,
1469 custom_compactor_reminders,
1470 mask_callback,
1471 summarize_prompt,
1472 policy,
1473 recap_budget_bytes,
1474 ctx,
1475 } = input;
1476 match strategy {
1477 CompactStrategy::Truncate => Ok((
1478 truncate_compaction_summary(old_messages, archived_count),
1479 None,
1480 )),
1481 CompactStrategy::Llm => llm_compaction_summary(
1482 old_messages,
1483 archived_count,
1484 llm_opts.ok_or_else(|| {
1485 VmError::Runtime(
1486 "LLM transcript compaction requires active LLM call options".to_string(),
1487 )
1488 })?,
1489 summarize_prompt,
1490 policy,
1491 )
1492 .await
1493 .map(|summary| (summary, None)),
1494 CompactStrategy::Custom => custom_compaction_summary(
1495 ctx,
1496 old_messages,
1497 archived_count,
1498 custom_compactor.ok_or_else(|| {
1499 VmError::Runtime(
1500 "compact_callback is required when compact_strategy is 'custom'".to_string(),
1501 )
1502 })?,
1503 custom_compactor_reminders,
1504 policy,
1505 )
1506 .await
1507 .map(|summary| (summary, None)),
1508 CompactStrategy::ObservationMask => {
1509 let mask_results = if let Some(cb) = mask_callback {
1510 Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1511 } else {
1512 None
1513 };
1514 let (summary, metrics) = observation_mask_compaction_with_callback(
1515 old_messages,
1516 archived_count,
1517 mask_results.as_deref(),
1518 recap_budget_bytes,
1519 );
1520 Ok((summary, Some(metrics)))
1521 }
1522 }
1523}
1524
1525async fn apply_compaction_strategy_with_fallback(
1526 input: CompactionStrategyInputs<'_>,
1527 fallback_strategy: Option<&CompactStrategy>,
1528) -> Result<(String, CompactStrategy, Option<RecapMetrics>), VmError> {
1529 match apply_compaction_strategy(input).await {
1530 Ok((summary, metrics)) => Ok((summary, input.strategy.clone(), metrics)),
1531 Err(primary_error) => {
1532 let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1533 else {
1534 return Err(primary_error);
1535 };
1536 let fallback_input = CompactionStrategyInputs {
1537 strategy: fallback,
1538 ..input
1539 };
1540 apply_compaction_strategy(fallback_input)
1541 .await
1542 .map(|(summary, metrics)| (summary, fallback.clone(), metrics))
1543 }
1544 }
1545}
1546
1547pub(crate) struct AutoCompactResult {
1548 pub summary: String,
1549 pub strategy: CompactStrategy,
1550 pub recap_metrics: Option<RecapMetrics>,
1551}
1552
1553#[cfg(test)]
1555pub(crate) async fn auto_compact_messages_with_result(
1556 messages: &mut Vec<serde_json::Value>,
1557 config: &AutoCompactConfig,
1558 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1559) -> Result<Option<AutoCompactResult>, VmError> {
1560 auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1561}
1562
1563pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1564 ctx: Option<&AsyncBuiltinCtx>,
1565 messages: &mut Vec<serde_json::Value>,
1566 config: &AutoCompactConfig,
1567 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1568) -> Result<Option<AutoCompactResult>, VmError> {
1569 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1570 return Ok(None);
1571 }
1572 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1573 return Ok(None);
1574 }
1575 let compact_start = config.keep_first.min(messages.len());
1576 let original_split = messages.len().saturating_sub(config.keep_last);
1577 let mut split_at = original_split;
1578 while split_at > compact_start
1582 && split_at < messages.len()
1583 && messages[split_at]
1584 .get("role")
1585 .and_then(|r| r.as_str())
1586 .is_none_or(|r| r != "user")
1587 {
1588 split_at -= 1;
1589 }
1590 if split_at == compact_start {
1593 split_at = original_split;
1594 }
1595 if let Some(volatile_start) = messages[split_at..]
1596 .iter()
1597 .position(is_reasoning_or_tool_turn_message)
1598 .map(|offset| split_at + offset)
1599 {
1600 if let Some(boundary) = volatile_start
1601 .checked_sub(1)
1602 .and_then(|idx| find_prev_user_boundary(messages, idx))
1603 .filter(|boundary| *boundary > compact_start)
1604 {
1605 split_at = boundary;
1606 }
1607 }
1608 split_at = snap_split_off_tool_results(messages, split_at, compact_start);
1613 if split_at <= compact_start {
1614 return Ok(None);
1615 }
1616 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1617 let archived_count = old_messages.len();
1618
1619 clamp_tool_outputs(ctx, messages, config).await?;
1627
1628 let (mut summary, mut strategy, mut recap_metrics) = apply_compaction_strategy_with_fallback(
1629 CompactionStrategyInputs {
1630 ctx,
1631 strategy: &config.compact_strategy,
1632 old_messages: &old_messages,
1633 archived_count,
1634 llm_opts,
1635 custom_compactor: config.custom_compactor.as_ref(),
1636 custom_compactor_reminders: &config.custom_compactor_reminders,
1637 mask_callback: config.mask_callback.as_ref(),
1638 summarize_prompt: config.summarize_prompt.as_deref(),
1639 policy: &config.policy,
1640 recap_budget_bytes: config.recap_budget_bytes,
1641 },
1642 config.fallback_strategy.as_ref(),
1643 )
1644 .await?;
1645
1646 if let Some(hard_limit) = config.hard_limit_tokens {
1647 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1648 let mut estimate_msgs = vec![summary_msg];
1649 estimate_msgs.extend_from_slice(messages.as_slice());
1650 let estimated = estimate_message_tokens(&estimate_msgs);
1651 if estimated > hard_limit {
1652 let tier1_as_messages = vec![serde_json::json!({
1653 "role": "user",
1654 "content": summary,
1655 })];
1656 let (hard_limit_summary, hard_limit_strategy, hard_limit_metrics) =
1657 apply_compaction_strategy_with_fallback(
1658 CompactionStrategyInputs {
1659 ctx,
1660 strategy: &config.hard_limit_strategy,
1661 old_messages: &tier1_as_messages,
1662 archived_count,
1663 llm_opts,
1664 custom_compactor: config.custom_compactor.as_ref(),
1665 custom_compactor_reminders: &config.custom_compactor_reminders,
1666 mask_callback: None,
1667 summarize_prompt: config.summarize_prompt.as_deref(),
1668 policy: &config.policy,
1669 recap_budget_bytes: config.recap_budget_bytes,
1670 },
1671 config.fallback_strategy.as_ref(),
1672 )
1673 .await?;
1674 summary = hard_limit_summary;
1675 strategy = hard_limit_strategy;
1676 recap_metrics = hard_limit_metrics.or(recap_metrics);
1679 }
1680 }
1681
1682 summary = super::repair_ledger::append_repair_ledger_to_summary(
1683 apply_model_visible_policy(summary, &config.policy),
1684 &old_messages,
1685 );
1686
1687 messages.insert(
1688 compact_start,
1689 serde_json::json!({
1690 "role": "user",
1691 "content": summary,
1692 }),
1693 );
1694 Ok(Some(AutoCompactResult {
1695 summary,
1696 strategy,
1697 recap_metrics,
1698 }))
1699}
1700
1701#[cfg(test)]
1703pub(crate) async fn auto_compact_messages(
1704 messages: &mut Vec<serde_json::Value>,
1705 config: &AutoCompactConfig,
1706 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1707) -> Result<Option<String>, VmError> {
1708 Ok(
1709 auto_compact_messages_with_result(messages, config, llm_opts)
1710 .await?
1711 .map(|result| result.summary),
1712 )
1713}
1714
1715fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1716 if !policy.is_model_visible_scope() {
1717 return summary;
1718 }
1719 let Some(directives) = policy.prompt_directives() else {
1720 return summary;
1721 };
1722 summary.push_str("\n\n[compaction instructions]\n");
1723 summary.push_str(&directives);
1724 summary
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729 use super::*;
1730
1731 #[test]
1732 fn microcompact_short_output_unchanged() {
1733 let output = "line1\nline2\nline3\n";
1734 assert_eq!(microcompact_tool_output(output, 1000), output);
1735 }
1736
1737 #[test]
1738 fn microcompact_snaps_to_line_boundaries() {
1739 let lines: Vec<String> = (0..20)
1740 .map(|i| format!("line {i:02} content here"))
1741 .collect();
1742 let output = lines.join("\n");
1743 let result = microcompact_tool_output(&output, 200);
1744 assert!(result.contains("[... "), "should have snip marker");
1745 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1746 assert!(parts.len() >= 2, "should split at marker");
1747 let head = parts[0];
1748 for line in head.lines() {
1749 assert!(
1750 line.starts_with("line "),
1751 "head line should be complete: {line}"
1752 );
1753 }
1754 }
1755
1756 #[test]
1757 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1758 let mut lines = Vec::new();
1759 for i in 0..50 {
1760 lines.push(format!("verbose output line {i}"));
1761 }
1762 lines.push("src/main.rs:42: error: cannot find value".to_string());
1763 for i in 50..100 {
1764 lines.push(format!("verbose output line {i}"));
1765 }
1766 let output = lines.join("\n");
1767 let result = microcompact_tool_output(&output, 600);
1768 assert!(result.contains("cannot find value"), "diagnostic preserved");
1769 assert!(
1770 result.contains("[diagnostic lines preserved]"),
1771 "has diagnostic marker"
1772 );
1773 }
1774
1775 #[test]
1779 fn failure_signal_filter_keeps_structured_failure_lines() {
1780 for keep in [
1781 "left: 3",
1782 "right: 4",
1783 "expected: foo",
1784 "actual: bar",
1785 " --> src/main.rs:4:9",
1786 "= help: add `use std::fmt;`",
1787 "12 | let x = bad();",
1788 " | ^^^^^^^ not found",
1789 "L42: assertion failed",
1790 "src/main.rs:42: error: cannot find value",
1791 "FAIL: TestThing",
1792 "panic: index out of range",
1793 ] {
1794 assert!(
1795 is_failure_signal_line(keep),
1796 "should keep failure-signal line: {keep:?}"
1797 );
1798 }
1799 for drop in [
1800 "verbose output line 7",
1801 "compiling crate foo",
1802 " let y = ok();",
1803 "",
1804 ] {
1805 assert!(
1806 !is_failure_signal_line(drop),
1807 "should drop ordinary line: {drop:?}"
1808 );
1809 }
1810 }
1811
1812 #[test]
1816 fn default_mask_preserves_failure_detail() {
1817 let mut lines = vec!["running 1 test".to_string()];
1818 for i in 0..40 {
1819 lines.push(format!("noise line {i}"));
1820 }
1821 lines.push("assertion `left == right` failed".to_string());
1822 lines.push(" left: 3".to_string());
1823 lines.push(" right: 4".to_string());
1824 lines.push(" --> src/lib.rs:10:5".to_string());
1825 for i in 40..80 {
1826 lines.push(format!("more noise {i}"));
1827 }
1828 let content = lines.join("\n");
1829 let masked = default_mask_tool_result("tool", &content);
1830 assert!(
1831 masked.contains("masked"),
1832 "still reports it masked: {masked}"
1833 );
1834 assert!(
1835 masked.contains("failure lines preserved"),
1836 "should flag preserved lines: {masked}"
1837 );
1838 assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1839 assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1840 assert!(
1841 masked.contains("--> src/lib.rs:10:5"),
1842 "keeps rustc location: {masked}"
1843 );
1844 assert!(
1845 !masked.contains("noise line 7"),
1846 "drops ordinary noise: {masked}"
1847 );
1848 }
1849
1850 #[test]
1852 fn default_mask_without_failure_lines_stays_terse() {
1853 let mut lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1854 lines[0] = format!("{}日本語テキスト", "x".repeat(118));
1855 let masked = default_mask_tool_result("tool", &lines.join("\n"));
1856 assert!(masked.contains("masked]"), "should mask: {masked}");
1857 assert!(
1858 !masked.contains("failure lines preserved"),
1859 "no failure lines to preserve: {masked}"
1860 );
1861 }
1862
1863 #[test]
1864 fn token_estimate_counts_structured_message_content() {
1865 let text = "x".repeat(400);
1866 let messages = vec![serde_json::json!({
1867 "role": "user",
1868 "content": [
1869 {"type": "text", "text": text},
1870 {"type": "input_text", "text": "tail"},
1871 ],
1872 "reasoning": {"text": "scratch"},
1873 "tool_calls": [{
1874 "id": "call_1",
1875 "type": "function",
1876 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1877 }],
1878 })];
1879
1880 assert!(
1881 estimate_message_tokens(&messages) >= 100,
1882 "structured content must not count as zero"
1883 );
1884 }
1885
1886 #[test]
1887 fn compaction_policy_instructions_extend_by_default() {
1888 let policy = CompactionPolicy {
1889 instructions: Some("Keep the failing test names.".to_string()),
1890 ..Default::default()
1891 };
1892 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1893 .expect("prompt renders");
1894
1895 assert_eq!(policy.instruction_mode(), "extend");
1896 assert!(prompt.contains("Preserve goals, constraints"));
1897 assert!(prompt.contains("Additional compaction instructions"));
1898 assert!(prompt.contains("Keep the failing test names."));
1899 }
1900
1901 #[test]
1902 fn compaction_policy_can_replace_default_instructions() {
1903 let policy = CompactionPolicy {
1904 instructions: Some("Only keep repro steps.".to_string()),
1905 extend_default_instructions: Some(false),
1906 ..Default::default()
1907 };
1908 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1909 .expect("prompt renders");
1910
1911 assert_eq!(policy.instruction_mode(), "replace");
1912 assert!(prompt.contains("according to these instructions"));
1913 assert!(prompt.contains("Only keep repro steps."));
1914 assert!(!prompt.contains("Preserve goals, constraints"));
1915 }
1916
1917 #[test]
1918 fn snap_to_line_end_finds_newline() {
1919 let s = "line1\nline2\nline3\nline4\n";
1920 let head = snap_to_line_end(s, 12);
1921 assert!(head.ends_with('\n'), "should end at newline");
1922 assert!(head.contains("line1"));
1923 }
1924
1925 #[test]
1926 fn snap_to_line_start_finds_newline() {
1927 let s = "line1\nline2\nline3\nline4\n";
1928 let tail = snap_to_line_start(s, 12);
1929 assert!(
1930 tail.starts_with("line"),
1931 "should start at line boundary: {tail}"
1932 );
1933 }
1934
1935 #[test]
1936 fn auto_compact_preserves_reasoning_tool_suffix() {
1937 let mut messages = vec![
1938 serde_json::json!({"role": "user", "content": "old task"}),
1939 serde_json::json!({"role": "assistant", "content": "old reply"}),
1940 serde_json::json!({"role": "user", "content": "new task"}),
1941 serde_json::json!({
1942 "role": "assistant",
1943 "content": "",
1944 "reasoning": "think first",
1945 "tool_calls": [{
1946 "id": "call_1",
1947 "type": "function",
1948 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1949 }],
1950 }),
1951 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1952 ];
1953 let config = AutoCompactConfig {
1954 token_threshold: 1,
1955 keep_last: 2,
1956 ..Default::default()
1957 };
1958
1959 let runtime = tokio::runtime::Builder::new_current_thread()
1960 .enable_all()
1961 .build()
1962 .expect("runtime");
1963 let summary = runtime
1964 .block_on(auto_compact_messages(&mut messages, &config, None))
1965 .expect("compaction succeeds");
1966
1967 assert!(summary.is_some());
1968 assert_eq!(messages[1]["role"], "user");
1969 assert_eq!(messages[2]["role"], "assistant");
1970 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1971 assert_eq!(messages[3]["role"], "tool");
1972 assert_eq!(messages[3]["tool_call_id"], "call_1");
1973 }
1974
1975 #[test]
1982 fn auto_compact_never_splits_assistant_tool_use_from_its_result() {
1983 let tool_call = |id: &str| {
1984 serde_json::json!({
1985 "id": id,
1986 "type": "function",
1987 "function": {"name": "run", "arguments": "{}"}
1988 })
1989 };
1990 let mut messages = vec![
1991 serde_json::json!({"role": "user", "content": "task"}),
1992 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c0")]}),
1993 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1994 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c1")]}),
1995 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1996 serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c2")]}),
1997 serde_json::json!({"role": "tool", "tool_call_id": "c2", "content": "r2"}),
1998 ];
1999 let config = AutoCompactConfig {
2002 token_threshold: 1,
2003 keep_first: 0,
2004 keep_last: 3,
2005 ..Default::default()
2006 };
2007
2008 let runtime = tokio::runtime::Builder::new_current_thread()
2009 .enable_all()
2010 .build()
2011 .expect("runtime");
2012 let summary = runtime
2013 .block_on(auto_compact_messages(&mut messages, &config, None))
2014 .expect("compaction succeeds");
2015 assert!(summary.is_some(), "compaction should trigger");
2016
2017 assert_eq!(messages[0]["role"], "user", "summary head");
2019 assert_eq!(messages[1]["role"], "assistant");
2020 assert_eq!(messages[1]["tool_calls"][0]["id"], "c1");
2021 assert_eq!(messages[2]["role"], "tool");
2022 assert_eq!(messages[2]["tool_call_id"], "c1");
2023 assert_eq!(messages[3]["tool_calls"][0]["id"], "c2");
2024 assert_eq!(messages[4]["tool_call_id"], "c2");
2025 for (idx, message) in messages.iter().enumerate() {
2027 if message["role"] == "tool" {
2028 let id = message["tool_call_id"].as_str().expect("tool_call_id");
2029 let paired = messages[..idx].iter().any(|prev| {
2030 prev["tool_calls"]
2031 .as_array()
2032 .is_some_and(|calls| calls.iter().any(|call| call["id"] == id))
2033 });
2034 assert!(paired, "tool_result {id} orphaned in kept window");
2035 }
2036 }
2037 }
2038
2039 #[test]
2040 fn snap_split_off_tool_results_handles_all_result_shapes() {
2041 let openai = vec![
2045 serde_json::json!({"role": "user", "content": "task"}),
2046 serde_json::json!({"role": "assistant", "content": "", "tool_calls": []}),
2047 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2048 ];
2049 assert_eq!(snap_split_off_tool_results(&openai, 2, 0), 1);
2050 let anthropic = vec![
2052 serde_json::json!({"role": "user", "content": "task"}),
2053 serde_json::json!({"role": "assistant", "content": ""}),
2054 serde_json::json!({"role": "tool_result", "tool_use_id": "c0", "content": "r0"}),
2055 ];
2056 assert_eq!(snap_split_off_tool_results(&anthropic, 2, 0), 1);
2057 let user_blocks = vec![
2059 serde_json::json!({"role": "user", "content": "task"}),
2060 serde_json::json!({"role": "assistant", "content": ""}),
2061 serde_json::json!({
2062 "role": "user",
2063 "content": [{"type": "tool_result", "tool_use_id": "c0", "content": "r0"}],
2064 }),
2065 ];
2066 assert_eq!(snap_split_off_tool_results(&user_blocks, 2, 0), 1);
2067 let text = vec![
2069 serde_json::json!({"role": "assistant", "content": ""}),
2070 serde_json::json!({"role": "user", "content": "plain"}),
2071 ];
2072 assert_eq!(snap_split_off_tool_results(&text, 1, 0), 1);
2073 let pinned = vec![
2077 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2078 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
2079 serde_json::json!({"role": "assistant", "content": "done"}),
2080 ];
2081 assert_eq!(snap_split_off_tool_results(&pinned, 1, 0), 2);
2082 }
2083
2084 #[test]
2085 fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
2086 let big = "x".repeat(4000);
2089 let big_len = big.len();
2090 let mut messages = vec![
2091 serde_json::json!({"role": "user", "content": "old task"}),
2092 serde_json::json!({"role": "assistant", "content": "old reply"}),
2093 serde_json::json!({"role": "user", "content": "new task"}),
2094 serde_json::json!({"role": "assistant", "content": "calling tool"}),
2095 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2096 ];
2097 let config = AutoCompactConfig {
2098 token_threshold: 1,
2099 keep_last: 2,
2100 tool_output_max_chars: 500,
2101 ..Default::default()
2102 };
2103
2104 let runtime = tokio::runtime::Builder::new_current_thread()
2105 .enable_all()
2106 .build()
2107 .expect("runtime");
2108 let result = runtime
2109 .block_on(auto_compact_messages(&mut messages, &config, None))
2110 .expect("compaction succeeds");
2111 assert!(result.is_some(), "compaction should trigger");
2112
2113 let tool_msg = messages
2114 .iter()
2115 .find(|message| message["role"] == "tool")
2116 .expect("tool message kept in window");
2117 assert_eq!(tool_msg["tool_call_id"], "call_1");
2119 let content = tool_msg["content"].as_str().expect("string content");
2121 assert!(
2122 content.len() < big_len,
2123 "tool output should be clamped: {} vs {}",
2124 content.len(),
2125 big_len
2126 );
2127 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2128 }
2129
2130 #[test]
2133 fn observation_mask_preserves_pinned_live_file_view() {
2134 let pinned_body = format!(
2135 "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
2136 NO_COMPACT_MARKER,
2137 (0..40)
2138 .map(|i| format!(" {i} let x = compute({i});"))
2139 .collect::<Vec<_>>()
2140 .join("\n")
2141 );
2142 let verbose_unpinned = (0..60)
2143 .map(|i| format!("verbose scan output line {i}"))
2144 .collect::<Vec<_>>()
2145 .join("\n");
2146 let archived = vec![
2148 serde_json::json!({"role": "user", "content": verbose_unpinned}),
2149 serde_json::json!({"role": "user", "content": pinned_body}),
2150 ];
2151 let summary = observation_mask_compaction(&archived, archived.len());
2152 assert!(
2154 summary.contains("Edited region now reads"),
2155 "pinned heading survived: {summary}"
2156 );
2157 assert!(
2158 summary.contains("let x = compute(39);"),
2159 "pinned body survived verbatim"
2160 );
2161 assert!(summary.contains("masked]"), "unpinned output was masked");
2163 assert!(!summary.contains("verbose scan output line 30"));
2164 }
2165
2166 #[test]
2169 fn clamp_exempts_pinned_tool_output() {
2170 let pinned_big = format!(
2171 "## Exact current file text {}\n{}",
2172 NO_COMPACT_MARKER,
2173 "x".repeat(4000)
2174 );
2175 let pinned_len = pinned_big.len();
2176 let unpinned_big = "y".repeat(4000);
2177 let unpinned_len = unpinned_big.len();
2178 let mut messages = vec![
2179 serde_json::json!({"role": "user", "content": "old task"}),
2180 serde_json::json!({"role": "assistant", "content": "reply"}),
2181 serde_json::json!({"role": "user", "content": "new task"}),
2182 serde_json::json!({"role": "assistant", "content": "calling tools"}),
2183 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
2184 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
2185 serde_json::json!({"role": "user", "content": "continue"}),
2186 ];
2187 let config = AutoCompactConfig {
2188 token_threshold: 1,
2189 keep_last: 4,
2190 tool_output_max_chars: 500,
2191 ..Default::default()
2192 };
2193 let runtime = tokio::runtime::Builder::new_current_thread()
2194 .enable_all()
2195 .build()
2196 .expect("runtime");
2197 runtime
2198 .block_on(auto_compact_messages(&mut messages, &config, None))
2199 .expect("compaction succeeds");
2200
2201 let pinned_msg = messages
2202 .iter()
2203 .find(|m| m["tool_call_id"] == "c1")
2204 .expect("pinned tool message kept");
2205 assert_eq!(
2206 pinned_msg["content"].as_str().map(str::len),
2207 Some(pinned_len),
2208 "pinned output must be intact (unclamped)"
2209 );
2210 let unpinned_msg = messages
2211 .iter()
2212 .find(|m| m["tool_call_id"] == "c0")
2213 .expect("unpinned tool message kept");
2214 assert!(
2215 unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
2216 "unpinned output of the same size must be clamped"
2217 );
2218 }
2219
2220 #[test]
2225 fn pin_bound_keeps_only_latest_segments() {
2226 let make = |gen: usize| {
2230 let body = (0..40)
2231 .map(|i| format!("marker-gen-{gen} body line {i}"))
2232 .collect::<Vec<_>>()
2233 .join("\n");
2234 serde_json::json!({
2235 "role": "user",
2236 "content": format!(
2237 "## Edited region now reads (gen {gen}) {}\n{}",
2238 NO_COMPACT_MARKER, body
2239 ),
2240 })
2241 };
2242 let archived: Vec<_> = (0..6).map(make).collect();
2243
2244 let pinned = latest_pinned_indices(archived.iter(), |m| {
2246 m.get("content").and_then(|c| c.as_str())
2247 });
2248 assert_eq!(
2249 pinned.len(),
2250 MAX_PINNED_SEGMENTS,
2251 "only the latest MAX_PINNED_SEGMENTS are pinned"
2252 );
2253 assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
2254 assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
2255
2256 let summary = observation_mask_compaction(&archived, archived.len());
2260 assert!(
2261 summary.contains("marker-gen-5")
2262 && summary.contains("marker-gen-4")
2263 && summary.contains("marker-gen-3"),
2264 "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
2265 );
2266 assert!(
2267 !summary.contains("marker-gen-0")
2268 && !summary.contains("marker-gen-1")
2269 && !summary.contains("marker-gen-2"),
2270 "older pinned snapshots are masked (bound enforced)"
2271 );
2272 assert!(summary.contains("masked]"), "older snapshots were masked");
2273 }
2274
2275 #[test]
2277 fn no_pins_preserves_prior_clamp_behavior() {
2278 let big = "x".repeat(4000);
2279 let big_len = big.len();
2280 let mut messages = vec![
2281 serde_json::json!({"role": "user", "content": "old task"}),
2282 serde_json::json!({"role": "assistant", "content": "old reply"}),
2283 serde_json::json!({"role": "user", "content": "new task"}),
2284 serde_json::json!({"role": "assistant", "content": "calling tool"}),
2285 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2286 ];
2287 let config = AutoCompactConfig {
2288 token_threshold: 1,
2289 keep_last: 2,
2290 tool_output_max_chars: 500,
2291 ..Default::default()
2292 };
2293 let runtime = tokio::runtime::Builder::new_current_thread()
2294 .enable_all()
2295 .build()
2296 .expect("runtime");
2297 let result = runtime
2298 .block_on(auto_compact_messages(&mut messages, &config, None))
2299 .expect("compaction succeeds");
2300 assert!(result.is_some());
2301 let tool_msg = messages
2302 .iter()
2303 .find(|m| m["role"] == "tool")
2304 .expect("tool kept");
2305 let content = tool_msg["content"].as_str().expect("string content");
2306 assert!(content.len() < big_len, "unpinned output clamped as before");
2307 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2308 }
2309}