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    #[expect(
724        clippy::string_slice,
725        reason = "digits is an ASCII-digit prefix of rest, so its len is a boundary"
726    )]
727    let failing_line_marker = {
728        if let Some(rest) = trimmed.strip_prefix('L') {
729            let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
730            !digits.is_empty() && rest[digits.len()..].starts_with(':')
731        } else {
732            false
733        }
734    };
735
736    has_strong_keyword
737        || (has_file_line && has_weak_keyword)
738        || positional
739        || assertion_value
740        || rustc_continuation
741        || failing_line_marker
742}
743
744/// Snap a byte offset to the nearest preceding line boundary (end of a complete line).
745/// Returns the substring from the start up to and including the last complete line
746/// that fits within `max_bytes`. Never cuts mid-line.
747#[expect(
748    clippy::string_slice,
749    reason = "search_end is a floor_char_boundary; pos indexes an ASCII newline"
750)]
751fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
752    if max_bytes >= s.len() {
753        return s;
754    }
755    let search_end = s.floor_char_boundary(max_bytes);
756    match s[..search_end].rfind('\n') {
757        Some(pos) => &s[..pos + 1],
758        None => &s[..search_end], // single long line — fall back to char boundary
759    }
760}
761
762/// Snap a byte offset to the nearest following line boundary (start of a complete line).
763/// Returns the substring from the first complete line at or after `start_byte`.
764/// Never cuts mid-line.
765#[expect(
766    clippy::string_slice,
767    reason = "search_start is a ceil_char_boundary; line_start follows an ASCII newline"
768)]
769fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
770    if start_byte == 0 {
771        return s;
772    }
773    let search_start = s.ceil_char_boundary(start_byte);
774    if search_start >= s.len() {
775        return "";
776    }
777    match s[search_start..].find('\n') {
778        Some(pos) => {
779            let line_start = search_start + pos + 1;
780            if line_start < s.len() {
781                &s[line_start..]
782            } else {
783                &s[search_start..]
784            }
785        }
786        None => &s[search_start..], // already at start of last line
787    }
788}
789
790fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
791    messages
792        .iter()
793        .map(|msg| {
794            let role = msg
795                .get("role")
796                .and_then(|v| v.as_str())
797                .unwrap_or("user")
798                .to_uppercase();
799            let content = msg
800                .get("content")
801                .and_then(|v| v.as_str())
802                .unwrap_or_default();
803            format!("{role}: {content}")
804        })
805        .collect::<Vec<_>>()
806        .join("\n")
807}
808
809fn truncate_compaction_summary(
810    old_messages: &[serde_json::Value],
811    archived_count: usize,
812) -> String {
813    truncate_compaction_summary_with_context(old_messages, archived_count, false)
814}
815
816fn truncate_compaction_summary_with_context(
817    old_messages: &[serde_json::Value],
818    archived_count: usize,
819    is_llm_fallback: bool,
820) -> String {
821    let per_msg_limit = 500_usize;
822    let summary_parts: Vec<String> = old_messages
823        .iter()
824        .filter_map(|m| {
825            let role = m.get("role")?.as_str()?;
826            let content = m.get("content")?.as_str()?;
827            if content.is_empty() {
828                return None;
829            }
830            #[expect(
831                clippy::string_slice,
832                reason = "floor_char_boundary returns a char boundary"
833            )]
834            let truncated = if content.len() > per_msg_limit {
835                format!(
836                    "{}... [truncated from {} chars]",
837                    &content[..content.floor_char_boundary(per_msg_limit)],
838                    content.len()
839                )
840            } else {
841                content.to_string()
842            };
843            Some(format!("[{role}] {truncated}"))
844        })
845        .take(15)
846        .collect();
847    let header = if is_llm_fallback {
848        format!(
849            "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
850        )
851    } else {
852        format!("[auto-compacted {archived_count} older messages via truncate strategy]")
853    };
854    format!(
855        "{header}\n{}{}",
856        summary_parts.join("\n"),
857        if archived_count > 15 {
858            format!("\n... and {} more", archived_count - 15)
859        } else {
860            String::new()
861        }
862    )
863}
864
865fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
866    if let Some(map) = value.as_dict() {
867        if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
868            return Ok(summary.display());
869        }
870    }
871    match value {
872        VmValue::String(text) => Ok(text.to_string()),
873        VmValue::Nil => Ok(String::new()),
874        _ => serde_json::to_string_pretty(&vm_value_to_json(value))
875            .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
876    }
877}
878
879async fn llm_compaction_summary(
880    old_messages: &[serde_json::Value],
881    archived_count: usize,
882    llm_opts: &crate::llm::api::LlmCallOptions,
883    summarize_prompt: Option<&str>,
884    policy: &CompactionPolicy,
885) -> Result<String, VmError> {
886    let mut compact_opts = llm_opts.clone();
887    let formatted = format_compaction_messages(old_messages);
888    compact_opts.system = None;
889    compact_opts.transcript_summary = None;
890    compact_opts.native_tools = None;
891    compact_opts.tool_choice = None;
892    compact_opts.output_format = crate::llm::api::OutputFormat::Text;
893    compact_opts.output_schema = None;
894    let prompt =
895        render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count, policy)?;
896    compact_opts.messages = vec![serde_json::json!({
897        "role": "user",
898        "content": prompt,
899    })];
900    let manifest = &mut compact_opts.context_manifest;
901    manifest.record_system_transform("compaction", "stdlib:compaction", "removed system", None);
902    compact_opts.set_call_role("compaction");
903    let result = vm_call_llm_full(&compact_opts).await?;
904    let summary = result.text.trim();
905    if summary.is_empty() {
906        Ok(truncate_compaction_summary_with_context(
907            old_messages,
908            archived_count,
909            true,
910        ))
911    } else {
912        Ok(format!(
913            "[auto-compacted {archived_count} older messages]\n{summary}"
914        ))
915    }
916}
917
918fn render_llm_compaction_prompt(
919    summarize_prompt: Option<&str>,
920    formatted: &str,
921    archived_count: usize,
922    policy: &CompactionPolicy,
923) -> Result<String, VmError> {
924    if policy.has_prompt_directives() && policy.extend_default_instructions == Some(false) {
925        return render_replacement_compaction_prompt(policy, formatted, archived_count);
926    }
927    let mut bindings = crate::value::DictMap::new();
928    bindings.put_str("formatted_messages", formatted);
929    bindings.insert(
930        crate::value::intern_key("archived_count"),
931        VmValue::Int(archived_count as i64),
932    );
933    let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
934        let prompt = crate::stdlib::template::render_stdlib_prompt_asset(
935            "orchestration/prompts/compaction_summary.harn.prompt",
936            Some(&bindings),
937        )?;
938        return Ok(extend_compaction_prompt(prompt, policy));
939    };
940
941    let asset = crate::stdlib::template::TemplateAsset::render_target(path)
942        .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
943    let prompt = crate::stdlib::template::render_asset_result(&asset, Some(&bindings))
944        .map_err(VmError::from)?;
945    Ok(extend_compaction_prompt(prompt, policy))
946}
947
948fn render_replacement_compaction_prompt(
949    policy: &CompactionPolicy,
950    formatted: &str,
951    archived_count: usize,
952) -> Result<String, VmError> {
953    let directives = policy.prompt_directives().unwrap_or_default();
954    let mut bindings = crate::value::DictMap::new();
955    bindings.put_str("directives", directives);
956    bindings.put_str("formatted_messages", formatted);
957    bindings.insert(
958        crate::value::intern_key("archived_count"),
959        VmValue::Int(archived_count as i64),
960    );
961    crate::stdlib::template::render_stdlib_prompt_asset(
962        "orchestration/prompts/compaction_policy_replacement.harn.prompt",
963        Some(&bindings),
964    )
965}
966
967fn extend_compaction_prompt(mut prompt: String, policy: &CompactionPolicy) -> String {
968    let Some(directives) = policy.prompt_directives() else {
969        return prompt;
970    };
971    prompt.push_str(
972        "\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",
973    );
974    prompt.push_str(&directives);
975    prompt
976}
977
978async fn custom_compaction_summary(
979    ctx: Option<&AsyncBuiltinCtx>,
980    old_messages: &[serde_json::Value],
981    archived_count: usize,
982    callback: &VmValue,
983    reminders: &[VmValue],
984    policy: &CompactionPolicy,
985) -> Result<String, VmError> {
986    let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
987        return Err(VmError::Runtime(
988            "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
989        ));
990    };
991    let Some(ctx) = ctx else {
992        return Err(VmError::Runtime(
993            "custom transcript compaction requires an async builtin VM context".to_string(),
994        ));
995    };
996    let mut vm = ctx.child_vm();
997    let messages_vm = VmValue::List(std::sync::Arc::new(
998        old_messages
999            .iter()
1000            .map(crate::stdlib::json_to_vm_value)
1001            .collect(),
1002    ));
1003    let result = if policy.has_metadata()
1004        && (closure.func.params.len() >= 3 || closure.func.has_rest_param)
1005    {
1006        let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
1007        let policy_vm = compaction_policy_to_vm_value(policy);
1008        vm.call_closure_pub(&closure, &[messages_vm, reminders_vm, policy_vm])
1009            .await
1010    } else if closure.func.params.len() >= 2 || closure.func.has_rest_param {
1011        let reminders_vm = VmValue::List(std::sync::Arc::new(reminders.to_vec()));
1012        vm.call_closure_pub(&closure, &[messages_vm, reminders_vm])
1013            .await
1014    } else {
1015        vm.call_closure_pub(&closure, &[messages_vm]).await
1016    };
1017    let summary = compact_summary_text_from_value(&result?)?;
1018    ctx.forward_output(&vm.take_output());
1019    if summary.trim().is_empty() {
1020        Ok(truncate_compaction_summary(old_messages, archived_count))
1021    } else {
1022        Ok(format!(
1023            "[auto-compacted {archived_count} older messages]\n{summary}"
1024        ))
1025    }
1026}
1027
1028/// Marker the host emits inside a tool-output (or message) body to pin its
1029/// live grounding — the current file view and just-edited window — so it
1030/// survives a compaction pass. The host renders this literal substring inside
1031/// markdown headings (e.g. `## Exact current file text [no-compact]`,
1032/// `## Edited region now reads (...) [no-compact]`) in
1033/// `lib/tools/result-format.harn`. Compaction matches the substring; it does
1034/// not invent a new vocabulary.
1035pub(crate) const NO_COMPACT_MARKER: &str = "[no-compact]";
1036
1037/// Upper bound on how many of the most-recent pinned segments survive a
1038/// compaction pass verbatim. A pin that could never be evicted would let a
1039/// long session accumulate unbounded pinned snapshots (e.g. one edited-window
1040/// per edit) and eventually overflow the context window — defeating the
1041/// purpose of compaction. Keeping only the latest few preserves the agent's
1042/// *current* grounding (the file it is editing now, emitted as the exact-text
1043/// block plus the numbered-lines block in one or two adjacent outputs) while
1044/// letting stale duplicates from earlier in the session compact normally.
1045pub(crate) const MAX_PINNED_SEGMENTS: usize = 3;
1046
1047/// Whether a content body carries the host's `[no-compact]` pin marker.
1048fn is_pinned_content(content: &str) -> bool {
1049    content.contains(NO_COMPACT_MARKER)
1050}
1051
1052/// Compute the set of message indices into `messages` that are pinned AND fall
1053/// within the most-recent [`MAX_PINNED_SEGMENTS`] pinned bodies. Older pinned
1054/// bodies are intentionally excluded so they compact normally (the bound).
1055/// `content_of` extracts the body text to inspect for each message.
1056fn latest_pinned_indices<'a, F>(
1057    messages: impl Iterator<Item = &'a serde_json::Value>,
1058    content_of: F,
1059) -> std::collections::HashSet<usize>
1060where
1061    F: Fn(&serde_json::Value) -> Option<&str>,
1062{
1063    // Walk newest-first, collecting up to MAX_PINNED_SEGMENTS pinned indices.
1064    let pinned: Vec<usize> = messages
1065        .enumerate()
1066        .filter(|(_, msg)| content_of(msg).is_some_and(is_pinned_content))
1067        .map(|(idx, _)| idx)
1068        .collect();
1069    pinned.into_iter().rev().take(MAX_PINNED_SEGMENTS).collect()
1070}
1071
1072/// Check whether a tool-result string should be preserved verbatim during
1073/// observation masking. Uses content length as the primary heuristic:
1074/// short results (< 500 chars) are kept since they're typically error messages,
1075/// status lines, or concise answers that are cheap to retain and risky to mask.
1076/// Long results are masked to save context budget.
1077fn content_should_preserve(content: &str) -> bool {
1078    content.len() < 500
1079}
1080
1081/// Default per-message masking for tool results.
1082///
1083/// Beyond the first-line preview, this KEEPS any failure-signal lines
1084/// (assertion values, located diagnostics, rustc help/caret/source rows,
1085/// `Lnnn:` markers) via the shared [`is_failure_signal_line`] filter, so the
1086/// model re-reads the actual-vs-expected detail it needs to fix the bug. The
1087/// previous mask dropped everything but the first line, silently shredding the
1088/// structured failure that the strong microcompact filter preserves at the
1089/// emission side.
1090fn default_mask_tool_result(role: &str, content: &str) -> String {
1091    let first_line = content.lines().next().unwrap_or(content);
1092    let line_count = content.lines().count();
1093    let char_count = content.len();
1094    if line_count <= 3 {
1095        return format!("[{role}] {content}");
1096    }
1097    #[expect(
1098        clippy::string_slice,
1099        reason = "floor_char_boundary returns a char boundary"
1100    )]
1101    let preview = &first_line[..first_line.floor_char_boundary(120)];
1102    // Preserve failure-signal lines (bounded so a huge log can't defeat the
1103    // mask). Skip the first line itself — it is already in the preview.
1104    let kept: Vec<&str> = content
1105        .lines()
1106        .skip(1)
1107        .filter(|line| is_failure_signal_line(line))
1108        .take(32)
1109        .collect();
1110    if kept.is_empty() {
1111        format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
1112    } else {
1113        format!(
1114            "[{role}] {preview}... [{line_count} lines, {char_count} chars masked; \
1115             failure lines preserved]\n{}",
1116            kept.join("\n")
1117        )
1118    }
1119}
1120
1121/// Stable marker on the first line of every observation-mask recap. A prior
1122/// recap re-enters the archive window on a later compaction as an ordinary
1123/// `{role: "user"}` message; matching this sentinel lets the next compaction
1124/// carry it forward once (bounded) instead of re-expanding and re-masking it —
1125/// the "recap of a recap = the same recap, updated" contract that stops recaps
1126/// from compounding across compactions.
1127pub(crate) const RECAP_HEADER_SENTINEL: &str = "via observation masking]";
1128
1129/// Byte cap on a single carried-forward prior recap. Bounds the compound-growth
1130/// term independently of the per-message budget so a chain of compactions
1131/// converges instead of accreting.
1132const PRIOR_RECAP_CARRY_CAP: usize = 6_000;
1133
1134/// Byte cap on the preview kept for an archived assistant turn. Assistant turns
1135/// carry the *calls* the model issued, not what it *learned*; keeping only a
1136/// short preview spends the recap budget on tool results (the observations)
1137/// rather than replaying verbatim call syntax.
1138const ASSISTANT_PREVIEW_CHARS: usize = 240;
1139
1140/// Whether a message body is a previous observation-mask recap.
1141fn is_prior_recap(content: &str) -> bool {
1142    content.contains(RECAP_HEADER_SENTINEL)
1143}
1144
1145/// Render one archived assistant turn as a bounded preview. Short turns pass
1146/// through; long turns keep a head slice plus a masked-marker tail so the drop
1147/// is visible in the same vocabulary as [`default_mask_tool_result`].
1148fn assistant_preview(content: &str) -> String {
1149    if content.len() <= ASSISTANT_PREVIEW_CHARS {
1150        return format!("[assistant] {content}");
1151    }
1152    let head = snap_to_line_end(content, ASSISTANT_PREVIEW_CHARS);
1153    let dropped = content.len().saturating_sub(head.len());
1154    format!("[assistant] {head}... [assistant turn truncated, {dropped} chars masked]")
1155}
1156
1157/// Collapse runs of byte-identical consecutive lines into a single line with an
1158/// `(xN)` suffix. Turns repetitive call/read sequences ("looked at X 4 times")
1159/// into a count instead of N verbatim copies.
1160fn collapse_repeats(lines: Vec<String>) -> Vec<String> {
1161    let mut out: Vec<String> = Vec::with_capacity(lines.len());
1162    let mut run = 0usize;
1163    for line in lines {
1164        if out
1165            .last()
1166            .is_some_and(|prev| strip_repeat_suffix(prev) == line)
1167        {
1168            run += 1;
1169            let base =
1170                strip_repeat_suffix(out.last().expect("run implies a last line")).to_string();
1171            *out.last_mut().expect("run implies a last line") = format!("{base} (x{})", run + 1);
1172        } else {
1173            run = 0;
1174            out.push(line);
1175        }
1176    }
1177    out
1178}
1179
1180#[expect(
1181    clippy::string_slice,
1182    reason = "idx is an rfind offset on the same line"
1183)]
1184fn strip_repeat_suffix(line: &str) -> &str {
1185    line.rfind(" (x")
1186        .filter(|_| line.ends_with(')'))
1187        .map(|idx| &line[..idx])
1188        .unwrap_or(line)
1189}
1190
1191/// Deterministic observation-mask compaction.
1192#[cfg(test)]
1193pub(crate) fn observation_mask_compaction(
1194    old_messages: &[serde_json::Value],
1195    archived_count: usize,
1196) -> String {
1197    observation_mask_compaction_with_callback(
1198        old_messages,
1199        archived_count,
1200        None,
1201        DEFAULT_RECAP_BUDGET_BYTES,
1202    )
1203    .0
1204}
1205
1206/// Test-only accessor exposing the recap body together with its
1207/// [`RecapMetrics`] and taking an explicit byte budget.
1208#[cfg(test)]
1209pub(crate) fn observation_mask_compaction_for_test(
1210    old_messages: &[serde_json::Value],
1211    archived_count: usize,
1212    budget_bytes: usize,
1213) -> (String, RecapMetrics) {
1214    observation_mask_compaction_with_callback(old_messages, archived_count, None, budget_bytes)
1215}
1216
1217/// Build the observation-mask recap body under `budget_bytes`.
1218///
1219/// Contract (harn#4731):
1220///   - A single header line names how many messages were archived.
1221///   - The most-recent *prior* recap in the archive window is carried forward
1222///     once, bounded to [`PRIOR_RECAP_CARRY_CAP`], and never re-expanded, so
1223///     recaps do not compound across successive compactions.
1224///   - The remaining budget is spent NEWEST-first on load-bearing observations
1225///     (tool results — verify/test/build outcomes and the errors that preceded
1226///     edits, preserved by [`default_mask_tool_result`]) rather than on verbatim
1227///     assistant call replay, which is reduced to a short preview.
1228///   - Pinned `[no-compact]` grounding is always kept (already bounded to
1229///     [`MAX_PINNED_SEGMENTS`]).
1230///   - Overflow past the budget is dropped and summarized in one trailing
1231///     masked-marker line; repetitive identical lines collapse to `(xN)`.
1232fn observation_mask_compaction_with_callback(
1233    old_messages: &[serde_json::Value],
1234    archived_count: usize,
1235    mask_results: Option<&[Option<String>]>,
1236    budget_bytes: usize,
1237) -> (String, RecapMetrics) {
1238    let header =
1239        format!("[auto-compacted {archived_count} older messages via observation masking]");
1240    let pinned = latest_pinned_indices(old_messages.iter(), |msg| {
1241        msg.get("content").and_then(|v| v.as_str())
1242    });
1243    // Carry forward only the single most-recent prior recap; older recaps in the
1244    // window are already folded into it and compact as ordinary text.
1245    let prior_recap_idx = old_messages
1246        .iter()
1247        .enumerate()
1248        .rev()
1249        .find(|(_, msg)| {
1250            msg.get("content")
1251                .and_then(|v| v.as_str())
1252                .is_some_and(is_prior_recap)
1253        })
1254        .map(|(idx, _)| idx);
1255
1256    let mut metrics = RecapMetrics {
1257        budget_bytes,
1258        ..RecapMetrics::default()
1259    };
1260    // Render newest-first so the budget lands on the most decision-relevant tail;
1261    // reverse to chronological order for output.
1262    let mut rendered_rev: Vec<String> = Vec::new();
1263    let mut used = header.len();
1264
1265    for (idx, msg) in old_messages.iter().enumerate().rev() {
1266        let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
1267        let content = msg
1268            .get("content")
1269            .and_then(|v| v.as_str())
1270            .unwrap_or_default();
1271        if content.is_empty() {
1272            continue;
1273        }
1274
1275        if Some(idx) == prior_recap_idx {
1276            let carried = snap_to_line_end(content, PRIOR_RECAP_CARRY_CAP);
1277            rendered_rev.push(format!("[prior recap] {carried}"));
1278            metrics.carried_prior_recap = true;
1279            continue;
1280        }
1281
1282        // Pinned grounding is unconditional (and already bounded).
1283        if pinned.contains(&idx) {
1284            rendered_rev.push(format!("[{role}] {content}"));
1285            continue;
1286        }
1287
1288        let (line, is_result) = if role == "assistant" {
1289            (assistant_preview(content), false)
1290        } else if content_should_preserve(content) {
1291            (format!("[{role}] {content}"), true)
1292        } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
1293            (custom.clone(), true)
1294        } else {
1295            (default_mask_tool_result(role, content), true)
1296        };
1297
1298        if used + line.len() + 1 > budget_bytes {
1299            metrics.dropped_count += 1;
1300            continue;
1301        }
1302        used += line.len() + 1;
1303        if is_result {
1304            metrics.kept_results_count += 1;
1305        }
1306        rendered_rev.push(line);
1307    }
1308
1309    let mut body: Vec<String> = rendered_rev.into_iter().rev().collect();
1310    body = collapse_repeats(body);
1311    let mut parts = vec![header];
1312    parts.append(&mut body);
1313    if metrics.dropped_count > 0 {
1314        parts.push(format!(
1315            "[{} older message(s) dropped to fit recap budget]",
1316            metrics.dropped_count
1317        ));
1318    }
1319    let summary = parts.join("\n");
1320    metrics.recap_bytes = summary.len();
1321    (summary, metrics)
1322}
1323
1324/// Invoke the mask_callback to get per-message custom masks.
1325async fn invoke_mask_callback(
1326    ctx: Option<&AsyncBuiltinCtx>,
1327    callback: &VmValue,
1328    old_messages: &[serde_json::Value],
1329) -> Result<Vec<Option<String>>, VmError> {
1330    let VmValue::Closure(closure) = callback.clone() else {
1331        return Err(VmError::Runtime(
1332            "mask_callback must be a closure".to_string(),
1333        ));
1334    };
1335    let Some(ctx) = ctx else {
1336        return Err(VmError::Runtime(
1337            "mask_callback requires an async builtin VM context".to_string(),
1338        ));
1339    };
1340    let mut vm = ctx.child_vm();
1341    let messages_vm = VmValue::List(std::sync::Arc::new(
1342        old_messages
1343            .iter()
1344            .map(crate::stdlib::json_to_vm_value)
1345            .collect(),
1346    ));
1347    let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
1348    ctx.forward_output(&vm.take_output());
1349    let list = match result {
1350        VmValue::List(items) => items,
1351        _ => return Ok(vec![None; old_messages.len()]),
1352    };
1353    Ok(list
1354        .iter()
1355        .map(|v| match v {
1356            VmValue::String(s) => Some(s.to_string()),
1357            VmValue::Nil => None,
1358            _ => None,
1359        })
1360        .collect())
1361}
1362
1363/// Rewrite each tool-result message in `messages` whose content exceeds
1364/// `config.tool_output_max_chars`, using `config.compress_callback` when set
1365/// (and a VM context is available) else the deterministic
1366/// [`microcompact_tool_output`]. Only the `content` text is replaced; the
1367/// message's `role`/`tool_call_id` are left untouched so tool-call pairing is
1368/// preserved. A `tool_output_max_chars` of 0 disables the pass.
1369async fn clamp_tool_outputs(
1370    ctx: Option<&AsyncBuiltinCtx>,
1371    messages: &mut [serde_json::Value],
1372    config: &AutoCompactConfig,
1373) -> Result<(), VmError> {
1374    if config.tool_output_max_chars == 0 {
1375        return Ok(());
1376    }
1377    // Exempt the most-recent pinned tool-outputs (those carrying the host's
1378    // `[no-compact]` marker) from length-clamping so the agent's live file view
1379    // stays intact. Bounded to the latest MAX_PINNED_SEGMENTS so older pinned
1380    // snapshots in the kept window still clamp and can't blow the budget.
1381    let pinned = latest_pinned_indices(messages.iter(), |msg| {
1382        if msg.get("role").and_then(|role| role.as_str()) == Some("tool") {
1383            msg.get("content").and_then(|content| content.as_str())
1384        } else {
1385            None
1386        }
1387    });
1388    for (idx, message) in messages.iter_mut().enumerate() {
1389        if message.get("role").and_then(|role| role.as_str()) != Some("tool") {
1390            continue;
1391        }
1392        let Some(content) = message.get("content").and_then(|content| content.as_str()) else {
1393            continue;
1394        };
1395        if content.len() <= config.tool_output_max_chars {
1396            continue;
1397        }
1398        if pinned.contains(&idx) {
1399            continue;
1400        }
1401        let content = content.to_string();
1402        let replacement = match (config.compress_callback.as_ref(), ctx) {
1403            (Some(callback), Some(ctx)) => {
1404                invoke_compress_callback(ctx, callback, &content, config.tool_output_max_chars)
1405                    .await?
1406            }
1407            _ => microcompact_tool_output(&content, config.tool_output_max_chars),
1408        };
1409        message["content"] = serde_json::Value::String(replacement);
1410    }
1411    Ok(())
1412}
1413
1414/// Invoke `compress_callback(content, max_chars)` to replace one oversized
1415/// tool-output body, mirroring [`invoke_mask_callback`]'s child-VM closure
1416/// invocation. A non-string return falls back to the deterministic primitive.
1417async fn invoke_compress_callback(
1418    ctx: &AsyncBuiltinCtx,
1419    callback: &VmValue,
1420    content: &str,
1421    max_chars: usize,
1422) -> Result<String, VmError> {
1423    let VmValue::Closure(closure) = callback.clone() else {
1424        return Err(VmError::Runtime(
1425            "compress_callback must be a closure".to_string(),
1426        ));
1427    };
1428    let mut vm = ctx.child_vm();
1429    let args = [
1430        VmValue::String(arcstr::ArcStr::from(content)),
1431        VmValue::Int(max_chars as i64),
1432    ];
1433    let result = vm.call_closure_pub(&closure, &args).await?;
1434    ctx.forward_output(&vm.take_output());
1435    match result {
1436        VmValue::String(text) => Ok(text.to_string()),
1437        _ => Ok(microcompact_tool_output(content, max_chars)),
1438    }
1439}
1440
1441#[derive(Clone, Copy)]
1442struct CompactionStrategyInputs<'a> {
1443    ctx: Option<&'a AsyncBuiltinCtx>,
1444    strategy: &'a CompactStrategy,
1445    old_messages: &'a [serde_json::Value],
1446    archived_count: usize,
1447    llm_opts: Option<&'a crate::llm::api::LlmCallOptions>,
1448    custom_compactor: Option<&'a VmValue>,
1449    custom_compactor_reminders: &'a [VmValue],
1450    mask_callback: Option<&'a VmValue>,
1451    summarize_prompt: Option<&'a str>,
1452    policy: &'a CompactionPolicy,
1453    recap_budget_bytes: usize,
1454}
1455
1456/// Apply a single compaction strategy to a list of archived messages. Returns
1457/// the summary text plus [`RecapMetrics`] for the observation-mask strategy
1458/// (the only strategy that spends a recap budget); other strategies return
1459/// `None`.
1460async fn apply_compaction_strategy(
1461    input: CompactionStrategyInputs<'_>,
1462) -> Result<(String, Option<RecapMetrics>), VmError> {
1463    let CompactionStrategyInputs {
1464        strategy,
1465        old_messages,
1466        archived_count,
1467        llm_opts,
1468        custom_compactor,
1469        custom_compactor_reminders,
1470        mask_callback,
1471        summarize_prompt,
1472        policy,
1473        recap_budget_bytes,
1474        ctx,
1475    } = input;
1476    match strategy {
1477        CompactStrategy::Truncate => Ok((
1478            truncate_compaction_summary(old_messages, archived_count),
1479            None,
1480        )),
1481        CompactStrategy::Llm => llm_compaction_summary(
1482            old_messages,
1483            archived_count,
1484            llm_opts.ok_or_else(|| {
1485                VmError::Runtime(
1486                    "LLM transcript compaction requires active LLM call options".to_string(),
1487                )
1488            })?,
1489            summarize_prompt,
1490            policy,
1491        )
1492        .await
1493        .map(|summary| (summary, None)),
1494        CompactStrategy::Custom => custom_compaction_summary(
1495            ctx,
1496            old_messages,
1497            archived_count,
1498            custom_compactor.ok_or_else(|| {
1499                VmError::Runtime(
1500                    "compact_callback is required when compact_strategy is 'custom'".to_string(),
1501                )
1502            })?,
1503            custom_compactor_reminders,
1504            policy,
1505        )
1506        .await
1507        .map(|summary| (summary, None)),
1508        CompactStrategy::ObservationMask => {
1509            let mask_results = if let Some(cb) = mask_callback {
1510                Some(invoke_mask_callback(ctx, cb, old_messages).await?)
1511            } else {
1512                None
1513            };
1514            let (summary, metrics) = observation_mask_compaction_with_callback(
1515                old_messages,
1516                archived_count,
1517                mask_results.as_deref(),
1518                recap_budget_bytes,
1519            );
1520            Ok((summary, Some(metrics)))
1521        }
1522    }
1523}
1524
1525async fn apply_compaction_strategy_with_fallback(
1526    input: CompactionStrategyInputs<'_>,
1527    fallback_strategy: Option<&CompactStrategy>,
1528) -> Result<(String, CompactStrategy, Option<RecapMetrics>), VmError> {
1529    match apply_compaction_strategy(input).await {
1530        Ok((summary, metrics)) => Ok((summary, input.strategy.clone(), metrics)),
1531        Err(primary_error) => {
1532            let Some(fallback) = fallback_strategy.filter(|fallback| *fallback != input.strategy)
1533            else {
1534                return Err(primary_error);
1535            };
1536            let fallback_input = CompactionStrategyInputs {
1537                strategy: fallback,
1538                ..input
1539            };
1540            apply_compaction_strategy(fallback_input)
1541                .await
1542                .map(|(summary, metrics)| (summary, fallback.clone(), metrics))
1543        }
1544    }
1545}
1546
1547pub(crate) struct AutoCompactResult {
1548    pub summary: String,
1549    pub strategy: CompactStrategy,
1550    pub recap_metrics: Option<RecapMetrics>,
1551}
1552
1553/// Auto-compact a message list in place using two-tier compaction.
1554#[cfg(test)]
1555pub(crate) async fn auto_compact_messages_with_result(
1556    messages: &mut Vec<serde_json::Value>,
1557    config: &AutoCompactConfig,
1558    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1559) -> Result<Option<AutoCompactResult>, VmError> {
1560    auto_compact_messages_with_result_with_ctx(None, messages, config, llm_opts).await
1561}
1562
1563pub(crate) async fn auto_compact_messages_with_result_with_ctx(
1564    ctx: Option<&AsyncBuiltinCtx>,
1565    messages: &mut Vec<serde_json::Value>,
1566    config: &AutoCompactConfig,
1567    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1568) -> Result<Option<AutoCompactResult>, VmError> {
1569    if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
1570        return Ok(None);
1571    }
1572    if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
1573        return Ok(None);
1574    }
1575    let compact_start = config.keep_first.min(messages.len());
1576    let original_split = messages.len().saturating_sub(config.keep_last);
1577    let mut split_at = original_split;
1578    // Snap back to a user-role boundary so the kept suffix begins at a clean
1579    // turn. OpenAI-compatible APIs reject tool results orphaned from their
1580    // assistant request, so splitting mid-turn corrupts the transcript.
1581    while split_at > compact_start
1582        && split_at < messages.len()
1583        && messages[split_at]
1584            .get("role")
1585            .and_then(|r| r.as_str())
1586            .is_none_or(|r| r != "user")
1587    {
1588        split_at -= 1;
1589    }
1590    // Fall back to the naive split (e.g. tool-heavy transcripts with the sole
1591    // user message at index 0) rather than skipping compaction entirely.
1592    if split_at == compact_start {
1593        split_at = original_split;
1594    }
1595    if let Some(volatile_start) = messages[split_at..]
1596        .iter()
1597        .position(is_reasoning_or_tool_turn_message)
1598        .map(|offset| split_at + offset)
1599    {
1600        if let Some(boundary) = volatile_start
1601            .checked_sub(1)
1602            .and_then(|idx| find_prev_user_boundary(messages, idx))
1603            .filter(|boundary| *boundary > compact_start)
1604        {
1605            split_at = boundary;
1606        }
1607    }
1608    // The naive fallback (and, in tool-heavy transcripts with no interior
1609    // user boundary, the volatile-start correction too) can still leave the
1610    // split pointing at a tool_result whose tool_use request would be
1611    // drained. Final pass: snap off any request/result pair.
1612    split_at = snap_split_off_tool_results(messages, split_at, compact_start);
1613    if split_at <= compact_start {
1614        return Ok(None);
1615    }
1616    let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
1617    let archived_count = old_messages.len();
1618
1619    // Clamp oversized tool-result bodies in the *kept* window so the live
1620    // context honors the policy's `tool_output_max_chars` (and the
1621    // `compress_callback` override), not just the archived/summarized window.
1622    // Runs before the hard-limit estimate so tier-2 escalation
1623    // keys off the post-clamp size. Only the text body is rewritten; `role`
1624    // and `tool_call_id` are preserved so tool_call/tool_result pairing stays
1625    // intact.
1626    clamp_tool_outputs(ctx, messages, config).await?;
1627
1628    let (mut summary, mut strategy, mut recap_metrics) = apply_compaction_strategy_with_fallback(
1629        CompactionStrategyInputs {
1630            ctx,
1631            strategy: &config.compact_strategy,
1632            old_messages: &old_messages,
1633            archived_count,
1634            llm_opts,
1635            custom_compactor: config.custom_compactor.as_ref(),
1636            custom_compactor_reminders: &config.custom_compactor_reminders,
1637            mask_callback: config.mask_callback.as_ref(),
1638            summarize_prompt: config.summarize_prompt.as_deref(),
1639            policy: &config.policy,
1640            recap_budget_bytes: config.recap_budget_bytes,
1641        },
1642        config.fallback_strategy.as_ref(),
1643    )
1644    .await?;
1645
1646    if let Some(hard_limit) = config.hard_limit_tokens {
1647        let summary_msg = serde_json::json!({"role": "user", "content": &summary});
1648        let mut estimate_msgs = vec![summary_msg];
1649        estimate_msgs.extend_from_slice(messages.as_slice());
1650        let estimated = estimate_message_tokens(&estimate_msgs);
1651        if estimated > hard_limit {
1652            let tier1_as_messages = vec![serde_json::json!({
1653                "role": "user",
1654                "content": summary,
1655            })];
1656            let (hard_limit_summary, hard_limit_strategy, hard_limit_metrics) =
1657                apply_compaction_strategy_with_fallback(
1658                    CompactionStrategyInputs {
1659                        ctx,
1660                        strategy: &config.hard_limit_strategy,
1661                        old_messages: &tier1_as_messages,
1662                        archived_count,
1663                        llm_opts,
1664                        custom_compactor: config.custom_compactor.as_ref(),
1665                        custom_compactor_reminders: &config.custom_compactor_reminders,
1666                        mask_callback: None,
1667                        summarize_prompt: config.summarize_prompt.as_deref(),
1668                        policy: &config.policy,
1669                        recap_budget_bytes: config.recap_budget_bytes,
1670                    },
1671                    config.fallback_strategy.as_ref(),
1672                )
1673                .await?;
1674            summary = hard_limit_summary;
1675            strategy = hard_limit_strategy;
1676            // Tier-2 re-summarized the tier-1 recap; its metrics (if any)
1677            // describe the delivered body, so they supersede tier-1's.
1678            recap_metrics = hard_limit_metrics.or(recap_metrics);
1679        }
1680    }
1681
1682    summary = super::repair_ledger::append_repair_ledger_to_summary(
1683        apply_model_visible_policy(summary, &config.policy),
1684        &old_messages,
1685    );
1686
1687    messages.insert(
1688        compact_start,
1689        serde_json::json!({
1690            "role": "user",
1691            "content": summary,
1692        }),
1693    );
1694    Ok(Some(AutoCompactResult {
1695        summary,
1696        strategy,
1697        recap_metrics,
1698    }))
1699}
1700
1701/// Auto-compact a message list in place using two-tier compaction.
1702#[cfg(test)]
1703pub(crate) async fn auto_compact_messages(
1704    messages: &mut Vec<serde_json::Value>,
1705    config: &AutoCompactConfig,
1706    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
1707) -> Result<Option<String>, VmError> {
1708    Ok(
1709        auto_compact_messages_with_result(messages, config, llm_opts)
1710            .await?
1711            .map(|result| result.summary),
1712    )
1713}
1714
1715fn apply_model_visible_policy(mut summary: String, policy: &CompactionPolicy) -> String {
1716    if !policy.is_model_visible_scope() {
1717        return summary;
1718    }
1719    let Some(directives) = policy.prompt_directives() else {
1720        return summary;
1721    };
1722    summary.push_str("\n\n[compaction instructions]\n");
1723    summary.push_str(&directives);
1724    summary
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    #[test]
1732    fn microcompact_short_output_unchanged() {
1733        let output = "line1\nline2\nline3\n";
1734        assert_eq!(microcompact_tool_output(output, 1000), output);
1735    }
1736
1737    #[test]
1738    fn microcompact_snaps_to_line_boundaries() {
1739        let lines: Vec<String> = (0..20)
1740            .map(|i| format!("line {i:02} content here"))
1741            .collect();
1742        let output = lines.join("\n");
1743        let result = microcompact_tool_output(&output, 200);
1744        assert!(result.contains("[... "), "should have snip marker");
1745        let parts: Vec<&str> = result.split("\n\n[... ").collect();
1746        assert!(parts.len() >= 2, "should split at marker");
1747        let head = parts[0];
1748        for line in head.lines() {
1749            assert!(
1750                line.starts_with("line "),
1751                "head line should be complete: {line}"
1752            );
1753        }
1754    }
1755
1756    #[test]
1757    fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
1758        let mut lines = Vec::new();
1759        for i in 0..50 {
1760            lines.push(format!("verbose output line {i}"));
1761        }
1762        lines.push("src/main.rs:42: error: cannot find value".to_string());
1763        for i in 50..100 {
1764            lines.push(format!("verbose output line {i}"));
1765        }
1766        let output = lines.join("\n");
1767        let result = microcompact_tool_output(&output, 600);
1768        assert!(result.contains("cannot find value"), "diagnostic preserved");
1769        assert!(
1770            result.contains("[diagnostic lines preserved]"),
1771            "has diagnostic marker"
1772        );
1773    }
1774
1775    // D1-durable: the shared failure-signal filter must keep the structured
1776    // failure kinds the old mask path dropped — assertion values, rustc
1777    // help/caret/source rows, and `Lnnn:` markers — not just keyword lines.
1778    #[test]
1779    fn failure_signal_filter_keeps_structured_failure_lines() {
1780        for keep in [
1781            "left: 3",
1782            "right: 4",
1783            "expected: foo",
1784            "actual: bar",
1785            "  --> src/main.rs:4:9",
1786            "= help: add `use std::fmt;`",
1787            "12 | let x = bad();",
1788            "   | ^^^^^^^ not found",
1789            "L42: assertion failed",
1790            "src/main.rs:42: error: cannot find value",
1791            "FAIL: TestThing",
1792            "panic: index out of range",
1793        ] {
1794            assert!(
1795                is_failure_signal_line(keep),
1796                "should keep failure-signal line: {keep:?}"
1797            );
1798        }
1799        for drop in [
1800            "verbose output line 7",
1801            "compiling crate foo",
1802            "    let y = ok();",
1803            "",
1804        ] {
1805            assert!(
1806                !is_failure_signal_line(drop),
1807                "should drop ordinary line: {drop:?}"
1808            );
1809        }
1810    }
1811
1812    // D1-durable: masking a large tool output must preserve the assertion
1813    // values and rustc detail (not just the first line) so the model can fix
1814    // the bug instead of re-reading a shredded summary.
1815    #[test]
1816    fn default_mask_preserves_failure_detail() {
1817        let mut lines = vec!["running 1 test".to_string()];
1818        for i in 0..40 {
1819            lines.push(format!("noise line {i}"));
1820        }
1821        lines.push("assertion `left == right` failed".to_string());
1822        lines.push("  left: 3".to_string());
1823        lines.push(" right: 4".to_string());
1824        lines.push("  --> src/lib.rs:10:5".to_string());
1825        for i in 40..80 {
1826            lines.push(format!("more noise {i}"));
1827        }
1828        let content = lines.join("\n");
1829        let masked = default_mask_tool_result("tool", &content);
1830        assert!(
1831            masked.contains("masked"),
1832            "still reports it masked: {masked}"
1833        );
1834        assert!(
1835            masked.contains("failure lines preserved"),
1836            "should flag preserved lines: {masked}"
1837        );
1838        assert!(masked.contains("left: 3"), "keeps left value: {masked}");
1839        assert!(masked.contains("right: 4"), "keeps right value: {masked}");
1840        assert!(
1841            masked.contains("--> src/lib.rs:10:5"),
1842            "keeps rustc location: {masked}"
1843        );
1844        assert!(
1845            !masked.contains("noise line 7"),
1846            "drops ordinary noise: {masked}"
1847        );
1848    }
1849
1850    // No failure signal → terse mask; a multibyte tail at byte 120 panicked.
1851    #[test]
1852    fn default_mask_without_failure_lines_stays_terse() {
1853        let mut lines: Vec<String> = (0..40).map(|i| format!("plain line {i}")).collect();
1854        lines[0] = format!("{}日本語テキスト", "x".repeat(118));
1855        let masked = default_mask_tool_result("tool", &lines.join("\n"));
1856        assert!(masked.contains("masked]"), "should mask: {masked}");
1857        assert!(
1858            !masked.contains("failure lines preserved"),
1859            "no failure lines to preserve: {masked}"
1860        );
1861    }
1862
1863    #[test]
1864    fn token_estimate_counts_structured_message_content() {
1865        let text = "x".repeat(400);
1866        let messages = vec![serde_json::json!({
1867            "role": "user",
1868            "content": [
1869                {"type": "text", "text": text},
1870                {"type": "input_text", "text": "tail"},
1871            ],
1872            "reasoning": {"text": "scratch"},
1873            "tool_calls": [{
1874                "id": "call_1",
1875                "type": "function",
1876                "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
1877            }],
1878        })];
1879
1880        assert!(
1881            estimate_message_tokens(&messages) >= 100,
1882            "structured content must not count as zero"
1883        );
1884    }
1885
1886    #[test]
1887    fn compaction_policy_instructions_extend_by_default() {
1888        let policy = CompactionPolicy {
1889            instructions: Some("Keep the failing test names.".to_string()),
1890            ..Default::default()
1891        };
1892        let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1893            .expect("prompt renders");
1894
1895        assert_eq!(policy.instruction_mode(), "extend");
1896        assert!(prompt.contains("Preserve goals, constraints"));
1897        assert!(prompt.contains("Additional compaction instructions"));
1898        assert!(prompt.contains("Keep the failing test names."));
1899    }
1900
1901    #[test]
1902    fn compaction_policy_can_replace_default_instructions() {
1903        let policy = CompactionPolicy {
1904            instructions: Some("Only keep repro steps.".to_string()),
1905            extend_default_instructions: Some(false),
1906            ..Default::default()
1907        };
1908        let prompt = render_llm_compaction_prompt(None, "[user] old context", 1, &policy)
1909            .expect("prompt renders");
1910
1911        assert_eq!(policy.instruction_mode(), "replace");
1912        assert!(prompt.contains("according to these instructions"));
1913        assert!(prompt.contains("Only keep repro steps."));
1914        assert!(!prompt.contains("Preserve goals, constraints"));
1915    }
1916
1917    #[test]
1918    fn snap_to_line_end_finds_newline() {
1919        let s = "line1\nline2\nline3\nline4\n";
1920        let head = snap_to_line_end(s, 12);
1921        assert!(head.ends_with('\n'), "should end at newline");
1922        assert!(head.contains("line1"));
1923    }
1924
1925    #[test]
1926    fn snap_to_line_start_finds_newline() {
1927        let s = "line1\nline2\nline3\nline4\n";
1928        let tail = snap_to_line_start(s, 12);
1929        assert!(
1930            tail.starts_with("line"),
1931            "should start at line boundary: {tail}"
1932        );
1933    }
1934
1935    #[test]
1936    fn auto_compact_preserves_reasoning_tool_suffix() {
1937        let mut messages = vec![
1938            serde_json::json!({"role": "user", "content": "old task"}),
1939            serde_json::json!({"role": "assistant", "content": "old reply"}),
1940            serde_json::json!({"role": "user", "content": "new task"}),
1941            serde_json::json!({
1942                "role": "assistant",
1943                "content": "",
1944                "reasoning": "think first",
1945                "tool_calls": [{
1946                    "id": "call_1",
1947                    "type": "function",
1948                    "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
1949                }],
1950            }),
1951            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
1952        ];
1953        let config = AutoCompactConfig {
1954            token_threshold: 1,
1955            keep_last: 2,
1956            ..Default::default()
1957        };
1958
1959        let runtime = tokio::runtime::Builder::new_current_thread()
1960            .enable_all()
1961            .build()
1962            .expect("runtime");
1963        let summary = runtime
1964            .block_on(auto_compact_messages(&mut messages, &config, None))
1965            .expect("compaction succeeds");
1966
1967        assert!(summary.is_some());
1968        assert_eq!(messages[1]["role"], "user");
1969        assert_eq!(messages[2]["role"], "assistant");
1970        assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
1971        assert_eq!(messages[3]["role"], "tool");
1972        assert_eq!(messages[3]["tool_call_id"], "call_1");
1973    }
1974
1975    /// Regression (transcript integrity): a tool-heavy transcript whose only
1976    /// user message is the pinned head has no interior user boundary, so the
1977    /// split falls back to the naive `len - keep_last` index — which can land
1978    /// BETWEEN an assistant tool_use message and its tool_result, orphaning
1979    /// the result at the kept-window head. The split must snap to the start
1980    /// of the request/result pair instead.
1981    #[test]
1982    fn auto_compact_never_splits_assistant_tool_use_from_its_result() {
1983        let tool_call = |id: &str| {
1984            serde_json::json!({
1985                "id": id,
1986                "type": "function",
1987                "function": {"name": "run", "arguments": "{}"}
1988            })
1989        };
1990        let mut messages = vec![
1991            serde_json::json!({"role": "user", "content": "task"}),
1992            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c0")]}),
1993            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
1994            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c1")]}),
1995            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
1996            serde_json::json!({"role": "assistant", "content": "", "tool_calls": [tool_call("c2")]}),
1997            serde_json::json!({"role": "tool", "tool_call_id": "c2", "content": "r2"}),
1998        ];
1999        // keep_last: 3 puts the naive split at index 4 — the tool_result for
2000        // c1 — exactly mid-pair.
2001        let config = AutoCompactConfig {
2002            token_threshold: 1,
2003            keep_first: 0,
2004            keep_last: 3,
2005            ..Default::default()
2006        };
2007
2008        let runtime = tokio::runtime::Builder::new_current_thread()
2009            .enable_all()
2010            .build()
2011            .expect("runtime");
2012        let summary = runtime
2013            .block_on(auto_compact_messages(&mut messages, &config, None))
2014            .expect("compaction succeeds");
2015        assert!(summary.is_some(), "compaction should trigger");
2016
2017        // Kept window: summary, then the INTACT c1 pair, then the c2 pair.
2018        assert_eq!(messages[0]["role"], "user", "summary head");
2019        assert_eq!(messages[1]["role"], "assistant");
2020        assert_eq!(messages[1]["tool_calls"][0]["id"], "c1");
2021        assert_eq!(messages[2]["role"], "tool");
2022        assert_eq!(messages[2]["tool_call_id"], "c1");
2023        assert_eq!(messages[3]["tool_calls"][0]["id"], "c2");
2024        assert_eq!(messages[4]["tool_call_id"], "c2");
2025        // No kept tool_result may reference a drained (missing) request.
2026        for (idx, message) in messages.iter().enumerate() {
2027            if message["role"] == "tool" {
2028                let id = message["tool_call_id"].as_str().expect("tool_call_id");
2029                let paired = messages[..idx].iter().any(|prev| {
2030                    prev["tool_calls"]
2031                        .as_array()
2032                        .is_some_and(|calls| calls.iter().any(|call| call["id"] == id))
2033                });
2034                assert!(paired, "tool_result {id} orphaned in kept window");
2035            }
2036        }
2037    }
2038
2039    #[test]
2040    fn snap_split_off_tool_results_handles_all_result_shapes() {
2041        // A split pointing at any tool-result shape walks back to the
2042        // request that initiated the run. OpenAI durable shape
2043        // (`role: "tool"`):
2044        let openai = vec![
2045            serde_json::json!({"role": "user", "content": "task"}),
2046            serde_json::json!({"role": "assistant", "content": "", "tool_calls": []}),
2047            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2048        ];
2049        assert_eq!(snap_split_off_tool_results(&openai, 2, 0), 1);
2050        // Anthropic durable shape (`role: "tool_result"`).
2051        let anthropic = vec![
2052            serde_json::json!({"role": "user", "content": "task"}),
2053            serde_json::json!({"role": "assistant", "content": ""}),
2054            serde_json::json!({"role": "tool_result", "tool_use_id": "c0", "content": "r0"}),
2055        ];
2056        assert_eq!(snap_split_off_tool_results(&anthropic, 2, 0), 1);
2057        // User message carrying tool_result blocks.
2058        let user_blocks = vec![
2059            serde_json::json!({"role": "user", "content": "task"}),
2060            serde_json::json!({"role": "assistant", "content": ""}),
2061            serde_json::json!({
2062                "role": "user",
2063                "content": [{"type": "tool_result", "tool_use_id": "c0", "content": "r0"}],
2064            }),
2065        ];
2066        assert_eq!(snap_split_off_tool_results(&user_blocks, 2, 0), 1);
2067        // Plain user text is a safe boundary — untouched.
2068        let text = vec![
2069            serde_json::json!({"role": "assistant", "content": ""}),
2070            serde_json::json!({"role": "user", "content": "plain"}),
2071        ];
2072        assert_eq!(snap_split_off_tool_results(&text, 1, 0), 1);
2073        // Backward walk pinned at compact_start: fall forward past the run
2074        // so compaction still makes progress (the whole pair is drained
2075        // together rather than split).
2076        let pinned = vec![
2077            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": "r0"}),
2078            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "r1"}),
2079            serde_json::json!({"role": "assistant", "content": "done"}),
2080        ];
2081        assert_eq!(snap_split_off_tool_results(&pinned, 1, 0), 2);
2082    }
2083
2084    #[test]
2085    fn auto_compact_clamps_oversized_tool_output_to_max_chars() {
2086        // A large tool result in the *kept* window must be clamped to honor
2087        // `tool_output_max_chars`.
2088        let big = "x".repeat(4000);
2089        let big_len = big.len();
2090        let mut messages = vec![
2091            serde_json::json!({"role": "user", "content": "old task"}),
2092            serde_json::json!({"role": "assistant", "content": "old reply"}),
2093            serde_json::json!({"role": "user", "content": "new task"}),
2094            serde_json::json!({"role": "assistant", "content": "calling tool"}),
2095            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2096        ];
2097        let config = AutoCompactConfig {
2098            token_threshold: 1,
2099            keep_last: 2,
2100            tool_output_max_chars: 500,
2101            ..Default::default()
2102        };
2103
2104        let runtime = tokio::runtime::Builder::new_current_thread()
2105            .enable_all()
2106            .build()
2107            .expect("runtime");
2108        let result = runtime
2109            .block_on(auto_compact_messages(&mut messages, &config, None))
2110            .expect("compaction succeeds");
2111        assert!(result.is_some(), "compaction should trigger");
2112
2113        let tool_msg = messages
2114            .iter()
2115            .find(|message| message["role"] == "tool")
2116            .expect("tool message kept in window");
2117        // Pairing preserved...
2118        assert_eq!(tool_msg["tool_call_id"], "call_1");
2119        // ...and the oversized body was clamped well below its original size.
2120        let content = tool_msg["content"].as_str().expect("string content");
2121        assert!(
2122            content.len() < big_len,
2123            "tool output should be clamped: {} vs {}",
2124            content.len(),
2125            big_len
2126        );
2127        assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2128    }
2129
2130    /// (1) A pinned tool-output survives an observation-mask pass that evicts
2131    /// (masks) the unpinned verbose outputs around it.
2132    #[test]
2133    fn observation_mask_preserves_pinned_live_file_view() {
2134        let pinned_body = format!(
2135            "## Edited region now reads (line 42, ±6 context) {}\n```\n{}\n```",
2136            NO_COMPACT_MARKER,
2137            (0..40)
2138                .map(|i| format!("   {i}  let x = compute({i});"))
2139                .collect::<Vec<_>>()
2140                .join("\n")
2141        );
2142        let verbose_unpinned = (0..60)
2143            .map(|i| format!("verbose scan output line {i}"))
2144            .collect::<Vec<_>>()
2145            .join("\n");
2146        // These are the ARCHIVED messages handed to the mask pass.
2147        let archived = vec![
2148            serde_json::json!({"role": "user", "content": verbose_unpinned}),
2149            serde_json::json!({"role": "user", "content": pinned_body}),
2150        ];
2151        let summary = observation_mask_compaction(&archived, archived.len());
2152        // Pinned live file view survives verbatim.
2153        assert!(
2154            summary.contains("Edited region now reads"),
2155            "pinned heading survived: {summary}"
2156        );
2157        assert!(
2158            summary.contains("let x = compute(39);"),
2159            "pinned body survived verbatim"
2160        );
2161        // The unpinned verbose neighbor was masked.
2162        assert!(summary.contains("masked]"), "unpinned output was masked");
2163        assert!(!summary.contains("verbose scan output line 30"));
2164    }
2165
2166    /// (2) A pinned large tool-output is NOT clamped, while an unpinned one of
2167    /// the same size IS.
2168    #[test]
2169    fn clamp_exempts_pinned_tool_output() {
2170        let pinned_big = format!(
2171            "## Exact current file text {}\n{}",
2172            NO_COMPACT_MARKER,
2173            "x".repeat(4000)
2174        );
2175        let pinned_len = pinned_big.len();
2176        let unpinned_big = "y".repeat(4000);
2177        let unpinned_len = unpinned_big.len();
2178        let mut messages = vec![
2179            serde_json::json!({"role": "user", "content": "old task"}),
2180            serde_json::json!({"role": "assistant", "content": "reply"}),
2181            serde_json::json!({"role": "user", "content": "new task"}),
2182            serde_json::json!({"role": "assistant", "content": "calling tools"}),
2183            serde_json::json!({"role": "tool", "tool_call_id": "c0", "content": unpinned_big}),
2184            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": pinned_big}),
2185            serde_json::json!({"role": "user", "content": "continue"}),
2186        ];
2187        let config = AutoCompactConfig {
2188            token_threshold: 1,
2189            keep_last: 4,
2190            tool_output_max_chars: 500,
2191            ..Default::default()
2192        };
2193        let runtime = tokio::runtime::Builder::new_current_thread()
2194            .enable_all()
2195            .build()
2196            .expect("runtime");
2197        runtime
2198            .block_on(auto_compact_messages(&mut messages, &config, None))
2199            .expect("compaction succeeds");
2200
2201        let pinned_msg = messages
2202            .iter()
2203            .find(|m| m["tool_call_id"] == "c1")
2204            .expect("pinned tool message kept");
2205        assert_eq!(
2206            pinned_msg["content"].as_str().map(str::len),
2207            Some(pinned_len),
2208            "pinned output must be intact (unclamped)"
2209        );
2210        let unpinned_msg = messages
2211            .iter()
2212            .find(|m| m["tool_call_id"] == "c0")
2213            .expect("unpinned tool message kept");
2214        assert!(
2215            unpinned_msg["content"].as_str().map(str::len).unwrap() < unpinned_len,
2216            "unpinned output of the same size must be clamped"
2217        );
2218    }
2219
2220    /// (3) Bounded policy: with MANY pinned outputs, only the latest
2221    /// MAX_PINNED_SEGMENTS survive verbatim; older pinned duplicates compact —
2222    /// so the pin can't prevent all compaction (and can't overflow the window
2223    /// on a very long session).
2224    #[test]
2225    fn pin_bound_keeps_only_latest_segments() {
2226        // Build 6 distinct pinned, oversized edited-window snapshots
2227        // (gen 0 = oldest .. gen 5 = newest), each tagged with the marker and
2228        // long enough that masking would otherwise truncate it.
2229        let make = |gen: usize| {
2230            let body = (0..40)
2231                .map(|i| format!("marker-gen-{gen} body line {i}"))
2232                .collect::<Vec<_>>()
2233                .join("\n");
2234            serde_json::json!({
2235                "role": "user",
2236                "content": format!(
2237                    "## Edited region now reads (gen {gen}) {}\n{}",
2238                    NO_COMPACT_MARKER, body
2239                ),
2240            })
2241        };
2242        let archived: Vec<_> = (0..6).map(make).collect();
2243
2244        // Unit-level: the index selection keeps exactly the latest N.
2245        let pinned = latest_pinned_indices(archived.iter(), |m| {
2246            m.get("content").and_then(|c| c.as_str())
2247        });
2248        assert_eq!(
2249            pinned.len(),
2250            MAX_PINNED_SEGMENTS,
2251            "only the latest MAX_PINNED_SEGMENTS are pinned"
2252        );
2253        assert!(pinned.contains(&5) && pinned.contains(&4) && pinned.contains(&3));
2254        assert!(!pinned.contains(&0) && !pinned.contains(&1) && !pinned.contains(&2));
2255
2256        // End-to-end through the mask pass: the 3 newest snapshots survive
2257        // verbatim; the 3 oldest are masked, proving the pin cannot defeat all
2258        // compaction.
2259        let summary = observation_mask_compaction(&archived, archived.len());
2260        assert!(
2261            summary.contains("marker-gen-5")
2262                && summary.contains("marker-gen-4")
2263                && summary.contains("marker-gen-3"),
2264            "latest {MAX_PINNED_SEGMENTS} pinned snapshots survive verbatim: {summary}"
2265        );
2266        assert!(
2267            !summary.contains("marker-gen-0")
2268                && !summary.contains("marker-gen-1")
2269                && !summary.contains("marker-gen-2"),
2270            "older pinned snapshots are masked (bound enforced)"
2271        );
2272        assert!(summary.contains("masked]"), "older snapshots were masked");
2273    }
2274
2275    /// (4) Regression: with NO pins, compaction behaves exactly as before.
2276    #[test]
2277    fn no_pins_preserves_prior_clamp_behavior() {
2278        let big = "x".repeat(4000);
2279        let big_len = big.len();
2280        let mut messages = vec![
2281            serde_json::json!({"role": "user", "content": "old task"}),
2282            serde_json::json!({"role": "assistant", "content": "old reply"}),
2283            serde_json::json!({"role": "user", "content": "new task"}),
2284            serde_json::json!({"role": "assistant", "content": "calling tool"}),
2285            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": big}),
2286        ];
2287        let config = AutoCompactConfig {
2288            token_threshold: 1,
2289            keep_last: 2,
2290            tool_output_max_chars: 500,
2291            ..Default::default()
2292        };
2293        let runtime = tokio::runtime::Builder::new_current_thread()
2294            .enable_all()
2295            .build()
2296            .expect("runtime");
2297        let result = runtime
2298            .block_on(auto_compact_messages(&mut messages, &config, None))
2299            .expect("compaction succeeds");
2300        assert!(result.is_some());
2301        let tool_msg = messages
2302            .iter()
2303            .find(|m| m["role"] == "tool")
2304            .expect("tool kept");
2305        let content = tool_msg["content"].as_str().expect("string content");
2306        assert!(content.len() < big_len, "unpinned output clamped as before");
2307        assert!(content.len() < 2000, "clamped near tool_output_max_chars");
2308    }
2309}