Skip to main content

harness/
compaction.rs

1//! Context-window-aware compaction.
2//!
3//! Long-running native sessions accumulate a `messages` history that
4//! eventually exceeds the model's context window. Without intervention
5//! the next turn either truncates server-side (silently losing context)
6//! or fails with a 400 / `context_length_exceeded`. Compaction folds the
7//! mid-conversation into a `<conversation-summary>` checkpoint while
8//! preserving all true user messages verbatim within a token budget —
9//! same idea as Codex local compaction.
10//!
11//! Strategy contract (`CompactionStrategy`):
12//!   * `should_compact` — pure boolean gate; the agent loop checks this
13//!     each step BEFORE building the next model request.
14//!   * `compact` — best-effort; returns a fresh `Vec<ChatMessage>` that
15//!     replaces the running history. Caller emits a separate event so
16//!     `native_adapter` can mark the boundary on the wire.
17//!
18//! Producer of compaction:
19//!   * `agent_loop::run_loop` constructs `SummarizeCompactionStrategy` per
20//!     turn (cheap — no internal state) and calls `should_compact` /
21//!     `compact` between steps.
22//!
23//! The strategy reuses the session's primary `ModelClient` to produce
24//! the summary; that keeps cache keys hot on the prefix shared with the
25//! main agent call (Anthropic prompt cache hits straight through).
26
27use async_trait::async_trait;
28use std::sync::Arc;
29
30use crate::event::HarnessUsage;
31use crate::model::{
32    collect_model_response, ChatMessage, ModelClient, ModelClientError, ModelResponse,
33    ModelTurnInput,
34};
35use crate::tools::ToolSpec;
36
37/// Fraction of the model's context window above which compaction fires.
38/// 0.90 leaves a ~10 % headroom for the in-flight turn's tool results and
39/// the model's reply, matching Codex's default threshold.
40pub const DEFAULT_TRIGGER_FRACTION: f64 = 0.90;
41
42/// Number of recent user-turn boundaries whose tool outputs are protected
43/// from the prune pass.  The last `PRUNE_PROTECT_TURNS` complete turns are
44/// left untouched so the model retains full detail for the current task.
45pub const PRUNE_PROTECT_TURNS: usize = 2;
46
47/// Minimum net tokens that the prune pass must free before its mutations
48/// are committed.  Below this floor the overhead is not worth the churn.
49pub const PRUNE_MINIMUM_TOKENS: u64 = 2_000;
50
51/// Per-output floor: tool results shorter than this are never pruned even
52/// when they fall outside the protected window.
53pub const PRUNE_MIN_CONTENT_TOKENS: u64 = 100;
54
55/// Replacement text written into pruned tool outputs.
56pub const PRUNED_CONTENT_STUB: &str =
57    "[pruned — output removed to free context; re-run the tool if needed]";
58
59/// Minimum number of messages the history must contain before compaction
60/// is allowed to run. Below this floor the history is too short to benefit
61/// from compaction and the call is a no-op.
62pub const DEFAULT_TAIL_MIN_MESSAGES: usize = 4;
63
64/// Soft cap on summary length when serialised back into a `User`
65/// `<conversation-summary>` block. Above this we let the model decide,
66/// but pass `max_tokens` hint so it doesn't ramble. 2 000 ≈ 8 KB —
67/// enough for most multi-turn dialogues; OMA uses the same default.
68pub const DEFAULT_SUMMARY_MAX_TOKENS: i32 = 2_000;
69
70/// Token budget for verbatim user-message retention in the replacement
71/// history. All true user messages are collected from the full history
72/// (oldest to newest) and as many as fit within this budget are kept
73/// verbatim — newest first. Matches Codex's `COMPACT_USER_MESSAGE_MAX_TOKENS`.
74pub const DEFAULT_USER_MESSAGE_TOKEN_BUDGET: u64 = 20_000;
75
76/// Mixed-script token estimator:
77/// ASCII runs compress at ~4 chars/token, while CJK and other non-ASCII
78/// text tokenizes at ≈1 token per char on every modern BPE vocabulary. The
79/// previous flat `bytes / 4` heuristic underestimated Chinese text ~3×
80/// (one CJK char = 3 UTF-8 bytes → counted as 0.75 tokens instead of ~1),
81/// so compaction fired far too late on Chinese-heavy conversations.
82/// Whitespace-only input costs 0. Still an estimator — not for billing.
83pub fn estimate_tokens(s: &str) -> u64 {
84    if s.trim().is_empty() {
85        return 0;
86    }
87    let mut ascii: u64 = 0;
88    let mut non_ascii: u64 = 0;
89    for c in s.chars() {
90        if c.is_ascii() {
91            ascii += 1;
92        } else {
93            non_ascii += 1;
94        }
95    }
96    ascii.div_ceil(4) + non_ascii
97}
98
99/// Per-message estimate: `estimate_tokens` over every text part, plus
100/// small fixed overheads for structural wrapping (tool-call envelope ≈ 8
101/// tokens, tool-result envelope ≈ 16 — same budgets as the old byte-based
102/// +32 / +64 at 4 bytes/token). Floors at 1 so empty messages still cost.
103pub fn estimate_chat_message_tokens(m: &ChatMessage) -> u64 {
104    let tokens = match m {
105        ChatMessage::User { content, .. } => estimate_tokens(content),
106        ChatMessage::Assistant {
107            text,
108            tool_calls,
109            thinking,
110            usage: _,
111        } => {
112            let text_tokens = text.as_deref().map(estimate_tokens).unwrap_or(0);
113            let tc_tokens: u64 = tool_calls
114                .iter()
115                .map(|tc| estimate_tokens(&tc.input.to_string()) + estimate_tokens(&tc.name) + 8)
116                .sum();
117            let thinking_tokens = thinking
118                .as_ref()
119                .map(|t| {
120                    estimate_tokens(&t.text)
121                        + t.signature.as_deref().map(estimate_tokens).unwrap_or(0)
122                })
123                .unwrap_or(0);
124            text_tokens + tc_tokens + thinking_tokens
125        }
126        ChatMessage::Tool { content, .. } => estimate_tokens(content) + 16,
127    };
128    tokens.max(1)
129}
130
131/// Sum the per-message estimates. Stable across providers because we
132/// only look at the rendered text / JSON size, not the wire shape.
133pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> u64 {
134    messages.iter().map(estimate_chat_message_tokens).sum()
135}
136
137/// Reduce a reported usage tally to a single context-size figure: the
138/// tokens the provider counted as present on input for that turn. Provider
139/// cache telemetry is intentionally not added here because those buckets are
140/// subsets/billing details of the prompt, not extra context-window occupancy.
141/// Output is excluded because it is emitted after the input window is measured
142/// (the emitted output only occupies the window on the *next* turn, where it
143/// appears as a later message we estimate directly).
144fn usage_context_tokens(u: &HarnessUsage) -> u64 {
145    // Treat `input_tokens` as the provider-reported prompt/window size.
146    //
147    // OpenAI-compatible usage reports `cached_tokens` as a subset of
148    // `prompt_tokens`; adding cache_read again double-counts cached prompt
149    // tokens and can trigger compaction far too early. Anthropic's cache
150    // fields are also telemetry about how the prompt was billed/cached; the
151    // value used for context-window occupancy must remain the total prompt
152    // size, not prompt size plus cache sub-buckets.
153    u.input_tokens
154}
155
156/// Estimate the total context size of `messages`, anchoring on the last
157/// assistant turn that carried a provider-reported usage tally.
158///
159/// The provider's reported input count is the exact number of tokens the
160/// model saw up to and including that turn — system prompt, tools, and all
161/// prior messages combined. Re-estimating that prefix by character count
162/// drifts (often by thousands of tokens) as a conversation grows, which
163/// skews the compaction trigger. So the newest usage-bearing assistant
164/// message is treated as ground truth for everything at or before it, and
165/// only the messages appended after it are estimated heuristically.
166///
167/// With no usage anywhere (first turn, or a client that never reports it)
168/// this falls back to a full heuristic sum, matching
169/// [`estimate_messages_tokens`].
170pub fn estimate_context_tokens_anchored(messages: &[ChatMessage]) -> u64 {
171    let anchor = messages
172        .iter()
173        .enumerate()
174        .rev()
175        .find_map(|(idx, m)| match m {
176            ChatMessage::Assistant { usage: Some(u), .. } => Some((idx, usage_context_tokens(u))),
177            _ => None,
178        });
179
180    match anchor {
181        Some((idx, measured)) => {
182            let tail: u64 = messages[idx + 1..]
183                .iter()
184                .map(estimate_chat_message_tokens)
185                .sum();
186            measured + tail
187        }
188        None => estimate_messages_tokens(messages),
189    }
190}
191
192/// Context passed to [`CompactionStrategy::compact`].
193///
194/// The policy receives the same system prompt, model client, resolved context
195/// window, and tool specs that the agent loop would use for the next model
196/// step. A strategy can use these inputs to run a summarization model call,
197/// make a deterministic retention decision without a model, or decline to
198/// change the history by returning the original `messages`.
199pub struct CompactionContext {
200    pub system_prompt: Option<String>,
201    pub model_client: Arc<dyn ModelClient>,
202    pub context_window_tokens: u64,
203    pub tools: Vec<ToolSpec>,
204}
205
206/// Result of a single `CompactionStrategy::compact` call. `messages` is
207/// the folded history caller installs; `usage` is the token spend for
208/// the summarize round trip (provider-reported via the same path as
209/// main turn calls). `usage` is `None` when the provider elides usage
210/// or when the strategy short-circuited without calling the model.
211#[derive(Debug, Clone, PartialEq)]
212pub struct CompactionOutcome {
213    pub messages: Vec<ChatMessage>,
214    pub usage: Option<crate::event::HarnessUsage>,
215}
216
217#[derive(Debug, thiserror::Error)]
218pub enum CompactionError {
219    #[error("compaction model call failed: {0}")]
220    ModelCall(#[from] ModelClientError),
221    /// The model returned an empty summary. We refuse to fold history
222    /// in that case — losing N turns of conversation for zero gain is
223    /// strictly worse than the original "ran out of context" failure.
224    /// Caller treats this as a no-op and lets the next turn try again.
225    #[error("model produced empty summary; refusing to fold history")]
226    EmptySummary,
227}
228
229#[async_trait]
230pub trait CompactionStrategy: Send + Sync {
231    /// Decide whether the agent loop should call [`Self::compact`] before the
232    /// next model step.
233    ///
234    /// This method should be cheap and side-effect-free because it is checked
235    /// before every step. Returning `false` leaves history unchanged.
236    fn should_compact(&self, messages: &[ChatMessage], context_window_tokens: u64) -> bool;
237
238    /// Return the message history that should replace the current history.
239    ///
240    /// Implementations must preserve provider invariants for any tool-call
241    /// history they keep: assistant tool calls must still be followed by their
242    /// matching tool results, and orphan tool results must not be introduced.
243    /// If the strategy cannot safely compact, it should return the original
244    /// `messages` with `usage: None` rather than fabricating an invalid
245    /// history. Errors are treated by the agent loop as "skip this turn's
246    /// compaction" rather than a failed user turn.
247    async fn compact(
248        &self,
249        messages: Vec<ChatMessage>,
250        ctx: &CompactionContext,
251    ) -> Result<CompactionOutcome, CompactionError>;
252}
253
254/// Default compaction/retention policy.
255///
256/// Alias for [`SummarizeCompactionStrategy`] so consumers can refer to the
257/// stable "default policy" concept while the implementation name remains
258/// descriptive.
259pub type DefaultCompactionStrategy = SummarizeCompactionStrategy;
260
261/// Codex-style local compaction: send the full history to the model with a
262/// handoff-summary prompt appended, collect the reply as a checkpoint, then
263/// rebuild history as `[...retained user messages, summary]`.
264///
265/// Design rationale:
266///   * User messages are preserved verbatim (up to `user_message_token_budget`)
267///     because they carry precise constraints and goals that paraphrasing loses.
268///   * Assistant / tool messages are folded into the summary — they are large
269///     but low-density and tolerate lossy compression.
270///   * The summary is placed LAST so the model reads it as the most recent
271///     context rather than as background preamble.
272///   * Reusing the same model client keeps the Anthropic prompt-cache prefix
273///     hot (identical system + tools on every call).
274pub struct SummarizeCompactionStrategy {
275    pub trigger_fraction: f64,
276    /// Minimum total message count below which compaction is skipped.
277    /// Does not control retention; use `user_message_token_budget` for that.
278    pub tail_min_messages: usize,
279    pub summary_max_tokens: i32,
280    pub summary_prompt: String,
281    /// Token budget for verbatim user-message retention in the replacement history.
282    pub user_message_token_budget: u64,
283}
284
285impl Default for SummarizeCompactionStrategy {
286    fn default() -> Self {
287        Self {
288            trigger_fraction: DEFAULT_TRIGGER_FRACTION,
289            tail_min_messages: DEFAULT_TAIL_MIN_MESSAGES,
290            summary_max_tokens: DEFAULT_SUMMARY_MAX_TOKENS,
291            summary_prompt: DEFAULT_SUMMARY_PROMPT.into(),
292            user_message_token_budget: DEFAULT_USER_MESSAGE_TOKEN_BUDGET,
293        }
294    }
295}
296
297impl SummarizeCompactionStrategy {
298    pub fn with_trigger_fraction(mut self, fraction: f64) -> Self {
299        self.trigger_fraction = fraction;
300        self
301    }
302
303    pub fn with_tail_min_messages(mut self, n: usize) -> Self {
304        self.tail_min_messages = n;
305        self
306    }
307
308    pub fn with_summary_max_tokens(mut self, n: i32) -> Self {
309        self.summary_max_tokens = n;
310        self
311    }
312
313    pub fn with_user_message_token_budget(mut self, budget: u64) -> Self {
314        self.user_message_token_budget = budget;
315        self
316    }
317}
318
319/// Handoff-oriented summarise prompt. Instructs the model to produce a
320/// structured checkpoint for *another agent instance* to resume from —
321/// not a human-readable recap. Mirrors Codex's `SUMMARIZATION_PROMPT`.
322pub const DEFAULT_SUMMARY_PROMPT: &str = "You are performing a CONTEXT CHECKPOINT COMPACTION. \
323    Create a handoff summary for another agent instance that will resume this task.\n\n\
324    Include:\n\
325    - Current progress and key decisions made\n\
326    - Important context, constraints, or user preferences that must be respected\n\
327    - What remains to be done (clear next steps)\n\
328    - Any critical data, file paths, command outputs, or references needed to continue\n\n\
329    If a prior <conversation-summary> block exists in this conversation, produce an UPDATED \
330    summary that supersedes it (incorporating all activity since). \
331    Output only the summary text — no preamble, no closing remarks.";
332
333#[async_trait]
334impl CompactionStrategy for SummarizeCompactionStrategy {
335    fn should_compact(&self, messages: &[ChatMessage], context_window_tokens: u64) -> bool {
336        // Too few messages → never compact; the summarize call would
337        // cost more than the prefix it's saving.
338        if messages.len() <= self.tail_min_messages {
339            return false;
340        }
341        let tokens = estimate_context_tokens_anchored(messages);
342        let threshold = ((context_window_tokens as f64) * self.trigger_fraction).round() as u64;
343        tokens > threshold
344    }
345
346    async fn compact(
347        &self,
348        messages: Vec<ChatMessage>,
349        ctx: &CompactionContext,
350    ) -> Result<CompactionOutcome, CompactionError> {
351        // Hard floor — refuse to compact if we'd end up with fewer than
352        // the tail count. Keeps the strategy idempotent in degenerate
353        // cases. `usage: None` because no model call ran.
354        if messages.len() <= self.tail_min_messages {
355            return Ok(CompactionOutcome {
356                messages,
357                usage: None,
358            });
359        }
360
361        // ── Phase 1: Prune old tool outputs (free tokens without a model call)
362        //
363        // Walk old turns and replace large tool outputs with a short stub.
364        // If pruning alone brings the history back under the trigger threshold
365        // we skip the expensive summarise round-trip entirely.
366        let threshold = ((ctx.context_window_tokens as f64) * self.trigger_fraction).round() as u64;
367        let (messages, freed_tokens) = prune_tool_outputs(messages, PRUNE_PROTECT_TURNS);
368        if freed_tokens > 0 {
369            tracing::debug!(
370                target: "harness::compaction",
371                freed_tokens,
372                "prune pass freed tokens"
373            );
374            // The usage anchor was measured before pruning and pruning only
375            // rewrites tool outputs that sit before it, so the anchored figure
376            // still reflects the pre-prune size; subtract what pruning freed
377            // to get the current size without re-summing the whole history.
378            let tokens_after_prune =
379                estimate_context_tokens_anchored(&messages).saturating_sub(freed_tokens);
380            if tokens_after_prune <= threshold {
381                tracing::info!(
382                    target: "harness::compaction",
383                    freed_tokens,
384                    tokens_after_prune,
385                    "prune sufficient — summarise skipped"
386                );
387                return Ok(CompactionOutcome {
388                    messages,
389                    usage: None,
390                });
391            }
392        }
393
394        // ── Phase 2: Summarise (model round-trip)
395        //
396        // Build the summarize request. Same system + same tools as the
397        // main agent would use, then append one User message asking
398        // for the summary. We DON'T set tools: vec![] — keeping them
399        // makes the prefix bytes match what the main call sent, which
400        // is what Anthropic's cache compares.
401        let mut summarize_messages = messages.clone();
402        summarize_messages.push(ChatMessage::User {
403            content: self.summary_prompt.clone(),
404            attachments: vec![],
405        });
406        let request = ModelTurnInput {
407            system_prompt: ctx.system_prompt.clone(),
408            messages: summarize_messages,
409            tools: ctx.tools.clone(),
410            hosted_tools: vec![],
411            tool_choice: crate::model::ToolChoice::Auto,
412            parallel_tool_calls: None,
413        };
414
415        // Drain the stream into a single response — compaction doesn't
416        // care about token-level emit; it just needs the text. The
417        // model client trait's default `next` does this for us, but we
418        // go through stream + collect explicitly so future Anthropic-
419        // path strategies can elide tool calls / thinking blocks if
420        // they want to.
421        let stream = ctx.model_client.stream(request).await?;
422        let response = collect_model_response(stream).await?;
423        let (summary_text, usage) = match response {
424            ModelResponse::Message { text, usage, .. } => (text, usage),
425            // Model decided to call a tool instead of answering — give
426            // up on this round, history stays put.
427            ModelResponse::ToolCall { .. } => return Err(CompactionError::EmptySummary),
428        };
429        if summary_text.trim().is_empty() {
430            return Err(CompactionError::EmptySummary);
431        }
432
433        // Collect all true user messages from history, skipping prior summary
434        // messages (they are superseded by the new checkpoint we just generated).
435        let user_texts = collect_user_message_texts(&messages);
436        if user_texts.is_empty() {
437            // No real user messages to retain — skip installing the summary.
438            // Surface usage so HR can account for the (now-discarded) model call.
439            return Ok(CompactionOutcome { messages, usage });
440        }
441
442        // Build replacement history: retained user messages first, summary last.
443        // User messages are selected newest-first within the token budget then
444        // reversed to chronological order. Placing the summary last means the
445        // model reads the most recent context at the end of the prompt.
446        let out =
447            build_compacted_history(&user_texts, &summary_text, self.user_message_token_budget);
448        Ok(CompactionOutcome {
449            messages: out,
450            usage,
451        })
452    }
453}
454
455fn serialize_summary(summary: &str) -> String {
456    format!("<conversation-summary>\n{summary}\n</conversation-summary>")
457}
458
459/// Collect the text of every real `User` message in `messages`, in order,
460/// filtering out prior summary messages. Prior summaries are superseded by
461/// the new checkpoint and must not be recycled into the replacement history.
462fn collect_user_message_texts(messages: &[ChatMessage]) -> Vec<String> {
463    messages
464        .iter()
465        .filter_map(|m| match m {
466            ChatMessage::User { content, .. } if !is_summary_message(content) => {
467                Some(content.clone())
468            }
469            _ => None,
470        })
471        .collect()
472}
473
474fn is_summary_message(content: &str) -> bool {
475    content.trim_start().starts_with("<conversation-summary>")
476}
477
478/// Build the replacement history: retained user messages (chronological order)
479/// followed by the summary as the final message.
480///
481/// `user_texts` is the full list of real user messages oldest→newest.
482/// Messages are selected newest-first within `token_budget`; if the oldest
483/// selected message only partially fits, it is truncated rather than dropped.
484fn build_compacted_history(
485    user_texts: &[String],
486    summary_text: &str,
487    token_budget: u64,
488) -> Vec<ChatMessage> {
489    let mut selected: Vec<String> = Vec::new();
490    let mut remaining = token_budget;
491    for text in user_texts.iter().rev() {
492        if remaining == 0 {
493            break;
494        }
495        let tokens = estimate_tokens(text);
496        if tokens <= remaining {
497            selected.push(text.clone());
498            remaining -= tokens;
499        } else {
500            // Partially fits: truncate rather than skip so the budget is not
501            // wasted and the oldest retained message still carries context.
502            selected.push(truncate_to_token_budget(text, remaining));
503            break;
504        }
505    }
506    selected.reverse(); // restore chronological order
507    let mut out = Vec::with_capacity(selected.len() + 1);
508    for text in selected {
509        out.push(ChatMessage::User {
510            content: text,
511            attachments: vec![],
512        });
513    }
514    // Summary goes last — the model reads this as the most recent context.
515    out.push(ChatMessage::User {
516        content: serialize_summary(summary_text),
517        attachments: vec![],
518    });
519    out
520}
521
522/// Truncate `s` to at most `budget` estimated tokens using the same
523/// mixed-script estimator as `estimate_tokens`. Cuts at the last complete
524/// character that keeps the running estimate within `budget`.
525fn truncate_to_token_budget(s: &str, budget: u64) -> String {
526    if budget == 0 {
527        return String::new();
528    }
529    let mut ascii: u64 = 0;
530    let mut non_ascii: u64 = 0;
531    let mut end = 0usize;
532    for (byte_pos, c) in s.char_indices() {
533        let (na, nn) = if c.is_ascii() {
534            (ascii + 1, non_ascii)
535        } else {
536            (ascii, non_ascii + 1)
537        };
538        if na.div_ceil(4) + nn > budget {
539            break;
540        }
541        ascii = na;
542        non_ascii = nn;
543        end = byte_pos + c.len_utf8();
544    }
545    s[..end].to_string()
546}
547
548/// Lightweight prune pass: replace large old tool outputs with
549/// [`PRUNED_CONTENT_STUB`] to free tokens before (or instead of) a full
550/// summarise round-trip.
551///
552/// Algorithm:
553///   1. Walk the message list backwards.
554///   2. Skip tool outputs inside the most recent `protect_turns` user turns.
555///   3. Stop at any prior `<conversation-summary>` boundary — everything
556///      before it was already compacted.
557///   4. Accumulate gross freed tokens for each `ChatMessage::Tool` whose
558///      content exceeds [`PRUNE_MIN_CONTENT_TOKENS`].
559///   5. If net freed tokens ≥ [`PRUNE_MINIMUM_TOKENS`], apply the mutations
560///      and return the updated list; otherwise return the original unchanged.
561///
562/// Returns `(messages, net_tokens_freed)`.  `net_tokens_freed == 0` means
563/// the list was not modified (either nothing qualified or the saving was
564/// below the minimum).
565pub fn prune_tool_outputs(
566    messages: Vec<ChatMessage>,
567    protect_turns: usize,
568) -> (Vec<ChatMessage>, u64) {
569    let stub_tokens = estimate_tokens(PRUNED_CONTENT_STUB);
570    let mut user_turns_seen: usize = 0;
571    // Collect (index, gross_tokens) for candidates.
572    let mut candidates: Vec<(usize, u64)> = Vec::new();
573    let mut gross_freed: u64 = 0;
574
575    for (i, msg) in messages.iter().enumerate().rev() {
576        match msg {
577            ChatMessage::User { content, .. } => {
578                if is_summary_message(content) {
579                    // Prior compaction boundary — stop here.
580                    break;
581                }
582                user_turns_seen += 1;
583            }
584            ChatMessage::Tool { content, .. } => {
585                // Protected window: skip recent turns.
586                if user_turns_seen < protect_turns {
587                    continue;
588                }
589                let tokens = estimate_tokens(content);
590                if tokens >= PRUNE_MIN_CONTENT_TOKENS {
591                    candidates.push((i, tokens));
592                    gross_freed += tokens;
593                }
594            }
595            _ => {}
596        }
597    }
598
599    // Net gain after we write the stub back in.
600    let replacements = candidates.len() as u64;
601    let net_freed = gross_freed.saturating_sub(stub_tokens * replacements);
602
603    if net_freed < PRUNE_MINIMUM_TOKENS {
604        return (messages, 0);
605    }
606
607    let mut out = messages;
608    for (i, _) in &candidates {
609        if let ChatMessage::Tool { content, .. } = &mut out[*i] {
610            *content = PRUNED_CONTENT_STUB.to_string();
611        }
612    }
613    (out, net_freed)
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::model::{ModelChunk, ModelClient};
620    use crate::tools::ToolInvocation;
621    use async_trait::async_trait;
622    use futures::stream::{BoxStream, StreamExt};
623
624    /// In-process model client that returns a fixed summary string.
625    /// Test fixture only — production summarisation goes through the
626    /// real model client.
627    #[derive(Clone)]
628    struct FixedSummaryClient {
629        summary: String,
630    }
631    #[async_trait]
632    impl ModelClient for FixedSummaryClient {
633        fn hosted_capability(
634            &self,
635            _capability: crate::model::HostedCapability,
636        ) -> crate::model::CapabilitySupport {
637            crate::model::CapabilitySupport::Unsupported
638        }
639
640        async fn stream(
641            &self,
642            _input: ModelTurnInput,
643        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
644        {
645            let chunks = vec![
646                Ok(ModelChunk::TextDelta {
647                    msg_id: "sum".into(),
648                    delta: self.summary.clone(),
649                }),
650                Ok(ModelChunk::Done {
651                    stop_reason: "end_turn".into(),
652                    usage: None,
653                }),
654            ];
655            Ok(futures::stream::iter(chunks).boxed())
656        }
657    }
658
659    fn user(s: &str) -> ChatMessage {
660        ChatMessage::User {
661            content: s.into(),
662            attachments: vec![],
663        }
664    }
665
666    fn assistant_text(s: &str) -> ChatMessage {
667        ChatMessage::Assistant {
668            text: Some(s.into()),
669            tool_calls: vec![],
670            thinking: None,
671            usage: None,
672        }
673    }
674
675    fn tool_msg(id: &str, content: &str) -> ChatMessage {
676        ChatMessage::Tool {
677            tool_call_id: id.into(),
678            content: content.into(),
679            is_error: false,
680            attachments: vec![],
681        }
682    }
683
684    #[test]
685    fn token_estimate_grows_with_content_size() {
686        let small = user("hi");
687        let big = user(&"x".repeat(8000));
688        assert!(estimate_chat_message_tokens(&big) > estimate_chat_message_tokens(&small));
689    }
690
691    #[test]
692    fn estimate_tokens_splits_ascii_and_cjk() {
693        // ASCII at 4 chars/token (ceil), non-ASCII at 1 token/char,
694        // whitespace-only is free.
695        assert_eq!(estimate_tokens(""), 0);
696        assert_eq!(estimate_tokens("   \n"), 0);
697        assert_eq!(estimate_tokens("abcd"), 1);
698        assert_eq!(estimate_tokens("abcde"), 2); // ceil(5/4)
699        assert_eq!(estimate_tokens("你好世界"), 4); // 4 CJK chars = 4 tokens
700        assert_eq!(estimate_tokens("hi你好"), 3); // ceil(2/4)=1 + 2
701    }
702
703    #[test]
704    fn token_estimate_counts_cjk_near_one_per_char() {
705        // 1000 CJK chars ≈ 1000 tokens. The old bytes/4 heuristic said
706        // ~750 (3 UTF-8 bytes / 4); the rune-aware estimator must not
707        // undercount, or compaction triggers too late on Chinese text.
708        let cjk = user(&"汉".repeat(1000));
709        let estimate = estimate_chat_message_tokens(&cjk);
710        assert!(
711            estimate >= 1000,
712            "CJK undercounted: got {estimate}, want >= 1000"
713        );
714    }
715
716    #[test]
717    fn token_estimate_includes_tool_call_input() {
718        // Same text length, but assistant carrying a tool_call should
719        // cost more (we count the JSON arguments).
720        let bare = assistant_text("done");
721        let with_tool = ChatMessage::Assistant {
722            text: Some("done".into()),
723            tool_calls: vec![ToolInvocation {
724                id: "tc".into(),
725                name: "bash".into(),
726                input: serde_json::json!({"command": "echo lots of bytes here for sure"}),
727                raw_emitted_args: None,
728            }],
729            thinking: None,
730            usage: None,
731        };
732        assert!(estimate_chat_message_tokens(&with_tool) > estimate_chat_message_tokens(&bare));
733    }
734
735    fn assistant_with_usage(text: &str, input: u64, cache_read: u64) -> ChatMessage {
736        ChatMessage::Assistant {
737            text: Some(text.into()),
738            tool_calls: vec![],
739            thinking: None,
740            usage: Some(HarnessUsage {
741                input_tokens: input,
742                cache_read_input_tokens: cache_read,
743                ..Default::default()
744            }),
745        }
746    }
747
748    #[test]
749    fn anchored_estimate_uses_reported_usage_for_prefix() {
750        // A short character-count prefix, but the assistant turn reports a
751        // large measured input. The anchor must dominate, plus the tail.
752        let msgs = vec![
753            user("hi"),
754            assistant_with_usage("ok", 40_000, 0),
755            user(&"x".repeat(4000)), // ~1000 tokens tail
756        ];
757        let tail = estimate_chat_message_tokens(&msgs[2]);
758        assert_eq!(estimate_context_tokens_anchored(&msgs), 40_000 + tail);
759    }
760
761    #[test]
762    fn anchored_estimate_does_not_double_count_cache_reads() {
763        // OpenAI-compatible cached_tokens is a subset of prompt_tokens. Cache
764        // telemetry must not inflate context-window occupancy.
765        let msgs = vec![assistant_with_usage("ok", 10_000, 25_000)];
766        assert_eq!(estimate_context_tokens_anchored(&msgs), 10_000);
767    }
768
769    #[test]
770    fn anchored_estimate_picks_newest_usage_anchor() {
771        // Two usage-bearing turns; the newest one wins and everything at or
772        // before it is covered by its measured count.
773        let msgs = vec![
774            user("a"),
775            assistant_with_usage("first", 10_000, 0),
776            user("b"),
777            assistant_with_usage("second", 30_000, 0),
778            user("tail"),
779        ];
780        let tail = estimate_chat_message_tokens(&msgs[4]);
781        assert_eq!(estimate_context_tokens_anchored(&msgs), 30_000 + tail);
782    }
783
784    #[test]
785    fn anchored_estimate_falls_back_without_usage() {
786        // No usage anywhere ⇒ identical to the full heuristic sum.
787        let msgs = vec![user("hi"), assistant_text("there"), user("more")];
788        assert_eq!(
789            estimate_context_tokens_anchored(&msgs),
790            estimate_messages_tokens(&msgs)
791        );
792    }
793
794    #[test]
795    fn should_compact_skips_when_below_threshold() {
796        let strat = SummarizeCompactionStrategy::default();
797        let messages = vec![user("hello"), assistant_text("hi")];
798        // 200K window, tiny conversation — never fires.
799        assert!(!strat.should_compact(&messages, 200_000));
800    }
801
802    #[test]
803    fn should_compact_fires_when_above_threshold() {
804        let strat = SummarizeCompactionStrategy::default();
805        // 5 messages * 8000 ASCII chars each ≈ 10K tokens.
806        // With an 11K window the 90% threshold is 9 900 tokens, so
807        // 10K tokens exceeds it and compaction must fire.
808        let messages = vec![
809            user(&"x".repeat(8000)),
810            assistant_text(&"y".repeat(8000)),
811            user(&"x".repeat(8000)),
812            assistant_text(&"y".repeat(8000)),
813            user(&"x".repeat(8000)),
814        ];
815        assert!(strat.should_compact(&messages, 11_000));
816    }
817
818    #[test]
819    fn should_compact_respects_tail_min_floor() {
820        let strat = SummarizeCompactionStrategy::default();
821        // Bigger than threshold but fewer than tail_min_messages — skip.
822        let messages = vec![
823            user(&"x".repeat(100_000)),
824            assistant_text(&"y".repeat(100_000)),
825        ];
826        assert!(!strat.should_compact(&messages, 1_000));
827    }
828
829    #[tokio::test]
830    async fn compact_folds_history_into_summary_plus_tail() {
831        let strat = SummarizeCompactionStrategy::default().with_tail_min_messages(2);
832        let ctx = CompactionContext {
833            system_prompt: None,
834            model_client: Arc::new(FixedSummaryClient {
835                summary: "we ran ls and grep".into(),
836            }),
837            context_window_tokens: 10_000,
838            tools: vec![],
839        };
840        let messages = vec![
841            user("first user"),
842            assistant_text("response 1"),
843            user("second user"),
844            tool_msg("tc1", "tool result"),
845            user("third user"),
846            assistant_text("final response"),
847        ];
848        let outcome = strat.compact(messages, &ctx).await.unwrap();
849        let out = outcome.messages;
850        // All three real user messages fit within the 20 000-token budget and
851        // are retained verbatim. The summary is appended as the final message.
852        assert_eq!(out.len(), 4, "3 user messages + 1 summary");
853        match &out[0] {
854            ChatMessage::User { content, .. } => assert_eq!(content, "first user"),
855            other => panic!("expected User at [0], got {other:?}"),
856        }
857        match &out[1] {
858            ChatMessage::User { content, .. } => assert_eq!(content, "second user"),
859            other => panic!("expected User at [1], got {other:?}"),
860        }
861        match &out[2] {
862            ChatMessage::User { content, .. } => assert_eq!(content, "third user"),
863            other => panic!("expected User at [2], got {other:?}"),
864        }
865        // Summary is the last message.
866        assert!(matches!(&out[3], ChatMessage::User { content, .. }
867                if content.contains("<conversation-summary>") && content.contains("we ran ls and grep")));
868        // Output is shorter than input (6 messages → 4).
869        assert!(out.len() < 6);
870        // FixedSummaryClient doesn't report usage → outcome.usage is None.
871        assert!(outcome.usage.is_none());
872    }
873
874    #[tokio::test]
875    async fn compact_returns_empty_summary_error_on_blank_response() {
876        let strat = SummarizeCompactionStrategy::default().with_tail_min_messages(2);
877        let ctx = CompactionContext {
878            system_prompt: None,
879            model_client: Arc::new(FixedSummaryClient { summary: "".into() }),
880            context_window_tokens: 10_000,
881            tools: vec![],
882        };
883        let messages = vec![
884            user("a"),
885            assistant_text("b"),
886            user("c"),
887            assistant_text("d"),
888        ];
889        let err = strat.compact(messages, &ctx).await.unwrap_err();
890        assert!(matches!(err, CompactionError::EmptySummary));
891    }
892
893    #[tokio::test]
894    async fn compact_skips_when_messages_at_or_below_tail_min() {
895        let strat = SummarizeCompactionStrategy::default().with_tail_min_messages(4);
896        let ctx = CompactionContext {
897            system_prompt: None,
898            model_client: Arc::new(FixedSummaryClient {
899                summary: "irrelevant".into(),
900            }),
901            context_window_tokens: 1_000,
902            tools: vec![],
903        };
904        let messages = vec![
905            user("1"),
906            assistant_text("2"),
907            user("3"),
908            assistant_text("4"),
909        ];
910        let outcome = strat.compact(messages.clone(), &ctx).await.unwrap();
911        // Same messages back — no compaction happened, no model call.
912        assert_eq!(outcome.messages, messages);
913        assert!(outcome.usage.is_none());
914    }
915
916    // ── prune_tool_outputs tests ─────────────────────────────────────────────
917
918    fn big_tool(id: &str) -> ChatMessage {
919        // ~2 100 tokens — above PRUNE_MIN_CONTENT_TOKENS (100) and,
920        // when a single instance is pruneable, above PRUNE_MINIMUM_TOKENS (2 000)
921        // net of the stub replacement (~18 tokens).
922        tool_msg(id, &"x".repeat(8_400))
923    }
924
925    fn small_tool(id: &str) -> ChatMessage {
926        // 10 tokens — below the per-output floor, must NOT be pruned
927        tool_msg(id, &"x".repeat(40))
928    }
929
930    #[test]
931    fn prune_replaces_old_large_tool_outputs() {
932        // History: 3 user turns, each followed by a big tool result.
933        // protect_turns=2 → last 2 turns are safe; turn 0 (oldest) is pruneable.
934        let messages = vec![
935            user("turn 0"),
936            big_tool("t0"),
937            user("turn 1"),
938            big_tool("t1"),
939            user("turn 2"),
940            big_tool("t2"),
941        ];
942        let (pruned, freed) = prune_tool_outputs(messages, 2);
943        // Only t0 should be pruned (oldest, outside the 2-turn window).
944        assert!(freed > 0, "expected tokens to be freed");
945        assert_eq!(pruned[1], tool_msg("t0", PRUNED_CONTENT_STUB));
946        // t1 and t2 are inside protected window — untouched.
947        assert_ne!(pruned[3], tool_msg("t1", PRUNED_CONTENT_STUB));
948        assert_ne!(pruned[5], tool_msg("t2", PRUNED_CONTENT_STUB));
949    }
950
951    #[test]
952    fn prune_does_not_touch_small_tool_outputs() {
953        let messages = vec![
954            user("turn 0"),
955            small_tool("t0"),
956            user("turn 1"),
957            big_tool("t1"),
958            user("turn 2"),
959            big_tool("t2"),
960        ];
961        let (pruned, _freed) = prune_tool_outputs(messages.clone(), 2);
962        // t0 is old but small — must stay intact.
963        assert_eq!(pruned[1], messages[1]);
964    }
965
966    #[test]
967    fn prune_no_op_when_savings_below_minimum() {
968        // Only one small old tool output — net freed < PRUNE_MINIMUM_TOKENS.
969        let messages = vec![
970            user("turn 0"),
971            small_tool("t0"),
972            user("turn 1"),
973            big_tool("t1"),
974            user("turn 2"),
975        ];
976        // small_tool is below PRUNE_MIN_CONTENT_TOKENS → nothing qualifies.
977        let (out, freed) = prune_tool_outputs(messages.clone(), 2);
978        assert_eq!(freed, 0);
979        assert_eq!(out, messages);
980    }
981
982    #[test]
983    fn prune_stops_at_summary_boundary() {
984        // Layout (chronological):
985        //   turn 0 / t_before  ← before the summary; must NOT be pruned
986        //   <summary>          ← compaction boundary; reverse walk stops here
987        //   turn 1 / t1        ← after summary but inside protected window
988        //   turn 2 / t2        ← after summary but inside protected window
989        //
990        // Without the summary, t_before (oldest, outside protect window)
991        // WOULD be pruned.  With it, the reverse walk hits the summary and
992        // breaks before reaching t_before, so freed == 0.
993        let messages = vec![
994            user("turn 0"),
995            big_tool("t_before"),
996            user("<conversation-summary>\nprevious context\n</conversation-summary>"),
997            user("turn 1"),
998            big_tool("t1"),
999            user("turn 2"),
1000            big_tool("t2"),
1001        ];
1002        let (out, freed) = prune_tool_outputs(messages.clone(), 2);
1003        assert_eq!(
1004            freed, 0,
1005            "summary boundary must block pruning of earlier content"
1006        );
1007        assert_eq!(out, messages);
1008    }
1009
1010    #[tokio::test]
1011    async fn compact_skips_summarise_when_prune_sufficient() {
1012        // A strategy that would summarise if called — we verify it is NOT
1013        // called when prune brings tokens below the threshold.
1014        struct PanicSummaryClient;
1015        #[async_trait::async_trait]
1016        impl ModelClient for PanicSummaryClient {
1017            fn hosted_capability(
1018                &self,
1019                _capability: crate::model::HostedCapability,
1020            ) -> crate::model::CapabilitySupport {
1021                crate::model::CapabilitySupport::Unsupported
1022            }
1023
1024            async fn stream(
1025                &self,
1026                _: ModelTurnInput,
1027            ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
1028            {
1029                panic!("summarise should not be called when prune is sufficient");
1030            }
1031        }
1032
1033        // Build a history that is over the threshold purely because of one
1034        // big tool output in the oldest turn.  After pruning it the total
1035        // drops below the threshold.
1036        let big_content = "y".repeat(200_000); // ~50k tokens
1037        let messages = vec![
1038            user("turn 0"),
1039            tool_msg("t0", &big_content),
1040            user("turn 1"),
1041            assistant_text("ok"),
1042            user("turn 2"),
1043            assistant_text("done"),
1044        ];
1045        let ctx = CompactionContext {
1046            system_prompt: None,
1047            model_client: Arc::new(PanicSummaryClient),
1048            context_window_tokens: 60_000,
1049            tools: vec![],
1050        };
1051        let strat = SummarizeCompactionStrategy {
1052            trigger_fraction: 0.9,
1053            tail_min_messages: 2,
1054            ..Default::default()
1055        };
1056        let outcome = strat.compact(messages, &ctx).await.unwrap();
1057        // Prune replaced the big output — usage is None (no model call).
1058        assert!(outcome.usage.is_none());
1059        // The stub is present at index 1.
1060        assert_eq!(outcome.messages[1], tool_msg("t0", PRUNED_CONTENT_STUB));
1061    }
1062}