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}