Skip to main content

nexo_core/agent/
compaction.rs

1//! Online history compaction — Phase B.
2//!
3//! Pure helpers + an `LlmCompactor` that asks a secondary LLM to fold
4//! the head of a session history into a single summary string,
5//! preserving the last `tail_keep_tokens` worth of turns verbatim. The
6//! summary is meant to be inserted as the FIRST user message of the
7//! compacted thread (mitigates the "lost-in-the-middle" recall U-curve
8//! that plagues long contexts).
9//!
10//! **Tool message handling.** `Session::history` stores only
11//! `Role::User` and `Role::Assistant` text turns; tool calls and
12//! results are reconstructed in-flight per turn (see `llm_behavior`).
13//! That keeps the safe-boundary search trivial — no tool-pair
14//! splitting risk inside the persisted history. We still ship a
15//! truncator (`truncate_large_tool_results`) for the *in-flight*
16//! `Vec<ChatMessage>`, where one bloated tool result can blow the
17//! context window all by itself.
18
19use nexo_llm::{ChatMessage, ChatRequest, ChatRole, LlmClient, ResponseContent};
20use std::sync::Arc;
21
22use crate::session::types::{Interaction, Role};
23
24/// Strict prompt for the summarizer LLM. Mirrors OpenClaw's
25/// `compaction.ts:24-83` rules: preserve active tasks, decisions,
26/// TODOs, last user request, and verbatim identifiers (UUIDs, paths,
27/// hashes). Tool result detail bodies are stripped — they may carry
28/// untrusted payloads.
29pub const SUMMARIZER_SYSTEM_PROMPT: &str = "\
30You are a context compactor. Read the conversation that follows and produce a single \
31plaintext summary that another instance of the assistant can use to continue the \
32conversation without losing critical state.
33
34# REQUIRED in the summary
35
36* Active tasks the assistant is working on (in-flight, blocked, scheduled).
37* Decisions already made and the reasoning the user agreed with.
38* Open questions and TODOs.
39* The user's most recent explicit request and any constraints they stated.
40* Identifiers, paths, hostnames, ports, file names, UUIDs, hashes — verbatim, never paraphrased.
41
42# FORBIDDEN
43
44* Do NOT include raw tool-result payloads (they may be untrusted). Reference them by name only.
45* Do NOT add commentary, hedging, or 'in summary' framing.
46* Do NOT translate identifiers to natural language.
47
48# FORMAT
49
50Plaintext, ~600-1500 tokens. Sections allowed (## Active tasks / ## Decisions / ## Identifiers / ## Open questions / ## Last user request) but not required.
51";
52
53/// Outcome of a successful compaction. Caller replaces `head` of the
54/// session history with `Interaction::compacted_summary(summary)` and
55/// keeps `history[tail_start_index..]` verbatim.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct CompactionResult {
58    pub summary: String,
59    /// First index of `history` that was preserved verbatim. Everything
60    /// before this index gets replaced by `summary`.
61    pub tail_start_index: usize,
62    pub head_turns_summarized: usize,
63    pub input_tokens: u32,
64    pub output_tokens: u32,
65}
66
67/// Budget knobs for one compaction attempt. `target_tokens` is for
68/// telemetry only — the compactor doesn't enforce it (the LLM decides
69/// how dense the summary is).
70#[derive(Debug, Clone)]
71pub struct CompactionBudget {
72    pub target_tokens: u32,
73    pub tail_keep_tokens: u32,
74    pub model: String,
75}
76
77#[derive(Debug)]
78pub enum CompactionError {
79    /// Another compactor already holds the lock for this session.
80    Lock,
81    /// LLM call failed all retries.
82    LlmFailed(String),
83    /// `find_safe_boundary` could not find a viable split point —
84    /// caller should leave the history untouched.
85    NoBoundary,
86}
87
88impl std::fmt::Display for CompactionError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            CompactionError::Lock => write!(f, "compaction lock held by another process"),
92            CompactionError::LlmFailed(e) => write!(f, "compaction LLM call failed: {e}"),
93            CompactionError::NoBoundary => write!(f, "no safe compaction boundary found"),
94        }
95    }
96}
97
98impl std::error::Error for CompactionError {}
99
100/// Pick the first index of the verbatim tail. Targets ≥ `tail_keep_chars`
101/// of content in the tail (rough proxy for tokens — tiktoken averages
102/// ~4 chars/token in English). Returns `None` when the entire history
103/// is shorter than the target tail (no compaction needed).
104///
105/// Since `Session::history` doesn't carry tool messages, the search
106/// just walks turns from the end and stops once the cumulative char
107/// count crosses the threshold. The returned index falls on a
108/// turn boundary (no mid-turn cut).
109pub fn find_safe_boundary(history: &[Interaction], tail_keep_chars: usize) -> Option<usize> {
110    if history.is_empty() {
111        return None;
112    }
113    let mut chars_in_tail: usize = 0;
114    // Walk from the end until we have collected at least `tail_keep_chars`.
115    for i in (0..history.len()).rev() {
116        chars_in_tail = chars_in_tail.saturating_add(history[i].content.len());
117        if chars_in_tail >= tail_keep_chars {
118            // tail starts at i. Need at least 1 turn in the head
119            // for compaction to be worthwhile.
120            if i == 0 {
121                return None;
122            }
123            return Some(i);
124        }
125    }
126    // History too short — no compaction.
127    None
128}
129
130/// In-flight tool-result truncator. Replaces the body of any
131/// `Role::Tool` message whose content exceeds `max_chars` with a
132/// short marker. Operates on `Vec<ChatMessage>` (the per-turn message
133/// list), not on `Session::history` (which doesn't carry tool
134/// messages).
135///
136/// Marker shape: `[truncated NNN bytes]`. The original tool name +
137/// call id stay intact so the LLM can still reason about which call
138/// produced this result.
139pub fn truncate_large_tool_results(messages: &mut [ChatMessage], max_chars: usize) -> usize {
140    let mut truncated = 0usize;
141    for m in messages.iter_mut() {
142        if m.role != ChatRole::Tool {
143            continue;
144        }
145        if m.content.len() > max_chars {
146            let original_len = m.content.len();
147            // Keep a slice of the head so the LLM still gets a peek
148            // (handy for "what did the tool say?" style follow-ups).
149            // Cap the head at half the budget so the marker fits.
150            let head_cap = max_chars / 2;
151            let mut head: String = m.content.chars().take(head_cap).collect();
152            head.push_str(&format!(
153                "\n\n[truncated {} bytes; full tool result was dropped \
154                 to fit context window — re-run the tool if needed]",
155                original_len.saturating_sub(head.len())
156            ));
157            m.content = head;
158            truncated += 1;
159        }
160    }
161    truncated
162}
163
164#[derive(Debug, Clone)]
165pub struct MicroCompactBudget {
166    pub threshold_bytes: usize,
167    pub summary_max_chars: usize,
168    pub model: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct MicroCompactStats {
173    pub compacted: usize,
174    pub failed: usize,
175    pub original_bytes: usize,
176    pub compacted_bytes: usize,
177}
178
179impl MicroCompactStats {
180    fn empty() -> Self {
181        Self {
182            compacted: 0,
183            failed: 0,
184            original_bytes: 0,
185            compacted_bytes: 0,
186        }
187    }
188}
189
190pub const MICROCOMPACT_SYSTEM_PROMPT: &str = "\
191You are compacting a single tool result before it is sent back to an assistant.
192Produce a concise plaintext summary that preserves actionable facts, errors,
193paths, identifiers, counts, and next-step clues. Do not include unrelated
194commentary. Do not invent details. If the result is structured data, keep the
195important keys and values.";
196
197pub const MICROCOMPACT_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
198
199fn is_compactable_tool_name(name: Option<&str>) -> bool {
200    matches!(
201        name,
202        Some(
203            "Bash"
204                | "bash"
205                | "FileRead"
206                | "file_read"
207                | "FileWrite"
208                | "file_write"
209                | "FileEdit"
210                | "file_edit"
211                | "Grep"
212                | "grep"
213                | "Glob"
214                | "glob"
215                | "WebSearch"
216                | "web_search"
217                | "WebFetch"
218                | "web_fetch"
219        )
220    )
221}
222
223/// Claude Code's default microcompact path content-clears old compactable
224/// tool results while preserving the `tool_use_id`/`tool_result` pairing. In
225/// nexo-rs the local `messages` vector remains canonical; callers run this on
226/// a request clone so the full result remains available for audit and replay.
227pub fn clear_large_compactable_tool_results(
228    messages: &mut [ChatMessage],
229    threshold_bytes: usize,
230) -> MicroCompactStats {
231    if threshold_bytes == 0 {
232        return MicroCompactStats::empty();
233    }
234
235    let mut stats = MicroCompactStats::empty();
236    for m in messages.iter_mut() {
237        if m.role != ChatRole::Tool
238            || !is_compactable_tool_name(m.name.as_deref())
239            || m.content == MICROCOMPACT_CLEARED_MESSAGE
240            || m.content.len() <= threshold_bytes
241        {
242            continue;
243        }
244
245        let original_bytes = m.content.len();
246        m.content = MICROCOMPACT_CLEARED_MESSAGE.to_string();
247        stats.compacted += 1;
248        stats.original_bytes = stats.original_bytes.saturating_add(original_bytes);
249        stats.compacted_bytes = stats.compacted_bytes.saturating_add(m.content.len());
250    }
251    stats
252}
253
254/// Claude Code's microcompact keeps the tool_result correlation intact while
255/// replacing only the large content body. This mirrors that contract for our
256/// flat `ChatMessage::tool_result` representation.
257pub async fn microcompact_large_tool_results(
258    messages: &mut [ChatMessage],
259    llm: &dyn LlmClient,
260    budget: &MicroCompactBudget,
261) -> MicroCompactStats {
262    if budget.threshold_bytes == 0 {
263        return MicroCompactStats::empty();
264    }
265
266    let mut stats = MicroCompactStats::empty();
267    for m in messages.iter_mut() {
268        if m.role != ChatRole::Tool
269            || !is_compactable_tool_name(m.name.as_deref())
270            || m.content.len() <= budget.threshold_bytes
271        {
272            continue;
273        }
274
275        let original = m.content.clone();
276        let original_bytes = original.len();
277        let tool_name = m.name.clone().unwrap_or_else(|| "tool".to_string());
278        let req = ChatRequest {
279            model: budget.model.clone(),
280            messages: vec![ChatMessage::user(format!(
281                "Tool: {tool_name}\nOriginal byte length: {original_bytes}\n\n{original}"
282            ))],
283            tools: Vec::new(),
284            max_tokens: 1024,
285            temperature: 0.0,
286            system_prompt: Some(MICROCOMPACT_SYSTEM_PROMPT.to_string()),
287            stop_sequences: Vec::new(),
288            tool_choice: nexo_llm::ToolChoice::None,
289            system_blocks: Vec::new(),
290            cache_tools: false,
291        };
292
293        let summary = match llm.chat(req).await {
294            Ok(response) => match response.content {
295                ResponseContent::Text(text) if !text.trim().is_empty() => text,
296                _ => {
297                    stats.failed += 1;
298                    continue;
299                }
300            },
301            Err(e) => {
302                stats.failed += 1;
303                tracing::warn!(
304                    error = %e,
305                    tool = %tool_name,
306                    "microcompact summarizer failed; leaving tool result unchanged"
307                );
308                continue;
309            }
310        };
311
312        let mut summary: String = summary.chars().take(budget.summary_max_chars).collect();
313        if summary.trim().is_empty() {
314            stats.failed += 1;
315            continue;
316        }
317        if summary.len() < original_bytes {
318            summary.push_str(&format!(
319                "\n\n[microcompact: summarized {original_bytes} bytes; full tool result retained in local turn state]"
320            ));
321            m.content = summary;
322            stats.compacted += 1;
323            stats.original_bytes = stats.original_bytes.saturating_add(original_bytes);
324            stats.compacted_bytes = stats.compacted_bytes.saturating_add(m.content.len());
325        }
326    }
327    stats
328}
329
330/// LLM-backed compactor. Builds a summarizer request from
331/// `history[..tail_start_index]` and asks the secondary LLM to fold
332/// it into a single summary string. Caller is responsible for the
333/// lock + persistence (see `nexo_memory::CompactionStore`).
334pub struct LlmCompactor {
335    llm: Arc<dyn LlmClient>,
336}
337
338impl LlmCompactor {
339    pub fn new(llm: Arc<dyn LlmClient>) -> Self {
340        Self { llm }
341    }
342
343    /// Compact `history[..tail_start_index]` into a single summary.
344    /// Caller passes the full `history` slice + the boundary index
345    /// returned by `find_safe_boundary`.
346    pub async fn compact(
347        &self,
348        history: &[Interaction],
349        tail_start_index: usize,
350        budget: &CompactionBudget,
351    ) -> Result<CompactionResult, CompactionError> {
352        if tail_start_index == 0 || tail_start_index > history.len() {
353            return Err(CompactionError::NoBoundary);
354        }
355        let head = &history[..tail_start_index];
356        if head.is_empty() {
357            return Err(CompactionError::NoBoundary);
358        }
359        // Render the head as a clean user-readable transcript so the
360        // summarizer LLM has a clear input. Each turn becomes a
361        // labeled block.
362        let mut transcript = String::with_capacity(head.iter().map(|i| i.content.len() + 16).sum());
363        for i in head {
364            let label = match i.role {
365                Role::User => "USER",
366                Role::Assistant => "ASSISTANT",
367                Role::Tool => continue, // session history doesn't store these, but defensively skip
368            };
369            transcript.push_str("=== ");
370            transcript.push_str(label);
371            transcript.push_str(" ===\n");
372            transcript.push_str(&i.content);
373            transcript.push_str("\n\n");
374        }
375        let req = ChatRequest {
376            model: budget.model.clone(),
377            messages: vec![ChatMessage::user(transcript)],
378            tools: Vec::new(),
379            max_tokens: 4096,
380            temperature: 0.2,
381            system_prompt: Some(SUMMARIZER_SYSTEM_PROMPT.to_string()),
382            stop_sequences: Vec::new(),
383            tool_choice: nexo_llm::ToolChoice::None,
384            system_blocks: Vec::new(),
385            cache_tools: false,
386        };
387        let response = self
388            .llm
389            .chat(req)
390            .await
391            .map_err(|e| CompactionError::LlmFailed(e.to_string()))?;
392        let summary = match response.content {
393            ResponseContent::Text(t) => t,
394            ResponseContent::ToolCalls(_) => {
395                return Err(CompactionError::LlmFailed(
396                    "summarizer returned tool calls instead of text".to_string(),
397                ))
398            }
399        };
400        if summary.trim().is_empty() {
401            return Err(CompactionError::LlmFailed(
402                "summarizer returned empty text".to_string(),
403            ));
404        }
405        Ok(CompactionResult {
406            summary,
407            tail_start_index,
408            head_turns_summarized: head.len(),
409            input_tokens: response.usage.prompt_tokens,
410            output_tokens: response.usage.completion_tokens,
411        })
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use async_trait::async_trait;
419    use chrono::Utc;
420    use futures::stream::BoxStream;
421    use nexo_llm::{ChatResponse, FinishReason, LlmError, TokenUsage};
422
423    fn turn(role: Role, content: &str) -> Interaction {
424        Interaction {
425            role,
426            content: content.into(),
427            timestamp: Utc::now(),
428        }
429    }
430
431    #[test]
432    fn boundary_returns_none_for_empty_history() {
433        assert_eq!(find_safe_boundary(&[], 100), None);
434    }
435
436    #[test]
437    fn boundary_returns_none_when_history_smaller_than_tail_target() {
438        let h = vec![turn(Role::User, "hi"), turn(Role::Assistant, "hello")];
439        assert_eq!(find_safe_boundary(&h, 1000), None);
440    }
441
442    #[test]
443    fn boundary_picks_first_index_meeting_tail_target() {
444        // 4 turns, 50 chars each → 200 total. tail_target=100 →
445        // tail starts at index 2 (last 2 turns = 100 chars).
446        let body = "x".repeat(50);
447        let h: Vec<_> = (0..4)
448            .map(|i| {
449                turn(
450                    if i % 2 == 0 {
451                        Role::User
452                    } else {
453                        Role::Assistant
454                    },
455                    &body,
456                )
457            })
458            .collect();
459        let idx = find_safe_boundary(&h, 100).unwrap();
460        assert_eq!(idx, 2);
461    }
462
463    #[test]
464    fn boundary_returns_none_when_first_turn_alone_meets_tail_target() {
465        // Single huge user turn — no head → no compaction.
466        let body = "y".repeat(1000);
467        let h = vec![turn(Role::User, &body)];
468        assert_eq!(find_safe_boundary(&h, 100), None);
469    }
470
471    #[test]
472    fn boundary_returns_none_when_two_huge_turns_satisfy_tail_alone() {
473        // Two turns, each 5000 chars. tail_target=1000.
474        // Walking from end: i=1 → tail_chars=5000 ≥ 1000 → idx=1.
475        // Result: head=h[..1] (1 turn), tail=h[1..] (1 turn). OK.
476        let body = "z".repeat(5000);
477        let h = vec![turn(Role::User, &body), turn(Role::Assistant, &body)];
478        let idx = find_safe_boundary(&h, 1000).unwrap();
479        assert_eq!(idx, 1);
480    }
481
482    #[test]
483    fn truncate_large_tool_results_replaces_only_oversized() {
484        let mut msgs = vec![
485            ChatMessage::user("hi"),
486            ChatMessage::tool_result("c1", "fetch", "small ok"),
487            ChatMessage::tool_result("c2", "scan", "z".repeat(5000)),
488        ];
489        let n = truncate_large_tool_results(&mut msgs, 200);
490        assert_eq!(n, 1, "only the oversized tool_result should be truncated");
491        assert_eq!(msgs[1].content, "small ok");
492        assert!(msgs[2].content.contains("[truncated"));
493        assert!(
494            msgs[2].content.len() <= 300,
495            "got {}",
496            msgs[2].content.len()
497        );
498    }
499
500    #[test]
501    fn truncate_skips_non_tool_messages() {
502        let mut msgs = vec![
503            ChatMessage::user("z".repeat(5000)),
504            ChatMessage::assistant("z".repeat(5000)),
505        ];
506        let n = truncate_large_tool_results(&mut msgs, 100);
507        assert_eq!(n, 0);
508        assert_eq!(msgs[0].content.len(), 5000);
509    }
510
511    #[test]
512    fn microcompact_clears_only_large_compactable_tool_results() {
513        let mut msgs = vec![
514            ChatMessage::tool_result("c1", "Bash", "x".repeat(5000)),
515            ChatMessage::tool_result("c2", "UnknownTool", "y".repeat(5000)),
516            ChatMessage::tool_result("c3", "Grep", "small"),
517        ];
518
519        let stats = clear_large_compactable_tool_results(&mut msgs, 1024);
520
521        assert_eq!(stats.compacted, 1);
522        assert_eq!(msgs[0].content, MICROCOMPACT_CLEARED_MESSAGE);
523        assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
524        assert_eq!(msgs[0].name.as_deref(), Some("Bash"));
525        assert_eq!(msgs[1].content.len(), 5000);
526        assert_eq!(msgs[2].content, "small");
527    }
528
529    #[test]
530    fn microcompact_is_idempotent_for_already_cleared_results() {
531        let mut msgs = vec![ChatMessage::tool_result(
532            "c1",
533            "Grep",
534            MICROCOMPACT_CLEARED_MESSAGE,
535        )];
536
537        let stats = clear_large_compactable_tool_results(&mut msgs, 1);
538
539        assert_eq!(stats.compacted, 0);
540        assert_eq!(msgs[0].content, MICROCOMPACT_CLEARED_MESSAGE);
541        assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
542    }
543
544    /// Stub LLM that returns a fixed text response.
545    struct StubLlm {
546        reply: String,
547        prompt_tokens: u32,
548        completion_tokens: u32,
549    }
550    #[async_trait]
551    impl LlmClient for StubLlm {
552        async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
553            Ok(ChatResponse {
554                content: ResponseContent::Text(self.reply.clone()),
555                usage: TokenUsage {
556                    prompt_tokens: self.prompt_tokens,
557                    completion_tokens: self.completion_tokens,
558                },
559                finish_reason: FinishReason::Stop,
560                cache_usage: None,
561            })
562        }
563        fn provider(&self) -> &str {
564            "stub"
565        }
566        fn model_id(&self) -> &str {
567            "stub-1"
568        }
569        async fn stream<'a>(
570            &'a self,
571            _req: ChatRequest,
572        ) -> anyhow::Result<BoxStream<'a, anyhow::Result<nexo_llm::StreamChunk>>> {
573            anyhow::bail!("stream not implemented in stub")
574        }
575    }
576
577    /// Stub LLM that always errors — exercises the LlmFailed branch.
578    struct ErrLlm;
579    #[async_trait]
580    impl LlmClient for ErrLlm {
581        async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
582            Err(LlmError::Other(anyhow::anyhow!("kaboom")).into())
583        }
584        fn provider(&self) -> &str {
585            "stub"
586        }
587        fn model_id(&self) -> &str {
588            "stub-1"
589        }
590        async fn stream<'a>(
591            &'a self,
592            _req: ChatRequest,
593        ) -> anyhow::Result<BoxStream<'a, anyhow::Result<nexo_llm::StreamChunk>>> {
594            anyhow::bail!("stream not implemented in stub")
595        }
596    }
597
598    #[tokio::test]
599    async fn compact_happy_path_returns_summary() {
600        let llm = Arc::new(StubLlm {
601            reply: "Compacted: discussed weather.".into(),
602            prompt_tokens: 1500,
603            completion_tokens: 80,
604        });
605        let compactor = LlmCompactor::new(llm);
606        let history = vec![
607            turn(Role::User, "what's the weather"),
608            turn(Role::Assistant, "sunny in Medellin"),
609            turn(Role::User, "and tomorrow?"),
610            turn(Role::Assistant, "rain expected"),
611        ];
612        let budget = CompactionBudget {
613            target_tokens: 2000,
614            tail_keep_tokens: 0,
615            model: "stub-1".into(),
616        };
617        let result = compactor.compact(&history, 2, &budget).await.unwrap();
618        assert!(result.summary.contains("Compacted"));
619        assert_eq!(result.tail_start_index, 2);
620        assert_eq!(result.head_turns_summarized, 2);
621        assert_eq!(result.input_tokens, 1500);
622        assert_eq!(result.output_tokens, 80);
623    }
624
625    #[tokio::test]
626    async fn compact_rejects_zero_boundary() {
627        let llm = Arc::new(StubLlm {
628            reply: "x".into(),
629            prompt_tokens: 0,
630            completion_tokens: 0,
631        });
632        let compactor = LlmCompactor::new(llm);
633        let history = vec![turn(Role::User, "hi")];
634        let budget = CompactionBudget {
635            target_tokens: 0,
636            tail_keep_tokens: 0,
637            model: "stub-1".into(),
638        };
639        let err = compactor.compact(&history, 0, &budget).await.unwrap_err();
640        assert!(matches!(err, CompactionError::NoBoundary));
641    }
642
643    #[tokio::test]
644    async fn compact_rejects_empty_summary() {
645        let llm = Arc::new(StubLlm {
646            reply: "   ".into(),
647            prompt_tokens: 10,
648            completion_tokens: 0,
649        });
650        let compactor = LlmCompactor::new(llm);
651        let history = vec![
652            turn(Role::User, "hi"),
653            turn(Role::Assistant, "hello"),
654            turn(Role::User, "tail"),
655        ];
656        let budget = CompactionBudget {
657            target_tokens: 0,
658            tail_keep_tokens: 0,
659            model: "stub-1".into(),
660        };
661        let err = compactor.compact(&history, 2, &budget).await.unwrap_err();
662        assert!(matches!(err, CompactionError::LlmFailed(_)));
663    }
664
665    #[tokio::test]
666    async fn compact_propagates_llm_error() {
667        let compactor = LlmCompactor::new(Arc::new(ErrLlm));
668        let history = vec![
669            turn(Role::User, "hi"),
670            turn(Role::Assistant, "hello"),
671            turn(Role::User, "tail"),
672        ];
673        let budget = CompactionBudget {
674            target_tokens: 0,
675            tail_keep_tokens: 0,
676            model: "stub-1".into(),
677        };
678        let err = compactor.compact(&history, 2, &budget).await.unwrap_err();
679        assert!(matches!(err, CompactionError::LlmFailed(_)));
680    }
681
682    #[test]
683    fn summarizer_prompt_includes_required_rules() {
684        // Snapshot the key constraints so an accidental edit can't
685        // silently weaken the summarizer.
686        assert!(SUMMARIZER_SYSTEM_PROMPT.contains("Identifiers, paths"));
687        assert!(SUMMARIZER_SYSTEM_PROMPT.contains("FORBIDDEN"));
688        assert!(SUMMARIZER_SYSTEM_PROMPT.contains("Active tasks"));
689        assert!(SUMMARIZER_SYSTEM_PROMPT.contains("most recent explicit request"));
690    }
691}