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