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}
472
473impl Default for AutoCompactConfig {
474 fn default() -> Self {
475 Self {
476 keep_first: 0,
477 token_threshold: 48_000,
478 tool_output_max_chars: 16_000,
479 keep_last: 12,
480 compact_strategy: CompactStrategy::ObservationMask,
481 hard_limit_tokens: None,
482 hard_limit_strategy: CompactStrategy::Llm,
483 custom_compactor: None,
484 custom_compactor_reminders: Vec::new(),
485 mask_callback: None,
486 compress_callback: None,
487 summarize_prompt: None,
488 policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
489 fallback_strategy: None,
490 policy: CompactionPolicy::default(),
491 }
492 }
493}
494
495pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
497 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
498}
499
500fn estimate_message_chars(message: &serde_json::Value) -> usize {
501 let mut total = message
502 .get("content")
503 .map(estimate_content_chars)
504 .unwrap_or_default();
505 if let Some(reasoning) = message.get("reasoning") {
506 total += estimate_content_chars(reasoning);
507 }
508 if let Some(tool_calls) = message.get("tool_calls") {
509 total += estimate_content_chars(tool_calls);
510 }
511 total
512}
513
514fn estimate_content_chars(value: &serde_json::Value) -> usize {
515 match value {
516 serde_json::Value::String(text) => text.len(),
517 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
518 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
519 serde_json::Value::Null => 0,
520 other => other.to_string().len(),
521 }
522}
523
524fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
525 let role = message
526 .get("role")
527 .and_then(|value| value.as_str())
528 .unwrap_or_default();
529 role == "tool"
530 || message.get("tool_calls").is_some()
531 || message
532 .get("reasoning")
533 .map(|value| !value.is_null())
534 .unwrap_or(false)
535}
536
537fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
538 (0..=start)
539 .rev()
540 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
541}
542
543fn line_has_file_line_prefix(trimmed: &str) -> bool {
547 let bytes = trimmed.as_bytes();
548 let mut i = 0;
549 while i < bytes.len() && bytes[i] != b':' {
550 i += 1;
551 }
552 i < bytes.len() && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
553}
554
555fn is_failure_signal_line(line: &str) -> bool {
571 let trimmed = line.trim();
572 if trimmed.is_empty() {
573 return false;
574 }
575 let lower = trimmed.to_lowercase();
576
577 let has_file_line = line_has_file_line_prefix(trimmed);
578 let has_strong_keyword =
579 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
580 let has_weak_keyword = trimmed.contains("error")
581 || trimmed.contains("undefined")
582 || trimmed.contains("expected")
583 || trimmed.contains("got")
584 || lower.contains("cannot find")
585 || lower.contains("not found")
586 || lower.contains("no such")
587 || lower.contains("unresolved")
588 || lower.contains("missing")
589 || lower.contains("declared but not used")
590 || lower.contains("unused")
591 || lower.contains("mismatch");
592 let positional = lower.contains(" error ")
593 || lower.starts_with("error:")
594 || lower.starts_with("warning:")
595 || lower.starts_with("note:")
596 || lower.contains("panic:");
597
598 let assertion_value = lower.starts_with("left:")
600 || lower.starts_with("right:")
601 || lower.starts_with("expected:")
602 || lower.starts_with("actual:")
603 || lower.starts_with("got:")
604 || lower.starts_with("want:")
605 || lower.starts_with("got ")
606 || lower.starts_with("want ")
607 || lower.starts_with("assertion")
608 || lower.contains("assertionerror");
609
610 let rustc_continuation = trimmed.starts_with("-->")
613 || trimmed.starts_with("= help:")
614 || trimmed.starts_with("= note:")
615 || trimmed.contains('^')
616 || {
617 let mut chars = trimmed.chars();
619 let mut saw_digit = false;
620 let mut rest = trimmed;
621 while let Some(c) = chars.clone().next() {
622 if c.is_ascii_digit() {
623 saw_digit = true;
624 chars.next();
625 rest = chars.as_str();
626 } else {
627 break;
628 }
629 }
630 saw_digit && rest.trim_start().starts_with('|')
631 };
632
633 let failing_line_marker = {
635 if let Some(rest) = trimmed.strip_prefix('L') {
636 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
637 !digits.is_empty() && rest[digits.len()..].starts_with(':')
638 } else {
639 false
640 }
641 };
642
643 has_strong_keyword
644 || (has_file_line && has_weak_keyword)
645 || positional
646 || assertion_value
647 || rustc_continuation
648 || failing_line_marker
649}
650
651pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
654 if output.len() <= max_chars || max_chars < 200 {
655 return output.to_string();
656 }
657 let diagnostic_lines = output
658 .lines()
659 .filter(|line| is_failure_signal_line(line))
660 .take(32)
661 .collect::<Vec<_>>();
662 if !diagnostic_lines.is_empty() {
663 let diagnostics = diagnostic_lines.join("\n");
664 let budget = max_chars.saturating_sub(diagnostics.len() + 64);
665 let keep = budget / 2;
666 if keep >= 80 && output.len() > keep * 2 {
667 let head = snap_to_line_end(output, keep);
668 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
669 return format!(
670 "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
671 );
672 }
673 }
674 let keep = max_chars / 2;
675 let head = snap_to_line_end(output, keep);
676 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
677 let snipped = output.len().saturating_sub(head.len() + tail.len());
678 format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
679}
680
681fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
685 if max_bytes >= s.len() {
686 return s;
687 }
688 let search_end = s.floor_char_boundary(max_bytes);
689 match s[..search_end].rfind('\n') {
690 Some(pos) => &s[..pos + 1],
691 None => &s[..search_end], }
693}
694
695fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
699 if start_byte == 0 {
700 return s;
701 }
702 let search_start = s.ceil_char_boundary(start_byte);
703 if search_start >= s.len() {
704 return "";
705 }
706 match s[search_start..].find('\n') {
707 Some(pos) => {
708 let line_start = search_start + pos + 1;
709 if line_start < s.len() {
710 &s[line_start..]
711 } else {
712 &s[search_start..]
713 }
714 }
715 None => &s[search_start..], }
717}
718
719fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
720 messages
721 .iter()
722 .map(|msg| {
723 let role = msg
724 .get("role")
725 .and_then(|v| v.as_str())
726 .unwrap_or("user")
727 .to_uppercase();
728 let content = msg
729 .get("content")
730 .and_then(|v| v.as_str())
731 .unwrap_or_default();
732 format!("{role}: {content}")
733 })
734 .collect::<Vec<_>>()
735 .join("\n")
736}
737
738fn truncate_compaction_summary(
739 old_messages: &[serde_json::Value],
740 archived_count: usize,
741) -> String {
742 truncate_compaction_summary_with_context(old_messages, archived_count, false)
743}
744
745fn truncate_compaction_summary_with_context(
746 old_messages: &[serde_json::Value],
747 archived_count: usize,
748 is_llm_fallback: bool,
749) -> String {
750 let per_msg_limit = 500_usize;
751 let summary_parts: Vec<String> = old_messages
752 .iter()
753 .filter_map(|m| {
754 let role = m.get("role")?.as_str()?;
755 let content = m.get("content")?.as_str()?;
756 if content.is_empty() {
757 return None;
758 }
759 let truncated = if content.len() > per_msg_limit {
760 format!(
761 "{}... [truncated from {} chars]",
762 &content[..content.floor_char_boundary(per_msg_limit)],
763 content.len()
764 )
765 } else {
766 content.to_string()
767 };
768 Some(format!("[{role}] {truncated}"))
769 })
770 .take(15)
771 .collect();
772 let header = if is_llm_fallback {
773 format!(
774 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
775 )
776 } else {
777 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
778 };
779 format!(
780 "{header}\n{}{}",
781 summary_parts.join("\n"),
782 if archived_count > 15 {
783 format!("\n... and {} more", archived_count - 15)
784 } else {
785 String::new()
786 }
787 )
788}
789
790fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
791 if let Some(map) = value.as_dict() {
792 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
793 return Ok(summary.display());
794 }
795 }
796 match value {
797 VmValue::String(text) => Ok(text.to_string()),
798 VmValue::Nil => Ok(String::new()),
799 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
800 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
801 }
802}
803
804async fn llm_compaction_summary(
805 old_messages: &[serde_json::Value],
806 archived_count: usize,
807 llm_opts: &crate::llm::api::LlmCallOptions,
808 summarize_prompt: Option<&str>,
809 policy: &CompactionPolicy,
810) -> Result<String, VmError> {
811 let mut compact_opts = llm_opts.clone();
812 let formatted = format_compaction_messages(old_messages);
813 compact_opts.system = None;
814 compact_opts.transcript_summary = None;
815 compact_opts.native_tools = None;
816 compact_opts.tool_choice = None;
817 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
818 compact_opts.response_format = None;
819 compact_opts.json_schema = None;
820 compact_opts.output_schema = None;
821 let prompt =
822 render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
823 compact_opts.messages = vec![serde_json::json!({
824 "role": "user",
825 "content": prompt,
826 })];
827 let result = vm_call_llm_full(&compact_opts).await?;
828 let summary = result.text.trim();
829 if summary.is_empty() {
830 Ok(truncate_compaction_summary_with_context(
831 old_messages,
832 archived_count,
833 true,
834 ))
835 } else {
836 Ok(format!(
837 "[auto-compacted {archived_count} older messages]\n{summary}"
838 ))
839 }
840}
841
842fn render_llm_compaction_prompt(
843 summarize_prompt: Option<&str>,
844 formatted: &str,
845 archived_count: usize,
846 policy: &CompactionPolicy,
847) -> Result<String, VmError> {
848 if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
849 return render_replacement_compaction_prompt(policy, formatted, archived_count);
850 }
851 let mut bindings = crate::value::DictMap::new();
852 bindings.put_str("formatted_messages", formatted);
853 bindings.insert(
854 crate::value::intern_key("archived_count"),
855 VmValue::Int(archived_count as i64),
856 );
857 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
858 let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
859 "orchestration/prompts/compaction_summary.harn.prompt",
860 Some(&bindings),
861 )?;
862 return Ok(extend_compaction_prompt(prompt, policy));
863 };
864
865 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
866 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
867 let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
868 .map_err(VmError::from)?;
869 Ok(extend_compaction_prompt(prompt, policy))
870}
871
872fn render_replacement_compaction_prompt(
873 policy: &CompactionPolicy,
874 formatted: &str,
875 archived_count: usize,
876) -> Result<String, VmError> {
877 let directives = policy.prompt_directives().unwrap_or_default();
878 let mut bindings = crate::value::DictMap::new();
879 bindings.put_str("directives", directives);
880 bindings.put_str("formatted_messages", formatted);
881 bindings.insert(
882 crate::value::intern_key("archived_count"),
883 VmValue::Int(archived_count as i64),
884 );
885 crate::stdlib::template::render_stdlib_prompt_asset(
886 "orchestration/prompts/compaction_policy_replacement.harn.prompt",
887 Some(&bindings),
888 )
889}
890
891fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
892 let Some(directives) = policy.prompt_directives() else {
893 return prompt;
894 };
895 prompt.push_str(
896 "\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",
897 );
898 prompt.push_str(&directives);
899 prompt
900}
901
902async fn custom_compaction_summary(
903 ctx: Option<&AsyncBuiltinCtx>,
904 old_messages: &[serde_json::Value],
905 archived_count: usize,
906 callback: &VmValue,
907 reminders: &[VmValue],
908 policy: &CompactionPolicy,
909) -> Result<String, VmError> {
910 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
911 return Err(VmError::Runtime(
912 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
913 ));
914 };
915 let Some(ctx) = ctx else {
916 return Err(VmError::Runtime(
917 "custom transcript compaction requires an async builtin VM context".to_string(),
918 ));
919 };
920 let mut vm = ctx.child_vm();
921 let messages_vm = VmValue::List(std::sync::Arc::new(
922 old_messages
923 .iter()
924 .map(crate::stdlib::json_to_vm_value)
925 .collect(),
926 ));
927 let result = if policy.has_metadata()
928 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
929 {
930 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
931 let policy_vm = compaction_policy_to_vm_value(policy);
932 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
933 .await
934 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
935 let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
936 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
937 .await
938 } else {
939 vm.call_closure_pub(&closure, &[messages_vm]).await
940 };
941 let summary = compact_summary_text_from_value(&result?)?;
942 ctx.forward_output(&vm.take_output());
943 if summary.trim().is_empty() {
944 Ok(truncate_compaction_summary(old_messages, archived_count))
945 } else {
946 Ok(format!(
947 "[auto-compacted {archived_count} older messages]\n{summary}"
948 ))
949 }
950}
951
952pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
960
961pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
970
971fn is_pinned_content(content: &str) -> bool {
973 content.contains(NO_COMPACT_MARKER)
974}
975
976fn latest_pinned_indices<'a, F>(
981 messages: impl Iterator<Item = &'a serde_json::Value>,
982 content_of: F,
983) -> std::collections::HashSet<usize>
984where
985 F: Fn(&serde_json::Value) -> Option<&str>,
986{
987 let pinned: Vec<usize> = messages
989 .enumerate()
990 .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
991 .map(|(idx, _)| idx)
992 .collect();
993 pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
994}
995
996fn content_should_preserve(content: &str) -> bool {
1002 content.len() < 500
1003}
1004
1005fn default_mask_tool_result(role: &str, content: &str) -> String {
1015 let first_line = content.lines().next().unwrap_or(content);
1016 let line_count = content.lines().count();
1017 let char_count = content.len();
1018 if line_count <= 3 {
1019 return format!("[{role}] {content}");
1020 }
1021 let preview = &first_line[..first_line.len().min(120)];
1022 let kept: Vec<&str> = content
1025 .lines()
1026 .skip(1)
1027 .filter(|line| is_failure_signal_line(line))
1028 .take(32)
1029 .collect();
1030 if kept.is_empty() {
1031 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
1032 } else {
1033 format!(
1034 "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
1035 failure lines preserved]\n{}",
1036 kept.join("\n")
1037 )
1038 }
1039}
1040
1041#[cfg(test)]
1043pub(crate) fn observation_mask_compaction(
1044 old_messages: &[serde_json::Value],
1045 archived_count: usize,
1046) -> String {
1047 observation_mask_compaction_with_callback(old_messages, archived_count, None)
1048}
1049
1050fn observation_mask_compaction_with_callback(
1051 old_messages: &[serde_json::Value],
1052 archived_count: usize,
1053 mask_results: Option<&[Option<String>]>,
1054) -> String {
1055 let mut parts = Vec::new();
1056 parts.push(format!(
1057 "[auto-compacted {archived_count} older messages via observation masking]"
1058 ));
1059 let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
1064 msg.get("content").and_then(|v| v.as_str())
1065 });
1066 for (idx, msg) in old_messages.iter().enumerate() {
1067 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
1068 let content = msg
1069 .get("content")
1070 .and_then(|v| v.as_str())
1071 .unwrap_or_default();
1072 if content.is_empty() {
1073 continue;
1074 }
1075 if pinned.contains(&idx) {
1076 parts.push(format!("[{role}] {content}"));
1077 continue;
1078 }
1079 if role == "assistant" {
1080 parts.push(format!("[assistant] {content}"));
1081 continue;
1082 }
1083 if content_should_preserve(content) {
1084 parts.push(format!("[{role}] {content}"));
1085 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
1086 parts.push(custom.clone());
1087 } else {
1088 parts.push(default_mask_tool_result(role, content));
1089 }
1090 }
1091 parts.join("\n")
1092}
1093
1094async fn invoke_mask_callback(
1096 ctx: Option<&AsyncBuiltinCtx>,
1097 callback: &VmValue,
1098 old_messages: &[serde_json::Value],
1099) -> Result<Vec<Option<String>>, VmError> {
1100 let VmValue::Closure(closure) = callback.clone() else {
1101 return Err(VmError::Runtime(
1102 "mask_callback must be a closure".to_string(),
1103 ));
1104 };
1105 let Some(ctx) = ctx else {
1106 return Err(VmError::Runtime(
1107 "mask_callback requires an async builtin VM context".to_string(),
1108 ));
1109 };
1110 let mut vm = ctx.child_vm();
1111 let messages_vm = VmValue::List(std::sync::Arc::new(
1112 old_messages
1113 .iter()
1114 .map(crate::stdlib::json_to_vm_value)
1115 .collect(),
1116 ));
1117 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
1118 ctx.forward_output(&vm.take_output());
1119 let list = match result {
1120 VmValue::List(items) => items,
1121 _ => return Ok(vec![None; old_messages.len()]),
1122 };
1123 Ok(list
1124 .iter()
1125 .map(|v| match v {
1126 VmValue::String(s) => Some(s.to_string()),
1127 VmValue::Nil => None,
1128 _ => None,
1129 })
1130 .collect())
1131}
1132
1133async fn clamp_tool_outputs(
1140 ctx: Option<&AsyncBuiltinCtx>,
1141 messages: &mut [serde_json::Value],
1142 config: &AutoCompactConfig,
1143) -> Result<(), VmError> {
1144 if config.tool_output_max_chars == 0 {
1145 return Ok(());
1146 }
1147 let pinned = latest_pinned_indices(messages.iter(), |msg| {
1152 if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
1153 msg.get("content").and_then(|content| content.as_str())
1154 } else {
1155 None
1156 }
1157 });
1158 for (idx, message) in messages.iter_mut().enumerate() {
1159 if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
1160 continue;
1161 }
1162 let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
1163 continue;
1164 };
1165 if content.len() <= config.tool_output_max_chars {
1166 continue;
1167 }
1168 if pinned.contains(&idx) {
1169 continue;
1170 }
1171 let content = content.to_string();
1172 let replacement = match (config.compress_callback.as_ref(), ctx) {
1173 (Some(callback), Some(ctx)) => {
1174 invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
1175 .await?
1176 }
1177 _ => microcompact_tool_output(&content, config.tool_output_max_chars),
1178 };
1179 message["content"] = serde_json::Value::String(replacement);
1180 }
1181 Ok(())
1182}
1183
1184async fn invoke_compress_callback(
1188 ctx: &AsyncBuiltinCtx,
1189 callback: &VmValue,
1190 content: &str,
1191 max_chars: usize,
1192) -> Result<String, VmError> {
1193 let VmValue::Closure(closure) = callback.clone() else {
1194 return Err(VmError::Runtime(
1195 "compress_callback must be a closure".to_string(),
1196 ));
1197 };
1198 let mut vm = ctx.child_vm();
1199 let args = [
1200 VmValue::String(arcstr::ArcStr::from(content)),
1201 VmValue::Int(max_chars as i64),
1202 ];
1203 let result = vm.call_closure_pub(&closure, &args).await?;
1204 ctx.forward_output(&vm.take_output());
1205 match result {
1206 VmValue::String(text) => Ok(text.to_string()),
1207 _ => Ok(microcompact_tool_output(content, max_chars)),
1208 }
1209}
1210
1211#[derive(Clone, Copy)]
1212struct CompactionStrategyInputs<'a> {
1213 ctx: Option<&'a AsyncBuiltinCtx>,
1214 strategy: &'a CompactStrategy,
1215 old_messages: &'a [serde_json::Value],
1216 archived_count: usize,
1217 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
1218 custom_compactor: Option<&'a VmValue>,
1219 custom_compactor_reminders: &'a [VmValue],
1220 mask_callback: Option<&'a VmValue>,
1221 summarize_prompt: Option<&'a str>,
1222 policy: &'a CompactionPolicy,
1223}
1224
1225async fn apply_compaction_strategy(input: CompactionStrategyInputs<'_>) -> Result<String, VmError> {
1227 let CompactionStrategyInputs {
1228 strategy,
1229 old_messages,
1230 archived_count,
1231 llm_opts,
1232 custom_compactor,
1233 custom_compactor_reminders,
1234 mask_callback,
1235 summarize_prompt,
1236 policy,
1237 ctx,
1238 } = input;
1239 match strategy {
1240 CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
1241 CompactStrategy::Llm => {
1242 llm_compaction_summary(
1243 old_messages,
1244 archived_count,
1245 llm_opts.ok_or_else(|| {
1246 VmError::Runtime(
1247 "LLM transcript compaction requires active LLM call options".to_string(),
1248 )
1249 })?,
1250 summarize_prompt,
1251 policy,
1252 )
1253 .await
1254 }
1255 CompactStrategy::Custom => {
1256 custom_compaction_summary(
1257 ctx,
1258 old_messages,
1259 archived_count,
1260 custom_compactor.ok_or_else(|| {
1261 VmError::Runtime(
1262 "compact_callback is required when compact_strategy is 'custom'"
1263 .to_string(),
1264 )
1265 })?,
1266 custom_compactor_reminders,
1267 policy,
1268 )
1269 .await
1270 }
1271 CompactStrategy::ObservationMask => {
1272 let mask_results = if let Some(cb) = mask_callback {
1273 Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1274 } else {
1275 None
1276 };
1277 Ok(observation_mask_compaction_with_callback(
1278 old_messages,
1279 archived_count,
1280 mask_results.as_deref(),
1281 ))
1282 }
1283 }
1284}
1285
1286async fn apply_compaction_strategy_with_fallback(
1287 input: CompactionStrategyInputs<'_>,
1288 fallback_strategy: Option<&CompactStrategy>,
1289) -> Result<(String, CompactStrategy), VmError> {
1290 match apply_compaction_strategy(input).await {
1291 Ok(summary) => Ok((summary, input.strategy.clone())),
1292 Err(primary_error) => {
1293 let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1294 else {
1295 return Err(primary_error);
1296 };
1297 let fallback_input = CompactionStrategyInputs {
1298 strategy: fallback,
1299 ..input
1300 };
1301 apply_compaction_strategy(fallback_input)
1302 .await
1303 .map(|summary| (summary, fallback.clone()))
1304 }
1305 }
1306}
1307
1308pub(crate) struct AutoCompactResult {
1309 pub summary: String,
1310 pub strategy: CompactStrategy,
1311}
1312
1313#[cfg(test)]
1315pub(crate) async fn auto_compact_messages_with_result(
1316 messages: &mut Vec<serde_json::Value>,
1317 config: &AutoCompactConfig,
1318 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1319) -> Result<Option<AutoCompactResult>, VmError> {
1320 auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1321}
1322
1323pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1324 ctx: Option<&AsyncBuiltinCtx>,
1325 messages: &mut Vec<serde_json::Value>,
1326 config: &AutoCompactConfig,
1327 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1328) -> Result<Option<AutoCompactResult>, VmError> {
1329 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1330 return Ok(None);
1331 }
1332 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1333 return Ok(None);
1334 }
1335 let compact_start = config.keep_first.min(messages.len());
1336 let original_split = messages.len().saturating_sub(config.keep_last);
1337 let mut split_at = original_split;
1338 while split_at > compact_start
1342 && split_at < messages.len()
1343 && messages[split_at]
1344 .get("role")
1345 .and_then(|r| r.as_str())
1346 .is_none_or(|r| r != "user")
1347 {
1348 split_at -= 1;
1349 }
1350 if split_at == compact_start {
1353 split_at = original_split;
1354 }
1355 if let Some(volatile_start) = messages[split_at..]
1356 .iter()
1357 .position(is_reasoning_or_tool_turn_message)
1358 .map(|offset| split_at + offset)
1359 {
1360 if let Some(boundary) = volatile_start
1361 .checked_sub(1)
1362 .and_then(|idx| find_prev_user_boundary(messages, idx))
1363 .filter(|boundary| *boundary > compact_start)
1364 {
1365 split_at = boundary;
1366 }
1367 }
1368 if split_at <= compact_start {
1369 return Ok(None);
1370 }
1371 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1372 let archived_count = old_messages.len();
1373
1374 clamp_tool_outputs(ctx, messages, config).await?;
1382
1383 let (mut summary, mut strategy) = apply_compaction_strategy_with_fallback(
1384 CompactionStrategyInputs {
1385 ctx,
1386 strategy: &config.compact_strategy,
1387 old_messages: &old_messages,
1388 archived_count,
1389 llm_opts,
1390 custom_compactor: config.custom_compactor.as_ref(),
1391 custom_compactor_reminders: &config.custom_compactor_reminders,
1392 mask_callback: config.mask_callback.as_ref(),
1393 summarize_prompt: config.summarize_prompt.as_deref(),
1394 policy: &config.policy,
1395 },
1396 config.fallback_strategy.as_ref(),
1397 )
1398 .await?;
1399
1400 if let Some(hard_limit) = config.hard_limit_tokens {
1401 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1402 let mut estimate_msgs = vec![summary_msg];
1403 estimate_msgs.extend_from_slice(messages.as_slice());
1404 let estimated = estimate_message_tokens(&estimate_msgs);
1405 if estimated > hard_limit {
1406 let tier1_as_messages = vec![serde_json::json!({
1407 "role": "user",
1408 "content": summary,
1409 })];
1410 let (hard_limit_summary, hard_limit_strategy) =
1411 apply_compaction_strategy_with_fallback(
1412 CompactionStrategyInputs {
1413 ctx,
1414 strategy: &config.hard_limit_strategy,
1415 old_messages: &tier1_as_messages,
1416 archived_count,
1417 llm_opts,
1418 custom_compactor: config.custom_compactor.as_ref(),
1419 custom_compactor_reminders: &config.custom_compactor_reminders,
1420 mask_callback: None,
1421 summarize_prompt: config.summarize_prompt.as_deref(),
1422 policy: &config.policy,
1423 },
1424 config.fallback_strategy.as_ref(),
1425 )
1426 .await?;
1427 summary = hard_limit_summary;
1428 strategy = hard_limit_strategy;
1429 }
1430 }
1431
1432 summary = apply_model_visible_policy(summary, &config.policy);
1433
1434 messages.insert(
1435 compact_start,
1436 serde_json::json!({
1437 "role": "user",
1438 "content": summary,
1439 }),
1440 );
1441 Ok(Some(AutoCompactResult { summary, strategy }))
1442}
1443
1444#[cfg(test)]
1446pub(crate) async fn auto_compact_messages(
1447 messages: &mut Vec<serde_json::Value>,
1448 config: &AutoCompactConfig,
1449 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1450) -> Result<Option<String>, VmError> {
1451 Ok(
1452 auto_compact_messages_with_result(messages, config, llm_opts)
1453 .await?
1454 .map(|result| result.summary),
1455 )
1456}
1457
1458fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1459 if !policy.is_model_visible_scope() {
1460 return summary;
1461 }
1462 let Some(directives) = policy.prompt_directives() else {
1463 return summary;
1464 };
1465 summary.push_str("\n\n[compaction instructions]\n");
1466 summary.push_str(&directives);
1467 summary
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473
1474 #[test]
1475 fn microcompact_short_output_unchanged() {
1476 let output = "line1\nline2\nline3\n";
1477 assert_eq!(microcompact_tool_output(output, 1000), output);
1478 }
1479
1480 #[test]
1481 fn microcompact_snaps_to_line_boundaries() {
1482 let lines: Vec<String> = (0..20)
1483 .map(|i| format!("line {i:02} content here"))
1484 .collect();
1485 let output = lines.join("\n");
1486 let result = microcompact_tool_output(&output, 200);
1487 assert!(result.contains("[... "), "should have snip marker");
1488 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1489 assert!(parts.len() >= 2, "should split at marker");
1490 let head = parts[0];
1491 for line in head.lines() {
1492 assert!(
1493 line.starts_with("line "),
1494 "head line should be complete: {line}"
1495 );
1496 }
1497 }
1498
1499 #[test]
1500 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1501 let mut lines = Vec::new();
1502 for i in 0..50 {
1503 lines.push(format!("verbose output line {i}"));
1504 }
1505 lines.push("src/main.rs:42: error: cannot find value".to_string());
1506 for i in 50..100 {
1507 lines.push(format!("verbose output line {i}"));
1508 }
1509 let output = lines.join("\n");
1510 let result = microcompact_tool_output(&output, 600);
1511 assert!(result.contains("cannot find value"), "diagnostic preserved");
1512 assert!(
1513 result.contains("[diagnostic lines preserved]"),
1514 "has diagnostic marker"
1515 );
1516 }
1517
1518 #[test]
1522 fn failure_signal_filter_keeps_structured_failure_lines() {
1523 for keep in [
1524 "left: 3",
1525 "right: 4",
1526 "expected: foo",
1527 "actual: bar",
1528 " --> src/main.rs:4:9",
1529 "= help: add `use std::fmt;`",
1530 "12 | let x = bad();",
1531 " | ^^^^^^^ not found",
1532 "L42: assertion failed",
1533 "src/main.rs:42: error: cannot find value",
1534 "FAIL: TestThing",
1535 "panic: index out of range",
1536 ] {
1537 assert!(
1538 is_failure_signal_line(keep),
1539 "should keep failure-signal line: {keep:?}"
1540 );
1541 }
1542 for drop in [
1543 "verbose output line 7",
1544 "compiling crate foo",
1545 " let y = ok();",
1546 "",
1547 ] {
1548 assert!(
1549 !is_failure_signal_line(drop),
1550 "should drop ordinary line: {drop:?}"
1551 );
1552 }
1553 }
1554
1555 #[test]
1559 fn default_mask_preserves_failure_detail() {
1560 let mut lines = vec!["running 1 test".to_string()];
1561 for i in 0..40 {
1562 lines.push(format!("noise line {i}"));
1563 }
1564 lines.push("assertion `left == right` failed".to_string());
1565 lines.push(" left: 3".to_string());
1566 lines.push(" right: 4".to_string());
1567 lines.push(" --> src/lib.rs:10:5".to_string());
1568 for i in 40..80 {
1569 lines.push(format!("more noise {i}"));
1570 }
1571 let content = lines.join("\n");
1572 let masked = default_mask_tool_result("tool", &content);
1573 assert!(
1574 masked.contains("masked"),
1575 "still reports it masked: {masked}"
1576 );
1577 assert!(
1578 masked.contains("failure lines preserved"),
1579 "should flag preserved lines: {masked}"
1580 );
1581 assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1582 assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1583 assert!(
1584 masked.contains("--> src/lib.rs:10:5"),
1585 "keeps rustc location: {masked}"
1586 );
1587 assert!(
1588 !masked.contains("noise line 7"),
1589 "drops ordinary noise: {masked}"
1590 );
1591 }
1592
1593 #[test]
1595 fn default_mask_without_failure_lines_stays_terse() {
1596 let lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1597 let content = lines.join("\n");
1598 let masked = default_mask_tool_result("tool", &content);
1599 assert!(masked.contains("masked]"), "should mask: {masked}");
1600 assert!(
1601 !masked.contains("failure lines preserved"),
1602 "no failure lines to preserve: {masked}"
1603 );
1604 }
1605
1606 #[test]
1607 fn token_estimate_counts_structured_message_content() {
1608 let text = "x".repeat(400);
1609 let messages = vec![serde_json::json!({
1610 "role": "user",
1611 "content": [
1612 {"type": "text", "text": text},
1613 {"type": "input_text", "text": "tail"},
1614 ],
1615 "reasoning": {"text": "scratch"},
1616 "tool_calls": [{
1617 "id": "call_1",
1618 "type": "function",
1619 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1620 }],
1621 })];
1622
1623 assert!(
1624 estimate_message_tokens(&messages) >= 100,
1625 "structured content must not count as zero"
1626 );
1627 }
1628
1629 #[test]
1630 fn compaction_policy_instructions_extend_by_default() {
1631 let policy = CompactionPolicy {
1632 instructions: Some("Keep the failing test names.".to_string()),
1633 ..Default::default()
1634 };
1635 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1636 .expect("prompt renders");
1637
1638 assert_eq!(policy.instruction_mode(), "extend");
1639 assert!(prompt.contains("Preserve goals, constraints"));
1640 assert!(prompt.contains("Additional compaction instructions"));
1641 assert!(prompt.contains("Keep the failing test names."));
1642 }
1643
1644 #[test]
1645 fn compaction_policy_can_replace_default_instructions() {
1646 let policy = CompactionPolicy {
1647 instructions: Some("Only keep repro steps.".to_string()),
1648 extend_default_instructions: Some(false),
1649 ..Default::default()
1650 };
1651 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1652 .expect("prompt renders");
1653
1654 assert_eq!(policy.instruction_mode(), "replace");
1655 assert!(prompt.contains("according to these instructions"));
1656 assert!(prompt.contains("Only keep repro steps."));
1657 assert!(!prompt.contains("Preserve goals, constraints"));
1658 }
1659
1660 #[test]
1661 fn snap_to_line_end_finds_newline() {
1662 let s = "line1\nline2\nline3\nline4\n";
1663 let head = snap_to_line_end(s, 12);
1664 assert!(head.ends_with('\n'), "should end at newline");
1665 assert!(head.contains("line1"));
1666 }
1667
1668 #[test]
1669 fn snap_to_line_start_finds_newline() {
1670 let s = "line1\nline2\nline3\nline4\n";
1671 let tail = snap_to_line_start(s, 12);
1672 assert!(
1673 tail.starts_with("line"),
1674 "should start at line boundary: {tail}"
1675 );
1676 }
1677
1678 #[test]
1679 fn auto_compact_preserves_reasoning_tool_suffix() {
1680 let mut messages = vec![
1681 serde_json::json!({"role": "user", "content": "old task"}),
1682 serde_json::json!({"role": "assistant", "content": "old reply"}),
1683 serde_json::json!({"role": "user", "content": "new task"}),
1684 serde_json::json!({
1685 "role": "assistant",
1686 "content": "",
1687 "reasoning": "think first",
1688 "tool_calls": [{
1689 "id": "call_1",
1690 "type": "function",
1691 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1692 }],
1693 }),
1694 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1695 ];
1696 let config = AutoCompactConfig {
1697 token_threshold: 1,
1698 keep_last: 2,
1699 ..Default::default()
1700 };
1701
1702 let runtime = tokio::runtime::Builder::new_current_thread()
1703 .enable_all()
1704 .build()
1705 .expect("runtime");
1706 let summary = runtime
1707 .block_on(auto_compact_messages(&mut messages, &config, None))
1708 .expect("compaction succeeds");
1709
1710 assert!(summary.is_some());
1711 assert_eq!(messages[1]["role"], "user");
1712 assert_eq!(messages[2]["role"], "assistant");
1713 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1714 assert_eq!(messages[3]["role"], "tool");
1715 assert_eq!(messages[3]["tool_call_id"], "call_1");
1716 }
1717
1718 #[test]
1719 fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
1720 let big = "x".repeat(4000);
1723 let big_len = big.len();
1724 let mut messages = vec![
1725 serde_json::json!({"role": "user", "content": "old task"}),
1726 serde_json::json!({"role": "assistant", "content": "old reply"}),
1727 serde_json::json!({"role": "user", "content": "new task"}),
1728 serde_json::json!({"role": "assistant", "content": "calling tool"}),
1729 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
1730 ];
1731 let config = AutoCompactConfig {
1732 token_threshold: 1,
1733 keep_last: 2,
1734 tool_output_max_chars: 500,
1735 ..Default::default()
1736 };
1737
1738 let runtime = tokio::runtime::Builder::new_current_thread()
1739 .enable_all()
1740 .build()
1741 .expect("runtime");
1742 let result = runtime
1743 .block_on(auto_compact_messages(&mut messages, &config, None))
1744 .expect("compaction succeeds");
1745 assert!(result.is_some(), "compaction should trigger");
1746
1747 let tool_msg = messages
1748 .iter()
1749 .find(|message| message["role"] == "tool")
1750 .expect("tool message kept in window");
1751 assert_eq!(tool_msg["tool_call_id"], "call_1");
1753 let content = tool_msg["content"].as_str().expect("string content");
1755 assert!(
1756 content.len() < big_len,
1757 "tool output should be clamped: {} vs {}",
1758 content.len(),
1759 big_len
1760 );
1761 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
1762 }
1763
1764 #[test]
1767 fn observation_mask_preserves_pinned_live_file_view() {
1768 let pinned_body = format!(
1769 "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
1770 NO_COMPACT_MARKER,
1771 (0..40)
1772 .map(|i| format!(" {i} let x = compute({i});"))
1773 .collect::<Vec<_>>()
1774 .join("\n")
1775 );
1776 let verbose_unpinned = (0..60)
1777 .map(|i| format!("verbose scan output line {i}"))
1778 .collect::<Vec<_>>()
1779 .join("\n");
1780 let archived = vec![
1782 serde_json::json!({"role": "user", "content": verbose_unpinned}),
1783 serde_json::json!({"role": "user", "content": pinned_body}),
1784 ];
1785 let summary = observation_mask_compaction(&archived, archived.len());
1786 assert!(
1788 summary.contains("Edited region now reads"),
1789 "pinned heading survived: {summary}"
1790 );
1791 assert!(
1792 summary.contains("let x = compute(39);"),
1793 "pinned body survived verbatim"
1794 );
1795 assert!(summary.contains("masked]"), "unpinned output was masked");
1797 assert!(!summary.contains("verbose scan output line 30"));
1798 }
1799
1800 #[test]
1803 fn clamp_exempts_pinned_tool_output() {
1804 let pinned_big = format!(
1805 "## Exact current file text {}\n{}",
1806 NO_COMPACT_MARKER,
1807 "x".repeat(4000)
1808 );
1809 let pinned_len = pinned_big.len();
1810 let unpinned_big = "y".repeat(4000);
1811 let unpinned_len = unpinned_big.len();
1812 let mut messages = vec![
1813 serde_json::json!({"role": "user", "content": "old task"}),
1814 serde_json::json!({"role": "assistant", "content": "reply"}),
1815 serde_json::json!({"role": "user", "content": "new task"}),
1816 serde_json::json!({"role": "assistant", "content": "calling tools"}),
1817 serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
1818 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
1819 serde_json::json!({"role": "user", "content": "continue"}),
1820 ];
1821 let config = AutoCompactConfig {
1822 token_threshold: 1,
1823 keep_last: 4,
1824 tool_output_max_chars: 500,
1825 ..Default::default()
1826 };
1827 let runtime = tokio::runtime::Builder::new_current_thread()
1828 .enable_all()
1829 .build()
1830 .expect("runtime");
1831 runtime
1832 .block_on(auto_compact_messages(&mut messages, &config, None))
1833 .expect("compaction succeeds");
1834
1835 let pinned_msg = messages
1836 .iter()
1837 .find(|m| m["tool_call_id"] == "c1")
1838 .expect("pinned tool message kept");
1839 assert_eq!(
1840 pinned_msg["content"].as_str().map(str::len),
1841 Some(pinned_len),
1842 "pinned output must be intact (unclamped)"
1843 );
1844 let unpinned_msg = messages
1845 .iter()
1846 .find(|m| m["tool_call_id"] == "c0")
1847 .expect("unpinned tool message kept");
1848 assert!(
1849 unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
1850 "unpinned output of the same size must be clamped"
1851 );
1852 }
1853
1854 #[test]
1859 fn pin_bound_keeps_only_latest_segments() {
1860 let make = |gen: usize| {
1864 let body = (0..40)
1865 .map(|i| format!("marker-gen-{gen} body line {i}"))
1866 .collect::<Vec<_>>()
1867 .join("\n");
1868 serde_json::json!({
1869 "role": "user",
1870 "content": format!(
1871 "## Edited region now reads (gen {gen}) {}\n{}",
1872 NO_COMPACT_MARKER, body
1873 ),
1874 })
1875 };
1876 let archived: Vec<_> = (0..6).map(make).collect();
1877
1878 let pinned = latest_pinned_indices(archived.iter(), |m| {
1880 m.get("content").and_then(|c| c.as_str())
1881 });
1882 assert_eq!(
1883 pinned.len(),
1884 MAX_PINNED_SEGMENTS,
1885 "only the latest MAX_PINNED_SEGMENTS are pinned"
1886 );
1887 assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
1888 assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
1889
1890 let summary = observation_mask_compaction(&archived, archived.len());
1894 assert!(
1895 summary.contains("marker-gen-5")
1896 && summary.contains("marker-gen-4")
1897 && summary.contains("marker-gen-3"),
1898 "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
1899 );
1900 assert!(
1901 !summary.contains("marker-gen-0")
1902 && !summary.contains("marker-gen-1")
1903 && !summary.contains("marker-gen-2"),
1904 "older pinned snapshots are masked (bound enforced)"
1905 );
1906 assert!(summary.contains("masked]"), "older snapshots were masked");
1907 }
1908
1909 #[test]
1911 fn no_pins_preserves_prior_clamp_behavior() {
1912 let big = "x".repeat(4000);
1913 let big_len = big.len();
1914 let mut messages = vec![
1915 serde_json::json!({"role": "user", "content": "old task"}),
1916 serde_json::json!({"role": "assistant", "content": "old reply"}),
1917 serde_json::json!({"role": "user", "content": "new task"}),
1918 serde_json::json!({"role": "assistant", "content": "calling tool"}),
1919 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
1920 ];
1921 let config = AutoCompactConfig {
1922 token_threshold: 1,
1923 keep_last: 2,
1924 tool_output_max_chars: 500,
1925 ..Default::default()
1926 };
1927 let runtime = tokio::runtime::Builder::new_current_thread()
1928 .enable_all()
1929 .build()
1930 .expect("runtime");
1931 let result = runtime
1932 .block_on(auto_compact_messages(&mut messages, &config, None))
1933 .expect("compaction succeeds");
1934 assert!(result.is_some());
1935 let tool_msg = messages
1936 .iter()
1937 .find(|m| m["role"] == "tool")
1938 .expect("tool kept");
1939 let content = tool_msg["content"].as_str().expect("string content");
1940 assert!(content.len() < big_len, "unpinned output clamped as before");
1941 assert!(content.len() < 2000, "clamped near tool_output_max_chars");
1942 }
1943}