Skip to main content

agent_sdk/context/
compactor.rs

1//! Context compaction implementation.
2
3use crate::hooks::{AgentHooks, DefaultHooks, RequestDecision, ResponseDecision};
4use crate::llm::{
5    ChatOutcome, ChatRequest, Content, ContentBlock, LlmProvider, Message, Role, StopReason,
6};
7use crate::types::TokenUsage;
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::fmt::Write;
11use std::sync::Arc;
12
13use super::config::CompactionConfig;
14use super::estimator::TokenEstimator;
15
16const SUMMARY_PREFIX: &str = "[Previous conversation summary]\n\n";
17const COMPACTION_SYSTEM_PROMPT: &str = "You are a precise summarizer. Your task is to create concise but complete summaries of conversations, preserving all technical details needed to continue the work.";
18const COMPACTION_SUMMARY_PROMPT_PREFIX: &str = "Summarize this conversation concisely, preserving:\n- Key decisions and conclusions reached\n- Important file paths, code changes, and technical details\n- Current task context and what has been accomplished\n- Any pending items, errors encountered, or next steps\n\nBe specific about technical details (file names, function names, error messages) as these\nare critical for continuing the work.\n\nConversation:\n";
19const COMPACTION_SUMMARY_PROMPT_SUFFIX: &str =
20    "Provide a concise summary (aim for 500-1000 words):";
21const COMPACT_EMPTY_SUMMARY: &str = "No additional context was available to summarize; the previous messages were already compacted.";
22const SUMMARY_ACKNOWLEDGMENT: &str =
23    "I understand the context from the summary. Let me continue from where we left off.";
24const MAX_TOOL_RESULT_CHARS: usize = 500;
25const TRUNCATED_SUMMARY_MARKER: &str =
26    "\n\n[summary truncated: exceeded the configured summary_max_tokens budget]";
27
28/// Trait for context compaction strategies.
29///
30/// Implement this trait to provide custom compaction logic.
31#[async_trait]
32pub trait ContextCompactor: Send + Sync {
33    /// Compact a list of messages into a summary.
34    ///
35    /// # Errors
36    /// Returns an error if summarization fails.
37    async fn compact(&self, messages: &[Message]) -> Result<String>;
38
39    /// Estimate tokens for a message list.
40    fn estimate_tokens(&self, messages: &[Message]) -> usize;
41
42    /// Check if compaction is needed.
43    fn needs_compaction(&self, messages: &[Message]) -> bool;
44
45    /// Perform full compaction, returning new message history.
46    ///
47    /// # Errors
48    /// Returns an error if compaction fails.
49    async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult>;
50
51    /// Like [`compact_history`](Self::compact_history), but a failure
52    /// additionally reports the provider-billed usage of any summarization
53    /// calls already made, so callers can account billed-but-wasted spend.
54    ///
55    /// The default delegates to `compact_history` and reports zero usage on
56    /// failure — custom compactors that bill LLM calls should override this
57    /// (best-effort: an un-overridden custom compactor under-reports failed
58    /// attempts' usage, never over-reports).
59    ///
60    /// # Errors
61    /// Returns [`FailedCompaction`] when compaction fails.
62    async fn compact_history_with_usage(
63        &self,
64        messages: Vec<Message>,
65    ) -> Result<CompactionResult, FailedCompaction> {
66        self.compact_history(messages)
67            .await
68            .map_err(|error| FailedCompaction {
69                error,
70                llm_usage: TokenUsage::default(),
71            })
72    }
73}
74
75/// Result of a compaction operation.
76#[derive(Debug, Clone)]
77pub struct CompactionResult {
78    /// The new compacted message history.
79    pub messages: Vec<Message>,
80    /// Number of messages before compaction.
81    pub original_count: usize,
82    /// Number of messages after compaction.
83    pub new_count: usize,
84    /// Estimated tokens before compaction.
85    pub original_tokens: usize,
86    /// Estimated tokens after compaction.
87    pub new_tokens: usize,
88    /// Provider-billed usage of the summarization LLM call(s) that produced
89    /// this result (zero when compaction completed without an LLM call).
90    ///
91    /// Surfaced so the agent loop can fold compaction spend into the run's
92    /// cumulative usage — otherwise summarization tokens would be invisible
93    /// to `UsageLimits` budgets and under-reported on `Done`.
94    pub llm_usage: TokenUsage,
95}
96
97/// A failed compaction attempt, carrying the provider-billed usage of any
98/// summarization LLM calls that were already made before the failure.
99///
100/// Surfaced by [`ContextCompactor::compact_history_with_usage`] so the agent
101/// loop can fold billed-but-wasted summarization spend into the run's
102/// cumulative usage even when the history is left uncompacted (guardrail
103/// block, truncation-retry error, `replace_history` failure).
104#[derive(Debug)]
105pub struct FailedCompaction {
106    /// Why the compaction attempt failed.
107    pub error: anyhow::Error,
108    /// Usage billed by summarization calls made before the failure (zero
109    /// when the failure preceded any LLM call).
110    pub llm_usage: TokenUsage,
111}
112
113/// LLM-based context compactor.
114///
115/// Uses the LLM itself to summarize older messages into a compact form.
116///
117/// # Budgets
118///
119/// The compactor performs no budget evaluation of its own: it may issue up
120/// to **two** summarization LLM calls per compaction (the second only when
121/// the first summary was truncated, retried with a doubled token budget)
122/// before the agent loop's next [`UsageLimits`](crate::types::UsageLimits)
123/// boundary check runs. Every call's usage — including failed attempts — is
124/// reported via [`CompactionResult::llm_usage`] /
125/// [`FailedCompaction::llm_usage`] and folded by the loop immediately after
126/// compaction, so the overshoot is bounded and consistent with the loop's
127/// boundary-check semantics.
128///
129/// `P` is `?Sized` so callers can hold an `Arc<dyn LlmProvider>` —
130/// useful when the provider is resolved dynamically per-thread (e.g.
131/// inside `agent-server`'s daemon worker, where the same compactor
132/// type wraps whichever concrete provider the host's resolver picks).
133/// Concrete-type users (`Arc<AnthropicProvider>`, etc.) still work
134/// unchanged.
135pub struct LlmContextCompactor<P: LlmProvider + ?Sized, H: AgentHooks = DefaultHooks> {
136    provider: Arc<P>,
137    config: CompactionConfig,
138    /// Guardrail hooks applied to the summarization LLM call. `None` (the
139    /// default) skips the guardrails, preserving the historical behavior for
140    /// direct constructions; the agent loop always attaches its run hooks so
141    /// compaction cannot bypass `pre_llm_request` / `on_llm_response`.
142    hooks: Option<Arc<H>>,
143    system_prompt: String,
144    summary_prompt_prefix: String,
145    summary_prompt_suffix: String,
146}
147
148impl<P: LlmProvider + ?Sized> LlmContextCompactor<P> {
149    /// Create a new LLM context compactor.
150    #[must_use]
151    pub fn new(provider: Arc<P>, config: CompactionConfig) -> Self {
152        Self {
153            provider,
154            config,
155            hooks: None,
156            system_prompt: COMPACTION_SYSTEM_PROMPT.to_string(),
157            summary_prompt_prefix: COMPACTION_SUMMARY_PROMPT_PREFIX.to_string(),
158            summary_prompt_suffix: COMPACTION_SUMMARY_PROMPT_SUFFIX.to_string(),
159        }
160    }
161
162    /// Create with default configuration.
163    #[must_use]
164    pub fn with_defaults(provider: Arc<P>) -> Self {
165        Self::new(provider, CompactionConfig::default())
166    }
167}
168
169impl<P: LlmProvider + ?Sized, H: AgentHooks> LlmContextCompactor<P, H> {
170    /// Apply the run's guardrail hooks to every summarization LLM call.
171    ///
172    /// `pre_llm_request` runs before the call (`Proceed`/`Modify` apply;
173    /// `Block` aborts the compaction attempt with an error) and
174    /// `on_llm_response` runs on the produced summary (`Accept` applies;
175    /// `Block` **and** `RetryWithFeedback` abort the compaction attempt —
176    /// the compactor never retries a rejected summary, so a
177    /// deterministically-rejecting hook cannot start a paid retry loop
178    /// here). An aborted compaction surfaces as a `compact_history` error;
179    /// the agent loop then continues with the uncompacted history
180    /// (threshold trigger) or fails the recovery (overflow trigger).
181    #[must_use]
182    pub fn with_guardrail_hooks<H2: AgentHooks>(
183        self,
184        hooks: Arc<H2>,
185    ) -> LlmContextCompactor<P, H2> {
186        LlmContextCompactor {
187            provider: self.provider,
188            config: self.config,
189            hooks: Some(hooks),
190            system_prompt: self.system_prompt,
191            summary_prompt_prefix: self.summary_prompt_prefix,
192            summary_prompt_suffix: self.summary_prompt_suffix,
193        }
194    }
195
196    /// Get the configuration.
197    #[must_use]
198    pub const fn config(&self) -> &CompactionConfig {
199        &self.config
200    }
201
202    /// Override the prompts used for LLM-based summarization.
203    #[must_use]
204    pub fn with_prompts(
205        mut self,
206        system_prompt: impl Into<String>,
207        summary_prompt_prefix: impl Into<String>,
208        summary_prompt_suffix: impl Into<String>,
209    ) -> Self {
210        self.system_prompt = system_prompt.into();
211        self.summary_prompt_prefix = summary_prompt_prefix.into();
212        self.summary_prompt_suffix = summary_prompt_suffix.into();
213        self
214    }
215
216    /// If `content` is a previously inserted compaction summary, return its
217    /// text with the `SUMMARY_PREFIX` marker stripped; otherwise `None`.
218    ///
219    /// Used to carry a prior summary's prose forward into the next compaction
220    /// instead of discarding it (which silently destroyed all pre-first-
221    /// compaction context). The marker is still a content-prefix sentinel
222    /// because `Message` lives in a foundation crate and cannot carry a
223    /// structural flag from here; the compactor itself is the only writer of
224    /// the prefix.
225    fn extract_summary_text(content: &Content) -> Option<String> {
226        match content {
227            Content::Text(text) => text.strip_prefix(SUMMARY_PREFIX).map(str::to_string),
228            Content::Blocks(blocks) => blocks.iter().find_map(|block| match block {
229                ContentBlock::Text { text } => {
230                    text.strip_prefix(SUMMARY_PREFIX).map(str::to_string)
231                }
232                _ => None,
233            }),
234        }
235    }
236
237    /// Return true when a message contains a tool-use block.
238    fn has_tool_use(content: &Content) -> bool {
239        matches!(
240            content,
241            Content::Blocks(blocks)
242                if blocks
243                    .iter()
244                    .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
245        )
246    }
247
248    /// Return true when a message contains a tool-result block.
249    fn has_tool_result(content: &Content) -> bool {
250        matches!(
251            content,
252            Content::Blocks(blocks)
253                if blocks
254                    .iter()
255                    .any(|block| matches!(block, ContentBlock::ToolResult { .. }))
256        )
257    }
258
259    /// Return true when history contains provider-owned state that must be
260    /// replayed byte-for-byte on a follow-up request.
261    ///
262    /// A generic prose summary cannot stand in for this state: doing so would
263    /// both discard the payload and change its position relative to the
264    /// surrounding conversation. Until a provider-native compaction path can
265    /// preserve it, the only safe generic behavior is to leave the history
266    /// intact.
267    fn has_opaque_reasoning(messages: &[Message]) -> bool {
268        messages.iter().any(|message| {
269            matches!(
270                &message.content,
271                Content::Blocks(blocks)
272                    if blocks
273                        .iter()
274                        .any(|block| matches!(block, ContentBlock::OpaqueReasoning { .. }))
275            )
276        })
277    }
278
279    /// Shift split point backwards until a `tool_use`/`tool_result` pair is not
280    /// split.
281    ///
282    /// Only the `assistant(tool_use)` -> `user(tool_result)` boundary is
283    /// unsplittable: that is the single tool turn that must stay together for
284    /// the wire payload to be valid. Splitting at a `user(tool_result)` ->
285    /// `assistant(tool_use)` boundary is API-valid (the retained tail then
286    /// begins with an `assistant` `tool_use` followed by its own result), so
287    /// it is *not* treated as a pair. Treating it as a pair used to walk the
288    /// split backward through an entire unbroken tool chain — the dominant
289    /// shape of autonomous traces — defeating the retained-tail token cap and
290    /// summarizing almost nothing.
291    fn split_point_preserves_tool_pairs(messages: &[Message], mut split_point: usize) -> usize {
292        while split_point > 0 && split_point < messages.len() {
293            let prev = &messages[split_point - 1];
294            let next = &messages[split_point];
295
296            let crosses_tool_pair = prev.role == Role::Assistant
297                && Self::has_tool_use(&prev.content)
298                && next.role == Role::User
299                && Self::has_tool_result(&next.content);
300
301            if crosses_tool_pair {
302                split_point -= 1;
303                continue;
304            }
305
306            break;
307        }
308
309        split_point
310    }
311
312    /// Pick a split point that produces a self-consistent `to_keep`.
313    ///
314    /// `to_keep` is self-consistent (per Anthropic's API contract)
315    /// when every `tool_result` block it contains references a
316    /// `tool_use` block earlier in `to_keep`. The compactor inserts
317    /// a synthetic `[summary, summary_ack]` prefix in front of
318    /// `to_keep`, and that prefix has no `tool_use` blocks — so the
319    /// only path to a valid wire payload is for `to_keep` itself to
320    /// be self-contained.
321    ///
322    /// Three constraints, applied in order:
323    ///
324    /// 1. **Token cap (soft)** — push split forward to keep the
325    ///    retained tail under `max_tokens` of estimated content. The
326    ///    retained-tail cap is a soft hint; a tool chain that doesn't
327    ///    fit gets retained anyway because chain safety is hard.
328    /// 2. **Pair safety (hard)** — shift split backward to keep
329    ///    `assistant_with_tool_use` and the immediately following
330    ///    `user_with_tool_result` together. Catches the common case
331    ///    where the boundary lands inside a single tool turn.
332    /// 3. **Chain safety (hard)** — advance split forward past any
333    ///    leading `user_with_tool_result` whose `tool_use_id` isn't
334    ///    in the rest of `to_keep`. Catches the case pair-preservation
335    ///    can't see: when the message immediately before the original
336    ///    boundary is text-only (e.g. a `summary_ack` from a prior
337    ///    compaction), pair-preservation has nothing to anchor on
338    ///    and silently leaves the orphan in `to_keep[0]`. The wire
339    ///    payload would then start `[summary, summary_ack,
340    ///    user(orphan_tool_result), …]` — which Anthropic rejects
341    ///    with `messages.2.content.0: unexpected tool_use_id`. Step
342    ///    3 makes the split-point selection responsible for chain
343    ///    integrity instead of post-hoc stripping the output.
344    ///
345    /// Step 2 and step 3 can pull in opposite directions (step 2
346    /// shifts back, step 3 shifts forward), so the function applies
347    /// step 3 last: pair-safety puts the candidate as far back as
348    /// it needs to go, then chain-safety advances past any leading
349    /// orphan that survived because the immediate prev was text-only.
350    fn split_point_preserves_tool_pairs_with_cap(
351        messages: &[Message],
352        split_point: usize,
353        max_tokens: usize,
354    ) -> usize {
355        let cap_limit = Self::retain_tail_with_token_cap(messages, split_point, max_tokens);
356        let pair_safe = Self::split_point_preserves_tool_pairs(messages, cap_limit);
357        Self::split_point_skips_leading_orphan(messages, pair_safe)
358    }
359
360    /// Advance `split_point` forward until `to_keep[0]` doesn't
361    /// contain an orphan `tool_result` block — i.e. a `tool_result`
362    /// whose `tool_use_id` isn't satisfied by some `tool_use` block
363    /// in `to_keep`.
364    ///
365    /// Implements step 3 of `split_point_preserves_tool_pairs_with_cap`
366    /// (chain safety). Pair-preservation alone can't catch the
367    /// "synthetic `summary_ack` precedes an orphan" shape because it
368    /// only inspects the immediate prev/next pair; this helper
369    /// inspects whether `to_keep[0]`'s `tool_result` blocks point
370    /// anywhere `to_keep` will host a matching `tool_use`. When they
371    /// don't, the `tool_result` belongs in `to_summarize` (where it
372    /// gets text-ified into the summary prose), not in `to_keep`.
373    ///
374    /// Walks at most `messages.len()` steps because each iteration
375    /// advances `split_point` by at least 1.
376    fn split_point_skips_leading_orphan(messages: &[Message], mut split_point: usize) -> usize {
377        while split_point < messages.len() {
378            if Self::leading_message_has_orphan_tool_result(&messages[split_point..]) {
379                split_point = split_point.saturating_add(1);
380                continue;
381            }
382            break;
383        }
384        split_point
385    }
386
387    /// True when `to_keep[0]` is a `user` message whose `tool_result`
388    /// blocks reference at least one `tool_use_id` not present in
389    /// `to_keep`. The check is scoped to the first message because
390    /// well-formed Anthropic conversations always have `tool_use`
391    /// immediately before `tool_result` — an orphan deeper than
392    /// `to_keep[0]` would require the input itself to be malformed
393    /// upstream of compaction, which is out of scope here.
394    fn leading_message_has_orphan_tool_result(to_keep: &[Message]) -> bool {
395        let Some(first) = to_keep.first() else {
396            return false;
397        };
398        let Content::Blocks(blocks) = &first.content else {
399            return false;
400        };
401
402        // Pull the tool_result ids that appear in the first message.
403        // If there are none, the first message can't contribute an
404        // orphan and we're done early without scanning the tail.
405        let mut needed: Vec<&str> = Vec::new();
406        for block in blocks {
407            if let ContentBlock::ToolResult { tool_use_id, .. } = block {
408                needed.push(tool_use_id.as_str());
409            }
410        }
411        if needed.is_empty() {
412            return false;
413        }
414
415        // Build the set of tool_use ids `to_keep` will host.
416        let known_ids: std::collections::HashSet<&str> = to_keep
417            .iter()
418            .flat_map(|message| match &message.content {
419                Content::Blocks(blocks) => blocks
420                    .iter()
421                    .filter_map(|block| match block {
422                        ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
423                        _ => None,
424                    })
425                    .collect::<Vec<_>>(),
426                Content::Text(_) => Vec::new(),
427            })
428            .collect();
429
430        needed.iter().any(|id| !known_ids.contains(id))
431    }
432
433    /// Keep most recent messages that fit within the retained-message token budget.
434    fn retain_tail_with_token_cap(messages: &[Message], start: usize, max_tokens: usize) -> usize {
435        if start >= messages.len() {
436            return messages.len();
437        }
438
439        if max_tokens == 0 {
440            return messages.len();
441        }
442
443        let mut used = 0usize;
444        let mut retained_start = messages.len();
445
446        for idx in (start..messages.len()).rev() {
447            let message_tokens = TokenEstimator::estimate_message(&messages[idx]);
448            if used + message_tokens > max_tokens {
449                break;
450            }
451
452            retained_start = idx;
453            used += message_tokens;
454        }
455
456        retained_start
457    }
458
459    /// Format messages for summarization.
460    ///
461    /// Borrows each message rather than taking a slice of owned values so the
462    /// caller can pass a filtered view (`Vec<&Message>`) without cloning.
463    fn format_messages_for_summary<'a>(messages: impl IntoIterator<Item = &'a Message>) -> String {
464        let mut output = String::new();
465
466        for message in messages {
467            let role = match message.role {
468                Role::User => "User",
469                Role::Assistant => "Assistant",
470            };
471
472            let _ = write!(output, "{role}: ");
473
474            match &message.content {
475                Content::Text(text) => {
476                    let _ = writeln!(output, "{text}");
477                }
478                Content::Blocks(blocks) => {
479                    for block in blocks {
480                        match block {
481                            ContentBlock::Text { text } => {
482                                let _ = writeln!(output, "{text}");
483                            }
484                            ContentBlock::Thinking { thinking, .. } => {
485                                // Include thinking in summaries for context
486                                let _ = writeln!(output, "[Thinking: {thinking}]");
487                            }
488                            ContentBlock::RedactedThinking { .. } => {
489                                let _ = writeln!(output, "[Redacted thinking]");
490                            }
491                            ContentBlock::OpaqueReasoning { .. } => {
492                                // Provider state is deliberately not rendered
493                                // into a summarization prompt. Moving or
494                                // paraphrasing it would both expose the opaque
495                                // payload and break exact replay semantics.
496                                let _ = writeln!(output, "[Opaque reasoning state omitted]");
497                            }
498                            ContentBlock::ToolUse { name, input, .. } => {
499                                let _ = writeln!(
500                                    output,
501                                    "[Called tool: {name} with input: {}]",
502                                    serde_json::to_string(input).unwrap_or_default()
503                                );
504                            }
505                            ContentBlock::ToolResult {
506                                content, is_error, ..
507                            } => {
508                                let status = if is_error.unwrap_or(false) {
509                                    "error"
510                                } else {
511                                    "success"
512                                };
513                                // Truncate long tool results (Unicode-safe; avoid slicing mid-codepoint)
514                                let truncated = if content.chars().count() > MAX_TOOL_RESULT_CHARS {
515                                    let prefix: String =
516                                        content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
517                                    format!("{prefix}... (truncated)")
518                                } else {
519                                    content.clone()
520                                };
521                                let _ = writeln!(output, "[Tool result ({status}): {truncated}]");
522                            }
523                            ContentBlock::Image { source } => {
524                                let _ = writeln!(output, "[Image: {}]", source.media_type);
525                            }
526                            ContentBlock::Document { source } => {
527                                let _ = writeln!(output, "[Document: {}]", source.media_type);
528                            }
529                            // `ContentBlock` is `#[non_exhaustive]`; render an
530                            // unknown future block kind with a generic marker.
531                            _ => {
532                                let _ = writeln!(output, "[Unrecognized content block]");
533                            }
534                        }
535                    }
536                }
537            }
538            output.push('\n');
539        }
540
541        output
542    }
543
544    /// Build the summarization prompt.
545    ///
546    /// When `prior_summaries` is non-empty (a re-compaction is folding earlier
547    /// summaries back in), their prose is prepended as a labeled section so the
548    /// model preserves and subsumes those facts in the new summary rather than
549    /// losing all pre-first-compaction context.
550    fn build_summary_prompt(&self, prior_summaries: &[String], messages_text: &str) -> String {
551        let base = format!(
552            "{}{}{}",
553            self.summary_prompt_prefix, messages_text, self.summary_prompt_suffix
554        );
555
556        if prior_summaries.is_empty() {
557            return base;
558        }
559
560        let prior = prior_summaries.join("\n\n");
561        format!(
562            "Previous summary of earlier conversation. Preserve every fact below \
563             in your new summary so no earlier context is lost:\n{prior}\n\n{base}"
564        )
565    }
566
567    /// Run a single summarization LLM call, applying the configured
568    /// guardrail hooks around it.
569    ///
570    /// The returned [`SummarizationCall`] reports whether the response hit
571    /// the `max_tokens` budget and carries the provider-billed usage so the
572    /// caller can surface compaction spend to the agent loop's budgets.
573    async fn run_summarization(
574        &self,
575        prompt: String,
576        max_tokens: usize,
577    ) -> Result<SummarizationCall, SummarizationFailure> {
578        let mut request = ChatRequest {
579            system: self.system_prompt.clone(),
580            messages: vec![Message::user(prompt)],
581            tools: None,
582            max_tokens: u32::try_from(max_tokens).unwrap_or(u32::MAX),
583            max_tokens_explicit: true,
584            session_id: None,
585            cached_content: None,
586            thinking: None,
587            tool_choice: None,
588            response_format: None,
589            cache: None,
590        };
591
592        // Input guardrail: the summarization call goes through the same
593        // `pre_llm_request` hook as regular turns, so compaction cannot be
594        // used to smuggle history past a request policy.
595        if let Some(hooks) = &self.hooks {
596            match hooks.pre_llm_request(&request).await {
597                RequestDecision::Modify(modified) => request = *modified,
598                RequestDecision::Block(reason) => {
599                    return Err(SummarizationFailure {
600                        error: anyhow::anyhow!(
601                            "Summarization request blocked by guardrail: {reason}"
602                        ),
603                        usage: TokenUsage::default(),
604                    });
605                }
606                // `Proceed`, plus any future `#[non_exhaustive]` variant,
607                // sends the request unchanged (mirrors the agent loop).
608                _ => {}
609            }
610        }
611
612        let outcome = self
613            .provider
614            .chat(request)
615            .await
616            .context("Failed to call LLM for summarization")
617            .map_err(|error| SummarizationFailure {
618                error,
619                usage: TokenUsage::default(),
620            })?;
621
622        match outcome {
623            ChatOutcome::Success(response) => {
624                // The provider billed this call regardless of what happens
625                // next, so its usage rides every outcome — including
626                // guardrail rejections — for budget accounting.
627                let usage = TokenUsage {
628                    input_tokens: response.usage.input_tokens,
629                    output_tokens: response.usage.output_tokens,
630                    cached_input_tokens: response.usage.cached_input_tokens,
631                    cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
632                };
633                // Output guardrail: a rejected summary aborts the compaction
634                // attempt so it is never persisted. `RetryWithFeedback` is
635                // deliberately NOT retried here — the compactor has no
636                // feedback loop, and honoring it would let a
637                // deterministically-rejecting hook start a paid retry loop
638                // inside compaction.
639                if let Some(hooks) = &self.hooks {
640                    match hooks.on_llm_response(&response).await {
641                        ResponseDecision::Block(reason) => {
642                            return Err(SummarizationFailure {
643                                error: anyhow::anyhow!(
644                                    "Summarization response blocked by guardrail: {reason}"
645                                ),
646                                usage,
647                            });
648                        }
649                        ResponseDecision::RetryWithFeedback(reason) => {
650                            return Err(SummarizationFailure {
651                                error: anyhow::anyhow!(
652                                    "Summarization response rejected by guardrail \
653                                     (RetryWithFeedback is not retried during compaction): {reason}"
654                                ),
655                                usage,
656                            });
657                        }
658                        // `Accept`, plus any future `#[non_exhaustive]`
659                        // variant, keeps the summary (mirrors the agent loop).
660                        _ => {}
661                    }
662                }
663                let truncated = response.stop_reason == Some(StopReason::MaxTokens);
664                let Some(text) = response.first_text().map(String::from) else {
665                    return Err(SummarizationFailure {
666                        error: anyhow::anyhow!("No text in summarization response"),
667                        usage,
668                    });
669                };
670                Ok(SummarizationCall {
671                    text,
672                    truncated,
673                    usage,
674                })
675            }
676            ChatOutcome::RateLimited(_) => Err(SummarizationFailure {
677                error: anyhow::anyhow!("Rate limited during summarization"),
678                usage: TokenUsage::default(),
679            }),
680            ChatOutcome::InvalidRequest(msg) => Err(SummarizationFailure {
681                error: anyhow::anyhow!("Invalid request during summarization: {msg}"),
682                usage: TokenUsage::default(),
683            }),
684            ChatOutcome::ServerError(msg) => Err(SummarizationFailure {
685                error: anyhow::anyhow!("Server error during summarization: {msg}"),
686                usage: TokenUsage::default(),
687            }),
688            // `ChatOutcome` is `#[non_exhaustive]`; an unrecognized outcome
689            // fails the summarization rather than returning an empty summary.
690            _ => Err(SummarizationFailure {
691                error: anyhow::anyhow!("Unrecognized provider outcome during summarization"),
692                usage: TokenUsage::default(),
693            }),
694        }
695    }
696
697    /// Summarize `messages`, tracking the provider-billed usage of every
698    /// LLM call made (including the enlarged-budget retry on truncation).
699    ///
700    /// This is the usage-aware core behind both the
701    /// [`ContextCompactor::compact`] trait method (which discards the usage
702    /// for backward compatibility) and [`ContextCompactor::compact_history`]
703    /// (which surfaces it via [`CompactionResult::llm_usage`]).
704    async fn summarize_with_usage(
705        &self,
706        messages: &[Message],
707    ) -> Result<(String, TokenUsage), SummarizationFailure> {
708        // Separate prior compaction summaries (whose prose must be carried
709        // forward) from fresh messages (which still need summarizing). Prior
710        // summaries used to be filtered out and silently dropped, destroying
711        // all context from before the previous compaction.
712        let mut prior_summaries: Vec<String> = Vec::new();
713        let mut fresh: Vec<&Message> = Vec::new();
714        for message in messages {
715            if let Some(text) = Self::extract_summary_text(&message.content) {
716                if !text.is_empty() {
717                    prior_summaries.push(text);
718                }
719            } else {
720                fresh.push(message);
721            }
722        }
723
724        // Nothing fresh to summarize: carry prior summaries forward verbatim
725        // (no LLM call needed) rather than discarding them.
726        if fresh.is_empty() {
727            if prior_summaries.is_empty() {
728                return Ok((COMPACT_EMPTY_SUMMARY.to_string(), TokenUsage::default()));
729            }
730            return Ok((prior_summaries.join("\n\n"), TokenUsage::default()));
731        }
732
733        let messages_text = Self::format_messages_for_summary(fresh.iter().copied());
734        let prompt = self.build_summary_prompt(&prior_summaries, &messages_text);
735
736        let budget = self.config.summary_max_tokens;
737        let first = self.run_summarization(prompt.clone(), budget).await?;
738        let mut summary = first.text;
739        let mut total_usage = first.usage;
740
741        if first.truncated {
742            log::warn!(
743                "compaction summary hit the max_tokens budget ({budget}); \
744                 retrying with a larger budget to avoid silent context loss"
745            );
746            let retry = match self
747                .run_summarization(prompt, budget.saturating_mul(2))
748                .await
749            {
750                Ok(retry) => retry,
751                Err(mut failure) => {
752                    // The first (truncated) call was still billed: carry its
753                    // usage on the failure so the caller can account it.
754                    failure.usage.add(&total_usage);
755                    return Err(failure);
756                }
757            };
758            total_usage.add(&retry.usage);
759            summary = retry.text;
760            if retry.truncated {
761                log::warn!(
762                    "compaction summary still truncated after retry; appending a \
763                     truncation marker so downstream context loss is visible"
764                );
765                summary.push_str(TRUNCATED_SUMMARY_MARKER);
766            }
767        }
768
769        Ok((summary, total_usage))
770    }
771}
772
773/// Outcome of one summarization LLM round-trip.
774struct SummarizationCall {
775    text: String,
776    truncated: bool,
777    usage: TokenUsage,
778}
779
780/// A failed summarization round-trip, carrying whatever usage was billed
781/// before the failure (a response rejected by the output guardrail was
782/// still billed; a request blocked before dispatch was not).
783struct SummarizationFailure {
784    error: anyhow::Error,
785    usage: TokenUsage,
786}
787
788impl<P: LlmProvider + ?Sized, H: AgentHooks> LlmContextCompactor<P, H> {
789    /// Usage-aware core of [`ContextCompactor::compact_history`]: a failure
790    /// carries the billed usage of any summarization calls already made.
791    async fn compact_history_inner(
792        &self,
793        mut messages: Vec<Message>,
794    ) -> Result<CompactionResult, FailedCompaction> {
795        let original_count = messages.len();
796        let original_tokens = self.estimate_tokens(&messages);
797
798        // OpenAI Responses reasoning items (and any future provider-owned
799        // opaque state) must remain in their original history position for a
800        // valid follow-up. Generic compaction turns older messages into prose,
801        // so it cannot preserve that contract safely.
802        if Self::has_opaque_reasoning(&messages) {
803            log::debug!(
804                "skipping generic context compaction for history with opaque provider reasoning state"
805            );
806            return Ok(CompactionResult {
807                messages,
808                original_count,
809                new_count: original_count,
810                original_tokens,
811                new_tokens: original_tokens,
812                llm_usage: TokenUsage::default(),
813            });
814        }
815
816        // Ensure we have enough messages to compact
817        if messages.len() <= self.config.retain_recent {
818            return Ok(CompactionResult {
819                messages,
820                original_count,
821                new_count: original_count,
822                original_tokens,
823                new_tokens: original_tokens,
824                llm_usage: TokenUsage::default(),
825            });
826        }
827
828        // Split messages: old messages to summarize, recent messages to keep
829        let mut split_point = messages.len().saturating_sub(self.config.retain_recent);
830        split_point = Self::split_point_preserves_tool_pairs_with_cap(
831            &messages,
832            split_point,
833            self.config.max_retained_tail_tokens,
834        );
835
836        // Move the retained tail out of `messages` so it doesn't have to be
837        // cloned: `messages` then holds exactly the slice to summarize.
838        let to_keep = messages.split_off(split_point);
839        let to_summarize = messages;
840
841        // Summarize old messages
842        let (summary, llm_usage) =
843            self.summarize_with_usage(&to_summarize)
844                .await
845                .map_err(|failure| FailedCompaction {
846                    error: failure.error,
847                    llm_usage: failure.usage,
848                })?;
849
850        // Build new message history
851        let mut new_messages = Vec::with_capacity(2 + to_keep.len());
852
853        // Add summary as a user message
854        new_messages.push(Message::user(format!("{SUMMARY_PREFIX}{summary}")));
855
856        // Add acknowledgment from assistant only when some recent tail remains.
857        // If compaction drops the entire retained tail due to the token cap, ending
858        // the request with this synthetic assistant message would act like assistant
859        // prefill and Anthropic rejects that shape.
860        if !to_keep.is_empty() {
861            new_messages.push(Message::assistant(SUMMARY_ACKNOWLEDGMENT));
862        }
863
864        // Add recent messages. `to_keep` is guaranteed self-consistent
865        // by `split_point_preserves_tool_pairs_with_cap` (steps 2 and
866        // 3): any orphan `tool_result` was either folded into the
867        // summary (split shifted forward) or paired with its
868        // `tool_use` inside `to_keep` (split shifted backward). No
869        // post-hoc rewriting of the assembled output is required.
870        // The tail is moved (not cloned) since `compact_history` owns it.
871        new_messages.extend(to_keep);
872
873        let new_count = new_messages.len();
874        let new_tokens = self.estimate_tokens(&new_messages);
875
876        Ok(CompactionResult {
877            messages: new_messages,
878            original_count,
879            new_count,
880            original_tokens,
881            new_tokens,
882            llm_usage,
883        })
884    }
885}
886
887#[async_trait]
888impl<P: LlmProvider + ?Sized, H: AgentHooks> ContextCompactor for LlmContextCompactor<P, H> {
889    async fn compact(&self, messages: &[Message]) -> Result<String> {
890        let (summary, _usage) = self
891            .summarize_with_usage(messages)
892            .await
893            .map_err(|failure| failure.error)?;
894        Ok(summary)
895    }
896
897    fn estimate_tokens(&self, messages: &[Message]) -> usize {
898        TokenEstimator::estimate_history(messages)
899    }
900
901    fn needs_compaction(&self, messages: &[Message]) -> bool {
902        if !self.config.auto_compact {
903            return false;
904        }
905
906        // Provider-owned opaque reasoning state has exact replay semantics.
907        // Do not repeatedly schedule a generic compaction that must refuse to
908        // rewrite this history; a provider-native compactor can replace this
909        // guard when it becomes available.
910        if Self::has_opaque_reasoning(messages) {
911            return false;
912        }
913
914        if messages.len() < self.config.min_messages_for_compaction {
915            return false;
916        }
917
918        let estimated_tokens = self.estimate_tokens(messages);
919        estimated_tokens > self.config.threshold_tokens
920    }
921
922    async fn compact_history(&self, messages: Vec<Message>) -> Result<CompactionResult> {
923        self.compact_history_inner(messages)
924            .await
925            .map_err(|failure| failure.error)
926    }
927
928    async fn compact_history_with_usage(
929        &self,
930        messages: Vec<Message>,
931    ) -> Result<CompactionResult, FailedCompaction> {
932        self.compact_history_inner(messages).await
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::llm::{ChatResponse, StopReason, Usage};
940    use anyhow::bail;
941    use std::sync::Mutex;
942
943    struct MockProvider {
944        summary_response: String,
945        requests: Arc<Mutex<Vec<String>>>,
946        /// When true, the summary echoes the received prompt (simulating an LLM
947        /// that faithfully preserves its input — used to assert carry-forward).
948        echo_input: bool,
949        /// `stop_reason` returned by the mock; `MaxTokens` simulates truncation.
950        stop_reason: StopReason,
951    }
952
953    impl MockProvider {
954        fn build(
955            summary: &str,
956            requests: Arc<Mutex<Vec<String>>>,
957            echo_input: bool,
958            stop_reason: StopReason,
959        ) -> Self {
960            Self {
961                summary_response: summary.to_string(),
962                requests,
963                echo_input,
964                stop_reason,
965            }
966        }
967
968        fn new(summary: &str) -> Self {
969            Self::build(
970                summary,
971                Arc::new(Mutex::new(Vec::new())),
972                false,
973                StopReason::EndTurn,
974            )
975        }
976
977        fn new_with_request_log(summary: &str, requests: Arc<Mutex<Vec<String>>>) -> Self {
978            Self::build(summary, requests, false, StopReason::EndTurn)
979        }
980
981        /// A provider whose summary echoes the received prompt verbatim.
982        fn new_echo(requests: Arc<Mutex<Vec<String>>>) -> Self {
983            Self::build("", requests, true, StopReason::EndTurn)
984        }
985
986        /// A provider that always reports `MaxTokens` (a truncated summary).
987        fn new_truncating(summary: &str, requests: Arc<Mutex<Vec<String>>>) -> Self {
988            Self::build(summary, requests, false, StopReason::MaxTokens)
989        }
990
991        fn user_prompt_of(request: &ChatRequest) -> String {
992            request
993                .messages
994                .iter()
995                .find_map(|message| match &message.content {
996                    Content::Text(text) => Some(text.clone()),
997                    Content::Blocks(blocks) => {
998                        let text = blocks
999                            .iter()
1000                            .filter_map(|block| {
1001                                if let ContentBlock::Text { text } = block {
1002                                    Some(text.as_str())
1003                                } else {
1004                                    None
1005                                }
1006                            })
1007                            .collect::<Vec<_>>()
1008                            .join("\n");
1009                        if text.is_empty() { None } else { Some(text) }
1010                    }
1011                })
1012                .unwrap_or_default()
1013        }
1014    }
1015
1016    #[async_trait]
1017    impl LlmProvider for MockProvider {
1018        async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
1019            let user_prompt = Self::user_prompt_of(&request);
1020            if let Ok(mut entries) = self.requests.lock() {
1021                entries.push(user_prompt.clone());
1022            }
1023            let text = if self.echo_input {
1024                user_prompt
1025            } else {
1026                self.summary_response.clone()
1027            };
1028            Ok(ChatOutcome::Success(ChatResponse {
1029                id: "test".to_string(),
1030                content: vec![ContentBlock::Text { text }],
1031                model: "mock".to_string(),
1032                stop_reason: Some(self.stop_reason),
1033                usage: Usage {
1034                    input_tokens: 100,
1035                    output_tokens: 50,
1036                    cached_input_tokens: 0,
1037                    cache_creation_input_tokens: 0,
1038                },
1039            }))
1040        }
1041
1042        fn model(&self) -> &'static str {
1043            "mock-model"
1044        }
1045
1046        fn provider(&self) -> &'static str {
1047            "mock"
1048        }
1049    }
1050
1051    #[test]
1052    fn test_needs_compaction_below_threshold() {
1053        let provider = Arc::new(MockProvider::new("summary"));
1054        let config = CompactionConfig::default()
1055            .with_threshold_tokens(10_000)
1056            .with_min_messages(5);
1057        let compactor = LlmContextCompactor::new(provider, config);
1058
1059        // Only 3 messages, below min_messages
1060        let messages = vec![
1061            Message::user("Hello"),
1062            Message::assistant("Hi"),
1063            Message::user("How are you?"),
1064        ];
1065
1066        assert!(!compactor.needs_compaction(&messages));
1067    }
1068
1069    #[test]
1070    fn test_needs_compaction_above_threshold() {
1071        let provider = Arc::new(MockProvider::new("summary"));
1072        let config = CompactionConfig::default()
1073            .with_threshold_tokens(50) // Very low threshold
1074            .with_min_messages(3);
1075        let compactor = LlmContextCompactor::new(provider, config);
1076
1077        // Messages that exceed threshold
1078        let messages = vec![
1079            Message::user("Hello, this is a longer message to test compaction"),
1080            Message::assistant(
1081                "Hi there! This is also a longer response to help trigger compaction",
1082            ),
1083            Message::user("Great, let's continue with even more text here"),
1084            Message::assistant("Absolutely, adding more content to ensure we exceed the threshold"),
1085        ];
1086
1087        assert!(compactor.needs_compaction(&messages));
1088    }
1089
1090    #[test]
1091    fn test_needs_compaction_auto_disabled() {
1092        let provider = Arc::new(MockProvider::new("summary"));
1093        let config = CompactionConfig::default()
1094            .with_threshold_tokens(10) // Very low
1095            .with_min_messages(1)
1096            .with_auto_compact(false);
1097        let compactor = LlmContextCompactor::new(provider, config);
1098
1099        let messages = vec![
1100            Message::user("Hello, this is a longer message"),
1101            Message::assistant("Response here"),
1102        ];
1103
1104        assert!(!compactor.needs_compaction(&messages));
1105    }
1106
1107    #[test]
1108    fn summary_prompt_redacts_opaque_reasoning_payload() {
1109        let secret = "opaque-secret-that-must-not-enter-the-summary-prompt";
1110        let message = Message::assistant_with_content(vec![ContentBlock::OpaqueReasoning {
1111            provider: "test-provider".to_owned(),
1112            data: serde_json::json!({"encrypted_content": secret}),
1113        }]);
1114
1115        let rendered = LlmContextCompactor::<MockProvider>::format_messages_for_summary([&message]);
1116        assert!(rendered.contains("[Opaque reasoning state omitted]"));
1117        assert!(!rendered.contains(secret));
1118    }
1119
1120    #[tokio::test]
1121    async fn compact_history_preserves_opaque_reasoning_in_retained_tail() -> Result<()> {
1122        let provider = Arc::new(MockProvider::new("older context"));
1123        let config = CompactionConfig::default()
1124            .with_retain_recent(2)
1125            .with_min_messages(3);
1126        let compactor = LlmContextCompactor::new(provider, config);
1127        let opaque_data = serde_json::json!({
1128            "id": "reasoning_1",
1129            "encrypted_content": "ciphertext"
1130        });
1131        let messages = vec![
1132            Message::user("old question"),
1133            Message::assistant("old answer"),
1134            Message::user("current question"),
1135            Message::assistant_with_content(vec![ContentBlock::OpaqueReasoning {
1136                provider: "test-provider".to_owned(),
1137                data: opaque_data.clone(),
1138            }]),
1139        ];
1140
1141        let result = compactor.compact_history(messages).await?;
1142        let retained = result
1143            .messages
1144            .last()
1145            .context("compacted history should retain the newest assistant message")?;
1146        let Content::Blocks(blocks) = &retained.content else {
1147            bail!("retained assistant message should contain blocks");
1148        };
1149        assert!(matches!(
1150            blocks.first(),
1151            Some(ContentBlock::OpaqueReasoning { provider, data })
1152                if provider == "test-provider" && data == &opaque_data
1153        ));
1154        Ok(())
1155    }
1156
1157    #[tokio::test]
1158    async fn compact_history_bypasses_opaque_reasoning_before_the_split() -> Result<()> {
1159        let requests = Arc::new(Mutex::new(Vec::new()));
1160        let provider = Arc::new(MockProvider::new_with_request_log(
1161            "summary that must not be requested",
1162            Arc::clone(&requests),
1163        ));
1164        let config = CompactionConfig::default()
1165            .with_retain_recent(1)
1166            .with_min_messages(1)
1167            .with_threshold_tokens(1);
1168        let compactor = LlmContextCompactor::new(provider, config);
1169        let opaque_data = serde_json::json!({
1170            "type": "reasoning",
1171            "id": "rs_1",
1172            "encrypted_content": "ciphertext"
1173        });
1174        let messages = vec![
1175            Message::user("older user context"),
1176            // This message lies before the normal `len - retain_recent` split
1177            // and would previously have been converted into a prose summary.
1178            Message::assistant_with_content(vec![
1179                ContentBlock::OpaqueReasoning {
1180                    provider: "openai-responses".to_owned(),
1181                    data: opaque_data,
1182                },
1183                ContentBlock::Text {
1184                    text: "older assistant response".to_owned(),
1185                },
1186            ]),
1187            Message::user("newer user context"),
1188            Message::assistant("newer assistant response"),
1189        ];
1190        let serialized_before = serde_json::to_value(&messages)?;
1191
1192        assert!(
1193            !compactor.needs_compaction(&messages),
1194            "generic compaction must not be scheduled for opaque replay state"
1195        );
1196
1197        let result = compactor.compact_history(messages).await?;
1198
1199        assert_eq!(serde_json::to_value(&result.messages)?, serialized_before);
1200        assert_eq!(result.new_count, result.original_count);
1201        assert_eq!(result.new_tokens, result.original_tokens);
1202        let recorded = requests
1203            .lock()
1204            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1205        assert!(
1206            recorded.is_empty(),
1207            "opaque replay state must not be sent to the generic summarizer"
1208        );
1209        drop(recorded);
1210        Ok(())
1211    }
1212
1213    #[tokio::test]
1214    async fn test_compact_history() -> Result<()> {
1215        let provider = Arc::new(MockProvider::new(
1216            "User asked about Rust programming. Assistant explained ownership, borrowing, and lifetimes.",
1217        ));
1218        let config = CompactionConfig::default()
1219            .with_retain_recent(2)
1220            .with_min_messages(3);
1221        let compactor = LlmContextCompactor::new(provider, config);
1222
1223        // Use longer messages to ensure compaction actually reduces tokens
1224        let messages = vec![
1225            Message::user(
1226                "What is Rust? I've heard it's a systems programming language but I don't know much about it. Can you explain the key features and why people are excited about it?",
1227            ),
1228            Message::assistant(
1229                "Rust is a systems programming language focused on safety, speed, and concurrency. It achieves memory safety without garbage collection through its ownership system. The key features include zero-cost abstractions, guaranteed memory safety, threads without data races, and minimal runtime.",
1230            ),
1231            Message::user(
1232                "Tell me about ownership in detail. How does it work and what are the rules? I want to understand this core concept thoroughly.",
1233            ),
1234            Message::assistant(
1235                "Ownership is Rust's central feature with three rules: each value has one owner, only one owner at a time, and the value is dropped when owner goes out of scope. This system prevents memory leaks, double frees, and dangling pointers at compile time.",
1236            ),
1237            Message::user("What about borrowing?"), // Keep
1238            Message::assistant("Borrowing allows references to data without taking ownership."), // Keep
1239        ];
1240
1241        let result = compactor.compact_history(messages).await?;
1242
1243        // Should have: summary message + ack + 2 recent messages = 4
1244        assert_eq!(result.new_count, 4);
1245        assert_eq!(result.original_count, 6);
1246
1247        // With longer original messages, compaction should reduce tokens
1248        assert!(
1249            result.new_tokens < result.original_tokens,
1250            "Expected fewer tokens after compaction: new={} < original={}",
1251            result.new_tokens,
1252            result.original_tokens
1253        );
1254
1255        // First message should be the summary
1256        if let Content::Text(text) = &result.messages[0].content {
1257            assert!(text.contains("Previous conversation summary"));
1258        }
1259
1260        Ok(())
1261    }
1262
1263    #[tokio::test]
1264    async fn test_compact_history_too_few_messages() -> Result<()> {
1265        let provider = Arc::new(MockProvider::new("summary"));
1266        let config = CompactionConfig::default().with_retain_recent(5);
1267        let compactor = LlmContextCompactor::new(provider, config);
1268
1269        // Only 3 messages, less than retain_recent
1270        let messages = vec![
1271            Message::user("Hello"),
1272            Message::assistant("Hi"),
1273            Message::user("Bye"),
1274        ];
1275
1276        let result = compactor.compact_history(messages.clone()).await?;
1277
1278        // Should return original messages unchanged
1279        assert_eq!(result.new_count, 3);
1280        assert_eq!(result.messages.len(), 3);
1281
1282        Ok(())
1283    }
1284
1285    #[test]
1286    fn test_format_messages_for_summary() {
1287        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
1288
1289        let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
1290
1291        assert!(formatted.contains("User: Hello"));
1292        assert!(formatted.contains("Assistant: Hi there!"));
1293    }
1294
1295    #[test]
1296    fn test_format_messages_for_summary_truncates_tool_results_unicode_safely() {
1297        let long_unicode = "é".repeat(600);
1298
1299        let messages = vec![Message {
1300            role: Role::Assistant,
1301            content: Content::Blocks(vec![ContentBlock::ToolResult {
1302                tool_use_id: "tool-1".to_string(),
1303                content: long_unicode,
1304                is_error: Some(false),
1305            }]),
1306        }];
1307
1308        let formatted = LlmContextCompactor::<MockProvider>::format_messages_for_summary(&messages);
1309
1310        assert!(formatted.contains("... (truncated)"));
1311    }
1312
1313    #[tokio::test]
1314    async fn test_compact_carries_prior_summary_into_request() -> Result<()> {
1315        // A prior compaction summary must be carried forward into the
1316        // summarization input (not silently filtered out), so its facts are
1317        // preserved across re-compaction. The fresh message is summarized as
1318        // usual; the prior summary is included as a "Previous summary" section.
1319        let requests = Arc::new(Mutex::new(Vec::new()));
1320        let provider = Arc::new(MockProvider::new_with_request_log(
1321            "Fresh summary",
1322            requests.clone(),
1323        ));
1324        let config = CompactionConfig::default().with_min_messages(1);
1325        let compactor = LlmContextCompactor::new(provider, config);
1326
1327        let messages = vec![
1328            Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
1329            Message::assistant("Continue with the next task using this context."),
1330        ];
1331
1332        let summary = compactor.compact(&messages).await?;
1333
1334        let recorded = requests
1335            .lock()
1336            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1337        assert_eq!(recorded.len(), 1);
1338        // The new summary is the LLM's output; the prior summary lives in the
1339        // request, where a real model subsumes it into the new summary.
1340        assert_eq!(summary, "Fresh summary");
1341        assert!(recorded[0].contains("Continue with the next task using this context."));
1342        assert!(
1343            recorded[0].contains("already compacted context"),
1344            "prior summary must be carried into the summarization input"
1345        );
1346        drop(recorded);
1347
1348        Ok(())
1349    }
1350
1351    #[tokio::test]
1352    async fn test_compact_history_carries_prior_summary_in_candidate_payload() -> Result<()> {
1353        let requests = Arc::new(Mutex::new(Vec::new()));
1354        let provider = Arc::new(MockProvider::new_with_request_log(
1355            "Fresh history summary",
1356            requests.clone(),
1357        ));
1358        let config = CompactionConfig::default()
1359            .with_retain_recent(2)
1360            .with_min_messages(1);
1361        let compactor = LlmContextCompactor::new(provider, config);
1362
1363        let messages = vec![
1364            Message::user(format!("{SUMMARY_PREFIX}already compacted context")),
1365            Message::assistant("Current turn content from the latest exchange."),
1366            Message::assistant("Recent message that should stay."),
1367            Message::user("Newest note that should stay."),
1368        ];
1369
1370        let result = compactor.compact_history(messages).await?;
1371
1372        let recorded = requests
1373            .lock()
1374            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1375        assert_eq!(recorded.len(), 1);
1376        assert!(recorded[0].contains("Current turn content from the latest exchange."));
1377        // The prior summary is carried into the summarization input rather than
1378        // being silently discarded.
1379        assert!(
1380            recorded[0].contains("already compacted context"),
1381            "prior summary content must reach the summarizer"
1382        );
1383        drop(recorded);
1384        assert_eq!(result.new_count, 4);
1385
1386        Ok(())
1387    }
1388
1389    #[tokio::test]
1390    async fn test_compact_history_carries_summaries_forward_when_window_has_only_summaries()
1391    -> Result<()> {
1392        let requests = Arc::new(Mutex::new(Vec::new()));
1393        let provider = Arc::new(MockProvider::new_with_request_log(
1394            "This summary should not be used",
1395            requests.clone(),
1396        ));
1397        let config = CompactionConfig::default()
1398            .with_retain_recent(2)
1399            .with_min_messages(1);
1400        let compactor = LlmContextCompactor::new(provider, config);
1401
1402        let messages = vec![
1403            Message::user(format!("{SUMMARY_PREFIX}first prior compacted section")),
1404            Message::assistant(format!("{SUMMARY_PREFIX}second prior compacted section")),
1405            Message::user(format!("{SUMMARY_PREFIX}third prior compacted section")),
1406            Message::assistant("final short note"),
1407        ];
1408
1409        let result = compactor.compact_history(messages).await?;
1410
1411        // No fresh content in the candidate window -> no LLM call is made, but
1412        // the prior summaries must be carried forward verbatim, NOT replaced
1413        // with an empty-summary placeholder (which used to destroy context).
1414        let recorded = requests
1415            .lock()
1416            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1417        assert!(recorded.is_empty());
1418        drop(recorded);
1419        assert_eq!(result.new_count, 4);
1420        assert_eq!(result.messages.len(), 4);
1421
1422        if let Content::Text(text) = &result.messages[0].content {
1423            assert!(
1424                text.contains("first prior compacted section"),
1425                "first prior summary lost"
1426            );
1427            assert!(
1428                text.contains("second prior compacted section"),
1429                "second prior summary lost"
1430            );
1431            assert!(!text.contains(COMPACT_EMPTY_SUMMARY));
1432        } else {
1433            panic!("Expected summary text in first message");
1434        }
1435
1436        Ok(())
1437    }
1438
1439    #[tokio::test]
1440    async fn test_compact_history_preserves_tool_use_tool_result_pairs() -> Result<()> {
1441        let provider = Arc::new(MockProvider::new("Summary of earlier conversation."));
1442        let config = CompactionConfig::default()
1443            .with_retain_recent(2)
1444            .with_min_messages(3);
1445        let compactor = LlmContextCompactor::new(provider, config);
1446
1447        // Build a history where the split_point (len - retain_recent = 5 - 2 = 3)
1448        // would land exactly on the user tool_result message at index 3,
1449        // which would orphan it from its assistant tool_use at index 2.
1450        let messages = vec![
1451            // index 0: user
1452            Message::user("What files are in the project?"),
1453            // index 1: assistant text
1454            Message::assistant("Let me check that for you."),
1455            // index 2: assistant with tool_use
1456            Message {
1457                role: Role::Assistant,
1458                content: Content::Blocks(vec![ContentBlock::ToolUse {
1459                    id: "tool_1".to_string(),
1460                    name: "list_files".to_string(),
1461                    input: serde_json::json!({}),
1462                    thought_signature: None,
1463                }]),
1464            },
1465            // index 3: user with tool_result (naive split would land here)
1466            Message {
1467                role: Role::User,
1468                content: Content::Blocks(vec![ContentBlock::ToolResult {
1469                    tool_use_id: "tool_1".to_string(),
1470                    content: "file1.rs\nfile2.rs".to_string(),
1471                    is_error: None,
1472                }]),
1473            },
1474            // index 4: assistant final response
1475            Message::assistant("The project contains file1.rs and file2.rs."),
1476        ];
1477
1478        let result = compactor.compact_history(messages).await?;
1479
1480        // The split_point should have been adjusted back from 3 to 2,
1481        // so to_keep includes: [assistant tool_use, user tool_result, assistant response]
1482        // Plus summary + ack = 5 total
1483        assert_eq!(result.new_count, 5);
1484
1485        // Verify the kept messages include the tool_use/tool_result pair
1486        // After summary + ack, the third message should be the assistant with tool_use
1487        let kept_assistant = &result.messages[2];
1488        if let Content::Blocks(blocks) = &kept_assistant.content {
1489            assert!(
1490                blocks
1491                    .iter()
1492                    .any(|b| matches!(b, ContentBlock::ToolUse { .. })),
1493                "Expected assistant tool_use in kept messages"
1494            );
1495        } else {
1496            panic!("Expected Blocks content for assistant tool_use message");
1497        }
1498
1499        // The fourth message should be the user tool_result
1500        let kept_user = &result.messages[3];
1501        if let Content::Blocks(blocks) = &kept_user.content {
1502            assert!(
1503                blocks
1504                    .iter()
1505                    .any(|b| matches!(b, ContentBlock::ToolResult { .. })),
1506                "Expected user tool_result in kept messages"
1507            );
1508        } else {
1509            panic!("Expected Blocks content for user tool_result message");
1510        }
1511
1512        Ok(())
1513    }
1514
1515    #[tokio::test]
1516    async fn test_compact_history_split_skips_leading_orphan_after_summary_ack() -> Result<()> {
1517        // The user-visible bug at M7.5: a previously
1518        // compacted history was re-compacted in a later turn. The
1519        // first compaction left
1520        // `[summary, summary_ack, user(tool_result toolu_X),
1521        //  assistant(toolu_X reply), ...]`. On the second pass the
1522        // default `split_point` (len - retain_recent = 5 - 3 = 2)
1523        // would have made `to_keep[0] == user(tool_result toolu_X)`,
1524        // and the synthetic `[summary, summary_ack, …]` prefix the
1525        // compactor inserts in front of `to_keep` has no `tool_use`
1526        // blocks — so the next request to Anthropic blew up with
1527        // `messages.2.content.0: unexpected tool_use_id`.
1528        //
1529        // Pair-preservation alone can't fix this: it only inspects
1530        // the immediate prev/next pair (here `summary_ack` vs
1531        // `user(tool_result)`) and `summary_ack` is text-only, so the
1532        // pair check sees no `tool_use` to anchor on and lets the
1533        // orphan through. The chain-safety pass added in
1534        // `split_point_preserves_tool_pairs_with_cap` step 3 walks
1535        // the candidate forward past any leading orphan, so the
1536        // `tool_result` lands in `to_summarize` and gets folded into
1537        // the summary's prose where it's harmless.
1538        //
1539        // The assertion is structural, not block-counting: every
1540        // surviving `tool_result` must reference a `tool_use` that
1541        // appears earlier in the new message list. No
1542        // post-compaction stripping is involved — the split point
1543        // alone is responsible for chain integrity.
1544        let provider = Arc::new(MockProvider::new("Re-summary."));
1545        let config = CompactionConfig::default()
1546            .with_retain_recent(3)
1547            .with_min_messages(1);
1548        let compactor = LlmContextCompactor::new(provider, config);
1549
1550        let messages = vec![
1551            Message::user(format!("{SUMMARY_PREFIX}Old summary about toolu_X.")),
1552            Message::assistant(SUMMARY_ACKNOWLEDGMENT),
1553            Message {
1554                role: Role::User,
1555                content: Content::Blocks(vec![ContentBlock::ToolResult {
1556                    tool_use_id: "toolu_X".to_string(),
1557                    content: "result for X".to_string(),
1558                    is_error: None,
1559                }]),
1560            },
1561            Message::assistant("Result interpreted."),
1562            Message::user("Now what?"),
1563        ];
1564
1565        let result = compactor.compact_history(messages).await?;
1566
1567        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1568        for msg in &result.messages {
1569            if let Content::Blocks(blocks) = &msg.content {
1570                for block in blocks {
1571                    match block {
1572                        ContentBlock::ToolResult { tool_use_id, .. } => {
1573                            assert!(
1574                                seen_ids.contains(tool_use_id),
1575                                "orphan tool_use_id {tool_use_id} survived split selection",
1576                            );
1577                        }
1578                        ContentBlock::ToolUse { id, .. } => {
1579                            seen_ids.insert(id.clone());
1580                        }
1581                        _ => {}
1582                    }
1583                }
1584            }
1585        }
1586
1587        Ok(())
1588    }
1589
1590    #[tokio::test]
1591    async fn test_compact_history_keeps_tool_pair_when_immediate_prev_is_text_only() -> Result<()> {
1592        // Tighter regression for the chain-safety boundary: even
1593        // when the message *before* the candidate split point is
1594        // text-only (so pair-preservation has nothing to anchor on),
1595        // chain-safety must shift the split forward past a leading
1596        // `user(tool_result)` whose `tool_use` would otherwise be
1597        // folded into the summary.
1598        let provider = Arc::new(MockProvider::new("Boundary summary."));
1599        let config = CompactionConfig::default()
1600            .with_retain_recent(2)
1601            .with_min_messages(1);
1602        let compactor = LlmContextCompactor::new(provider, config);
1603
1604        // Layout (5 messages, retain_recent=2 → initial split=3):
1605        //   0: user("first turn") — to_summarize
1606        //   1: assistant("text only") — to_summarize, immediate prev
1607        //   2: user(tool_result toolu_Y) — orphan in default to_keep
1608        //   3: assistant("then a reply")
1609        //   4: user("ok thanks")
1610        //
1611        // The corresponding `tool_use` for toolu_Y was lost long
1612        // ago — there's no `tool_use` anywhere in `messages`. With
1613        // pair-preservation alone, `to_keep` would start at index 3
1614        // (or 2 unshifted), leaving the orphan at the head and
1615        // tripping Anthropic.
1616        let messages = vec![
1617            Message::user("first turn"),
1618            Message::assistant("text only"),
1619            Message {
1620                role: Role::User,
1621                content: Content::Blocks(vec![ContentBlock::ToolResult {
1622                    tool_use_id: "toolu_Y".to_string(),
1623                    content: "ancient result".to_string(),
1624                    is_error: None,
1625                }]),
1626            },
1627            Message::assistant("then a reply"),
1628            Message::user("ok thanks"),
1629        ];
1630
1631        let result = compactor.compact_history(messages).await?;
1632
1633        // No tool_result block survives anywhere — the only one in
1634        // input was orphaned and the split-shift folded it into the
1635        // summary.
1636        let has_tool_result = result.messages.iter().any(|m| {
1637            matches!(
1638                &m.content,
1639                Content::Blocks(blocks)
1640                    if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))
1641            )
1642        });
1643        assert!(
1644            !has_tool_result,
1645            "orphan tool_result should have been pushed into to_summarize, not retained",
1646        );
1647
1648        Ok(())
1649    }
1650
1651    #[tokio::test]
1652    async fn test_compact_history_retained_tail_is_token_capped() -> Result<()> {
1653        let provider = Arc::new(MockProvider::new(
1654            "Project summary with a long context and technical context.",
1655        ));
1656        let config = CompactionConfig::default()
1657            .with_retain_recent(8)
1658            .with_min_messages(1)
1659            .with_threshold_tokens(1);
1660        let compactor = LlmContextCompactor::new(provider, config);
1661
1662        let mut messages = Vec::new();
1663
1664        // Older messages that will be summarized away.
1665        messages.extend((0..6).map(|index| Message::user(format!("pre-compaction noise {index}"))));
1666
1667        // Newer long messages: intentionally large to force retained-tail truncation.
1668        messages.extend(
1669            (0..8).map(|index| Message::assistant(format!("kept-{index}: {}", "x".repeat(12_000)))),
1670        );
1671
1672        let result = compactor.compact_history(messages).await?;
1673
1674        // The retained tail should be token capped and therefore shorter than retain_recent.
1675        let retained_tail = &result.messages[2..];
1676        assert!(retained_tail.len() < 8);
1677
1678        let mut latest_index = -1i32;
1679        let mut all_retained = true;
1680        for message in retained_tail {
1681            if let Content::Text(text) = &message.content {
1682                if let Some(number) = text.split(':').next().and_then(|prefix| {
1683                    prefix
1684                        .strip_prefix("kept-")
1685                        .and_then(|rest| rest.parse::<i32>().ok())
1686                }) {
1687                    if number >= 0 {
1688                        latest_index = latest_index.max(number);
1689                    }
1690                } else {
1691                    all_retained = false;
1692                }
1693            } else {
1694                all_retained = false;
1695            }
1696        }
1697
1698        assert!(all_retained);
1699        assert_eq!(latest_index, 7);
1700        assert!(
1701            TokenEstimator::estimate_history(retained_tail)
1702                <= compactor.config().max_retained_tail_tokens
1703        );
1704        assert!(compactor.needs_compaction(&result.messages));
1705
1706        Ok(())
1707    }
1708
1709    #[tokio::test]
1710    async fn test_compact_history_skips_summary_ack_when_retained_tail_is_empty() -> Result<()> {
1711        let provider = Arc::new(MockProvider::new("Summary for oversized user turn."));
1712        let config = CompactionConfig::default()
1713            .with_retain_recent(1)
1714            .with_min_messages(1)
1715            .with_threshold_tokens(1);
1716        let compactor = LlmContextCompactor::new(provider, config);
1717
1718        let messages = vec![
1719            Message::assistant("Earlier assistant context."),
1720            Message::user(format!("oversized-user-turn: {}", "x".repeat(200_000))),
1721        ];
1722
1723        let result = compactor.compact_history(messages).await?;
1724
1725        assert_eq!(result.new_count, 1);
1726        assert_eq!(result.messages.len(), 1);
1727
1728        let only_message = &result.messages[0];
1729        assert_eq!(only_message.role, Role::User);
1730
1731        if let Content::Text(text) = &only_message.content {
1732            assert!(text.contains("Previous conversation summary"));
1733            assert!(!text.contains(SUMMARY_ACKNOWLEDGMENT));
1734        } else {
1735            panic!("Expected summary text when retained tail is empty");
1736        }
1737
1738        Ok(())
1739    }
1740
1741    fn message_contains(message: &Message, needle: &str) -> bool {
1742        match &message.content {
1743            Content::Text(text) => text.contains(needle),
1744            Content::Blocks(blocks) => blocks.iter().any(|block| match block {
1745                ContentBlock::Text { text } => text.contains(needle),
1746                _ => false,
1747            }),
1748        }
1749    }
1750
1751    #[tokio::test]
1752    async fn test_epoch_one_facts_survive_two_compactions() -> Result<()> {
1753        // Regression for re-compaction data loss: a fact recorded before the
1754        // first compaction must still be present in the history after a second
1755        // compaction. The echo provider models a faithful LLM that preserves
1756        // its input; the bug was that the first summary (carrying the epoch-1
1757        // fact) was filtered out of the second summarization and dropped.
1758        const EPOCH1_FACT: &str = "EPOCH1_FACT: the API key lives in config/secrets.toml";
1759
1760        let requests = Arc::new(Mutex::new(Vec::new()));
1761        let provider = Arc::new(MockProvider::new_echo(requests.clone()));
1762        let config = CompactionConfig::default()
1763            .with_retain_recent(2)
1764            .with_min_messages(1);
1765        let compactor = LlmContextCompactor::new(provider, config);
1766
1767        let epoch1 = vec![
1768            Message::user(EPOCH1_FACT),
1769            Message::assistant("Understood, noted the secrets path."),
1770            Message::user("Now add error handling to main.rs."),
1771            Message::assistant("Added error handling to main.rs."),
1772            Message::user("latest user message one"),
1773            Message::assistant("latest assistant message two"),
1774        ];
1775
1776        let first = compactor.compact_history(epoch1).await?;
1777        assert!(
1778            first
1779                .messages
1780                .iter()
1781                .any(|m| message_contains(m, "EPOCH1_FACT")),
1782            "epoch-1 fact must be captured in the first summary"
1783        );
1784
1785        // Build the epoch-2 history on top of the first compaction's output.
1786        let mut epoch2 = first.messages;
1787        epoch2.push(Message::user("Another later turn."));
1788        epoch2.push(Message::assistant("Reply to the later turn."));
1789        epoch2.push(Message::user("Final turn a."));
1790        epoch2.push(Message::assistant("Final turn b."));
1791
1792        let second = compactor.compact_history(epoch2).await?;
1793
1794        assert!(
1795            second
1796                .messages
1797                .iter()
1798                .any(|m| message_contains(m, "EPOCH1_FACT")),
1799            "epoch-1 fact must survive the second compaction"
1800        );
1801
1802        // Sanity: the second compaction actually summarized (made an LLM call
1803        // on the prior summary), so this is a true re-compaction path.
1804        let recorded = requests
1805            .lock()
1806            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1807        assert!(
1808            recorded.iter().any(|req| req.contains("EPOCH1_FACT")),
1809            "prior summary carrying the epoch-1 fact must reach the summarizer"
1810        );
1811        drop(recorded);
1812
1813        Ok(())
1814    }
1815
1816    #[tokio::test]
1817    async fn test_compact_history_long_tool_chain_respects_token_cap() -> Result<()> {
1818        // Regression for the pair-shift bug: in an unbroken tool chain, the
1819        // old second clause of `crosses_tool_pair` walked the split point back
1820        // through the entire chain, retaining everything and defeating the
1821        // token cap. With only the assistant(tool_use)->user(tool_result)
1822        // boundary unsplittable, the retained tail stays bounded near the cap.
1823        let provider = Arc::new(MockProvider::new("Summary of the early tool chain."));
1824        let cap = 20_000;
1825        // retain_recent asks to keep many messages, but the cap must override
1826        // it. retain_recent < message count so we don't hit the early return.
1827        let config = CompactionConfig::default()
1828            .with_retain_recent(18)
1829            .with_min_messages(1)
1830            .with_threshold_tokens(1)
1831            .with_max_retained_tail_tokens(cap);
1832        let compactor = LlmContextCompactor::new(provider, config);
1833
1834        // 10 alternating tool pairs (20 messages), each large enough that the
1835        // whole chain dwarfs the cap.
1836        let mut messages = Vec::new();
1837        for i in 0..10 {
1838            messages.push(Message {
1839                role: Role::Assistant,
1840                content: Content::Blocks(vec![ContentBlock::ToolUse {
1841                    id: format!("tool_{i}"),
1842                    name: "run".to_string(),
1843                    input: serde_json::json!({ "arg": "y".repeat(12_000) }),
1844                    thought_signature: None,
1845                }]),
1846            });
1847            messages.push(Message {
1848                role: Role::User,
1849                content: Content::Blocks(vec![ContentBlock::ToolResult {
1850                    tool_use_id: format!("tool_{i}"),
1851                    content: format!("result-{i}: {}", "z".repeat(12_000)),
1852                    is_error: None,
1853                }]),
1854            });
1855        }
1856
1857        let full_tokens = TokenEstimator::estimate_history(&messages);
1858        assert!(
1859            full_tokens > cap * 2,
1860            "test setup: full chain must far exceed the cap"
1861        );
1862
1863        let result = compactor.compact_history(messages).await?;
1864
1865        // The retained tail is non-empty, so the output is
1866        // [summary, ack, ...tail]; skip the synthetic summary + ack prefix.
1867        let retained_tail = &result.messages[2..];
1868
1869        let tail_tokens = TokenEstimator::estimate_history(retained_tail);
1870        // Bounded near the cap (soft cap allows one extra message from pair
1871        // preservation); crucially NOT the entire chain.
1872        assert!(
1873            tail_tokens <= cap + 8_000,
1874            "retained tail {tail_tokens} should be bounded by the cap {cap}, not the whole chain"
1875        );
1876        assert!(
1877            retained_tail.len() < 20,
1878            "compaction must have summarized part of the chain"
1879        );
1880
1881        Ok(())
1882    }
1883
1884    #[tokio::test]
1885    async fn test_compact_warns_and_marks_truncated_summary() -> Result<()> {
1886        // Regression for silent summary truncation: when the summarizer hits
1887        // MaxTokens, the compactor retries with a larger budget and, if still
1888        // truncated, appends a visible marker instead of silently accepting a
1889        // clipped summary.
1890        let requests = Arc::new(Mutex::new(Vec::new()));
1891        let provider = Arc::new(MockProvider::new_truncating(
1892            "partial summary cut off mid-",
1893            requests.clone(),
1894        ));
1895        let config = CompactionConfig::default().with_min_messages(1);
1896        let compactor = LlmContextCompactor::new(provider, config);
1897
1898        let messages = vec![
1899            Message::user("Some content that needs summarizing."),
1900            Message::assistant("More content to summarize here."),
1901        ];
1902
1903        let summary = compactor.compact(&messages).await?;
1904
1905        assert!(
1906            summary.contains("[summary truncated"),
1907            "a persistently truncated summary must carry a truncation marker"
1908        );
1909
1910        // The compactor retried once with a larger budget: two calls total.
1911        let recorded = requests
1912            .lock()
1913            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1914        assert_eq!(recorded.len(), 2, "truncation should trigger one retry");
1915        drop(recorded);
1916
1917        Ok(())
1918    }
1919
1920    /// Long-enough history that `compact_history` performs a real
1921    /// summarization call (more messages than `retain_recent`).
1922    fn summarizable_messages() -> Vec<Message> {
1923        vec![
1924            Message::user("First question with enough words to summarize meaningfully."),
1925            Message::assistant("First answer, also carrying plenty of prose to compact."),
1926            Message::user("Second question continuing the earlier conversation topic."),
1927            Message::assistant("Second answer expanding on the topic at some length."),
1928            Message::user("Third question?"),
1929            Message::assistant("Third answer."),
1930        ]
1931    }
1932
1933    struct BlockRequestHooks;
1934
1935    #[async_trait]
1936    impl crate::hooks::AgentHooks for BlockRequestHooks {
1937        async fn pre_llm_request(&self, _request: &ChatRequest) -> RequestDecision {
1938            RequestDecision::Block("summaries are not allowed".to_string())
1939        }
1940    }
1941
1942    struct ModifyRequestHooks;
1943
1944    #[async_trait]
1945    impl crate::hooks::AgentHooks for ModifyRequestHooks {
1946        async fn pre_llm_request(&self, request: &ChatRequest) -> RequestDecision {
1947            let mut modified = request.clone();
1948            modified.messages = vec![Message::user("MODIFIED_SUMMARY_PROMPT")];
1949            RequestDecision::Modify(Box::new(modified))
1950        }
1951    }
1952
1953    struct BlockResponseHooks;
1954
1955    #[async_trait]
1956    impl crate::hooks::AgentHooks for BlockResponseHooks {
1957        async fn on_llm_response(&self, _response: &ChatResponse) -> ResponseDecision {
1958            ResponseDecision::Block("summary leaks a secret".to_string())
1959        }
1960    }
1961
1962    struct RetryResponseHooks;
1963
1964    #[async_trait]
1965    impl crate::hooks::AgentHooks for RetryResponseHooks {
1966        async fn on_llm_response(&self, _response: &ChatResponse) -> ResponseDecision {
1967            ResponseDecision::RetryWithFeedback("try harder".to_string())
1968        }
1969    }
1970
1971    #[tokio::test]
1972    async fn blocking_request_hook_aborts_compaction_before_the_llm_call() -> Result<()> {
1973        let requests = Arc::new(Mutex::new(Vec::new()));
1974        let provider = Arc::new(MockProvider::new_with_request_log(
1975            "summary",
1976            requests.clone(),
1977        ));
1978        let config = CompactionConfig::default().with_retain_recent(2);
1979        let compactor = LlmContextCompactor::new(provider, config)
1980            .with_guardrail_hooks(Arc::new(BlockRequestHooks));
1981
1982        let error = match compactor.compact_history(summarizable_messages()).await {
1983            Ok(result) => anyhow::bail!("blocked compaction must not succeed: {result:?}"),
1984            Err(error) => error,
1985        };
1986        assert!(
1987            error.to_string().contains("blocked by guardrail"),
1988            "unexpected error: {error}"
1989        );
1990
1991        let recorded = requests
1992            .lock()
1993            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
1994        assert!(
1995            recorded.is_empty(),
1996            "a blocked request must never reach the provider"
1997        );
1998        drop(recorded);
1999        Ok(())
2000    }
2001
2002    #[tokio::test]
2003    async fn modify_request_hook_reaches_the_provider() -> Result<()> {
2004        let requests = Arc::new(Mutex::new(Vec::new()));
2005        let provider = Arc::new(MockProvider::new_with_request_log(
2006            "summary",
2007            requests.clone(),
2008        ));
2009        let config = CompactionConfig::default().with_retain_recent(2);
2010        let compactor = LlmContextCompactor::new(provider, config)
2011            .with_guardrail_hooks(Arc::new(ModifyRequestHooks));
2012
2013        let result = compactor.compact_history(summarizable_messages()).await?;
2014        assert!(result.new_count < result.original_count);
2015
2016        let recorded = requests
2017            .lock()
2018            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
2019        assert_eq!(
2020            recorded.as_slice(),
2021            ["MODIFIED_SUMMARY_PROMPT"],
2022            "the provider must receive the hook-modified request"
2023        );
2024        drop(recorded);
2025        Ok(())
2026    }
2027
2028    #[tokio::test]
2029    async fn blocked_response_aborts_compaction() -> Result<()> {
2030        let provider = Arc::new(MockProvider::new("a summary that leaks the secret"));
2031        let config = CompactionConfig::default().with_retain_recent(2);
2032        let compactor = LlmContextCompactor::new(provider, config)
2033            .with_guardrail_hooks(Arc::new(BlockResponseHooks));
2034
2035        let error = match compactor.compact_history(summarizable_messages()).await {
2036            Ok(result) => anyhow::bail!("blocked summary must not be returned: {result:?}"),
2037            Err(error) => error,
2038        };
2039        assert!(
2040            error.to_string().contains("blocked by guardrail"),
2041            "unexpected error: {error}"
2042        );
2043        Ok(())
2044    }
2045
2046    #[tokio::test]
2047    async fn retry_with_feedback_response_aborts_compaction_without_retrying() -> Result<()> {
2048        let requests = Arc::new(Mutex::new(Vec::new()));
2049        let provider = Arc::new(MockProvider::new_with_request_log(
2050            "summary",
2051            requests.clone(),
2052        ));
2053        let config = CompactionConfig::default().with_retain_recent(2);
2054        let compactor = LlmContextCompactor::new(provider, config)
2055            .with_guardrail_hooks(Arc::new(RetryResponseHooks));
2056
2057        let error = match compactor.compact_history(summarizable_messages()).await {
2058            Ok(result) => anyhow::bail!("rejected summary must not be returned: {result:?}"),
2059            Err(error) => error,
2060        };
2061        assert!(
2062            error.to_string().contains("not retried during compaction"),
2063            "unexpected error: {error}"
2064        );
2065
2066        // No paid retry loop: exactly one LLM call was made.
2067        let recorded = requests
2068            .lock()
2069            .map_err(|_| anyhow::anyhow!("request log poisoned"))?;
2070        assert_eq!(recorded.len(), 1, "RetryWithFeedback must not retry");
2071        drop(recorded);
2072        Ok(())
2073    }
2074
2075    #[tokio::test]
2076    async fn compact_history_reports_summarization_usage() -> Result<()> {
2077        let provider = Arc::new(MockProvider::new("summary"));
2078        let config = CompactionConfig::default().with_retain_recent(2);
2079        let compactor = LlmContextCompactor::new(provider, config);
2080
2081        let result = compactor.compact_history(summarizable_messages()).await?;
2082        // The mock bills 100 input / 50 output per call; one call was made.
2083        assert_eq!(result.llm_usage.input_tokens, 100);
2084        assert_eq!(result.llm_usage.output_tokens, 50);
2085        Ok(())
2086    }
2087}