Skip to main content

harn_vm/orchestration/
compaction.rs

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