Skip to main content

harn_vm/orchestration/
compaction.rs

1//! Auto-compaction — transcript size management strategies.
2
3use 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/// Configuration for automatic transcript compaction in agent loops.
417///
418/// Two-tier compaction:
419///   Tier 1 (`token_threshold` / `compact_strategy`): lightweight, deterministic
420///     observation masking that fires early. Masks verbose tool results while
421///     preserving assistant prose and error output.
422///   Tier 2 (`hard_limit_tokens` / `hard_limit_strategy`): aggressive LLM-powered
423///     summarization that fires when tier-1 alone isn't enough, typically as the
424///     transcript approaches the model's actual context window.
425#[derive(Clone, Debug)]
426pub struct AutoCompactConfig {
427    /// Number of earliest messages to keep verbatim before the compacted
428    /// summary. The system prompt is not part of this list and is always
429    /// preserved separately by the caller.
430    pub keep_first: usize,
431    /// Tier-1 threshold: estimated tokens before lightweight compaction.
432    pub token_threshold: usize,
433    /// Maximum character length for a single tool result before microcompaction.
434    pub tool_output_max_chars: usize,
435    /// Number of recent messages to keep during compaction.
436    pub keep_last: usize,
437    /// Tier-1 strategy (default: ObservationMask).
438    pub compact_strategy: CompactStrategy,
439    /// Tier-2 threshold: fires when tier-1 result still exceeds this.
440    /// Typically set to ~75% of the model's actual context window.
441    /// When `None`, tier-2 is disabled.
442    pub hard_limit_tokens: Option<usize>,
443    /// Tier-2 strategy (default: Llm).
444    pub hard_limit_strategy: CompactStrategy,
445    /// Optional Harn callback used when a strategy is `custom`.
446    pub custom_compactor: Option<VmValue>,
447    /// Pending reminders supplied to `custom_compactor` as a second
448    /// argument. Built-in compaction strategies decide reminder retention
449    /// before rebuilding the transcript, so they do not consume this list.
450    pub custom_compactor_reminders: Vec<VmValue>,
451    /// Optional callback for domain-specific per-message masking during
452    /// observation mask compaction. Called with a list of archived messages,
453    /// returns a list of `Option<String>` — `Some(masked)` to override the
454    /// default mask for that message, `None` to use the default.
455    /// This lets the host (e.g. an IDE or cloud runner) inject AST outlines,
456    /// file summaries, etc. without putting language-specific logic in Harn.
457    pub mask_callback: Option<VmValue>,
458    /// Optional callback for per-tool-result compression. Called with
459    /// `{tool_name, output, max_chars}` and returns compressed output string.
460    /// When set, used INSTEAD of the built-in `microcompact_tool_output`.
461    /// This allows the pipeline to use LLM-based compression rather than
462    /// keyword heuristics.
463    pub compress_callback: Option<VmValue>,
464    /// Optional prompt-template asset path used when LLM compaction is
465    /// selected. The rendered template becomes the user message sent to
466    /// the summarizer.
467    pub summarize_prompt: Option<String>,
468    /// User-facing policy label for replay and observability. This can be
469    /// broader than the engine strategy, e.g. `hybrid` lowers to LLM
470    /// summarization plus truncate fallback.
471    pub policy_strategy: String,
472    /// Strategy to try when the primary strategy fails. Budget-pressure
473    /// compaction uses this to keep the session within its hard cap even when
474    /// an LLM summarizer is unavailable.
475    pub fallback_strategy: Option<CompactStrategy>,
476    /// Host/user-supplied instructions that guide compaction without
477    /// becoming part of the compacted transcript unless `scope` explicitly
478    /// asks for model-visible policy text.
479    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
504/// Estimate token count from a list of JSON messages (chars / 4 heuristic).
505pub 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
552/// Microcompact a tool result: if it exceeds `max_chars`, keep the first and
553/// last portions with a snip marker in between.
554pub 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
618/// Snap a byte offset to the nearest preceding line boundary (end of a complete line).
619/// Returns the substring from the start up to and including the last complete line
620/// that fits within `max_bytes`. Never cuts mid-line.
621fn 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], // single long line — fall back to char boundary
629    }
630}
631
632/// Snap a byte offset to the nearest following line boundary (start of a complete line).
633/// Returns the substring from the first complete line at or after `start_byte`.
634/// Never cuts mid-line.
635fn 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..], // already at start of last line
653    }
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
895/// Check whether a tool-result string should be preserved verbatim during
896/// observation masking. Uses content length as the primary heuristic:
897/// short results (< 500 chars) are kept since they're typically error messages,
898/// status lines, or concise answers that are cheap to retain and risky to mask.
899/// Long results are masked to save context budget.
900fn content_should_preserve(content: &str) -> bool {
901    content.len() < 500
902}
903
904/// Default per-message masking for tool results.
905fn 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/// Deterministic observation-mask compaction.
918#[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
959/// Invoke the mask_callback to get per-message custom masks.
960async 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
1006/// Apply a single compaction strategy to a list of archived messages.
1007async 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
1092/// Auto-compact a message list in place using two-tier compaction.
1093pub(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    // Snap back to a user-role boundary so the kept suffix begins at a clean
1108    // turn. OpenAI-compatible APIs reject tool results orphaned from their
1109    // assistant request, so splitting mid-turn corrupts the transcript.
1110    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    // Fall back to the naive split (e.g. tool-heavy transcripts with the sole
1120    // user message at index 0) rather than skipping compaction entirely.
1121    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/// Auto-compact a message list in place using two-tier compaction.
1203#[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}