1use std::collections::BTreeMap;
4use std::rc::Rc;
5
6use serde::{Deserialize, Serialize};
7
8use crate::llm::{vm_call_llm_full, vm_value_to_json};
9use crate::value::{VmError, VmValue};
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 = BTreeMap::new();
198 if let Some(instructions) = policy.instructions.as_ref() {
199 map.insert(
200 "instructions".to_string(),
201 VmValue::String(Rc::from(instructions.clone())),
202 );
203 }
204 if let Some(mode) = policy.mode.as_ref() {
205 map.insert("mode".to_string(), VmValue::String(Rc::from(mode.clone())));
206 }
207 if let Some(scope) = policy.scope.as_ref() {
208 map.insert(
209 "scope".to_string(),
210 VmValue::String(Rc::from(scope.clone())),
211 );
212 }
213 map.insert(
214 "preserve".to_string(),
215 VmValue::List(Rc::new(
216 policy
217 .preserve
218 .iter()
219 .map(|item| VmValue::String(Rc::from(item.clone())))
220 .collect(),
221 )),
222 );
223 map.insert(
224 "drop".to_string(),
225 VmValue::List(Rc::new(
226 policy
227 .drop_items
228 .iter()
229 .map(|item| VmValue::String(Rc::from(item.clone())))
230 .collect(),
231 )),
232 );
233 if let Some(extend_default_instructions) = policy.extend_default_instructions {
234 map.insert(
235 "extend_default_instructions".to_string(),
236 VmValue::Bool(extend_default_instructions),
237 );
238 }
239 if let Some(author) = policy.author.as_ref() {
240 map.insert(
241 "author".to_string(),
242 VmValue::String(Rc::from(author.clone())),
243 );
244 }
245 VmValue::Dict(Rc::new(map))
246}
247
248pub fn parse_compaction_policy_options(
249 options: Option<&BTreeMap<String, VmValue>>,
250 builtin: &str,
251) -> Result<CompactionPolicy, VmError> {
252 let mut policy = options
253 .and_then(|map| {
254 map.get("policy")
255 .or_else(|| map.get("compaction_policy"))
256 .or_else(|| map.get("compaction_request"))
257 })
258 .map(|value| parse_compaction_policy_value(value, builtin))
259 .transpose()?
260 .unwrap_or_default();
261 if let Some(options) = options {
262 apply_compaction_policy_fields(&mut policy, options, builtin)?;
263 }
264 Ok(policy)
265}
266
267fn parse_compaction_policy_value(
268 value: &VmValue,
269 builtin: &str,
270) -> Result<CompactionPolicy, VmError> {
271 match value {
272 VmValue::Nil => Ok(CompactionPolicy::default()),
273 VmValue::Dict(map) => {
274 if let Some(nested) = map
275 .get("policy")
276 .or_else(|| map.get("compaction_policy"))
277 .or_else(|| map.get("compaction_request"))
278 {
279 let mut policy = parse_compaction_policy_value(nested, builtin)?;
280 apply_compaction_policy_fields(&mut policy, map, builtin)?;
281 Ok(policy)
282 } else {
283 let mut policy = CompactionPolicy::default();
284 apply_compaction_policy_fields(&mut policy, map, builtin)?;
285 Ok(policy)
286 }
287 }
288 other => Err(VmError::Runtime(format!(
289 "{builtin}: compaction policy must be a dict or nil, got {}",
290 other.type_name()
291 ))),
292 }
293}
294
295fn apply_compaction_policy_fields(
296 policy: &mut CompactionPolicy,
297 map: &BTreeMap<String, VmValue>,
298 builtin: &str,
299) -> Result<(), VmError> {
300 if let Some(value) = optional_policy_string(map, "instructions", builtin)? {
301 policy.instructions = Some(value);
302 }
303 if let Some(value) = optional_policy_string(map, "mode", builtin)? {
304 policy.mode = Some(value);
305 }
306 if let Some(value) = optional_policy_string(map, "scope", builtin)? {
307 policy.scope = Some(value);
308 }
309 if map.contains_key("preserve") {
310 policy.preserve = policy_string_list(map.get("preserve"), builtin, "preserve")?;
311 }
312 if map.contains_key("drop") {
313 policy.drop_items = policy_string_list(map.get("drop"), builtin, "drop")?;
314 }
315 if let Some(value) = optional_policy_bool(map, "extend_default_instructions", builtin)? {
316 policy.extend_default_instructions = Some(value);
317 }
318 if let Some(value) = optional_policy_string(map, "author", builtin)? {
319 policy.author = Some(value);
320 }
321 Ok(())
322}
323
324fn optional_policy_string(
325 map: &BTreeMap<String, VmValue>,
326 key: &str,
327 builtin: &str,
328) -> Result<Option<String>, VmError> {
329 match map.get(key) {
330 None | Some(VmValue::Nil) => Ok(None),
331 Some(VmValue::String(text)) => {
332 let trimmed = text.trim();
333 if trimmed.is_empty() {
334 Ok(None)
335 } else {
336 Ok(Some(trimmed.to_string()))
337 }
338 }
339 Some(other) => Err(VmError::Runtime(format!(
340 "{builtin}: compaction policy `{key}` must be a string, got {}",
341 other.type_name()
342 ))),
343 }
344}
345
346fn optional_policy_bool(
347 map: &BTreeMap<String, VmValue>,
348 key: &str,
349 builtin: &str,
350) -> Result<Option<bool>, VmError> {
351 match map.get(key) {
352 None | Some(VmValue::Nil) => Ok(None),
353 Some(VmValue::Bool(value)) => Ok(Some(*value)),
354 Some(other) => Err(VmError::Runtime(format!(
355 "{builtin}: compaction policy `{key}` must be a bool, got {}",
356 other.type_name()
357 ))),
358 }
359}
360
361fn policy_string_list(
362 value: Option<&VmValue>,
363 builtin: &str,
364 key: &str,
365) -> Result<Vec<String>, VmError> {
366 match value {
367 None | Some(VmValue::Nil) => Ok(Vec::new()),
368 Some(VmValue::String(text)) => {
369 let trimmed = text.trim();
370 if trimmed.is_empty() {
371 Ok(Vec::new())
372 } else {
373 Ok(vec![trimmed.to_string()])
374 }
375 }
376 Some(VmValue::List(items)) => items
377 .iter()
378 .map(|item| match item {
379 VmValue::String(text) => Ok(text.trim().to_string()),
380 other => Err(VmError::Runtime(format!(
381 "{builtin}: compaction policy `{key}` entries must be strings, got {}",
382 other.type_name()
383 ))),
384 })
385 .filter_map(|result| match result {
386 Ok(value) if value.is_empty() => None,
387 other => Some(other),
388 })
389 .collect(),
390 Some(other) => Err(VmError::Runtime(format!(
391 "{builtin}: compaction policy `{key}` must be a string or list, got {}",
392 other.type_name()
393 ))),
394 }
395}
396
397pub fn compaction_policy_metadata_fields(
398 policy: &CompactionPolicy,
399) -> Vec<(&'static str, serde_json::Value)> {
400 let mut fields = vec![(
401 "instruction_mode",
402 serde_json::Value::String(policy.instruction_mode().to_string()),
403 )];
404 if let Some(source) = policy.instruction_source() {
405 fields.push((
406 "instruction_source",
407 serde_json::Value::String(source.to_string()),
408 ));
409 }
410 if let Some(policy_json) = policy.metadata_json() {
411 fields.push(("compaction_policy", policy_json));
412 }
413 fields
414}
415
416#[derive(Clone, Debug)]
426pub struct AutoCompactConfig {
427 pub keep_first: usize,
431 pub token_threshold: usize,
433 pub tool_output_max_chars: usize,
435 pub keep_last: usize,
437 pub compact_strategy: CompactStrategy,
439 pub hard_limit_tokens: Option<usize>,
443 pub hard_limit_strategy: CompactStrategy,
445 pub custom_compactor: Option<VmValue>,
447 pub custom_compactor_reminders: Vec<VmValue>,
451 pub mask_callback: Option<VmValue>,
458 pub compress_callback: Option<VmValue>,
464 pub summarize_prompt: Option<String>,
468 pub policy_strategy: String,
472 pub fallback_strategy: Option<CompactStrategy>,
476 pub policy: CompactionPolicy,
480}
481
482impl Default for AutoCompactConfig {
483 fn default() -> Self {
484 Self {
485 keep_first: 0,
486 token_threshold: 48_000,
487 tool_output_max_chars: 16_000,
488 keep_last: 12,
489 compact_strategy: CompactStrategy::ObservationMask,
490 hard_limit_tokens: None,
491 hard_limit_strategy: CompactStrategy::Llm,
492 custom_compactor: None,
493 custom_compactor_reminders: Vec::new(),
494 mask_callback: None,
495 compress_callback: None,
496 summarize_prompt: None,
497 policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
498 fallback_strategy: None,
499 policy: CompactionPolicy::default(),
500 }
501 }
502}
503
504pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
506 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
507}
508
509fn estimate_message_chars(message: &serde_json::Value) -> usize {
510 let mut total = message
511 .get("content")
512 .map(estimate_content_chars)
513 .unwrap_or_default();
514 if let Some(reasoning) = message.get("reasoning") {
515 total += estimate_content_chars(reasoning);
516 }
517 if let Some(tool_calls) = message.get("tool_calls") {
518 total += estimate_content_chars(tool_calls);
519 }
520 total
521}
522
523fn estimate_content_chars(value: &serde_json::Value) -> usize {
524 match value {
525 serde_json::Value::String(text) => text.len(),
526 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
527 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
528 serde_json::Value::Null => 0,
529 other => other.to_string().len(),
530 }
531}
532
533fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
534 let role = message
535 .get("role")
536 .and_then(|value| value.as_str())
537 .unwrap_or_default();
538 role == "tool"
539 || message.get("tool_calls").is_some()
540 || message
541 .get("reasoning")
542 .map(|value| !value.is_null())
543 .unwrap_or(false)
544}
545
546fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
547 (0..=start)
548 .rev()
549 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
550}
551
552pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
555 if output.len() <= max_chars || max_chars < 200 {
556 return output.to_string();
557 }
558 let diagnostic_lines = output
559 .lines()
560 .filter(|line| {
561 let trimmed = line.trim();
562 let lower = trimmed.to_lowercase();
563 let has_file_line = {
564 let bytes = trimmed.as_bytes();
565 let mut i = 0;
566 let mut found_colon = false;
567 while i < bytes.len() {
568 if bytes[i] == b':' {
569 found_colon = true;
570 break;
571 }
572 i += 1;
573 }
574 found_colon && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
575 };
576 let has_strong_keyword =
577 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
578 let has_weak_keyword = trimmed.contains("error")
579 || trimmed.contains("undefined")
580 || trimmed.contains("expected")
581 || trimmed.contains("got")
582 || lower.contains("cannot find")
583 || lower.contains("not found")
584 || lower.contains("no such")
585 || lower.contains("unresolved")
586 || lower.contains("missing")
587 || lower.contains("declared but not used")
588 || lower.contains("unused")
589 || lower.contains("mismatch");
590 let positional = lower.contains(" error ")
591 || lower.starts_with("error:")
592 || lower.starts_with("warning:")
593 || lower.starts_with("note:")
594 || lower.contains("panic:");
595 has_strong_keyword || (has_file_line && has_weak_keyword) || positional
596 })
597 .take(32)
598 .collect::<Vec<_>>();
599 if !diagnostic_lines.is_empty() {
600 let diagnostics = diagnostic_lines.join("\n");
601 let budget = max_chars.saturating_sub(diagnostics.len() + 64);
602 let keep = budget / 2;
603 if keep >= 80 && output.len() > keep * 2 {
604 let head = snap_to_line_end(output, keep);
605 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
606 return format!(
607 "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
608 );
609 }
610 }
611 let keep = max_chars / 2;
612 let head = snap_to_line_end(output, keep);
613 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
614 let snipped = output.len().saturating_sub(head.len() + tail.len());
615 format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
616}
617
618fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
622 if max_bytes >= s.len() {
623 return s;
624 }
625 let search_end = s.floor_char_boundary(max_bytes);
626 match s[..search_end].rfind('\n') {
627 Some(pos) => &s[..pos + 1],
628 None => &s[..search_end], }
630}
631
632fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
636 if start_byte == 0 {
637 return s;
638 }
639 let search_start = s.ceil_char_boundary(start_byte);
640 if search_start >= s.len() {
641 return "";
642 }
643 match s[search_start..].find('\n') {
644 Some(pos) => {
645 let line_start = search_start + pos + 1;
646 if line_start < s.len() {
647 &s[line_start..]
648 } else {
649 &s[search_start..]
650 }
651 }
652 None => &s[search_start..], }
654}
655
656fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
657 messages
658 .iter()
659 .map(|msg| {
660 let role = msg
661 .get("role")
662 .and_then(|v| v.as_str())
663 .unwrap_or("user")
664 .to_uppercase();
665 let content = msg
666 .get("content")
667 .and_then(|v| v.as_str())
668 .unwrap_or_default();
669 format!("{role}: {content}")
670 })
671 .collect::<Vec<_>>()
672 .join("\n")
673}
674
675fn truncate_compaction_summary(
676 old_messages: &[serde_json::Value],
677 archived_count: usize,
678) -> String {
679 truncate_compaction_summary_with_context(old_messages, archived_count, false)
680}
681
682fn truncate_compaction_summary_with_context(
683 old_messages: &[serde_json::Value],
684 archived_count: usize,
685 is_llm_fallback: bool,
686) -> String {
687 let per_msg_limit = 500_usize;
688 let summary_parts: Vec<String> = old_messages
689 .iter()
690 .filter_map(|m| {
691 let role = m.get("role")?.as_str()?;
692 let content = m.get("content")?.as_str()?;
693 if content.is_empty() {
694 return None;
695 }
696 let truncated = if content.len() > per_msg_limit {
697 format!(
698 "{}... [truncated from {} chars]",
699 &content[..content.floor_char_boundary(per_msg_limit)],
700 content.len()
701 )
702 } else {
703 content.to_string()
704 };
705 Some(format!("[{role}] {truncated}"))
706 })
707 .take(15)
708 .collect();
709 let header = if is_llm_fallback {
710 format!(
711 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
712 )
713 } else {
714 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
715 };
716 format!(
717 "{header}\n{}{}",
718 summary_parts.join("\n"),
719 if archived_count > 15 {
720 format!("\n... and {} more", archived_count - 15)
721 } else {
722 String::new()
723 }
724 )
725}
726
727fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
728 if let Some(map) = value.as_dict() {
729 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
730 return Ok(summary.display());
731 }
732 }
733 match value {
734 VmValue::String(text) => Ok(text.to_string()),
735 VmValue::Nil => Ok(String::new()),
736 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
737 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
738 }
739}
740
741async fn llm_compaction_summary(
742 old_messages: &[serde_json::Value],
743 archived_count: usize,
744 llm_opts: &crate::llm::api::LlmCallOptions,
745 summarize_prompt: Option<&str>,
746 policy: &CompactionPolicy,
747) -> Result<String, VmError> {
748 let mut compact_opts = llm_opts.clone();
749 let formatted = format_compaction_messages(old_messages);
750 compact_opts.system = None;
751 compact_opts.transcript_summary = None;
752 compact_opts.native_tools = None;
753 compact_opts.tool_choice = None;
754 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
755 compact_opts.response_format = None;
756 compact_opts.json_schema = None;
757 compact_opts.output_schema = None;
758 let prompt =
759 render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
760 compact_opts.messages = vec![serde_json::json!({
761 "role": "user",
762 "content": prompt,
763 })];
764 let result = vm_call_llm_full(&compact_opts).await?;
765 let summary = result.text.trim();
766 if summary.is_empty() {
767 Ok(truncate_compaction_summary_with_context(
768 old_messages,
769 archived_count,
770 true,
771 ))
772 } else {
773 Ok(format!(
774 "[auto-compacted {archived_count} older messages]\n{summary}"
775 ))
776 }
777}
778
779fn render_llm_compaction_prompt(
780 summarize_prompt: Option<&str>,
781 formatted: &str,
782 archived_count: usize,
783 policy: &CompactionPolicy,
784) -> Result<String, VmError> {
785 if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
786 return render_replacement_compaction_prompt(policy, formatted, archived_count);
787 }
788 let mut bindings = BTreeMap::new();
789 bindings.insert(
790 "formatted_messages".to_string(),
791 VmValue::String(Rc::from(formatted.to_string())),
792 );
793 bindings.insert(
794 "archived_count".to_string(),
795 VmValue::Int(archived_count as i64),
796 );
797 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
798 let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
799 "orchestration/prompts/compaction_summary.harn.prompt",
800 Some(&bindings),
801 )?;
802 return Ok(extend_compaction_prompt(prompt, policy));
803 };
804
805 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
806 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
807 let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
808 .map_err(VmError::from)?;
809 Ok(extend_compaction_prompt(prompt, policy))
810}
811
812fn render_replacement_compaction_prompt(
813 policy: &CompactionPolicy,
814 formatted: &str,
815 archived_count: usize,
816) -> Result<String, VmError> {
817 let directives = policy.prompt_directives().unwrap_or_default();
818 let mut bindings = BTreeMap::new();
819 bindings.insert(
820 "directives".to_string(),
821 VmValue::String(Rc::from(directives)),
822 );
823 bindings.insert(
824 "formatted_messages".to_string(),
825 VmValue::String(Rc::from(formatted.to_string())),
826 );
827 bindings.insert(
828 "archived_count".to_string(),
829 VmValue::Int(archived_count as i64),
830 );
831 crate::stdlib::template::render_stdlib_prompt_asset(
832 "orchestration/prompts/compaction_policy_replacement.harn.prompt",
833 Some(&bindings),
834 )
835}
836
837fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
838 let Some(directives) = policy.prompt_directives() else {
839 return prompt;
840 };
841 prompt.push_str(
842 "\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",
843 );
844 prompt.push_str(&directives);
845 prompt
846}
847
848async fn custom_compaction_summary(
849 old_messages: &[serde_json::Value],
850 archived_count: usize,
851 callback: &VmValue,
852 reminders: &[VmValue],
853 policy: &CompactionPolicy,
854) -> Result<String, VmError> {
855 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
856 return Err(VmError::Runtime(
857 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
858 ));
859 };
860 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
861 VmError::Runtime(
862 "custom transcript compaction requires an async builtin VM context".to_string(),
863 )
864 })?;
865 let messages_vm = VmValue::List(Rc::new(
866 old_messages
867 .iter()
868 .map(crate::stdlib::json_to_vm_value)
869 .collect(),
870 ));
871 let result = if policy.has_metadata()
872 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
873 {
874 let reminders_vm = VmValue::List(Rc::new(reminders.to_vec()));
875 let policy_vm = compaction_policy_to_vm_value(policy);
876 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
877 .await
878 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
879 let reminders_vm = VmValue::List(Rc::new(reminders.to_vec()));
880 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
881 .await
882 } else {
883 vm.call_closure_pub(&closure, &[messages_vm]).await
884 };
885 let summary = compact_summary_text_from_value(&result?)?;
886 if summary.trim().is_empty() {
887 Ok(truncate_compaction_summary(old_messages, archived_count))
888 } else {
889 Ok(format!(
890 "[auto-compacted {archived_count} older messages]\n{summary}"
891 ))
892 }
893}
894
895fn content_should_preserve(content: &str) -> bool {
901 content.len() < 500
902}
903
904fn default_mask_tool_result(role: &str, content: &str) -> String {
906 let first_line = content.lines().next().unwrap_or(content);
907 let line_count = content.lines().count();
908 let char_count = content.len();
909 if line_count <= 3 {
910 format!("[{role}] {content}")
911 } else {
912 let preview = &first_line[..first_line.len().min(120)];
913 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
914 }
915}
916
917#[cfg(test)]
919pub(crate) fn observation_mask_compaction(
920 old_messages: &[serde_json::Value],
921 archived_count: usize,
922) -> String {
923 observation_mask_compaction_with_callback(old_messages, archived_count, None)
924}
925
926fn observation_mask_compaction_with_callback(
927 old_messages: &[serde_json::Value],
928 archived_count: usize,
929 mask_results: Option<&[Option<String>]>,
930) -> String {
931 let mut parts = Vec::new();
932 parts.push(format!(
933 "[auto-compacted {archived_count} older messages via observation masking]"
934 ));
935 for (idx, msg) in old_messages.iter().enumerate() {
936 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
937 let content = msg
938 .get("content")
939 .and_then(|v| v.as_str())
940 .unwrap_or_default();
941 if content.is_empty() {
942 continue;
943 }
944 if role == "assistant" {
945 parts.push(format!("[assistant] {content}"));
946 continue;
947 }
948 if content_should_preserve(content) {
949 parts.push(format!("[{role}] {content}"));
950 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
951 parts.push(custom.clone());
952 } else {
953 parts.push(default_mask_tool_result(role, content));
954 }
955 }
956 parts.join("\n")
957}
958
959async fn invoke_mask_callback(
961 callback: &VmValue,
962 old_messages: &[serde_json::Value],
963) -> Result<Vec<Option<String>>, VmError> {
964 let VmValue::Closure(closure) = callback.clone() else {
965 return Err(VmError::Runtime(
966 "mask_callback must be a closure".to_string(),
967 ));
968 };
969 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
970 VmError::Runtime("mask_callback requires an async builtin VM context".to_string())
971 })?;
972 let messages_vm = VmValue::List(Rc::new(
973 old_messages
974 .iter()
975 .map(crate::stdlib::json_to_vm_value)
976 .collect(),
977 ));
978 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
979 let list = match result {
980 VmValue::List(items) => items,
981 _ => return Ok(vec![None; old_messages.len()]),
982 };
983 Ok(list
984 .iter()
985 .map(|v| match v {
986 VmValue::String(s) => Some(s.to_string()),
987 VmValue::Nil => None,
988 _ => None,
989 })
990 .collect())
991}
992
993#[derive(Clone, Copy)]
994struct CompactionStrategyInputs<'a> {
995 strategy: &'a CompactStrategy,
996 old_messages: &'a [serde_json::Value],
997 archived_count: usize,
998 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
999 custom_compactor: Option<&'a VmValue>,
1000 custom_compactor_reminders: &'a [VmValue],
1001 mask_callback: Option<&'a VmValue>,
1002 summarize_prompt: Option<&'a str>,
1003 policy: &'a CompactionPolicy,
1004}
1005
1006async fn apply_compaction_strategy(input: CompactionStrategyInputs<'_>) -> Result<String, VmError> {
1008 let CompactionStrategyInputs {
1009 strategy,
1010 old_messages,
1011 archived_count,
1012 llm_opts,
1013 custom_compactor,
1014 custom_compactor_reminders,
1015 mask_callback,
1016 summarize_prompt,
1017 policy,
1018 } = input;
1019 match strategy {
1020 CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
1021 CompactStrategy::Llm => {
1022 llm_compaction_summary(
1023 old_messages,
1024 archived_count,
1025 llm_opts.ok_or_else(|| {
1026 VmError::Runtime(
1027 "LLM transcript compaction requires active LLM call options".to_string(),
1028 )
1029 })?,
1030 summarize_prompt,
1031 policy,
1032 )
1033 .await
1034 }
1035 CompactStrategy::Custom => {
1036 custom_compaction_summary(
1037 old_messages,
1038 archived_count,
1039 custom_compactor.ok_or_else(|| {
1040 VmError::Runtime(
1041 "compact_callback is required when compact_strategy is 'custom'"
1042 .to_string(),
1043 )
1044 })?,
1045 custom_compactor_reminders,
1046 policy,
1047 )
1048 .await
1049 }
1050 CompactStrategy::ObservationMask => {
1051 let mask_results = if let Some(cb) = mask_callback {
1052 Some(invoke_mask_callback(cb, old_messages).await?)
1053 } else {
1054 None
1055 };
1056 Ok(observation_mask_compaction_with_callback(
1057 old_messages,
1058 archived_count,
1059 mask_results.as_deref(),
1060 ))
1061 }
1062 }
1063}
1064
1065async fn apply_compaction_strategy_with_fallback(
1066 input: CompactionStrategyInputs<'_>,
1067 fallback_strategy: Option<&CompactStrategy>,
1068) -> Result<(String, CompactStrategy), VmError> {
1069 match apply_compaction_strategy(input).await {
1070 Ok(summary) => Ok((summary, input.strategy.clone())),
1071 Err(primary_error) => {
1072 let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1073 else {
1074 return Err(primary_error);
1075 };
1076 let fallback_input = CompactionStrategyInputs {
1077 strategy: fallback,
1078 ..input
1079 };
1080 apply_compaction_strategy(fallback_input)
1081 .await
1082 .map(|summary| (summary, fallback.clone()))
1083 }
1084 }
1085}
1086
1087pub(crate) struct AutoCompactResult {
1088 pub summary: String,
1089 pub strategy: CompactStrategy,
1090}
1091
1092pub(crate) async fn auto_compact_messages_with_result(
1094 messages: &mut Vec<serde_json::Value>,
1095 config: &AutoCompactConfig,
1096 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1097) -> Result<Option<AutoCompactResult>, VmError> {
1098 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1099 return Ok(None);
1100 }
1101 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1102 return Ok(None);
1103 }
1104 let compact_start = config.keep_first.min(messages.len());
1105 let original_split = messages.len().saturating_sub(config.keep_last);
1106 let mut split_at = original_split;
1107 while split_at > compact_start
1111 && split_at < messages.len()
1112 && messages[split_at]
1113 .get("role")
1114 .and_then(|r| r.as_str())
1115 .is_none_or(|r| r != "user")
1116 {
1117 split_at -= 1;
1118 }
1119 if split_at == compact_start {
1122 split_at = original_split;
1123 }
1124 if let Some(volatile_start) = messages[split_at..]
1125 .iter()
1126 .position(is_reasoning_or_tool_turn_message)
1127 .map(|offset| split_at + offset)
1128 {
1129 if let Some(boundary) = volatile_start
1130 .checked_sub(1)
1131 .and_then(|idx| find_prev_user_boundary(messages, idx))
1132 .filter(|boundary| *boundary > compact_start)
1133 {
1134 split_at = boundary;
1135 }
1136 }
1137 if split_at <= compact_start {
1138 return Ok(None);
1139 }
1140 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1141 let archived_count = old_messages.len();
1142
1143 let (mut summary, mut strategy) = apply_compaction_strategy_with_fallback(
1144 CompactionStrategyInputs {
1145 strategy: &config.compact_strategy,
1146 old_messages: &old_messages,
1147 archived_count,
1148 llm_opts,
1149 custom_compactor: config.custom_compactor.as_ref(),
1150 custom_compactor_reminders: &config.custom_compactor_reminders,
1151 mask_callback: config.mask_callback.as_ref(),
1152 summarize_prompt: config.summarize_prompt.as_deref(),
1153 policy: &config.policy,
1154 },
1155 config.fallback_strategy.as_ref(),
1156 )
1157 .await?;
1158
1159 if let Some(hard_limit) = config.hard_limit_tokens {
1160 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1161 let mut estimate_msgs = vec![summary_msg];
1162 estimate_msgs.extend_from_slice(messages.as_slice());
1163 let estimated = estimate_message_tokens(&estimate_msgs);
1164 if estimated > hard_limit {
1165 let tier1_as_messages = vec![serde_json::json!({
1166 "role": "user",
1167 "content": summary,
1168 })];
1169 let (hard_limit_summary, hard_limit_strategy) =
1170 apply_compaction_strategy_with_fallback(
1171 CompactionStrategyInputs {
1172 strategy: &config.hard_limit_strategy,
1173 old_messages: &tier1_as_messages,
1174 archived_count,
1175 llm_opts,
1176 custom_compactor: config.custom_compactor.as_ref(),
1177 custom_compactor_reminders: &config.custom_compactor_reminders,
1178 mask_callback: None,
1179 summarize_prompt: config.summarize_prompt.as_deref(),
1180 policy: &config.policy,
1181 },
1182 config.fallback_strategy.as_ref(),
1183 )
1184 .await?;
1185 summary = hard_limit_summary;
1186 strategy = hard_limit_strategy;
1187 }
1188 }
1189
1190 summary = apply_model_visible_policy(summary, &config.policy);
1191
1192 messages.insert(
1193 compact_start,
1194 serde_json::json!({
1195 "role": "user",
1196 "content": summary,
1197 }),
1198 );
1199 Ok(Some(AutoCompactResult { summary, strategy }))
1200}
1201
1202#[cfg(test)]
1204pub(crate) async fn auto_compact_messages(
1205 messages: &mut Vec<serde_json::Value>,
1206 config: &AutoCompactConfig,
1207 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1208) -> Result<Option<String>, VmError> {
1209 Ok(
1210 auto_compact_messages_with_result(messages, config, llm_opts)
1211 .await?
1212 .map(|result| result.summary),
1213 )
1214}
1215
1216fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1217 if !policy.is_model_visible_scope() {
1218 return summary;
1219 }
1220 let Some(directives) = policy.prompt_directives() else {
1221 return summary;
1222 };
1223 summary.push_str("\n\n[compaction instructions]\n");
1224 summary.push_str(&directives);
1225 summary
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231
1232 #[test]
1233 fn microcompact_short_output_unchanged() {
1234 let output = "line1\nline2\nline3\n";
1235 assert_eq!(microcompact_tool_output(output, 1000), output);
1236 }
1237
1238 #[test]
1239 fn microcompact_snaps_to_line_boundaries() {
1240 let lines: Vec<String> = (0..20)
1241 .map(|i| format!("line {i:02} content here"))
1242 .collect();
1243 let output = lines.join("\n");
1244 let result = microcompact_tool_output(&output, 200);
1245 assert!(result.contains("[... "), "should have snip marker");
1246 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1247 assert!(parts.len() >= 2, "should split at marker");
1248 let head = parts[0];
1249 for line in head.lines() {
1250 assert!(
1251 line.starts_with("line "),
1252 "head line should be complete: {line}"
1253 );
1254 }
1255 }
1256
1257 #[test]
1258 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1259 let mut lines = Vec::new();
1260 for i in 0..50 {
1261 lines.push(format!("verbose output line {i}"));
1262 }
1263 lines.push("src/main.rs:42: error: cannot find value".to_string());
1264 for i in 50..100 {
1265 lines.push(format!("verbose output line {i}"));
1266 }
1267 let output = lines.join("\n");
1268 let result = microcompact_tool_output(&output, 600);
1269 assert!(result.contains("cannot find value"), "diagnostic preserved");
1270 assert!(
1271 result.contains("[diagnostic lines preserved]"),
1272 "has diagnostic marker"
1273 );
1274 }
1275
1276 #[test]
1277 fn token_estimate_counts_structured_message_content() {
1278 let text = "x".repeat(400);
1279 let messages = vec![serde_json::json!({
1280 "role": "user",
1281 "content": [
1282 {"type": "text", "text": text},
1283 {"type": "input_text", "text": "tail"},
1284 ],
1285 "reasoning": {"text": "scratch"},
1286 "tool_calls": [{
1287 "id": "call_1",
1288 "type": "function",
1289 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1290 }],
1291 })];
1292
1293 assert!(
1294 estimate_message_tokens(&messages) >= 100,
1295 "structured content must not count as zero"
1296 );
1297 }
1298
1299 #[test]
1300 fn compaction_policy_instructions_extend_by_default() {
1301 let policy = CompactionPolicy {
1302 instructions: Some("Keep the failing test names.".to_string()),
1303 ..Default::default()
1304 };
1305 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1306 .expect("prompt renders");
1307
1308 assert_eq!(policy.instruction_mode(), "extend");
1309 assert!(prompt.contains("Preserve goals, constraints"));
1310 assert!(prompt.contains("Additional compaction instructions"));
1311 assert!(prompt.contains("Keep the failing test names."));
1312 }
1313
1314 #[test]
1315 fn compaction_policy_can_replace_default_instructions() {
1316 let policy = CompactionPolicy {
1317 instructions: Some("Only keep repro steps.".to_string()),
1318 extend_default_instructions: Some(false),
1319 ..Default::default()
1320 };
1321 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1322 .expect("prompt renders");
1323
1324 assert_eq!(policy.instruction_mode(), "replace");
1325 assert!(prompt.contains("according to these instructions"));
1326 assert!(prompt.contains("Only keep repro steps."));
1327 assert!(!prompt.contains("Preserve goals, constraints"));
1328 }
1329
1330 #[test]
1331 fn snap_to_line_end_finds_newline() {
1332 let s = "line1\nline2\nline3\nline4\n";
1333 let head = snap_to_line_end(s, 12);
1334 assert!(head.ends_with('\n'), "should end at newline");
1335 assert!(head.contains("line1"));
1336 }
1337
1338 #[test]
1339 fn snap_to_line_start_finds_newline() {
1340 let s = "line1\nline2\nline3\nline4\n";
1341 let tail = snap_to_line_start(s, 12);
1342 assert!(
1343 tail.starts_with("line"),
1344 "should start at line boundary: {tail}"
1345 );
1346 }
1347
1348 #[test]
1349 fn auto_compact_preserves_reasoning_tool_suffix() {
1350 let mut messages = vec![
1351 serde_json::json!({"role": "user", "content": "old task"}),
1352 serde_json::json!({"role": "assistant", "content": "old reply"}),
1353 serde_json::json!({"role": "user", "content": "new task"}),
1354 serde_json::json!({
1355 "role": "assistant",
1356 "content": "",
1357 "reasoning": "think first",
1358 "tool_calls": [{
1359 "id": "call_1",
1360 "type": "function",
1361 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1362 }],
1363 }),
1364 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1365 ];
1366 let config = AutoCompactConfig {
1367 token_threshold: 1,
1368 keep_last: 2,
1369 ..Default::default()
1370 };
1371
1372 let runtime = tokio::runtime::Builder::new_current_thread()
1373 .enable_all()
1374 .build()
1375 .expect("runtime");
1376 let summary = runtime
1377 .block_on(auto_compact_messages(&mut messages, &config, None))
1378 .expect("compaction succeeds");
1379
1380 assert!(summary.is_some());
1381 assert_eq!(messages[1]["role"], "user");
1382 assert_eq!(messages[2]["role"], "assistant");
1383 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1384 assert_eq!(messages[3]["role"], "tool");
1385 assert_eq!(messages[3]["tool_call_id"], "call_1");
1386 }
1387}