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 policy: CompactionPolicy,
476}
477
478impl Default for AutoCompactConfig {
479 fn default() -> Self {
480 Self {
481 keep_first: 0,
482 token_threshold: 48_000,
483 tool_output_max_chars: 16_000,
484 keep_last: 12,
485 compact_strategy: CompactStrategy::ObservationMask,
486 hard_limit_tokens: None,
487 hard_limit_strategy: CompactStrategy::Llm,
488 custom_compactor: None,
489 custom_compactor_reminders: Vec::new(),
490 mask_callback: None,
491 compress_callback: None,
492 summarize_prompt: None,
493 policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
494 policy: CompactionPolicy::default(),
495 }
496 }
497}
498
499pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
501 messages.iter().map(estimate_message_chars).sum::<usize>() / 4
502}
503
504fn estimate_message_chars(message: &serde_json::Value) -> usize {
505 let mut total = message
506 .get("content")
507 .map(estimate_content_chars)
508 .unwrap_or_default();
509 if let Some(reasoning) = message.get("reasoning") {
510 total += estimate_content_chars(reasoning);
511 }
512 if let Some(tool_calls) = message.get("tool_calls") {
513 total += estimate_content_chars(tool_calls);
514 }
515 total
516}
517
518fn estimate_content_chars(value: &serde_json::Value) -> usize {
519 match value {
520 serde_json::Value::String(text) => text.len(),
521 serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
522 serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
523 serde_json::Value::Null => 0,
524 other => other.to_string().len(),
525 }
526}
527
528fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
529 let role = message
530 .get("role")
531 .and_then(|value| value.as_str())
532 .unwrap_or_default();
533 role == "tool"
534 || message.get("tool_calls").is_some()
535 || message
536 .get("reasoning")
537 .map(|value| !value.is_null())
538 .unwrap_or(false)
539}
540
541fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
542 (0..=start)
543 .rev()
544 .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
545}
546
547pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
550 if output.len() <= max_chars || max_chars < 200 {
551 return output.to_string();
552 }
553 let diagnostic_lines = output
554 .lines()
555 .filter(|line| {
556 let trimmed = line.trim();
557 let lower = trimmed.to_lowercase();
558 let has_file_line = {
559 let bytes = trimmed.as_bytes();
560 let mut i = 0;
561 let mut found_colon = false;
562 while i < bytes.len() {
563 if bytes[i] == b':' {
564 found_colon = true;
565 break;
566 }
567 i += 1;
568 }
569 found_colon && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
570 };
571 let has_strong_keyword =
572 trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
573 let has_weak_keyword = trimmed.contains("error")
574 || trimmed.contains("undefined")
575 || trimmed.contains("expected")
576 || trimmed.contains("got")
577 || lower.contains("cannot find")
578 || lower.contains("not found")
579 || lower.contains("no such")
580 || lower.contains("unresolved")
581 || lower.contains("missing")
582 || lower.contains("declared but not used")
583 || lower.contains("unused")
584 || lower.contains("mismatch");
585 let positional = lower.contains(" error ")
586 || lower.starts_with("error:")
587 || lower.starts_with("warning:")
588 || lower.starts_with("note:")
589 || lower.contains("panic:");
590 has_strong_keyword || (has_file_line && has_weak_keyword) || positional
591 })
592 .take(32)
593 .collect::<Vec<_>>();
594 if !diagnostic_lines.is_empty() {
595 let diagnostics = diagnostic_lines.join("\n");
596 let budget = max_chars.saturating_sub(diagnostics.len() + 64);
597 let keep = budget / 2;
598 if keep >= 80 && output.len() > keep * 2 {
599 let head = snap_to_line_end(output, keep);
600 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
601 return format!(
602 "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
603 );
604 }
605 }
606 let keep = max_chars / 2;
607 let head = snap_to_line_end(output, keep);
608 let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
609 let snipped = output.len().saturating_sub(head.len() + tail.len());
610 format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
611}
612
613fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
617 if max_bytes >= s.len() {
618 return s;
619 }
620 let search_end = s.floor_char_boundary(max_bytes);
621 match s[..search_end].rfind('\n') {
622 Some(pos) => &s[..pos + 1],
623 None => &s[..search_end], }
625}
626
627fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
631 if start_byte == 0 {
632 return s;
633 }
634 let search_start = s.ceil_char_boundary(start_byte);
635 if search_start >= s.len() {
636 return "";
637 }
638 match s[search_start..].find('\n') {
639 Some(pos) => {
640 let line_start = search_start + pos + 1;
641 if line_start < s.len() {
642 &s[line_start..]
643 } else {
644 &s[search_start..]
645 }
646 }
647 None => &s[search_start..], }
649}
650
651fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
652 messages
653 .iter()
654 .map(|msg| {
655 let role = msg
656 .get("role")
657 .and_then(|v| v.as_str())
658 .unwrap_or("user")
659 .to_uppercase();
660 let content = msg
661 .get("content")
662 .and_then(|v| v.as_str())
663 .unwrap_or_default();
664 format!("{role}: {content}")
665 })
666 .collect::<Vec<_>>()
667 .join("\n")
668}
669
670fn truncate_compaction_summary(
671 old_messages: &[serde_json::Value],
672 archived_count: usize,
673) -> String {
674 truncate_compaction_summary_with_context(old_messages, archived_count, false)
675}
676
677fn truncate_compaction_summary_with_context(
678 old_messages: &[serde_json::Value],
679 archived_count: usize,
680 is_llm_fallback: bool,
681) -> String {
682 let per_msg_limit = 500_usize;
683 let summary_parts: Vec<String> = old_messages
684 .iter()
685 .filter_map(|m| {
686 let role = m.get("role")?.as_str()?;
687 let content = m.get("content")?.as_str()?;
688 if content.is_empty() {
689 return None;
690 }
691 let truncated = if content.len() > per_msg_limit {
692 format!(
693 "{}... [truncated from {} chars]",
694 &content[..content.floor_char_boundary(per_msg_limit)],
695 content.len()
696 )
697 } else {
698 content.to_string()
699 };
700 Some(format!("[{role}] {truncated}"))
701 })
702 .take(15)
703 .collect();
704 let header = if is_llm_fallback {
705 format!(
706 "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
707 )
708 } else {
709 format!("[auto-compacted {archived_count} older messages via truncate strategy]")
710 };
711 format!(
712 "{header}\n{}{}",
713 summary_parts.join("\n"),
714 if archived_count > 15 {
715 format!("\n... and {} more", archived_count - 15)
716 } else {
717 String::new()
718 }
719 )
720}
721
722fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
723 if let Some(map) = value.as_dict() {
724 if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
725 return Ok(summary.display());
726 }
727 }
728 match value {
729 VmValue::String(text) => Ok(text.to_string()),
730 VmValue::Nil => Ok(String::new()),
731 _ => serde_json::to_string_pretty(&vm_value_to_json(value))
732 .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
733 }
734}
735
736async fn llm_compaction_summary(
737 old_messages: &[serde_json::Value],
738 archived_count: usize,
739 llm_opts: &crate::llm::api::LlmCallOptions,
740 summarize_prompt: Option<&str>,
741 policy: &CompactionPolicy,
742) -> Result<String, VmError> {
743 let mut compact_opts = llm_opts.clone();
744 let formatted = format_compaction_messages(old_messages);
745 compact_opts.system = None;
746 compact_opts.transcript_summary = None;
747 compact_opts.native_tools = None;
748 compact_opts.tool_choice = None;
749 compact_opts.output_format = crate::llm::api::OutputFormat::Text;
750 compact_opts.response_format = None;
751 compact_opts.json_schema = None;
752 compact_opts.output_schema = None;
753 let prompt =
754 render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
755 compact_opts.messages = vec![serde_json::json!({
756 "role": "user",
757 "content": prompt,
758 })];
759 let result = vm_call_llm_full(&compact_opts).await?;
760 let summary = result.text.trim();
761 if summary.is_empty() {
762 Ok(truncate_compaction_summary_with_context(
763 old_messages,
764 archived_count,
765 true,
766 ))
767 } else {
768 Ok(format!(
769 "[auto-compacted {archived_count} older messages]\n{summary}"
770 ))
771 }
772}
773
774fn render_llm_compaction_prompt(
775 summarize_prompt: Option<&str>,
776 formatted: &str,
777 archived_count: usize,
778 policy: &CompactionPolicy,
779) -> Result<String, VmError> {
780 if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
781 return render_replacement_compaction_prompt(policy, formatted, archived_count);
782 }
783 let mut bindings = BTreeMap::new();
784 bindings.insert(
785 "formatted_messages".to_string(),
786 VmValue::String(Rc::from(formatted.to_string())),
787 );
788 bindings.insert(
789 "archived_count".to_string(),
790 VmValue::Int(archived_count as i64),
791 );
792 let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
793 let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
794 "orchestration/prompts/compaction_summary.harn.prompt",
795 Some(&bindings),
796 )?;
797 return Ok(extend_compaction_prompt(prompt, policy));
798 };
799
800 let asset = crate::stdlib::template::TemplateAsset::render_target(path)
801 .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
802 let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
803 .map_err(VmError::from)?;
804 Ok(extend_compaction_prompt(prompt, policy))
805}
806
807fn render_replacement_compaction_prompt(
808 policy: &CompactionPolicy,
809 formatted: &str,
810 archived_count: usize,
811) -> Result<String, VmError> {
812 let directives = policy.prompt_directives().unwrap_or_default();
813 let mut bindings = BTreeMap::new();
814 bindings.insert(
815 "directives".to_string(),
816 VmValue::String(Rc::from(directives)),
817 );
818 bindings.insert(
819 "formatted_messages".to_string(),
820 VmValue::String(Rc::from(formatted.to_string())),
821 );
822 bindings.insert(
823 "archived_count".to_string(),
824 VmValue::Int(archived_count as i64),
825 );
826 crate::stdlib::template::render_stdlib_prompt_asset(
827 "orchestration/prompts/compaction_policy_replacement.harn.prompt",
828 Some(&bindings),
829 )
830}
831
832fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
833 let Some(directives) = policy.prompt_directives() else {
834 return prompt;
835 };
836 prompt.push_str(
837 "\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",
838 );
839 prompt.push_str(&directives);
840 prompt
841}
842
843async fn custom_compaction_summary(
844 old_messages: &[serde_json::Value],
845 archived_count: usize,
846 callback: &VmValue,
847 reminders: &[VmValue],
848 policy: &CompactionPolicy,
849) -> Result<String, VmError> {
850 let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
851 return Err(VmError::Runtime(
852 "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
853 ));
854 };
855 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
856 VmError::Runtime(
857 "custom transcript compaction requires an async builtin VM context".to_string(),
858 )
859 })?;
860 let messages_vm = VmValue::List(Rc::new(
861 old_messages
862 .iter()
863 .map(crate::stdlib::json_to_vm_value)
864 .collect(),
865 ));
866 let result = if policy.has_metadata()
867 && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
868 {
869 let reminders_vm = VmValue::List(Rc::new(reminders.to_vec()));
870 let policy_vm = compaction_policy_to_vm_value(policy);
871 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
872 .await
873 } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
874 let reminders_vm = VmValue::List(Rc::new(reminders.to_vec()));
875 vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
876 .await
877 } else {
878 vm.call_closure_pub(&closure, &[messages_vm]).await
879 };
880 let summary = compact_summary_text_from_value(&result?)?;
881 if summary.trim().is_empty() {
882 Ok(truncate_compaction_summary(old_messages, archived_count))
883 } else {
884 Ok(format!(
885 "[auto-compacted {archived_count} older messages]\n{summary}"
886 ))
887 }
888}
889
890fn content_should_preserve(content: &str) -> bool {
896 content.len() < 500
897}
898
899fn default_mask_tool_result(role: &str, content: &str) -> String {
901 let first_line = content.lines().next().unwrap_or(content);
902 let line_count = content.lines().count();
903 let char_count = content.len();
904 if line_count <= 3 {
905 format!("[{role}] {content}")
906 } else {
907 let preview = &first_line[..first_line.len().min(120)];
908 format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
909 }
910}
911
912#[cfg(test)]
914pub(crate) fn observation_mask_compaction(
915 old_messages: &[serde_json::Value],
916 archived_count: usize,
917) -> String {
918 observation_mask_compaction_with_callback(old_messages, archived_count, None)
919}
920
921fn observation_mask_compaction_with_callback(
922 old_messages: &[serde_json::Value],
923 archived_count: usize,
924 mask_results: Option<&[Option<String>]>,
925) -> String {
926 let mut parts = Vec::new();
927 parts.push(format!(
928 "[auto-compacted {archived_count} older messages via observation masking]"
929 ));
930 for (idx, msg) in old_messages.iter().enumerate() {
931 let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
932 let content = msg
933 .get("content")
934 .and_then(|v| v.as_str())
935 .unwrap_or_default();
936 if content.is_empty() {
937 continue;
938 }
939 if role == "assistant" {
940 parts.push(format!("[assistant] {content}"));
941 continue;
942 }
943 if content_should_preserve(content) {
944 parts.push(format!("[{role}] {content}"));
945 } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
946 parts.push(custom.clone());
947 } else {
948 parts.push(default_mask_tool_result(role, content));
949 }
950 }
951 parts.join("\n")
952}
953
954async fn invoke_mask_callback(
956 callback: &VmValue,
957 old_messages: &[serde_json::Value],
958) -> Result<Vec<Option<String>>, VmError> {
959 let VmValue::Closure(closure) = callback.clone() else {
960 return Err(VmError::Runtime(
961 "mask_callback must be a closure".to_string(),
962 ));
963 };
964 let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
965 VmError::Runtime("mask_callback requires an async builtin VM context".to_string())
966 })?;
967 let messages_vm = VmValue::List(Rc::new(
968 old_messages
969 .iter()
970 .map(crate::stdlib::json_to_vm_value)
971 .collect(),
972 ));
973 let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
974 let list = match result {
975 VmValue::List(items) => items,
976 _ => return Ok(vec![None; old_messages.len()]),
977 };
978 Ok(list
979 .iter()
980 .map(|v| match v {
981 VmValue::String(s) => Some(s.to_string()),
982 VmValue::Nil => None,
983 _ => None,
984 })
985 .collect())
986}
987
988struct CompactionStrategyInputs<'a> {
989 strategy: &'a CompactStrategy,
990 old_messages: &'a [serde_json::Value],
991 archived_count: usize,
992 llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
993 custom_compactor: Option<&'a VmValue>,
994 custom_compactor_reminders: &'a [VmValue],
995 mask_callback: Option<&'a VmValue>,
996 summarize_prompt: Option<&'a str>,
997 policy: &'a CompactionPolicy,
998}
999
1000async fn apply_compaction_strategy(input: CompactionStrategyInputs<'_>) -> Result<String, VmError> {
1002 let CompactionStrategyInputs {
1003 strategy,
1004 old_messages,
1005 archived_count,
1006 llm_opts,
1007 custom_compactor,
1008 custom_compactor_reminders,
1009 mask_callback,
1010 summarize_prompt,
1011 policy,
1012 } = input;
1013 match strategy {
1014 CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
1015 CompactStrategy::Llm => {
1016 llm_compaction_summary(
1017 old_messages,
1018 archived_count,
1019 llm_opts.ok_or_else(|| {
1020 VmError::Runtime(
1021 "LLM transcript compaction requires active LLM call options".to_string(),
1022 )
1023 })?,
1024 summarize_prompt,
1025 policy,
1026 )
1027 .await
1028 }
1029 CompactStrategy::Custom => {
1030 custom_compaction_summary(
1031 old_messages,
1032 archived_count,
1033 custom_compactor.ok_or_else(|| {
1034 VmError::Runtime(
1035 "compact_callback is required when compact_strategy is 'custom'"
1036 .to_string(),
1037 )
1038 })?,
1039 custom_compactor_reminders,
1040 policy,
1041 )
1042 .await
1043 }
1044 CompactStrategy::ObservationMask => {
1045 let mask_results = if let Some(cb) = mask_callback {
1046 Some(invoke_mask_callback(cb, old_messages).await?)
1047 } else {
1048 None
1049 };
1050 Ok(observation_mask_compaction_with_callback(
1051 old_messages,
1052 archived_count,
1053 mask_results.as_deref(),
1054 ))
1055 }
1056 }
1057}
1058
1059pub(crate) async fn auto_compact_messages(
1061 messages: &mut Vec<serde_json::Value>,
1062 config: &AutoCompactConfig,
1063 llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1064) -> Result<Option<String>, VmError> {
1065 if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1066 return Ok(None);
1067 }
1068 if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1069 return Ok(None);
1070 }
1071 let compact_start = config.keep_first.min(messages.len());
1072 let original_split = messages.len().saturating_sub(config.keep_last);
1073 let mut split_at = original_split;
1074 while split_at > compact_start
1078 && split_at < messages.len()
1079 && messages[split_at]
1080 .get("role")
1081 .and_then(|r| r.as_str())
1082 .is_none_or(|r| r != "user")
1083 {
1084 split_at -= 1;
1085 }
1086 if split_at == compact_start {
1089 split_at = original_split;
1090 }
1091 if let Some(volatile_start) = messages[split_at..]
1092 .iter()
1093 .position(is_reasoning_or_tool_turn_message)
1094 .map(|offset| split_at + offset)
1095 {
1096 if let Some(boundary) = volatile_start
1097 .checked_sub(1)
1098 .and_then(|idx| find_prev_user_boundary(messages, idx))
1099 .filter(|boundary| *boundary > compact_start)
1100 {
1101 split_at = boundary;
1102 }
1103 }
1104 if split_at <= compact_start {
1105 return Ok(None);
1106 }
1107 let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1108 let archived_count = old_messages.len();
1109
1110 let mut summary = apply_compaction_strategy(CompactionStrategyInputs {
1111 strategy: &config.compact_strategy,
1112 old_messages: &old_messages,
1113 archived_count,
1114 llm_opts,
1115 custom_compactor: config.custom_compactor.as_ref(),
1116 custom_compactor_reminders: &config.custom_compactor_reminders,
1117 mask_callback: config.mask_callback.as_ref(),
1118 summarize_prompt: config.summarize_prompt.as_deref(),
1119 policy: &config.policy,
1120 })
1121 .await?;
1122
1123 if let Some(hard_limit) = config.hard_limit_tokens {
1124 let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1125 let mut estimate_msgs = vec![summary_msg];
1126 estimate_msgs.extend_from_slice(messages.as_slice());
1127 let estimated = estimate_message_tokens(&estimate_msgs);
1128 if estimated > hard_limit {
1129 let tier1_as_messages = vec![serde_json::json!({
1130 "role": "user",
1131 "content": summary,
1132 })];
1133 summary = apply_compaction_strategy(CompactionStrategyInputs {
1134 strategy: &config.hard_limit_strategy,
1135 old_messages: &tier1_as_messages,
1136 archived_count,
1137 llm_opts,
1138 custom_compactor: config.custom_compactor.as_ref(),
1139 custom_compactor_reminders: &config.custom_compactor_reminders,
1140 mask_callback: None,
1141 summarize_prompt: config.summarize_prompt.as_deref(),
1142 policy: &config.policy,
1143 })
1144 .await?;
1145 }
1146 }
1147
1148 summary = apply_model_visible_policy(summary, &config.policy);
1149
1150 messages.insert(
1151 compact_start,
1152 serde_json::json!({
1153 "role": "user",
1154 "content": summary,
1155 }),
1156 );
1157 Ok(Some(summary))
1158}
1159
1160fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1161 if !policy.is_model_visible_scope() {
1162 return summary;
1163 }
1164 let Some(directives) = policy.prompt_directives() else {
1165 return summary;
1166 };
1167 summary.push_str("\n\n[compaction instructions]\n");
1168 summary.push_str(&directives);
1169 summary
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175
1176 #[test]
1177 fn microcompact_short_output_unchanged() {
1178 let output = "line1\nline2\nline3\n";
1179 assert_eq!(microcompact_tool_output(output, 1000), output);
1180 }
1181
1182 #[test]
1183 fn microcompact_snaps_to_line_boundaries() {
1184 let lines: Vec<String> = (0..20)
1185 .map(|i| format!("line {:02} content here", i))
1186 .collect();
1187 let output = lines.join("\n");
1188 let result = microcompact_tool_output(&output, 200);
1189 assert!(result.contains("[... "), "should have snip marker");
1190 let parts: Vec<&str> = result.split("\n\n[... ").collect();
1191 assert!(parts.len() >= 2, "should split at marker");
1192 let head = parts[0];
1193 for line in head.lines() {
1194 assert!(
1195 line.starts_with("line "),
1196 "head line should be complete: {line}"
1197 );
1198 }
1199 }
1200
1201 #[test]
1202 fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1203 let mut lines = Vec::new();
1204 for i in 0..50 {
1205 lines.push(format!("verbose output line {i}"));
1206 }
1207 lines.push("src/main.rs:42: error: cannot find value".to_string());
1208 for i in 50..100 {
1209 lines.push(format!("verbose output line {i}"));
1210 }
1211 let output = lines.join("\n");
1212 let result = microcompact_tool_output(&output, 600);
1213 assert!(result.contains("cannot find value"), "diagnostic preserved");
1214 assert!(
1215 result.contains("[diagnostic lines preserved]"),
1216 "has diagnostic marker"
1217 );
1218 }
1219
1220 #[test]
1221 fn token_estimate_counts_structured_message_content() {
1222 let text = "x".repeat(400);
1223 let messages = vec![serde_json::json!({
1224 "role": "user",
1225 "content": [
1226 {"type": "text", "text": text},
1227 {"type": "input_text", "text": "tail"},
1228 ],
1229 "reasoning": {"text": "scratch"},
1230 "tool_calls": [{
1231 "id": "call_1",
1232 "type": "function",
1233 "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1234 }],
1235 })];
1236
1237 assert!(
1238 estimate_message_tokens(&messages) >= 100,
1239 "structured content must not count as zero"
1240 );
1241 }
1242
1243 #[test]
1244 fn compaction_policy_instructions_extend_by_default() {
1245 let policy = CompactionPolicy {
1246 instructions: Some("Keep the failing test names.".to_string()),
1247 ..Default::default()
1248 };
1249 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1250 .expect("prompt renders");
1251
1252 assert_eq!(policy.instruction_mode(), "extend");
1253 assert!(prompt.contains("Preserve goals, constraints"));
1254 assert!(prompt.contains("Additional compaction instructions"));
1255 assert!(prompt.contains("Keep the failing test names."));
1256 }
1257
1258 #[test]
1259 fn compaction_policy_can_replace_default_instructions() {
1260 let policy = CompactionPolicy {
1261 instructions: Some("Only keep repro steps.".to_string()),
1262 extend_default_instructions: Some(false),
1263 ..Default::default()
1264 };
1265 let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1266 .expect("prompt renders");
1267
1268 assert_eq!(policy.instruction_mode(), "replace");
1269 assert!(prompt.contains("according to these instructions"));
1270 assert!(prompt.contains("Only keep repro steps."));
1271 assert!(!prompt.contains("Preserve goals, constraints"));
1272 }
1273
1274 #[test]
1275 fn snap_to_line_end_finds_newline() {
1276 let s = "line1\nline2\nline3\nline4\n";
1277 let head = snap_to_line_end(s, 12);
1278 assert!(head.ends_with('\n'), "should end at newline");
1279 assert!(head.contains("line1"));
1280 }
1281
1282 #[test]
1283 fn snap_to_line_start_finds_newline() {
1284 let s = "line1\nline2\nline3\nline4\n";
1285 let tail = snap_to_line_start(s, 12);
1286 assert!(
1287 tail.starts_with("line"),
1288 "should start at line boundary: {tail}"
1289 );
1290 }
1291
1292 #[test]
1293 fn auto_compact_preserves_reasoning_tool_suffix() {
1294 let mut messages = vec![
1295 serde_json::json!({"role": "user", "content": "old task"}),
1296 serde_json::json!({"role": "assistant", "content": "old reply"}),
1297 serde_json::json!({"role": "user", "content": "new task"}),
1298 serde_json::json!({
1299 "role": "assistant",
1300 "content": "",
1301 "reasoning": "think first",
1302 "tool_calls": [{
1303 "id": "call_1",
1304 "type": "function",
1305 "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1306 }],
1307 }),
1308 serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1309 ];
1310 let config = AutoCompactConfig {
1311 token_threshold: 1,
1312 keep_last: 2,
1313 ..Default::default()
1314 };
1315
1316 let runtime = tokio::runtime::Builder::new_current_thread()
1317 .enable_all()
1318 .build()
1319 .expect("runtime");
1320 let summary = runtime
1321 .block_on(auto_compact_messages(&mut messages, &config, None))
1322 .expect("compaction succeeds");
1323
1324 assert!(summary.is_some());
1325 assert_eq!(messages[1]["role"], "user");
1326 assert_eq!(messages[2]["role"], "assistant");
1327 assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1328 assert_eq!(messages[3]["role"], "tool");
1329 assert_eq!(messages[3]["tool_call_id"], "call_1");
1330 }
1331}