Skip to main content

harn_vm/orchestration/
compaction.rs

1//! Auto-compaction — transcript size management strategies.
2
3use crate::llm::{vm_call_llm_full, vm_value_to_json};
4use crate::value::{VmDictExt, VmError, VmValue};
5use serde::{Deserialize, Serialize};
6
7mod tool_output;
8use crate::vm::AsyncBuiltinCtx;
9pub use tool_output::{
10    microcompact_tool_output, microcompact_tool_output_result, MicrocompactedToolOutput,
11};
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum CompactStrategy {
15    Llm,
16    Truncate,
17    Custom,
18    ObservationMask,
19}
20
21pub fn parse_compact_strategy(value: &str) -> Result<CompactStrategy, VmError> {
22    match value {
23        "llm" => Ok(CompactStrategy::Llm),
24        "truncate" => Ok(CompactStrategy::Truncate),
25        "custom" => Ok(CompactStrategy::Custom),
26        "observation_mask" => Ok(CompactStrategy::ObservationMask),
27        other => Err(VmError::Runtime(format!(
28            "unknown compact_strategy '{other}' (expected 'llm', 'truncate', 'custom', or 'observation_mask')"
29        ))),
30    }
31}
32
33pub fn compact_strategy_name(strategy: &CompactStrategy) -> &'static str {
34    match strategy {
35        CompactStrategy::Llm => "llm",
36        CompactStrategy::Truncate => "truncate",
37        CompactStrategy::Custom => "custom",
38        CompactStrategy::ObservationMask => "observation_mask",
39    }
40}
41
42const COMPACTION_POLICY_KEYS: &[&str] = &[
43    "instructions",
44    "mode",
45    "scope",
46    "preserve",
47    "drop",
48    "extend_default_instructions",
49    "author",
50];
51
52#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(default)]
54pub struct CompactionPolicy {
55    pub instructions: Option<String>,
56    pub mode: Option<String>,
57    pub scope: Option<String>,
58    pub preserve: Vec<String>,
59    #[serde(rename = "drop")]
60    pub drop_items: Vec<String>,
61    pub extend_default_instructions: Option<bool>,
62    pub author: Option<String>,
63}
64
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(default)]
67pub struct CompactionRequest {
68    pub mode: Option<String>,
69    pub policy: CompactionPolicy,
70}
71
72impl CompactionPolicy {
73    pub fn has_metadata(&self) -> bool {
74        self.instructions.is_some()
75            || self.mode.is_some()
76            || self.scope.is_some()
77            || !self.preserve.is_empty()
78            || !self.drop_items.is_empty()
79            || self.extend_default_instructions.is_some()
80            || self.author.is_some()
81    }
82
83    fn has_prompt_directives(&self) -> bool {
84        self.instructions
85            .as_deref()
86            .is_some_and(|value| !value.trim().is_empty())
87            || !self.preserve.is_empty()
88            || !self.drop_items.is_empty()
89    }
90
91    pub fn instruction_mode(&self) -> &'static str {
92        if !self.has_prompt_directives() {
93            "default"
94        } else if self.extend_default_instructions == Some(false) {
95            "replace"
96        } else {
97            "extend"
98        }
99    }
100
101    pub fn instruction_source(&self) -> Option<&str> {
102        self.author
103            .as_deref()
104            .filter(|author| !author.trim().is_empty())
105    }
106
107    pub fn metadata_json(&self) -> Option<serde_json::Value> {
108        if !self.has_metadata() {
109            return None;
110        }
111        let mut map = serde_json::Map::new();
112        if let Some(instructions) = self.instructions.as_ref() {
113            map.insert(
114                "instructions".to_string(),
115                serde_json::Value::String(instructions.clone()),
116            );
117        }
118        if let Some(mode) = self.mode.as_ref() {
119            map.insert("mode".to_string(), serde_json::Value::String(mode.clone()));
120        }
121        if let Some(scope) = self.scope.as_ref() {
122            map.insert(
123                "scope".to_string(),
124                serde_json::Value::String(scope.clone()),
125            );
126        }
127        if !self.preserve.is_empty() {
128            map.insert(
129                "preserve".to_string(),
130                serde_json::to_value(&self.preserve).unwrap_or_default(),
131            );
132        }
133        if !self.drop_items.is_empty() {
134            map.insert(
135                "drop".to_string(),
136                serde_json::to_value(&self.drop_items).unwrap_or_default(),
137            );
138        }
139        if let Some(extend_default_instructions) = self.extend_default_instructions {
140            map.insert(
141                "extend_default_instructions".to_string(),
142                serde_json::Value::Bool(extend_default_instructions),
143            );
144        }
145        if let Some(author) = self.author.as_ref() {
146            map.insert(
147                "author".to_string(),
148                serde_json::Value::String(author.clone()),
149            );
150        }
151        map.insert(
152            "instruction_mode".to_string(),
153            serde_json::Value::String(self.instruction_mode().to_string()),
154        );
155        if let Some(source) = self.instruction_source() {
156            map.insert(
157                "instruction_source".to_string(),
158                serde_json::Value::String(source.to_string()),
159            );
160        }
161        Some(serde_json::Value::Object(map))
162    }
163
164    fn prompt_directives(&self) -> Option<String> {
165        if !self.has_prompt_directives() {
166            return None;
167        }
168        let mut parts = Vec::new();
169        if let Some(instructions) = self
170            .instructions
171            .as_deref()
172            .map(str::trim)
173            .filter(|value| !value.is_empty())
174        {
175            parts.push(instructions.to_string());
176        }
177        if !self.preserve.is_empty() {
178            parts.push(format!("Preserve: {}.", self.preserve.join("; ")));
179        }
180        if !self.drop_items.is_empty() {
181            parts.push(format!("Drop: {}.", self.drop_items.join("; ")));
182        }
183        Some(parts.join("\n"))
184    }
185
186    fn is_model_visible_scope(&self) -> bool {
187        matches!(
188            self.scope.as_deref(),
189            Some("model_visible" | "summary" | "transcript")
190        )
191    }
192}
193
194pub fn compaction_policy_option_keys() -> &'static [&'static str] {
195    COMPACTION_POLICY_KEYS
196}
197
198pub fn compaction_policy_to_vm_value(policy: &CompactionPolicy) -> VmValue {
199    let mut map = crate::value::DictMap::new();
200    if let Some(instructions) = policy.instructions.as_ref() {
201        map.put_str("instructions", instructions.clone());
202    }
203    if let Some(mode) = policy.mode.as_ref() {
204        map.put_str("mode", mode.clone());
205    }
206    if let Some(scope) = policy.scope.as_ref() {
207        map.put_str("scope", scope.clone());
208    }
209    map.insert(
210        crate::value::intern_key("preserve"),
211        VmValue::List(std::sync::Arc::new(
212            policy
213                .preserve
214                .iter()
215                .map(|item| VmValue::String(arcstr::ArcStr::from(item.clone())))
216                .collect(),
217        )),
218    );
219    map.insert(
220        crate::value::intern_key("drop"),
221        VmValue::List(std::sync::Arc::new(
222            policy
223                .drop_items
224                .iter()
225                .map(|item| VmValue::String(arcstr::ArcStr::from(item.clone())))
226                .collect(),
227        )),
228    );
229    if let Some(extend_default_instructions) = policy.extend_default_instructions {
230        map.insert(
231            crate::value::intern_key("extend_default_instructions"),
232            VmValue::Bool(extend_default_instructions),
233        );
234    }
235    if let Some(author) = policy.author.as_ref() {
236        map.put_str("author", author.clone());
237    }
238    VmValue::dict(map)
239}
240
241pub fn parse_compaction_policy_options(
242    options: Option<&crate::value::DictMap>,
243    builtin: &str,
244) -> Result<CompactionPolicy, VmError> {
245    let mut policy = options
246        .and_then(|map| {
247            map.get("policy")
248                .or_else(|| map.get("compaction_policy"))
249                .or_else(|| map.get("compaction_request"))
250        })
251        .map(|value| parse_compaction_policy_value(value, builtin))
252        .transpose()?
253        .unwrap_or_default();
254    if let Some(options) = options {
255        apply_compaction_policy_fields(&mut policy, options, builtin)?;
256    }
257    Ok(policy)
258}
259
260fn parse_compaction_policy_value(
261    value: &VmValue,
262    builtin: &str,
263) -> Result<CompactionPolicy, VmError> {
264    match value {
265        VmValue::Nil => Ok(CompactionPolicy::default()),
266        VmValue::Dict(map) => {
267            if let Some(nested) = map
268                .get("policy")
269                .or_else(|| map.get("compaction_policy"))
270                .or_else(|| map.get("compaction_request"))
271            {
272                let mut policy = parse_compaction_policy_value(nested, builtin)?;
273                apply_compaction_policy_fields(&mut policy, map, builtin)?;
274                Ok(policy)
275            } else {
276                let mut policy = CompactionPolicy::default();
277                apply_compaction_policy_fields(&mut policy, map, builtin)?;
278                Ok(policy)
279            }
280        }
281        other => Err(VmError::Runtime(format!(
282            "{builtin}: compaction policy must be a dict or nil, got {}",
283            other.type_name()
284        ))),
285    }
286}
287
288fn apply_compaction_policy_fields(
289    policy: &mut CompactionPolicy,
290    map: &crate::value::DictMap,
291    builtin: &str,
292) -> Result<(), VmError> {
293    if let Some(value) = optional_policy_string(map, "instructions", builtin)? {
294        policy.instructions = Some(value);
295    }
296    if let Some(value) = optional_policy_string(map, "mode", builtin)? {
297        policy.mode = Some(value);
298    }
299    if let Some(value) = optional_policy_string(map, "scope", builtin)? {
300        policy.scope = Some(value);
301    }
302    if map.contains_key("preserve") {
303        policy.preserve = policy_string_list(map.get("preserve"), builtin, "preserve")?;
304    }
305    if map.contains_key("drop") {
306        policy.drop_items = policy_string_list(map.get("drop"), builtin, "drop")?;
307    }
308    if let Some(value) = optional_policy_bool(map, "extend_default_instructions", builtin)? {
309        policy.extend_default_instructions = Some(value);
310    }
311    if let Some(value) = optional_policy_string(map, "author", builtin)? {
312        policy.author = Some(value);
313    }
314    Ok(())
315}
316
317fn optional_policy_string(
318    map: &crate::value::DictMap,
319    key: &str,
320    builtin: &str,
321) -> Result<Option<String>, VmError> {
322    match map.get(key) {
323        None | Some(VmValue::Nil) => Ok(None),
324        Some(VmValue::String(text)) => {
325            let trimmed = text.trim();
326            if trimmed.is_empty() {
327                Ok(None)
328            } else {
329                Ok(Some(trimmed.to_string()))
330            }
331        }
332        Some(other) => Err(VmError::Runtime(format!(
333            "{builtin}: compaction policy `{key}` must be a string, got {}",
334            other.type_name()
335        ))),
336    }
337}
338
339fn optional_policy_bool(
340    map: &crate::value::DictMap,
341    key: &str,
342    builtin: &str,
343) -> Result<Option<bool>, VmError> {
344    match map.get(key) {
345        None | Some(VmValue::Nil) => Ok(None),
346        Some(VmValue::Bool(value)) => Ok(Some(*value)),
347        Some(other) => Err(VmError::Runtime(format!(
348            "{builtin}: compaction policy `{key}` must be a bool, got {}",
349            other.type_name()
350        ))),
351    }
352}
353
354fn policy_string_list(
355    value: Option<&VmValue>,
356    builtin: &str,
357    key: &str,
358) -> Result<Vec<String>, VmError> {
359    match value {
360        None | Some(VmValue::Nil) => Ok(Vec::new()),
361        Some(VmValue::String(text)) => {
362            let trimmed = text.trim();
363            if trimmed.is_empty() {
364                Ok(Vec::new())
365            } else {
366                Ok(vec![trimmed.to_string()])
367            }
368        }
369        Some(VmValue::List(items)) => items
370            .iter()
371            .map(|item| match item {
372                VmValue::String(text) => Ok(text.trim().to_string()),
373                other => Err(VmError::Runtime(format!(
374                    "{builtin}: compaction policy `{key}` entries must be strings, got {}",
375                    other.type_name()
376                ))),
377            })
378            .filter_map(|result| match result {
379                Ok(value) if value.is_empty() => None,
380                other => Some(other),
381            })
382            .collect(),
383        Some(other) => Err(VmError::Runtime(format!(
384            "{builtin}: compaction policy `{key}` must be a string or list, got {}",
385            other.type_name()
386        ))),
387    }
388}
389
390pub fn compaction_policy_metadata_fields(
391    policy: &CompactionPolicy,
392) -> Vec<(&'static str, serde_json::Value)> {
393    let mut fields = vec![(
394        "instruction_mode",
395        serde_json::Value::String(policy.instruction_mode().to_string()),
396    )];
397    if let Some(source) = policy.instruction_source() {
398        fields.push((
399            "instruction_source",
400            serde_json::Value::String(source.to_string()),
401        ));
402    }
403    if let Some(policy_json) = policy.metadata_json() {
404        fields.push(("compaction_policy", policy_json));
405    }
406    fields
407}
408
409/// Configuration for automatic transcript compaction in agent loops.
410///
411/// Two-tier compaction:
412///   Tier 1 (`token_threshold` / `compact_strategy`): lightweight, deterministic
413///     observation masking that fires early. Masks verbose tool results while
414///     preserving assistant prose and error output.
415///   Tier 2 (`hard_limit_tokens` / `hard_limit_strategy`): aggressive LLM-powered
416///     summarization that fires when tier-1 alone isn't enough, typically as the
417///     transcript approaches the model's actual context window.
418#[derive(Clone, Debug)]
419pub struct AutoCompactConfig {
420    /// Number of earliest messages to keep verbatim before the compacted
421    /// summary. The system prompt is not part of this list and is always
422    /// preserved separately by the caller.
423    pub keep_first: usize,
424    /// Tier-1 threshold: estimated tokens before lightweight compaction.
425    pub token_threshold: usize,
426    /// Maximum character length for a single tool result before microcompaction.
427    pub tool_output_max_chars: usize,
428    /// Number of recent messages to keep during compaction.
429    pub keep_last: usize,
430    /// Tier-1 strategy (default: ObservationMask).
431    pub compact_strategy: CompactStrategy,
432    /// Tier-2 threshold: fires when tier-1 result still exceeds this.
433    /// Typically set to ~75% of the model's actual context window.
434    /// When `None`, tier-2 is disabled.
435    pub hard_limit_tokens: Option<usize>,
436    /// Tier-2 strategy (default: Llm).
437    pub hard_limit_strategy: CompactStrategy,
438    /// Optional Harn callback used when a strategy is `custom`.
439    pub custom_compactor: Option<VmValue>,
440    /// Pending reminders supplied to `custom_compactor` as a second
441    /// argument. Built-in compaction strategies decide reminder retention
442    /// before rebuilding the transcript, so they do not consume this list.
443    pub custom_compactor_reminders: Vec<VmValue>,
444    /// Optional callback for domain-specific per-message masking during
445    /// observation mask compaction. Called with a list of archived messages,
446    /// returns a list of `Option<String>` — `Some(masked)` to override the
447    /// default mask for that message, `None` to use the default.
448    /// This lets the host (e.g. an IDE or cloud runner) inject AST outlines,
449    /// file summaries, etc. without putting language-specific logic in Harn.
450    pub mask_callback: Option<VmValue>,
451    /// Optional callback for per-tool-result compression. Called with
452    /// `{tool_name, output, max_chars}` and returns compressed output string.
453    /// When set, used INSTEAD of the built-in `microcompact_tool_output`.
454    /// This allows the pipeline to use LLM-based compression rather than
455    /// keyword heuristics.
456    pub compress_callback: Option<VmValue>,
457    /// Optional prompt-template asset path used when LLM compaction is
458    /// selected. The rendered template becomes the user message sent to
459    /// the summarizer.
460    pub summarize_prompt: Option<String>,
461    /// User-facing policy label for replay and observability. This can be
462    /// broader than the engine strategy, e.g. `hybrid` lowers to LLM
463    /// summarization plus truncate fallback.
464    pub policy_strategy: String,
465    /// Strategy to try when the primary strategy fails. Budget-pressure
466    /// compaction uses this to keep the session within its hard cap even when
467    /// an LLM summarizer is unavailable.
468    pub fallback_strategy: Option<CompactStrategy>,
469    /// Host/user-supplied instructions that guide compaction without
470    /// becoming part of the compacted transcript unless `scope` explicitly
471    /// asks for model-visible policy text.
472    pub policy: CompactionPolicy,
473    /// Upper bound (bytes) on the observation-mask recap body, excluding the
474    /// bounded pinned snapshots and the single carried-forward prior recap.
475    /// The budget is spent newest-first on load-bearing observations (verify /
476    /// test / build results and the errors that preceded edits) rather than on
477    /// verbatim assistant call replay, so a long session cannot inject an
478    /// unbounded (and compounding) recap at the exact moment context pressure
479    /// forced compaction.
480    pub recap_budget_bytes: usize,
481}
482
483impl Default for AutoCompactConfig {
484    fn default() -> Self {
485        Self {
486            keep_first: 0,
487            token_threshold: 48_000,
488            tool_output_max_chars: 16_000,
489            keep_last: 12,
490            compact_strategy: CompactStrategy::ObservationMask,
491            hard_limit_tokens: None,
492            hard_limit_strategy: CompactStrategy::Llm,
493            custom_compactor: None,
494            custom_compactor_reminders: Vec::new(),
495            mask_callback: None,
496            compress_callback: None,
497            summarize_prompt: None,
498            policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
499            fallback_strategy: None,
500            policy: CompactionPolicy::default(),
501            recap_budget_bytes: DEFAULT_RECAP_BUDGET_BYTES,
502        }
503    }
504}
505
506/// Default byte budget for an observation-mask recap body (~4k tokens). Chosen
507/// well below the multi-tens-of-KB recaps that motivated the bound: large
508/// enough to carry the load-bearing observations across a compaction, small
509/// enough that re-injecting it never re-creates the pressure that forced the
510/// compaction.
511pub const DEFAULT_RECAP_BUDGET_BYTES: usize = 16_000;
512
513/// Observation-mask recap metrics, carried verbatim (typed) inside
514/// [`super::CompactionReceipt`] so recap behavior survives every projection.
515#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
516#[serde(default)]
517pub struct RecapMetrics {
518    pub recap_bytes: usize,
519    pub budget_bytes: usize,
520    pub kept_results_count: usize,
521    pub dropped_count: usize,
522    pub carried_prior_recap: bool,
523}
524
525impl RecapMetrics {
526    pub fn to_json(self) -> serde_json::Value {
527        serde_json::json!({
528            "recap_bytes": self.recap_bytes,
529            "budget_bytes": self.budget_bytes,
530            "kept_results_count": self.kept_results_count,
531            "dropped_count": self.dropped_count,
532            "carried_prior_recap": self.carried_prior_recap,
533        })
534    }
535}
536
537/// Estimate token count from a list of JSON messages (chars / 4 heuristic).
538pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
539    messages.iter().map(estimate_message_chars).sum::<usize>() / 4
540}
541
542fn estimate_message_chars(message: &serde_json::Value) -> usize {
543    let mut total = message
544        .get("content")
545        .map(estimate_content_chars)
546        .unwrap_or_default();
547    if let Some(reasoning) = message.get("reasoning") {
548        total += estimate_content_chars(reasoning);
549    }
550    if let Some(tool_calls) = message.get("tool_calls") {
551        total += estimate_content_chars(tool_calls);
552    }
553    total
554}
555
556fn estimate_content_chars(value: &serde_json::Value) -> usize {
557    match value {
558        serde_json::Value::String(text) => text.len(),
559        serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
560        serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
561        serde_json::Value::Null => 0,
562        other => other.to_string().len(),
563    }
564}
565
566fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
567    let role = message
568        .get("role")
569        .and_then(|value| value.as_str())
570        .unwrap_or_default();
571    role == "tool"
572        || message.get("tool_calls").is_some()
573        || message
574            .get("reasoning")
575            .map(|value| !value.is_null())
576            .unwrap_or(false)
577}
578
579fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
580    (0..=start)
581        .rev()
582        .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
583}
584
585/// True when the message carries a tool result: the OpenAI durable shape
586/// (`role: "tool"`), the Anthropic durable shape (`role: "tool_result"`), or
587/// a user message whose content blocks include a `tool_result`. Text-channel
588/// results are ordinary user strings and intentionally don't match — they
589/// have no provider-level pairing to protect.
590fn is_tool_result_message(message: &serde_json::Value) -> bool {
591    match message.get("role").and_then(|role| role.as_str()) {
592        Some("tool") | Some("tool_result") => true,
593        Some("user") => message
594            .get("content")
595            .and_then(|content| content.as_array())
596            .is_some_and(|blocks| {
597                blocks.iter().any(|block| {
598                    block.get("type").and_then(|value| value.as_str()) == Some("tool_result")
599                })
600            }),
601        _ => false,
602    }
603}
604
605/// A compaction split must never land between an assistant tool-use message
606/// and its tool_result message(s): a kept window that begins with a
607/// tool_result whose request was drained is rejected by providers as an
608/// orphaned result. Results always immediately follow their request, so a
609/// split index is unsafe exactly when it points AT a tool-result message.
610/// Walk backward to the message that initiated the result run (keeping the
611/// request together with its results); when that would consume the whole
612/// compactable window, walk forward past the run instead so compaction still
613/// makes progress.
614fn snap_split_off_tool_results(
615    messages: &[serde_json::Value],
616    split_at: usize,
617    compact_start: usize,
618) -> usize {
619    if split_at >= messages.len() || !is_tool_result_message(&messages[split_at]) {
620        return split_at;
621    }
622    let mut backward = split_at;
623    while backward > compact_start && is_tool_result_message(&messages[backward]) {
624        backward -= 1;
625    }
626    if backward > compact_start {
627        return backward;
628    }
629    let mut forward = split_at;
630    while forward < messages.len() && is_tool_result_message(&messages[forward]) {
631        forward += 1;
632    }
633    forward
634}
635
636/// True when `trimmed` (an already-trimmed line) begins with `file:line` —
637/// a colon immediately followed by a digit, with no whitespace before the
638/// colon. Used as a strong signal that a line carries a located diagnostic.
639fn line_has_file_line_prefix(trimmed: &str) -> bool {
640    let bytes = trimmed.as_bytes();
641    let mut i = 0;
642    while i < bytes.len() && bytes[i] != b':' {
643        i += 1;
644    }
645    i < bytes.len() && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
646}
647
648/// True when a single line carries failure signal worth preserving verbatim
649/// when we shrink a large tool output. This is the ONE filter shared by both
650/// the microcompact path (`microcompact_tool_output`) and the observation-mask
651/// path (`default_mask_tool_result`). Keep these paths on one filter so
652/// assertion values, rustc continuation lines, and structured failing-line
653/// markers survive in the detail the model re-reads to fix the bug.
654///
655///   - assertion value lines with no `file:line` (`left:`, `right:`,
656///     `expected:`, `actual:`, `got`, `want`) — the values the model needs to
657///     see the actual-vs-expected mismatch.
658///   - rustc continuation lines (`-->` location pointers, `= help:`/`= note:`,
659///     numbered source rows like `12 |`, and `^^^` carets).
660///   - `Lnnn:` failing-line structure some test harnesses emit.
661pub(super) fn is_failure_signal_line(line: &str) -> bool {
662    let trimmed = line.trim();
663    if trimmed.is_empty() {
664        return false;
665    }
666    let lower = trimmed.to_lowercase();
667
668    let has_file_line = line_has_file_line_prefix(trimmed);
669    let has_strong_keyword =
670        trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
671    let has_weak_keyword = trimmed.contains("error")
672        || trimmed.contains("undefined")
673        || trimmed.contains("expected")
674        || trimmed.contains("got")
675        || lower.contains("cannot find")
676        || lower.contains("not found")
677        || lower.contains("no such")
678        || lower.contains("unresolved")
679        || lower.contains("missing")
680        || lower.contains("declared but not used")
681        || lower.contains("unused")
682        || lower.contains("mismatch");
683    let positional = lower.contains(" error ")
684        || lower.starts_with("error:")
685        || lower.starts_with("warning:")
686        || lower.starts_with("note:")
687        || lower.contains("panic:");
688
689    let assertion_value = lower.starts_with("left:")
690        || lower.starts_with("right:")
691        || lower.starts_with("expected:")
692        || lower.starts_with("actual:")
693        || lower.starts_with("got:")
694        || lower.starts_with("want:")
695        || lower.starts_with("got ")
696        || lower.starts_with("want ")
697        || lower.starts_with("assertion")
698        || lower.contains("assertionerror");
699
700    // rustc continuation lines: location pointer, help/note, numbered source
701    // rows (`12 | ...`), and caret underlines (`^^^`).
702    let rustc_continuation = trimmed.starts_with("-->")
703        || trimmed.starts_with("= help:")
704        || trimmed.starts_with("= note:")
705        || trimmed.contains('^')
706        || {
707            // `<digits> |` numbered source row from rustc's snippet rendering.
708            let mut chars = trimmed.chars();
709            let mut saw_digit = false;
710            let mut rest = trimmed;
711            while let Some(c) = chars.clone().next() {
712                if c.is_ascii_digit() {
713                    saw_digit = true;
714                    chars.next();
715                    rest = chars.as_str();
716                } else {
717                    break;
718                }
719            }
720            saw_digit && rest.trim_start().starts_with('|')
721        };
722
723    let failing_line_marker = {
724        if let Some(rest) = trimmed.strip_prefix('L') {
725            let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
726            !digits.is_empty() && rest[digits.len()..].starts_with(':')
727        } else {
728            false
729        }
730    };
731
732    has_strong_keyword
733        || (has_file_line && has_weak_keyword)
734        || positional
735        || assertion_value
736        || rustc_continuation
737        || failing_line_marker
738}
739
740/// Snap a byte offset to the nearest preceding line boundary (end of a complete line).
741/// Returns the substring from the start up to and including the last complete line
742/// that fits within `max_bytes`. Never cuts mid-line.
743fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
744    if max_bytes >= s.len() {
745        return s;
746    }
747    let search_end = s.floor_char_boundary(max_bytes);
748    match s[..search_end].rfind('\n') {
749        Some(pos) => &s[..pos + 1],
750        None => &s[..search_end], // single long line — fall back to char boundary
751    }
752}
753
754/// Snap a byte offset to the nearest following line boundary (start of a complete line).
755/// Returns the substring from the first complete line at or after `start_byte`.
756/// Never cuts mid-line.
757fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
758    if start_byte == 0 {
759        return s;
760    }
761    let search_start = s.ceil_char_boundary(start_byte);
762    if search_start >= s.len() {
763        return "";
764    }
765    match s[search_start..].find('\n') {
766        Some(pos) => {
767            let line_start = search_start + pos + 1;
768            if line_start < s.len() {
769                &s[line_start..]
770            } else {
771                &s[search_start..]
772            }
773        }
774        None => &s[search_start..], // already at start of last line
775    }
776}
777
778fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
779    messages
780        .iter()
781        .map(|msg| {
782            let role = msg
783                .get("role")
784                .and_then(|v| v.as_str())
785                .unwrap_or("user")
786                .to_uppercase();
787            let content = msg
788                .get("content")
789                .and_then(|v| v.as_str())
790                .unwrap_or_default();
791            format!("{role}: {content}")
792        })
793        .collect::<Vec<_>>()
794        .join("\n")
795}
796
797fn truncate_compaction_summary(
798    old_messages: &[serde_json::Value],
799    archived_count: usize,
800) -> String {
801    truncate_compaction_summary_with_context(old_messages, archived_count, false)
802}
803
804fn truncate_compaction_summary_with_context(
805    old_messages: &[serde_json::Value],
806    archived_count: usize,
807    is_llm_fallback: bool,
808) -> String {
809    let per_msg_limit = 500_usize;
810    let summary_parts: Vec<String> = old_messages
811        .iter()
812        .filter_map(|m| {
813            let role = m.get("role")?.as_str()?;
814            let content = m.get("content")?.as_str()?;
815            if content.is_empty() {
816                return None;
817            }
818            let truncated = if content.len() > per_msg_limit {
819                format!(
820                    "{}... [truncated from {} chars]",
821                    &content[..content.floor_char_boundary(per_msg_limit)],
822                    content.len()
823                )
824            } else {
825                content.to_string()
826            };
827            Some(format!("[{role}] {truncated}"))
828        })
829        .take(15)
830        .collect();
831    let header = if is_llm_fallback {
832        format!(
833            "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
834        )
835    } else {
836        format!("[auto-compacted {archived_count} older messages via truncate strategy]")
837    };
838    format!(
839        "{header}\n{}{}",
840        summary_parts.join("\n"),
841        if archived_count > 15 {
842            format!("\n... and {} more", archived_count - 15)
843        } else {
844            String::new()
845        }
846    )
847}
848
849fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
850    if let Some(map) = value.as_dict() {
851        if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
852            return Ok(summary.display());
853        }
854    }
855    match value {
856        VmValue::String(text) => Ok(text.to_string()),
857        VmValue::Nil => Ok(String::new()),
858        _ => serde_json::to_string_pretty(&vm_value_to_json(value))
859            .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
860    }
861}
862
863async fn llm_compaction_summary(
864    old_messages: &[serde_json::Value],
865    archived_count: usize,
866    llm_opts: &crate::llm::api::LlmCallOptions,
867    summarize_prompt: Option<&str>,
868    policy: &CompactionPolicy,
869) -> Result<String, VmError> {
870    let mut compact_opts = llm_opts.clone();
871    let formatted = format_compaction_messages(old_messages);
872    compact_opts.system = None;
873    compact_opts.transcript_summary = None;
874    compact_opts.native_tools = None;
875    compact_opts.tool_choice = None;
876    compact_opts.output_format = crate::llm::api::OutputFormat::Text;
877    compact_opts.response_format = None;
878    compact_opts.json_schema = None;
879    compact_opts.output_schema = None;
880    let prompt =
881        render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
882    compact_opts.messages = vec![serde_json::json!({
883        "role": "user",
884        "content": prompt,
885    })];
886    let manifest = &mut compact_opts.context_manifest;
887    manifest.record_system_transform("compaction", "stdlib:compaction", "removed system", None);
888    compact_opts.set_call_role("compaction");
889    let result = vm_call_llm_full(&compact_opts).await?;
890    let summary = result.text.trim();
891    if summary.is_empty() {
892        Ok(truncate_compaction_summary_with_context(
893            old_messages,
894            archived_count,
895            true,
896        ))
897    } else {
898        Ok(format!(
899            "[auto-compacted {archived_count} older messages]\n{summary}"
900        ))
901    }
902}
903
904fn render_llm_compaction_prompt(
905    summarize_prompt: Option<&str>,
906    formatted: &str,
907    archived_count: usize,
908    policy: &CompactionPolicy,
909) -> Result<String, VmError> {
910    if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
911        return render_replacement_compaction_prompt(policy, formatted, archived_count);
912    }
913    let mut bindings = crate::value::DictMap::new();
914    bindings.put_str("formatted_messages", formatted);
915    bindings.insert(
916        crate::value::intern_key("archived_count"),
917        VmValue::Int(archived_count as i64),
918    );
919    let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
920        let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
921            "orchestration/prompts/compaction_summary.harn.prompt",
922            Some(&bindings),
923        )?;
924        return Ok(extend_compaction_prompt(prompt, policy));
925    };
926
927    let asset = crate::stdlib::template::TemplateAsset::render_target(path)
928        .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
929    let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
930        .map_err(VmError::from)?;
931    Ok(extend_compaction_prompt(prompt, policy))
932}
933
934fn render_replacement_compaction_prompt(
935    policy: &CompactionPolicy,
936    formatted: &str,
937    archived_count: usize,
938) -> Result<String, VmError> {
939    let directives = policy.prompt_directives().unwrap_or_default();
940    let mut bindings = crate::value::DictMap::new();
941    bindings.put_str("directives", directives);
942    bindings.put_str("formatted_messages", formatted);
943    bindings.insert(
944        crate::value::intern_key("archived_count"),
945        VmValue::Int(archived_count as i64),
946    );
947    crate::stdlib::template::render_stdlib_prompt_asset(
948        "orchestration/prompts/compaction_policy_replacement.harn.prompt",
949        Some(&bindings),
950    )
951}
952
953fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
954    let Some(directives) = policy.prompt_directives() else {
955        return prompt;
956    };
957    prompt.push_str(
958        "\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",
959    );
960    prompt.push_str(&directives);
961    prompt
962}
963
964async fn custom_compaction_summary(
965    ctx: Option<&AsyncBuiltinCtx>,
966    old_messages: &[serde_json::Value],
967    archived_count: usize,
968    callback: &VmValue,
969    reminders: &[VmValue],
970    policy: &CompactionPolicy,
971) -> Result<String, VmError> {
972    let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
973        return Err(VmError::Runtime(
974            "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
975        ));
976    };
977    let Some(ctx) = ctx else {
978        return Err(VmError::Runtime(
979            "custom transcript compaction requires an async builtin VM context".to_string(),
980        ));
981    };
982    let mut vm = ctx.child_vm();
983    let messages_vm = VmValue::List(std::sync::Arc::new(
984        old_messages
985            .iter()
986            .map(crate::stdlib::json_to_vm_value)
987            .collect(),
988    ));
989    let result = if policy.has_metadata()
990        && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
991    {
992        let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
993        let policy_vm = compaction_policy_to_vm_value(policy);
994        vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
995            .await
996    } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
997        let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
998        vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
999            .await
1000    } else {
1001        vm.call_closure_pub(&closure, &[messages_vm]).await
1002    };
1003    let summary = compact_summary_text_from_value(&result?)?;
1004    ctx.forward_output(&vm.take_output());
1005    if summary.trim().is_empty() {
1006        Ok(truncate_compaction_summary(old_messages, archived_count))
1007    } else {
1008        Ok(format!(
1009            "[auto-compacted {archived_count} older messages]\n{summary}"
1010        ))
1011    }
1012}
1013
1014/// Marker the host emits inside a tool-output (or message) body to pin its
1015/// live grounding — the current file view and just-edited window — so it
1016/// survives a compaction pass. The host renders this literal substring inside
1017/// markdown headings (e.g. `## Exact current file text [no-compact]`,
1018/// `## Edited region now reads (...) [no-compact]`) in
1019/// `lib/tools/result-format.harn`. Compaction matches the substring; it does
1020/// not invent a new vocabulary.
1021pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
1022
1023/// Upper bound on how many of the most-recent pinned segments survive a
1024/// compaction pass verbatim. A pin that could never be evicted would let a
1025/// long session accumulate unbounded pinned snapshots (e.g. one edited-window
1026/// per edit) and eventually overflow the context window — defeating the
1027/// purpose of compaction. Keeping only the latest few preserves the agent's
1028/// *current* grounding (the file it is editing now, emitted as the exact-text
1029/// block plus the numbered-lines block in one or two adjacent outputs) while
1030/// letting stale duplicates from earlier in the session compact normally.
1031pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
1032
1033/// Whether a content body carries the host's `[no-compact]` pin marker.
1034fn is_pinned_content(content: &str) -> bool {
1035    content.contains(NO_COMPACT_MARKER)
1036}
1037
1038/// Compute the set of message indices into `messages` that are pinned AND fall
1039/// within the most-recent [`MAX_PINNED_SEGMENTS`] pinned bodies. Older pinned
1040/// bodies are intentionally excluded so they compact normally (the bound).
1041/// `content_of` extracts the body text to inspect for each message.
1042fn latest_pinned_indices<'a, F>(
1043    messages: impl Iterator<Item = &'a serde_json::Value>,
1044    content_of: F,
1045) -> std::collections::HashSet<usize>
1046where
1047    F: Fn(&serde_json::Value) -> Option<&str>,
1048{
1049    // Walk newest-first, collecting up to MAX_PINNED_SEGMENTS pinned indices.
1050    let pinned: Vec<usize> = messages
1051        .enumerate()
1052        .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
1053        .map(|(idx, _)| idx)
1054        .collect();
1055    pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
1056}
1057
1058/// Check whether a tool-result string should be preserved verbatim during
1059/// observation masking. Uses content length as the primary heuristic:
1060/// short results (< 500 chars) are kept since they're typically error messages,
1061/// status lines, or concise answers that are cheap to retain and risky to mask.
1062/// Long results are masked to save context budget.
1063fn content_should_preserve(content: &str) -> bool {
1064    content.len() < 500
1065}
1066
1067/// Default per-message masking for tool results.
1068///
1069/// Beyond the first-line preview, this KEEPS any failure-signal lines
1070/// (assertion values, located diagnostics, rustc help/caret/source rows,
1071/// `Lnnn:` markers) via the shared [`is_failure_signal_line`] filter, so the
1072/// model re-reads the actual-vs-expected detail it needs to fix the bug. The
1073/// previous mask dropped everything but the first line, silently shredding the
1074/// structured failure that the strong microcompact filter preserves at the
1075/// emission side.
1076fn default_mask_tool_result(role: &str, content: &str) -> String {
1077    let first_line = content.lines().next().unwrap_or(content);
1078    let line_count = content.lines().count();
1079    let char_count = content.len();
1080    if line_count <= 3 {
1081        return format!("[{role}] {content}");
1082    }
1083    let preview = &first_line[..first_line.len().min(120)];
1084    // Preserve failure-signal lines (bounded so a huge log can't defeat the
1085    // mask). Skip the first line itself — it is already in the preview.
1086    let kept: Vec<&str> = content
1087        .lines()
1088        .skip(1)
1089        .filter(|line| is_failure_signal_line(line))
1090        .take(32)
1091        .collect();
1092    if kept.is_empty() {
1093        format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
1094    } else {
1095        format!(
1096            "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
1097             failure lines preserved]\n{}",
1098            kept.join("\n")
1099        )
1100    }
1101}
1102
1103/// Stable marker on the first line of every observation-mask recap. A prior
1104/// recap re-enters the archive window on a later compaction as an ordinary
1105/// `{role: "user"}` message; matching this sentinel lets the next compaction
1106/// carry it forward once (bounded) instead of re-expanding and re-masking it —
1107/// the "recap of a recap = the same recap, updated" contract that stops recaps
1108/// from compounding across compactions.
1109pub(crate) const RECAP_HEADER_SENTINEL: &str = "via observation masking]";
1110
1111/// Byte cap on a single carried-forward prior recap. Bounds the compound-growth
1112/// term independently of the per-message budget so a chain of compactions
1113/// converges instead of accreting.
1114const PRIOR_RECAP_CARRY_CAP: usize = 6_000;
1115
1116/// Byte cap on the preview kept for an archived assistant turn. Assistant turns
1117/// carry the *calls* the model issued, not what it *learned*; keeping only a
1118/// short preview spends the recap budget on tool results (the observations)
1119/// rather than replaying verbatim call syntax.
1120const ASSISTANT_PREVIEW_CHARS: usize = 240;
1121
1122/// Whether a message body is a previous observation-mask recap.
1123fn is_prior_recap(content: &str) -> bool {
1124    content.contains(RECAP_HEADER_SENTINEL)
1125}
1126
1127/// Render one archived assistant turn as a bounded preview. Short turns pass
1128/// through; long turns keep a head slice plus a masked-marker tail so the drop
1129/// is visible in the same vocabulary as [`default_mask_tool_result`].
1130fn assistant_preview(content: &str) -> String {
1131    if content.len() <= ASSISTANT_PREVIEW_CHARS {
1132        return format!("[assistant] {content}");
1133    }
1134    let head = snap_to_line_end(content, ASSISTANT_PREVIEW_CHARS);
1135    let dropped = content.len().saturating_sub(head.len());
1136    format!("[assistant] {head}... [assistant turn truncated, {dropped} chars masked]")
1137}
1138
1139/// Collapse runs of byte-identical consecutive lines into a single line with an
1140/// `(xN)` suffix. Turns repetitive call/read sequences ("looked at X 4 times")
1141/// into a count instead of N verbatim copies.
1142fn collapse_repeats(lines: Vec<String>) -> Vec<String> {
1143    let mut out: Vec<String> = Vec::with_capacity(lines.len());
1144    let mut run = 0usize;
1145    for line in lines {
1146        if out
1147            .last()
1148            .is_some_and(|prev| strip_repeat_suffix(prev) == line)
1149        {
1150            run += 1;
1151            let base =
1152                strip_repeat_suffix(out.last().expect("run implies a last line")).to_string();
1153            *out.last_mut().expect("run implies a last line") = format!("{base} (x{})", run + 1);
1154        } else {
1155            run = 0;
1156            out.push(line);
1157        }
1158    }
1159    out
1160}
1161
1162fn strip_repeat_suffix(line: &str) -> &str {
1163    line.rfind(" (x")
1164        .filter(|_| line.ends_with(')'))
1165        .map(|idx| &line[..idx])
1166        .unwrap_or(line)
1167}
1168
1169/// Deterministic observation-mask compaction.
1170#[cfg(test)]
1171pub(crate) fn observation_mask_compaction(
1172    old_messages: &[serde_json::Value],
1173    archived_count: usize,
1174) -> String {
1175    observation_mask_compaction_with_callback(
1176        old_messages,
1177        archived_count,
1178        None,
1179        DEFAULT_RECAP_BUDGET_BYTES,
1180    )
1181    .0
1182}
1183
1184/// Test-only accessor exposing the recap body together with its
1185/// [`RecapMetrics`] and taking an explicit byte budget.
1186#[cfg(test)]
1187pub(crate) fn observation_mask_compaction_for_test(
1188    old_messages: &[serde_json::Value],
1189    archived_count: usize,
1190    budget_bytes: usize,
1191) -> (String, RecapMetrics) {
1192    observation_mask_compaction_with_callback(old_messages, archived_count, None, budget_bytes)
1193}
1194
1195/// Build the observation-mask recap body under `budget_bytes`.
1196///
1197/// Contract (harn#4731):
1198///   - A single header line names how many messages were archived.
1199///   - The most-recent *prior* recap in the archive window is carried forward
1200///     once, bounded to [`PRIOR_RECAP_CARRY_CAP`], and never re-expanded, so
1201///     recaps do not compound across successive compactions.
1202///   - The remaining budget is spent NEWEST-first on load-bearing observations
1203///     (tool results — verify/test/build outcomes and the errors that preceded
1204///     edits, preserved by [`default_mask_tool_result`]) rather than on verbatim
1205///     assistant call replay, which is reduced to a short preview.
1206///   - Pinned `[no-compact]` grounding is always kept (already bounded to
1207///     [`MAX_PINNED_SEGMENTS`]).
1208///   - Overflow past the budget is dropped and summarized in one trailing
1209///     masked-marker line; repetitive identical lines collapse to `(xN)`.
1210fn observation_mask_compaction_with_callback(
1211    old_messages: &[serde_json::Value],
1212    archived_count: usize,
1213    mask_results: Option<&[Option<String>]>,
1214    budget_bytes: usize,
1215) -> (String, RecapMetrics) {
1216    let header =
1217        format!("[auto-compacted {archived_count} older messages via observation masking]");
1218    let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
1219        msg.get("content").and_then(|v| v.as_str())
1220    });
1221    // Carry forward only the single most-recent prior recap; older recaps in the
1222    // window are already folded into it and compact as ordinary text.
1223    let prior_recap_idx = old_messages
1224        .iter()
1225        .enumerate()
1226        .rev()
1227        .find(|(_, msg)| {
1228            msg.get("content")
1229                .and_then(|v| v.as_str())
1230                .is_some_and(is_prior_recap)
1231        })
1232        .map(|(idx, _)| idx);
1233
1234    let mut metrics = RecapMetrics {
1235        budget_bytes,
1236        ..RecapMetrics::default()
1237    };
1238    // Render newest-first so the budget lands on the most decision-relevant tail;
1239    // reverse to chronological order for output.
1240    let mut rendered_rev: Vec<String> = Vec::new();
1241    let mut used = header.len();
1242
1243    for (idx, msg) in old_messages.iter().enumerate().rev() {
1244        let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
1245        let content = msg
1246            .get("content")
1247            .and_then(|v| v.as_str())
1248            .unwrap_or_default();
1249        if content.is_empty() {
1250            continue;
1251        }
1252
1253        if Some(idx) == prior_recap_idx {
1254            let carried = snap_to_line_end(content, PRIOR_RECAP_CARRY_CAP);
1255            rendered_rev.push(format!("[prior recap] {carried}"));
1256            metrics.carried_prior_recap = true;
1257            continue;
1258        }
1259
1260        // Pinned grounding is unconditional (and already bounded).
1261        if pinned.contains(&idx) {
1262            rendered_rev.push(format!("[{role}] {content}"));
1263            continue;
1264        }
1265
1266        let (line, is_result) = if role == "assistant" {
1267            (assistant_preview(content), false)
1268        } else if content_should_preserve(content) {
1269            (format!("[{role}] {content}"), true)
1270        } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
1271            (custom.clone(), true)
1272        } else {
1273            (default_mask_tool_result(role, content), true)
1274        };
1275
1276        if used + line.len() + 1 > budget_bytes {
1277            metrics.dropped_count += 1;
1278            continue;
1279        }
1280        used += line.len() + 1;
1281        if is_result {
1282            metrics.kept_results_count += 1;
1283        }
1284        rendered_rev.push(line);
1285    }
1286
1287    let mut body: Vec<String> = rendered_rev.into_iter().rev().collect();
1288    body = collapse_repeats(body);
1289    let mut parts = vec![header];
1290    parts.append(&mut body);
1291    if metrics.dropped_count > 0 {
1292        parts.push(format!(
1293            "[{} older message(s) dropped to fit recap budget]",
1294            metrics.dropped_count
1295        ));
1296    }
1297    let summary = parts.join("\n");
1298    metrics.recap_bytes = summary.len();
1299    (summary, metrics)
1300}
1301
1302/// Invoke the mask_callback to get per-message custom masks.
1303async fn invoke_mask_callback(
1304    ctx: Option<&AsyncBuiltinCtx>,
1305    callback: &VmValue,
1306    old_messages: &[serde_json::Value],
1307) -> Result<Vec<Option<String>>, VmError> {
1308    let VmValue::Closure(closure) = callback.clone() else {
1309        return Err(VmError::Runtime(
1310            "mask_callback must be a closure".to_string(),
1311        ));
1312    };
1313    let Some(ctx) = ctx else {
1314        return Err(VmError::Runtime(
1315            "mask_callback requires an async builtin VM context".to_string(),
1316        ));
1317    };
1318    let mut vm = ctx.child_vm();
1319    let messages_vm = VmValue::List(std::sync::Arc::new(
1320        old_messages
1321            .iter()
1322            .map(crate::stdlib::json_to_vm_value)
1323            .collect(),
1324    ));
1325    let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
1326    ctx.forward_output(&vm.take_output());
1327    let list = match result {
1328        VmValue::List(items) => items,
1329        _ => return Ok(vec![None; old_messages.len()]),
1330    };
1331    Ok(list
1332        .iter()
1333        .map(|v| match v {
1334            VmValue::String(s) => Some(s.to_string()),
1335            VmValue::Nil => None,
1336            _ => None,
1337        })
1338        .collect())
1339}
1340
1341/// Rewrite each tool-result message in `messages` whose content exceeds
1342/// `config.tool_output_max_chars`, using `config.compress_callback` when set
1343/// (and a VM context is available) else the deterministic
1344/// [`microcompact_tool_output`]. Only the `content` text is replaced; the
1345/// message's `role`/`tool_call_id` are left untouched so tool-call pairing is
1346/// preserved. A `tool_output_max_chars` of 0 disables the pass.
1347async fn clamp_tool_outputs(
1348    ctx: Option<&AsyncBuiltinCtx>,
1349    messages: &mut [serde_json::Value],
1350    config: &AutoCompactConfig,
1351) -> Result<(), VmError> {
1352    if config.tool_output_max_chars == 0 {
1353        return Ok(());
1354    }
1355    // Exempt the most-recent pinned tool-outputs (those carrying the host's
1356    // `[no-compact]` marker) from length-clamping so the agent's live file view
1357    // stays intact. Bounded to the latest MAX_PINNED_SEGMENTS so older pinned
1358    // snapshots in the kept window still clamp and can't blow the budget.
1359    let pinned = latest_pinned_indices(messages.iter(), |msg| {
1360        if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
1361            msg.get("content").and_then(|content| content.as_str())
1362        } else {
1363            None
1364        }
1365    });
1366    for (idx, message) in messages.iter_mut().enumerate() {
1367        if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
1368            continue;
1369        }
1370        let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
1371            continue;
1372        };
1373        if content.len() <= config.tool_output_max_chars {
1374            continue;
1375        }
1376        if pinned.contains(&idx) {
1377            continue;
1378        }
1379        let content = content.to_string();
1380        let replacement = match (config.compress_callback.as_ref(), ctx) {
1381            (Some(callback), Some(ctx)) => {
1382                invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
1383                    .await?
1384            }
1385            _ => microcompact_tool_output(&content, config.tool_output_max_chars),
1386        };
1387        message["content"] = serde_json::Value::String(replacement);
1388    }
1389    Ok(())
1390}
1391
1392/// Invoke `compress_callback(content, max_chars)` to replace one oversized
1393/// tool-output body, mirroring [`invoke_mask_callback`]'s child-VM closure
1394/// invocation. A non-string return falls back to the deterministic primitive.
1395async fn invoke_compress_callback(
1396    ctx: &AsyncBuiltinCtx,
1397    callback: &VmValue,
1398    content: &str,
1399    max_chars: usize,
1400) -> Result<String, VmError> {
1401    let VmValue::Closure(closure) = callback.clone() else {
1402        return Err(VmError::Runtime(
1403            "compress_callback must be a closure".to_string(),
1404        ));
1405    };
1406    let mut vm = ctx.child_vm();
1407    let args = [
1408        VmValue::String(arcstr::ArcStr::from(content)),
1409        VmValue::Int(max_chars as i64),
1410    ];
1411    let result = vm.call_closure_pub(&closure, &args).await?;
1412    ctx.forward_output(&vm.take_output());
1413    match result {
1414        VmValue::String(text) => Ok(text.to_string()),
1415        _ => Ok(microcompact_tool_output(content, max_chars)),
1416    }
1417}
1418
1419#[derive(Clone, Copy)]
1420struct CompactionStrategyInputs<'a> {
1421    ctx: Option<&'a AsyncBuiltinCtx>,
1422    strategy: &'a CompactStrategy,
1423    old_messages: &'a [serde_json::Value],
1424    archived_count: usize,
1425    llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
1426    custom_compactor: Option<&'a VmValue>,
1427    custom_compactor_reminders: &'a [VmValue],
1428    mask_callback: Option<&'a VmValue>,
1429    summarize_prompt: Option<&'a str>,
1430    policy: &'a CompactionPolicy,
1431    recap_budget_bytes: usize,
1432}
1433
1434/// Apply a single compaction strategy to a list of archived messages. Returns
1435/// the summary text plus [`RecapMetrics`] for the observation-mask strategy
1436/// (the only strategy that spends a recap budget); other strategies return
1437/// `None`.
1438async fn apply_compaction_strategy(
1439    input: CompactionStrategyInputs<'_>,
1440) -> Result<(String, Option<RecapMetrics>), VmError> {
1441    let CompactionStrategyInputs {
1442        strategy,
1443        old_messages,
1444        archived_count,
1445        llm_opts,
1446        custom_compactor,
1447        custom_compactor_reminders,
1448        mask_callback,
1449        summarize_prompt,
1450        policy,
1451        recap_budget_bytes,
1452        ctx,
1453    } = input;
1454    match strategy {
1455        CompactStrategy::Truncate => Ok((
1456            truncate_compaction_summary(old_messages, archived_count),
1457            None,
1458        )),
1459        CompactStrategy::Llm => llm_compaction_summary(
1460            old_messages,
1461            archived_count,
1462            llm_opts.ok_or_else(|| {
1463                VmError::Runtime(
1464                    "LLM transcript compaction requires active LLM call options".to_string(),
1465                )
1466            })?,
1467            summarize_prompt,
1468            policy,
1469        )
1470        .await
1471        .map(|summary| (summary, None)),
1472        CompactStrategy::Custom => custom_compaction_summary(
1473            ctx,
1474            old_messages,
1475            archived_count,
1476            custom_compactor.ok_or_else(|| {
1477                VmError::Runtime(
1478                    "compact_callback is required when compact_strategy is 'custom'".to_string(),
1479                )
1480            })?,
1481            custom_compactor_reminders,
1482            policy,
1483        )
1484        .await
1485        .map(|summary| (summary, None)),
1486        CompactStrategy::ObservationMask => {
1487            let mask_results = if let Some(cb) = mask_callback {
1488                Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1489            } else {
1490                None
1491            };
1492            let (summary, metrics) = observation_mask_compaction_with_callback(
1493                old_messages,
1494                archived_count,
1495                mask_results.as_deref(),
1496                recap_budget_bytes,
1497            );
1498            Ok((summary, Some(metrics)))
1499        }
1500    }
1501}
1502
1503async fn apply_compaction_strategy_with_fallback(
1504    input: CompactionStrategyInputs<'_>,
1505    fallback_strategy: Option<&CompactStrategy>,
1506) -> Result<(String, CompactStrategy, Option<RecapMetrics>), VmError> {
1507    match apply_compaction_strategy(input).await {
1508        Ok((summary, metrics)) => Ok((summary, input.strategy.clone(), metrics)),
1509        Err(primary_error) => {
1510            let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1511            else {
1512                return Err(primary_error);
1513            };
1514            let fallback_input = CompactionStrategyInputs {
1515                strategy: fallback,
1516                ..input
1517            };
1518            apply_compaction_strategy(fallback_input)
1519                .await
1520                .map(|(summary, metrics)| (summary, fallback.clone(), metrics))
1521        }
1522    }
1523}
1524
1525pub(crate) struct AutoCompactResult {
1526    pub summary: String,
1527    pub strategy: CompactStrategy,
1528    pub recap_metrics: Option<RecapMetrics>,
1529}
1530
1531/// Auto-compact a message list in place using two-tier compaction.
1532#[cfg(test)]
1533pub(crate) async fn auto_compact_messages_with_result(
1534    messages: &mut Vec<serde_json::Value>,
1535    config: &AutoCompactConfig,
1536    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1537) -> Result<Option<AutoCompactResult>, VmError> {
1538    auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1539}
1540
1541pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1542    ctx: Option<&AsyncBuiltinCtx>,
1543    messages: &mut Vec<serde_json::Value>,
1544    config: &AutoCompactConfig,
1545    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1546) -> Result<Option<AutoCompactResult>, VmError> {
1547    if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1548        return Ok(None);
1549    }
1550    if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1551        return Ok(None);
1552    }
1553    let compact_start = config.keep_first.min(messages.len());
1554    let original_split = messages.len().saturating_sub(config.keep_last);
1555    let mut split_at = original_split;
1556    // Snap back to a user-role boundary so the kept suffix begins at a clean
1557    // turn. OpenAI-compatible APIs reject tool results orphaned from their
1558    // assistant request, so splitting mid-turn corrupts the transcript.
1559    while split_at > compact_start
1560        && split_at < messages.len()
1561        && messages[split_at]
1562            .get("role")
1563            .and_then(|r| r.as_str())
1564            .is_none_or(|r| r != "user")
1565    {
1566        split_at -= 1;
1567    }
1568    // Fall back to the naive split (e.g. tool-heavy transcripts with the sole
1569    // user message at index 0) rather than skipping compaction entirely.
1570    if split_at == compact_start {
1571        split_at = original_split;
1572    }
1573    if let Some(volatile_start) = messages[split_at..]
1574        .iter()
1575        .position(is_reasoning_or_tool_turn_message)
1576        .map(|offset| split_at + offset)
1577    {
1578        if let Some(boundary) = volatile_start
1579            .checked_sub(1)
1580            .and_then(|idx| find_prev_user_boundary(messages, idx))
1581            .filter(|boundary| *boundary > compact_start)
1582        {
1583            split_at = boundary;
1584        }
1585    }
1586    // The naive fallback (and, in tool-heavy transcripts with no interior
1587    // user boundary, the volatile-start correction too) can still leave the
1588    // split pointing at a tool_result whose tool_use request would be
1589    // drained. Final pass: snap off any request/result pair.
1590    split_at = snap_split_off_tool_results(messages, split_at, compact_start);
1591    if split_at <= compact_start {
1592        return Ok(None);
1593    }
1594    let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1595    let archived_count = old_messages.len();
1596
1597    // Clamp oversized tool-result bodies in the *kept* window so the live
1598    // context honors the policy's `tool_output_max_chars` (and the
1599    // `compress_callback` override), not just the archived/summarized window.
1600    // Runs before the hard-limit estimate so tier-2 escalation
1601    // keys off the post-clamp size. Only the text body is rewritten; `role`
1602    // and `tool_call_id` are preserved so tool_call/tool_result pairing stays
1603    // intact.
1604    clamp_tool_outputs(ctx, messages, config).await?;
1605
1606    let (mut summary, mut strategy, mut recap_metrics) = apply_compaction_strategy_with_fallback(
1607        CompactionStrategyInputs {
1608            ctx,
1609            strategy: &config.compact_strategy,
1610            old_messages: &old_messages,
1611            archived_count,
1612            llm_opts,
1613            custom_compactor: config.custom_compactor.as_ref(),
1614            custom_compactor_reminders: &config.custom_compactor_reminders,
1615            mask_callback: config.mask_callback.as_ref(),
1616            summarize_prompt: config.summarize_prompt.as_deref(),
1617            policy: &config.policy,
1618            recap_budget_bytes: config.recap_budget_bytes,
1619        },
1620        config.fallback_strategy.as_ref(),
1621    )
1622    .await?;
1623
1624    if let Some(hard_limit) = config.hard_limit_tokens {
1625        let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1626        let mut estimate_msgs = vec![summary_msg];
1627        estimate_msgs.extend_from_slice(messages.as_slice());
1628        let estimated = estimate_message_tokens(&estimate_msgs);
1629        if estimated > hard_limit {
1630            let tier1_as_messages = vec![serde_json::json!({
1631                "role": "user",
1632                "content": summary,
1633            })];
1634            let (hard_limit_summary, hard_limit_strategy, hard_limit_metrics) =
1635                apply_compaction_strategy_with_fallback(
1636                    CompactionStrategyInputs {
1637                        ctx,
1638                        strategy: &config.hard_limit_strategy,
1639                        old_messages: &tier1_as_messages,
1640                        archived_count,
1641                        llm_opts,
1642                        custom_compactor: config.custom_compactor.as_ref(),
1643                        custom_compactor_reminders: &config.custom_compactor_reminders,
1644                        mask_callback: None,
1645                        summarize_prompt: config.summarize_prompt.as_deref(),
1646                        policy: &config.policy,
1647                        recap_budget_bytes: config.recap_budget_bytes,
1648                    },
1649                    config.fallback_strategy.as_ref(),
1650                )
1651                .await?;
1652            summary = hard_limit_summary;
1653            strategy = hard_limit_strategy;
1654            // Tier-2 re-summarized the tier-1 recap; its metrics (if any)
1655            // describe the delivered body, so they supersede tier-1's.
1656            recap_metrics = hard_limit_metrics.or(recap_metrics);
1657        }
1658    }
1659
1660    summary = super::repair_ledger::append_repair_ledger_to_summary(
1661        apply_model_visible_policy(summary, &config.policy),
1662        &old_messages,
1663    );
1664
1665    messages.insert(
1666        compact_start,
1667        serde_json::json!({
1668            "role": "user",
1669            "content": summary,
1670        }),
1671    );
1672    Ok(Some(AutoCompactResult {
1673        summary,
1674        strategy,
1675        recap_metrics,
1676    }))
1677}
1678
1679/// Auto-compact a message list in place using two-tier compaction.
1680#[cfg(test)]
1681pub(crate) async fn auto_compact_messages(
1682    messages: &mut Vec<serde_json::Value>,
1683    config: &AutoCompactConfig,
1684    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1685) -> Result<Option<String>, VmError> {
1686    Ok(
1687        auto_compact_messages_with_result(messages, config, llm_opts)
1688            .await?
1689            .map(|result| result.summary),
1690    )
1691}
1692
1693fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1694    if !policy.is_model_visible_scope() {
1695        return summary;
1696    }
1697    let Some(directives) = policy.prompt_directives() else {
1698        return summary;
1699    };
1700    summary.push_str("\n\n[compaction instructions]\n");
1701    summary.push_str(&directives);
1702    summary
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707    use super::*;
1708
1709    #[test]
1710    fn microcompact_short_output_unchanged() {
1711        let output = "line1\nline2\nline3\n";
1712        assert_eq!(microcompact_tool_output(output, 1000), output);
1713    }
1714
1715    #[test]
1716    fn microcompact_snaps_to_line_boundaries() {
1717        let lines: Vec<String> = (0..20)
1718            .map(|i| format!("line {i:02} content here"))
1719            .collect();
1720        let output = lines.join("\n");
1721        let result = microcompact_tool_output(&output, 200);
1722        assert!(result.contains("[... "), "should have snip marker");
1723        let parts: Vec<&str> = result.split("\n\n[... ").collect();
1724        assert!(parts.len() >= 2, "should split at marker");
1725        let head = parts[0];
1726        for line in head.lines() {
1727            assert!(
1728                line.starts_with("line "),
1729                "head line should be complete: {line}"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1736        let mut lines = Vec::new();
1737        for i in 0..50 {
1738            lines.push(format!("verbose output line {i}"));
1739        }
1740        lines.push("src/main.rs:42: error: cannot find value".to_string());
1741        for i in 50..100 {
1742            lines.push(format!("verbose output line {i}"));
1743        }
1744        let output = lines.join("\n");
1745        let result = microcompact_tool_output(&output, 600);
1746        assert!(result.contains("cannot find value"), "diagnostic preserved");
1747        assert!(
1748            result.contains("[diagnostic lines preserved]"),
1749            "has diagnostic marker"
1750        );
1751    }
1752
1753    // D1-durable: the shared failure-signal filter must keep the structured
1754    // failure kinds the old mask path dropped — assertion values, rustc
1755    // help/caret/source rows, and `Lnnn:` markers — not just keyword lines.
1756    #[test]
1757    fn failure_signal_filter_keeps_structured_failure_lines() {
1758        for keep in [
1759            "left: 3",
1760            "right: 4",
1761            "expected: foo",
1762            "actual: bar",
1763            "  --> src/main.rs:4:9",
1764            "= help: add `use std::fmt;`",
1765            "12 | let x = bad();",
1766            "   | ^^^^^^^ not found",
1767            "L42: assertion failed",
1768            "src/main.rs:42: error: cannot find value",
1769            "FAIL: TestThing",
1770            "panic: index out of range",
1771        ] {
1772            assert!(
1773                is_failure_signal_line(keep),
1774                "should keep failure-signal line: {keep:?}"
1775            );
1776        }
1777        for drop in [
1778            "verbose output line 7",
1779            "compiling crate foo",
1780            "    let y = ok();",
1781            "",
1782        ] {
1783            assert!(
1784                !is_failure_signal_line(drop),
1785                "should drop ordinary line: {drop:?}"
1786            );
1787        }
1788    }
1789
1790    // D1-durable: masking a large tool output must preserve the assertion
1791    // values and rustc detail (not just the first line) so the model can fix
1792    // the bug instead of re-reading a shredded summary.
1793    #[test]
1794    fn default_mask_preserves_failure_detail() {
1795        let mut lines = vec!["running 1 test".to_string()];
1796        for i in 0..40 {
1797            lines.push(format!("noise line {i}"));
1798        }
1799        lines.push("assertion `left == right` failed".to_string());
1800        lines.push("  left: 3".to_string());
1801        lines.push(" right: 4".to_string());
1802        lines.push("  --> src/lib.rs:10:5".to_string());
1803        for i in 40..80 {
1804            lines.push(format!("more noise {i}"));
1805        }
1806        let content = lines.join("\n");
1807        let masked = default_mask_tool_result("tool", &content);
1808        assert!(
1809            masked.contains("masked"),
1810            "still reports it masked: {masked}"
1811        );
1812        assert!(
1813            masked.contains("failure lines preserved"),
1814            "should flag preserved lines: {masked}"
1815        );
1816        assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1817        assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1818        assert!(
1819            masked.contains("--> src/lib.rs:10:5"),
1820            "keeps rustc location: {masked}"
1821        );
1822        assert!(
1823            !masked.contains("noise line 7"),
1824            "drops ordinary noise: {masked}"
1825        );
1826    }
1827
1828    // Verbose output with NO failure signal still masks down to the preview.
1829    #[test]
1830    fn default_mask_without_failure_lines_stays_terse() {
1831        let lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1832        let content = lines.join("\n");
1833        let masked = default_mask_tool_result("tool", &content);
1834        assert!(masked.contains("masked]"), "should mask: {masked}");
1835        assert!(
1836            !masked.contains("failure lines preserved"),
1837            "no failure lines to preserve: {masked}"
1838        );
1839    }
1840
1841    #[test]
1842    fn token_estimate_counts_structured_message_content() {
1843        let text = "x".repeat(400);
1844        let messages = vec![serde_json::json!({
1845            "role": "user",
1846            "content": [
1847                {"type": "text", "text": text},
1848                {"type": "input_text", "text": "tail"},
1849            ],
1850            "reasoning": {"text": "scratch"},
1851            "tool_calls": [{
1852                "id": "call_1",
1853                "type": "function",
1854                "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1855            }],
1856        })];
1857
1858        assert!(
1859            estimate_message_tokens(&messages) >= 100,
1860            "structured content must not count as zero"
1861        );
1862    }
1863
1864    #[test]
1865    fn compaction_policy_instructions_extend_by_default() {
1866        let policy = CompactionPolicy {
1867            instructions: Some("Keep the failing test names.".to_string()),
1868            ..Default::default()
1869        };
1870        let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1871            .expect("prompt renders");
1872
1873        assert_eq!(policy.instruction_mode(), "extend");
1874        assert!(prompt.contains("Preserve goals, constraints"));
1875        assert!(prompt.contains("Additional compaction instructions"));
1876        assert!(prompt.contains("Keep the failing test names."));
1877    }
1878
1879    #[test]
1880    fn compaction_policy_can_replace_default_instructions() {
1881        let policy = CompactionPolicy {
1882            instructions: Some("Only keep repro steps.".to_string()),
1883            extend_default_instructions: Some(false),
1884            ..Default::default()
1885        };
1886        let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1887            .expect("prompt renders");
1888
1889        assert_eq!(policy.instruction_mode(), "replace");
1890        assert!(prompt.contains("according to these instructions"));
1891        assert!(prompt.contains("Only keep repro steps."));
1892        assert!(!prompt.contains("Preserve goals, constraints"));
1893    }
1894
1895    #[test]
1896    fn snap_to_line_end_finds_newline() {
1897        let s = "line1\nline2\nline3\nline4\n";
1898        let head = snap_to_line_end(s, 12);
1899        assert!(head.ends_with('\n'), "should end at newline");
1900        assert!(head.contains("line1"));
1901    }
1902
1903    #[test]
1904    fn snap_to_line_start_finds_newline() {
1905        let s = "line1\nline2\nline3\nline4\n";
1906        let tail = snap_to_line_start(s, 12);
1907        assert!(
1908            tail.starts_with("line"),
1909            "should start at line boundary: {tail}"
1910        );
1911    }
1912
1913    #[test]
1914    fn auto_compact_preserves_reasoning_tool_suffix() {
1915        let mut messages = vec![
1916            serde_json::json!({"role": "user", "content": "old task"}),
1917            serde_json::json!({"role": "assistant", "content": "old reply"}),
1918            serde_json::json!({"role": "user", "content": "new task"}),
1919            serde_json::json!({
1920                "role": "assistant",
1921                "content": "",
1922                "reasoning": "think first",
1923                "tool_calls": [{
1924                    "id": "call_1",
1925                    "type": "function",
1926                    "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1927                }],
1928            }),
1929            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1930        ];
1931        let config = AutoCompactConfig {
1932            token_threshold: 1,
1933            keep_last: 2,
1934            ..Default::default()
1935        };
1936
1937        let runtime = tokio::runtime::Builder::new_current_thread()
1938            .enable_all()
1939            .build()
1940            .expect("runtime");
1941        let summary = runtime
1942            .block_on(auto_compact_messages(&mut messages, &config, None))
1943            .expect("compaction succeeds");
1944
1945        assert!(summary.is_some());
1946        assert_eq!(messages[1]["role"], "user");
1947        assert_eq!(messages[2]["role"], "assistant");
1948        assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1949        assert_eq!(messages[3]["role"], "tool");
1950        assert_eq!(messages[3]["tool_call_id"], "call_1");
1951    }
1952
1953    /// Regression (transcript integrity): a tool-heavy transcript whose only
1954    /// user message is the pinned head has no interior user boundary, so the
1955    /// split falls back to the naive `len - keep_last` index — which can land
1956    /// BETWEEN an assistant tool_use message and its tool_result, orphaning
1957    /// the result at the kept-window head. The split must snap to the start
1958    /// of the request/result pair instead.
1959    #[test]
1960    fn auto_compact_never_splits_assistant_tool_use_from_its_result() {
1961        let tool_call = |id: &str| {
1962            serde_json::json!({
1963                "id": id,
1964                "type": "function",
1965                "function": {"name": "run", "arguments": "{}"}
1966            })
1967        };
1968        let mut messages = vec![
1969            serde_json::json!({"role": "user", "content": "task"}),
1970            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c0")]}),
1971            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1972            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c1")]}),
1973            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1974            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c2")]}),
1975            serde_json::json!({"role": "tool", "tool_call_id": "c2", "content": "r2"}),
1976        ];
1977        // keep_last: 3 puts the naive split at index 4 — the tool_result for
1978        // c1 — exactly mid-pair.
1979        let config = AutoCompactConfig {
1980            token_threshold: 1,
1981            keep_first: 0,
1982            keep_last: 3,
1983            ..Default::default()
1984        };
1985
1986        let runtime = tokio::runtime::Builder::new_current_thread()
1987            .enable_all()
1988            .build()
1989            .expect("runtime");
1990        let summary = runtime
1991            .block_on(auto_compact_messages(&mut messages, &config, None))
1992            .expect("compaction succeeds");
1993        assert!(summary.is_some(), "compaction should trigger");
1994
1995        // Kept window: summary, then the INTACT c1 pair, then the c2 pair.
1996        assert_eq!(messages[0]["role"], "user", "summary head");
1997        assert_eq!(messages[1]["role"], "assistant");
1998        assert_eq!(messages[1]["tool_calls"][0]["id"], "c1");
1999        assert_eq!(messages[2]["role"], "tool");
2000        assert_eq!(messages[2]["tool_call_id"], "c1");
2001        assert_eq!(messages[3]["tool_calls"][0]["id"], "c2");
2002        assert_eq!(messages[4]["tool_call_id"], "c2");
2003        // No kept tool_result may reference a drained (missing) request.
2004        for (idx, message) in messages.iter().enumerate() {
2005            if message["role"] == "tool" {
2006                let id = message["tool_call_id"].as_str().expect("tool_call_id");
2007                let paired = messages[..idx].iter().any(|prev| {
2008                    prev["tool_calls"]
2009                        .as_array()
2010                        .is_some_and(|calls| calls.iter().any(|call| call["id"] == id))
2011                });
2012                assert!(paired, "tool_result {id} orphaned in kept window");
2013            }
2014        }
2015    }
2016
2017    #[test]
2018    fn snap_split_off_tool_results_handles_all_result_shapes() {
2019        // A split pointing at any tool-result shape walks back to the
2020        // request that initiated the run. OpenAI durable shape
2021        // (`role: "tool"`):
2022        let openai = vec![
2023            serde_json::json!({"role": "user", "content": "task"}),
2024            serde_json::json!({"role": "assistant", "content": "", "tool_calls": []}),
2025            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2026        ];
2027        assert_eq!(snap_split_off_tool_results(&openai, 2, 0), 1);
2028        // Anthropic durable shape (`role: "tool_result"`).
2029        let anthropic = vec![
2030            serde_json::json!({"role": "user", "content": "task"}),
2031            serde_json::json!({"role": "assistant", "content": ""}),
2032            serde_json::json!({"role": "tool_result", "tool_use_id": "c0", "content": "r0"}),
2033        ];
2034        assert_eq!(snap_split_off_tool_results(&anthropic, 2, 0), 1);
2035        // User message carrying tool_result blocks.
2036        let user_blocks = vec![
2037            serde_json::json!({"role": "user", "content": "task"}),
2038            serde_json::json!({"role": "assistant", "content": ""}),
2039            serde_json::json!({
2040                "role": "user",
2041                "content": [{"type": "tool_result", "tool_use_id": "c0", "content": "r0"}],
2042            }),
2043        ];
2044        assert_eq!(snap_split_off_tool_results(&user_blocks, 2, 0), 1);
2045        // Plain user text is a safe boundary — untouched.
2046        let text = vec![
2047            serde_json::json!({"role": "assistant", "content": ""}),
2048            serde_json::json!({"role": "user", "content": "plain"}),
2049        ];
2050        assert_eq!(snap_split_off_tool_results(&text, 1, 0), 1);
2051        // Backward walk pinned at compact_start: fall forward past the run
2052        // so compaction still makes progress (the whole pair is drained
2053        // together rather than split).
2054        let pinned = vec![
2055            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2056            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
2057            serde_json::json!({"role": "assistant", "content": "done"}),
2058        ];
2059        assert_eq!(snap_split_off_tool_results(&pinned, 1, 0), 2);
2060    }
2061
2062    #[test]
2063    fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
2064        // A large tool result in the *kept* window must be clamped to honor
2065        // `tool_output_max_chars`.
2066        let big = "x".repeat(4000);
2067        let big_len = big.len();
2068        let mut messages = vec![
2069            serde_json::json!({"role": "user", "content": "old task"}),
2070            serde_json::json!({"role": "assistant", "content": "old reply"}),
2071            serde_json::json!({"role": "user", "content": "new task"}),
2072            serde_json::json!({"role": "assistant", "content": "calling tool"}),
2073            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2074        ];
2075        let config = AutoCompactConfig {
2076            token_threshold: 1,
2077            keep_last: 2,
2078            tool_output_max_chars: 500,
2079            ..Default::default()
2080        };
2081
2082        let runtime = tokio::runtime::Builder::new_current_thread()
2083            .enable_all()
2084            .build()
2085            .expect("runtime");
2086        let result = runtime
2087            .block_on(auto_compact_messages(&mut messages, &config, None))
2088            .expect("compaction succeeds");
2089        assert!(result.is_some(), "compaction should trigger");
2090
2091        let tool_msg = messages
2092            .iter()
2093            .find(|message| message["role"] == "tool")
2094            .expect("tool message kept in window");
2095        // Pairing preserved...
2096        assert_eq!(tool_msg["tool_call_id"], "call_1");
2097        // ...and the oversized body was clamped well below its original size.
2098        let content = tool_msg["content"].as_str().expect("string content");
2099        assert!(
2100            content.len() < big_len,
2101            "tool output should be clamped: {} vs {}",
2102            content.len(),
2103            big_len
2104        );
2105        assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2106    }
2107
2108    /// (1) A pinned tool-output survives an observation-mask pass that evicts
2109    /// (masks) the unpinned verbose outputs around it.
2110    #[test]
2111    fn observation_mask_preserves_pinned_live_file_view() {
2112        let pinned_body = format!(
2113            "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
2114            NO_COMPACT_MARKER,
2115            (0..40)
2116                .map(|i| format!("   {i}  let x = compute({i});"))
2117                .collect::<Vec<_>>()
2118                .join("\n")
2119        );
2120        let verbose_unpinned = (0..60)
2121            .map(|i| format!("verbose scan output line {i}"))
2122            .collect::<Vec<_>>()
2123            .join("\n");
2124        // These are the ARCHIVED messages handed to the mask pass.
2125        let archived = vec![
2126            serde_json::json!({"role": "user", "content": verbose_unpinned}),
2127            serde_json::json!({"role": "user", "content": pinned_body}),
2128        ];
2129        let summary = observation_mask_compaction(&archived, archived.len());
2130        // Pinned live file view survives verbatim.
2131        assert!(
2132            summary.contains("Edited region now reads"),
2133            "pinned heading survived: {summary}"
2134        );
2135        assert!(
2136            summary.contains("let x = compute(39);"),
2137            "pinned body survived verbatim"
2138        );
2139        // The unpinned verbose neighbor was masked.
2140        assert!(summary.contains("masked]"), "unpinned output was masked");
2141        assert!(!summary.contains("verbose scan output line 30"));
2142    }
2143
2144    /// (2) A pinned large tool-output is NOT clamped, while an unpinned one of
2145    /// the same size IS.
2146    #[test]
2147    fn clamp_exempts_pinned_tool_output() {
2148        let pinned_big = format!(
2149            "## Exact current file text {}\n{}",
2150            NO_COMPACT_MARKER,
2151            "x".repeat(4000)
2152        );
2153        let pinned_len = pinned_big.len();
2154        let unpinned_big = "y".repeat(4000);
2155        let unpinned_len = unpinned_big.len();
2156        let mut messages = vec![
2157            serde_json::json!({"role": "user", "content": "old task"}),
2158            serde_json::json!({"role": "assistant", "content": "reply"}),
2159            serde_json::json!({"role": "user", "content": "new task"}),
2160            serde_json::json!({"role": "assistant", "content": "calling tools"}),
2161            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
2162            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
2163            serde_json::json!({"role": "user", "content": "continue"}),
2164        ];
2165        let config = AutoCompactConfig {
2166            token_threshold: 1,
2167            keep_last: 4,
2168            tool_output_max_chars: 500,
2169            ..Default::default()
2170        };
2171        let runtime = tokio::runtime::Builder::new_current_thread()
2172            .enable_all()
2173            .build()
2174            .expect("runtime");
2175        runtime
2176            .block_on(auto_compact_messages(&mut messages, &config, None))
2177            .expect("compaction succeeds");
2178
2179        let pinned_msg = messages
2180            .iter()
2181            .find(|m| m["tool_call_id"] == "c1")
2182            .expect("pinned tool message kept");
2183        assert_eq!(
2184            pinned_msg["content"].as_str().map(str::len),
2185            Some(pinned_len),
2186            "pinned output must be intact (unclamped)"
2187        );
2188        let unpinned_msg = messages
2189            .iter()
2190            .find(|m| m["tool_call_id"] == "c0")
2191            .expect("unpinned tool message kept");
2192        assert!(
2193            unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
2194            "unpinned output of the same size must be clamped"
2195        );
2196    }
2197
2198    /// (3) Bounded policy: with MANY pinned outputs, only the latest
2199    /// MAX_PINNED_SEGMENTS survive verbatim; older pinned duplicates compact —
2200    /// so the pin can't prevent all compaction (and can't overflow the window
2201    /// on a very long session).
2202    #[test]
2203    fn pin_bound_keeps_only_latest_segments() {
2204        // Build 6 distinct pinned, oversized edited-window snapshots
2205        // (gen 0 = oldest .. gen 5 = newest), each tagged with the marker and
2206        // long enough that masking would otherwise truncate it.
2207        let make = |gen: usize| {
2208            let body = (0..40)
2209                .map(|i| format!("marker-gen-{gen} body line {i}"))
2210                .collect::<Vec<_>>()
2211                .join("\n");
2212            serde_json::json!({
2213                "role": "user",
2214                "content": format!(
2215                    "## Edited region now reads (gen {gen}) {}\n{}",
2216                    NO_COMPACT_MARKER, body
2217                ),
2218            })
2219        };
2220        let archived: Vec<_> = (0..6).map(make).collect();
2221
2222        // Unit-level: the index selection keeps exactly the latest N.
2223        let pinned = latest_pinned_indices(archived.iter(), |m| {
2224            m.get("content").and_then(|c| c.as_str())
2225        });
2226        assert_eq!(
2227            pinned.len(),
2228            MAX_PINNED_SEGMENTS,
2229            "only the latest MAX_PINNED_SEGMENTS are pinned"
2230        );
2231        assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
2232        assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
2233
2234        // End-to-end through the mask pass: the 3 newest snapshots survive
2235        // verbatim; the 3 oldest are masked, proving the pin cannot defeat all
2236        // compaction.
2237        let summary = observation_mask_compaction(&archived, archived.len());
2238        assert!(
2239            summary.contains("marker-gen-5")
2240                && summary.contains("marker-gen-4")
2241                && summary.contains("marker-gen-3"),
2242            "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
2243        );
2244        assert!(
2245            !summary.contains("marker-gen-0")
2246                && !summary.contains("marker-gen-1")
2247                && !summary.contains("marker-gen-2"),
2248            "older pinned snapshots are masked (bound enforced)"
2249        );
2250        assert!(summary.contains("masked]"), "older snapshots were masked");
2251    }
2252
2253    /// (4) Regression: with NO pins, compaction behaves exactly as before.
2254    #[test]
2255    fn no_pins_preserves_prior_clamp_behavior() {
2256        let big = "x".repeat(4000);
2257        let big_len = big.len();
2258        let mut messages = vec![
2259            serde_json::json!({"role": "user", "content": "old task"}),
2260            serde_json::json!({"role": "assistant", "content": "old reply"}),
2261            serde_json::json!({"role": "user", "content": "new task"}),
2262            serde_json::json!({"role": "assistant", "content": "calling tool"}),
2263            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2264        ];
2265        let config = AutoCompactConfig {
2266            token_threshold: 1,
2267            keep_last: 2,
2268            tool_output_max_chars: 500,
2269            ..Default::default()
2270        };
2271        let runtime = tokio::runtime::Builder::new_current_thread()
2272            .enable_all()
2273            .build()
2274            .expect("runtime");
2275        let result = runtime
2276            .block_on(auto_compact_messages(&mut messages, &config, None))
2277            .expect("compaction succeeds");
2278        assert!(result.is_some());
2279        let tool_msg = messages
2280            .iter()
2281            .find(|m| m["role"] == "tool")
2282            .expect("tool kept");
2283        let content = tool_msg["content"].as_str().expect("string content");
2284        assert!(content.len() < big_len, "unpinned output clamped as before");
2285        assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2286    }
2287}