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