Skip to main content

harn_vm/orchestration/
compaction.rs

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