Skip to main content

mermaid_cli/domain/
compaction.rs

1//! Conversation context compaction.
2//!
3//! The reducer/effect boundary treats compaction as a first-class
4//! operation: effects generate a checkpoint summary, the reducer swaps
5//! the model-visible history, and persistence archives the removed raw
6//! messages. This keeps compaction observable instead of hiding it inside
7//! a provider adapter.
8
9use chrono::{DateTime, Local};
10use serde::{Deserialize, Serialize};
11
12use crate::constants::{
13    COMPACTION_AUTO_THRESHOLD_PERCENT, COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
14    COMPACTION_MIN_RESPONSE_RESERVE_TOKENS, COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
15    COMPACTION_SUMMARY_MAX_TOKENS, COMPACTION_TAIL_TOKEN_BUDGET, COMPACTION_TAIL_TURNS,
16    COMPACTION_TOOL_OUTPUT_MAX_CHARS,
17};
18use crate::models::{ChatMessage, ChatMessageKind, MessageRole, ReasoningLevel, TokenUsage};
19
20use super::cmd::ChatRequest;
21use super::state::ContextUsageSnapshot;
22
23const CHECKPOINT_MARKER: &str = "MERMAID CONTEXT CHECKPOINT";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CompactionTrigger {
28    Manual,
29    AutoThreshold,
30    ContextLimitRetry,
31    /// A response was truncated because the context window filled mid-turn;
32    /// compact and resume the run (see the reducer's truncation-recovery path).
33    TruncationRecovery,
34}
35
36impl CompactionTrigger {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Manual => "manual",
40            Self::AutoThreshold => "auto_threshold",
41            Self::ContextLimitRetry => "context_limit_retry",
42            Self::TruncationRecovery => "truncation_recovery",
43        }
44    }
45
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::Manual => "manual",
49            Self::AutoThreshold => "automatic",
50            Self::ContextLimitRetry => "context-limit retry",
51            Self::TruncationRecovery => "truncation recovery",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CompactionPolicy {
58    pub auto_enabled: bool,
59    pub auto_threshold_percent: u8,
60    pub tail_turns: usize,
61    pub tail_token_budget: usize,
62    pub tool_output_max_chars: usize,
63    pub summary_max_tokens: usize,
64    pub summarizer_input_token_budget: usize,
65    pub min_response_reserve_tokens: usize,
66    pub max_response_reserve_tokens: usize,
67}
68
69impl Default for CompactionPolicy {
70    fn default() -> Self {
71        Self {
72            auto_enabled: true,
73            auto_threshold_percent: COMPACTION_AUTO_THRESHOLD_PERCENT,
74            tail_turns: COMPACTION_TAIL_TURNS,
75            tail_token_budget: COMPACTION_TAIL_TOKEN_BUDGET,
76            tool_output_max_chars: COMPACTION_TOOL_OUTPUT_MAX_CHARS,
77            summary_max_tokens: COMPACTION_SUMMARY_MAX_TOKENS,
78            summarizer_input_token_budget: COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
79            min_response_reserve_tokens: COMPACTION_MIN_RESPONSE_RESERVE_TOKENS,
80            max_response_reserve_tokens: COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
81        }
82    }
83}
84
85impl CompactionPolicy {
86    pub fn response_reserve(self, request_max_tokens: usize) -> usize {
87        request_max_tokens
88            .max(self.min_response_reserve_tokens)
89            .min(self.max_response_reserve_tokens)
90    }
91}
92
93#[derive(Debug, Clone)]
94pub struct CompactionRequest {
95    pub chat: ChatRequest,
96    pub trigger: CompactionTrigger,
97    pub instructions: Option<String>,
98    pub force: bool,
99    pub policy: CompactionPolicy,
100}
101
102impl CompactionRequest {
103    pub fn manual(chat: ChatRequest, instructions: Option<String>) -> Self {
104        Self {
105            chat,
106            trigger: CompactionTrigger::Manual,
107            instructions,
108            force: true,
109            policy: CompactionPolicy::default(),
110        }
111    }
112
113    pub fn auto(chat: ChatRequest, trigger: CompactionTrigger) -> Self {
114        Self {
115            chat,
116            trigger,
117            instructions: None,
118            force: false,
119            policy: CompactionPolicy::default(),
120        }
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CompactionRecord {
126    pub id: String,
127    pub trigger: CompactionTrigger,
128    pub created_at: DateTime<Local>,
129    pub before_tokens: usize,
130    pub after_tokens: usize,
131    pub archived_message_count: usize,
132    pub preserved_message_count: usize,
133    pub summary_tokens: usize,
134    pub duration_secs: f64,
135    #[serde(default = "default_verified")]
136    pub verified: bool,
137    #[serde(default)]
138    pub verification_error: Option<String>,
139    #[serde(default)]
140    pub focus: Option<String>,
141    #[serde(default)]
142    pub archive_path: Option<String>,
143}
144
145fn default_verified() -> bool {
146    true
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct CompactionArchive {
151    pub id: String,
152    pub conversation_id: String,
153    pub created_at: DateTime<Local>,
154    pub messages: Vec<ChatMessage>,
155}
156
157#[derive(Debug, Clone)]
158pub struct CompactionResult {
159    pub record: CompactionRecord,
160    pub replacement_messages: Vec<ChatMessage>,
161    pub archived_messages: Vec<ChatMessage>,
162    pub before_snapshot: ContextUsageSnapshot,
163    pub after_snapshot: ContextUsageSnapshot,
164    pub usage: Option<TokenUsage>,
165}
166
167#[derive(Debug, Clone)]
168pub struct PreparedCompaction {
169    pub archived_messages: Vec<ChatMessage>,
170    pub preserved_messages: Vec<ChatMessage>,
171    pub previous_summary: Option<String>,
172    pub history_excerpt: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum CompactionSkip {
177    NoKnownContextLimit,
178    AutoDisabled,
179    BelowThreshold,
180    NothingToCompact,
181}
182
183impl std::fmt::Display for CompactionSkip {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            Self::NoKnownContextLimit => write!(f, "model context limit is unknown"),
187            Self::AutoDisabled => write!(f, "automatic compaction is disabled"),
188            Self::BelowThreshold => write!(f, "context is below compaction threshold"),
189            Self::NothingToCompact => write!(f, "not enough conversation history to summarize"),
190        }
191    }
192}
193
194pub fn should_auto_compact(
195    snapshot: &ContextUsageSnapshot,
196    request: &ChatRequest,
197    policy: CompactionPolicy,
198) -> Result<(), CompactionSkip> {
199    if !policy.auto_enabled {
200        return Err(CompactionSkip::AutoDisabled);
201    }
202    let Some(max_tokens) = snapshot.max_tokens else {
203        return Err(CompactionSkip::NoKnownContextLimit);
204    };
205    if max_tokens == 0 {
206        return Err(CompactionSkip::NoKnownContextLimit);
207    }
208
209    let reserve = policy.response_reserve(request.max_tokens);
210    let over_percent = snapshot
211        .used_percent
212        .is_some_and(|p| p >= policy.auto_threshold_percent);
213    let low_remaining = snapshot
214        .remaining_tokens
215        .is_some_and(|remaining| remaining <= reserve);
216    if over_percent || low_remaining {
217        Ok(())
218    } else {
219        Err(CompactionSkip::BelowThreshold)
220    }
221}
222
223pub fn context_exceeds_hard_limit(
224    snapshot: &ContextUsageSnapshot,
225    request: &ChatRequest,
226    policy: CompactionPolicy,
227) -> bool {
228    let Some(max_tokens) = snapshot.max_tokens else {
229        return false;
230    };
231    let reserve = policy.response_reserve(request.max_tokens);
232    snapshot.used_tokens.saturating_add(reserve) >= max_tokens
233}
234
235pub fn prepare_compaction(
236    request: &CompactionRequest,
237    max_context_tokens: Option<usize>,
238) -> Result<PreparedCompaction, CompactionSkip> {
239    let messages = &request.chat.messages;
240    if messages.len() < 3 {
241        return Err(CompactionSkip::NothingToCompact);
242    }
243
244    let split =
245        tail_start_index(messages, request.policy).ok_or(CompactionSkip::NothingToCompact)?;
246    if split == 0 {
247        return Err(CompactionSkip::NothingToCompact);
248    }
249
250    let archived_messages = messages[..split].to_vec();
251    let mut preserved_messages = messages[split..].to_vec();
252    if archived_messages.is_empty() || preserved_messages.is_empty() {
253        return Err(CompactionSkip::NothingToCompact);
254    }
255    // The tail is forwarded verbatim into the next request; scrub any
256    // pre-existing orphan `tool_use`/`tool_result` so an unpaired block can't
257    // 400 the provider (#71 forward, #F64 reverse). One exception: when the run
258    // is resuming mid-tool (a context-limit retry or truncation recovery), a
259    // genuinely-pending trailing `tool_use` is preserved so the model needn't
260    // re-derive the action from the summary (#F65). A user cancel produces the
261    // same trailing shape but ends the run, so only the resume triggers — where
262    // the awaited `tool_result` really is forthcoming — opt into preserving it.
263    let preserve_pending_tail = matches!(
264        request.trigger,
265        CompactionTrigger::ContextLimitRetry | CompactionTrigger::TruncationRecovery
266    );
267    drop_orphan_tool_calls(&mut preserved_messages, preserve_pending_tail);
268
269    let previous_summary = archived_messages
270        .iter()
271        .rev()
272        .find(|m| {
273            m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
274        })
275        .map(|m| m.content.clone());
276
277    let max_input_tokens = max_context_tokens
278        .map(|max| max.saturating_sub(request.policy.response_reserve(request.chat.max_tokens)))
279        .filter(|max| *max > 0)
280        .unwrap_or(request.policy.summarizer_input_token_budget)
281        .min(request.policy.summarizer_input_token_budget);
282    let max_chars = max_input_tokens.saturating_mul(4).max(4_000);
283    let history_excerpt = truncate_middle(
284        &format_history_excerpt(&archived_messages, request.policy),
285        max_chars,
286    );
287
288    Ok(PreparedCompaction {
289        archived_messages,
290        preserved_messages,
291        previous_summary,
292        history_excerpt,
293    })
294}
295
296pub fn build_summary_request(
297    base: &ChatRequest,
298    prepared: &PreparedCompaction,
299    focus: Option<&str>,
300    policy: CompactionPolicy,
301) -> ChatRequest {
302    ChatRequest {
303        model_id: base.model_id.clone(),
304        messages: vec![ChatMessage::user(summary_prompt(prepared, focus))],
305        system_prompt: compaction_system_prompt().to_string(),
306        instructions: None,
307        reasoning: compaction_reasoning(base.reasoning),
308        temperature: 0.0,
309        max_tokens: policy.summary_max_tokens,
310        tools: Vec::new(),
311        ollama_num_ctx: base.ollama_num_ctx,
312        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
313    }
314}
315
316pub fn build_verification_request(
317    base: &ChatRequest,
318    prepared: &PreparedCompaction,
319    draft_summary: &str,
320    focus: Option<&str>,
321    policy: CompactionPolicy,
322) -> ChatRequest {
323    let prompt = format!(
324        "{}\n\n# Draft Summary\n{}\n\n# Verification Task\nCritically check the draft against the conversation excerpt. If it omitted specific file paths, commands, test results, tool results, user constraints, current state, or next steps, return an improved complete checkpoint. Otherwise return the draft unchanged. Return only the final checkpoint markdown.",
325        summary_prompt(prepared, focus),
326        draft_summary.trim()
327    );
328    ChatRequest {
329        model_id: base.model_id.clone(),
330        messages: vec![ChatMessage::user(prompt)],
331        system_prompt: compaction_system_prompt().to_string(),
332        instructions: None,
333        reasoning: compaction_reasoning(base.reasoning),
334        temperature: 0.0,
335        max_tokens: policy.summary_max_tokens,
336        tools: Vec::new(),
337        ollama_num_ctx: base.ollama_num_ctx,
338        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
339    }
340}
341
342pub fn build_replacement_messages(
343    summary: &str,
344    prepared: &PreparedCompaction,
345    record: &CompactionRecord,
346) -> Vec<ChatMessage> {
347    // The summary is model-generated from the full conversation and is persisted
348    // (replacement message + conversation file). Scrub any credential it echoed
349    // back from the archived turns before it's written (#70).
350    let summary = crate::utils::redact_secrets(summary);
351    let summary = summary.as_str();
352    let checkpoint = format!(
353        "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
354        CHECKPOINT_MARKER,
355        record.id,
356        record.trigger.as_str(),
357        record.created_at.to_rfc3339(),
358        record.archived_message_count,
359        record.preserved_message_count,
360        summary.trim()
361    );
362    let mut user = ChatMessage::user(checkpoint);
363    user.kind = ChatMessageKind::ContextCheckpoint;
364    user.metadata = Some(serde_json::json!({
365        "compaction_id": record.id,
366        "trigger": record.trigger.as_str(),
367        "before_tokens": record.before_tokens,
368        "after_tokens": record.after_tokens,
369        "archived_message_count": record.archived_message_count,
370        "preserved_message_count": record.preserved_message_count,
371        "duration_secs": record.duration_secs,
372        "verified": record.verified,
373        "verification_error": record.verification_error,
374    }));
375
376    let mut assistant = ChatMessage::assistant(compaction_receipt(record));
377    assistant.kind = ChatMessageKind::ContextCheckpoint;
378    assistant.metadata = user.metadata.clone();
379
380    let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
381    messages.push(user);
382    messages.push(assistant);
383    messages.extend(prepared.preserved_messages.clone());
384    messages
385}
386
387pub fn compaction_receipt(record: &CompactionRecord) -> String {
388    let verification = if record.verified {
389        "Verified.".to_string()
390    } else if let Some(error) = &record.verification_error {
391        format!("Used draft summary because verification failed: {error}.")
392    } else {
393        "Used draft summary without verification.".to_string()
394    };
395    format!(
396        "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
397        format_compact_count(record.before_tokens),
398        format_compact_count(record.after_tokens),
399        record.archived_message_count,
400        record.preserved_message_count,
401        record.duration_secs,
402        verification
403    )
404}
405
406pub fn normalize_summary(text: &str) -> String {
407    let trimmed = text.trim();
408    if let Some(summary) = extract_tagged_summary(trimmed) {
409        return summary.trim().to_string();
410    }
411    trimmed.to_string()
412}
413
414pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
415    match (a, b) {
416        (None, None) => None,
417        (Some(u), None) | (None, Some(u)) => Some(u),
418        (Some(mut left), Some(right)) => {
419            left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
420            left.completion_tokens = left
421                .completion_tokens
422                .saturating_add(right.completion_tokens);
423            left.total_tokens = left.total_tokens.saturating_add(right.total_tokens);
424            left.cached_input_tokens = left
425                .cached_input_tokens
426                .saturating_add(right.cached_input_tokens);
427            left.cache_creation_input_tokens = left
428                .cache_creation_input_tokens
429                .saturating_add(right.cache_creation_input_tokens);
430            left.reasoning_output_tokens = left
431                .reasoning_output_tokens
432                .saturating_add(right.reasoning_output_tokens);
433            Some(left)
434        },
435    }
436}
437
438pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
439    messages.iter().map(estimate_message_tokens).sum()
440}
441
442pub fn format_compact_count(value: usize) -> String {
443    if value >= 1_000_000 {
444        format!("{:.1}M", value as f64 / 1_000_000.0)
445    } else if value >= 1_000 {
446        format!("{:.1}k", value as f64 / 1_000.0)
447    } else {
448        value.to_string()
449    }
450}
451
452fn compaction_system_prompt() -> &'static str {
453    "You are performing context checkpoint compaction for Mermaid, a model-agnostic agentic coding CLI. Produce a faithful handoff summary for the next model call. Preserve exact file paths, commands, errors, tool results, user preferences, decisions, current state, and next steps. Do not invent facts. Be concise but complete."
454}
455
456fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
457    match current {
458        ReasoningLevel::None | ReasoningLevel::Minimal => current,
459        _ => ReasoningLevel::Low,
460    }
461}
462
463fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
464    let anchor = prepared
465        .previous_summary
466        .as_deref()
467        .map(|summary| {
468            format!(
469                "A previous checkpoint exists. Update it with the newer history, preserve still-true details, and remove stale details.\n\n<previous_checkpoint>\n{}\n</previous_checkpoint>",
470                summary.trim()
471            )
472        })
473        .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
474
475    let focus = focus
476        .filter(|s| !s.trim().is_empty())
477        .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
478        .unwrap_or_default();
479
480    format!(
481        "{anchor}{focus}\n# Required Output\nReturn exactly this Markdown structure and keep section order:\n\n## Goal\n- [single-sentence task summary]\n\n## User Preferences And Constraints\n- [preferences, constraints, mode, or \"(none)\"]\n\n## Project State\n- [repo/product state and important architecture facts]\n\n## Completed Work\n- [what has already been done]\n\n## Current Work\n- [what is actively in progress]\n\n## Key Decisions\n- [decision and rationale]\n\n## Critical Files And Symbols\n- [file path or symbol: why it matters]\n\n## Commands Tests And Results\n- [command/test/result/error]\n\n## Open Questions Or Risks\n- [risk/question/blocker]\n\n## Next Steps\n- [ordered next action]\n\nRules:\n- Preserve exact paths, commands, error strings, identifiers, and numeric facts when known.\n- Mention important omitted or truncated data explicitly.\n- Do not mention that you are an AI or explain the compaction process.\n\n# Conversation History To Compact\n{}",
482        prepared.history_excerpt
483    )
484}
485
486/// Scrub orphan tool-call/tool-result pairs from the preserved tail so a
487/// forwarded unpaired block can't 400 a provider (Anthropic). Compaction keeps
488/// a recent tail verbatim; if the split inherits a pre-existing orphan, both
489/// directions must be repaired symmetrically:
490///
491///   * Forward (#71): an assistant `tool_use` whose `tool_result` never
492///     committed — e.g. a turn cancelled after the model emitted calls. Drop
493///     the orphaned calls (keeping the assistant's text) rather than fabricate
494///     results. A call with no `id` can't be paired, so it's treated as orphaned.
495///   * Reverse (#F64): a `tool_result` (role=Tool) whose `tool_use` id is no
496///     longer present among the retained messages — e.g. the assistant turn was
497///     archived while its result landed in the tail. Anthropic equally rejects a
498///     `tool_result` with no preceding `tool_use`, so drop the orphaned result.
499///
500/// When `preserve_pending_tail` is set (the run is resuming mid-tool after a
501/// context-limit retry / truncation recovery), a *trailing* assistant `tool_use`
502/// is genuinely pending execution rather than abandoned, so its calls are kept
503/// across the checkpoint and the resumed turn appends the awaited results (#F65).
504/// Only the final message qualifies: anything after an assistant `tool_use` (a
505/// tool result, a follow-up, a user cancel) means it is no longer pending. This
506/// is trigger-gated by the caller because a user cancel yields the same trailing
507/// shape but must still drop — there, the result will never arrive.
508fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
509    let pending_tail = if preserve_pending_tail
510        && messages.last().is_some_and(|m| {
511            m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
512        }) {
513        Some(messages.len() - 1)
514    } else {
515        None
516    };
517
518    let answered: std::collections::HashSet<String> = messages
519        .iter()
520        .filter(|m| m.role == MessageRole::Tool)
521        .filter_map(|m| m.tool_call_id.clone())
522        .collect();
523
524    // Forward (#71): drop unanswered assistant `tool_use`, save a pending tail.
525    for (idx, m) in messages.iter_mut().enumerate() {
526        if Some(idx) == pending_tail {
527            continue;
528        }
529        let Some(calls) = m.tool_calls.as_mut() else {
530            continue;
531        };
532        calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
533        if calls.is_empty() {
534            m.tool_calls = None;
535        }
536    }
537
538    // Reverse (#F64): drop a `tool_result` whose `tool_use` id is no longer
539    // present among the assistant messages retained above (symmetric orphan).
540    let emitted: std::collections::HashSet<String> = messages
541        .iter()
542        .filter_map(|m| m.tool_calls.as_ref())
543        .flat_map(|calls| calls.iter())
544        .filter_map(|c| c.id.clone())
545        .collect();
546    messages.retain(|m| {
547        m.role != MessageRole::Tool
548            || m.tool_call_id
549                .as_deref()
550                .is_some_and(|id| emitted.contains(id))
551    });
552}
553
554fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
555    let mut user_turns = 0usize;
556    let mut start = None;
557    for (idx, msg) in messages.iter().enumerate().rev() {
558        if msg.role == MessageRole::User {
559            user_turns += 1;
560            start = Some(idx);
561            if user_turns >= policy.tail_turns {
562                break;
563            }
564        }
565    }
566    let mut start = start?;
567    while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
568        let next_user = messages
569            .iter()
570            .enumerate()
571            .skip(start + 1)
572            .find(|(_, msg)| msg.role == MessageRole::User)
573            .map(|(idx, _)| idx);
574        match next_user {
575            Some(idx) => start = idx,
576            None => break,
577        }
578    }
579    Some(start)
580}
581
582fn format_history_excerpt(messages: &[ChatMessage], policy: CompactionPolicy) -> String {
583    let mut out = String::new();
584    for (idx, msg) in messages.iter().enumerate() {
585        let role = match msg.role {
586            MessageRole::User => "USER",
587            MessageRole::Assistant => "ASSISTANT",
588            MessageRole::System => "SYSTEM",
589            MessageRole::Tool => "TOOL",
590        };
591        out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
592        if msg.kind != ChatMessageKind::Normal {
593            out.push_str(&format!("kind: {:?}\n", msg.kind));
594        }
595        if let Some(name) = &msg.tool_name {
596            out.push_str(&format!("tool_name: {}\n", name));
597        }
598        if let Some(id) = &msg.tool_call_id {
599            out.push_str(&format!("tool_call_id: {}\n", id));
600        }
601        if let Some(calls) = &msg.tool_calls {
602            let names: Vec<&str> = calls
603                .iter()
604                .map(|call| call.function.name.as_str())
605                .collect();
606            out.push_str(&format!("tool_calls: {}\n", names.join(", ")));
607        }
608        if let Some(images) = &msg.images
609            && !images.is_empty()
610        {
611            out.push_str(&format!("[{} image attachment(s) omitted]\n", images.len()));
612        }
613        for action in &msg.actions {
614            out.push_str(&format!(
615                "action: {}({}) duration={:?}\n",
616                action.action_type, action.target, action.duration_seconds
617            ));
618            if let Some(metadata) = &action.metadata {
619                out.push_str(&format!("action_metadata: {:?}\n", metadata));
620            }
621        }
622        let cap = if msg.role == MessageRole::Tool {
623            policy.tool_output_max_chars
624        } else {
625            policy.tool_output_max_chars.saturating_mul(4)
626        };
627        out.push_str(&truncate_middle(&msg.content, cap));
628    }
629    out
630}
631
632fn estimate_message_tokens(msg: &ChatMessage) -> usize {
633    let mut chars = msg.content.len();
634    chars = chars.saturating_add(format!("{:?}", msg.role).len());
635    chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
636    chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
637    if let Some(images) = &msg.images {
638        chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
639    }
640    // Assistant tool calls carry the function name + a JSON arguments payload
641    // (often kilobytes for a file write or shell script). Omitting these made
642    // the estimate run systematically low for this tool-heavy agent, causing
643    // under-compaction and provider-side context overflows.
644    if let Some(tool_calls) = &msg.tool_calls {
645        for tc in tool_calls {
646            chars = chars.saturating_add(tc.function.name.len());
647            chars = chars.saturating_add(tc.function.arguments.to_string().len());
648            chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
649        }
650    }
651    chars.div_ceil(4)
652}
653
654fn truncate_middle(text: &str, max_chars: usize) -> String {
655    if text.chars().count() <= max_chars {
656        return text.to_string();
657    }
658    if max_chars < 128 {
659        return text.chars().take(max_chars).collect();
660    }
661    let marker = "\n\n[... truncated during context compaction ...]\n\n";
662    let keep = max_chars.saturating_sub(marker.len());
663    let head = keep / 2;
664    let tail = keep.saturating_sub(head);
665    let start: String = text.chars().take(head).collect();
666    let end: String = text
667        .chars()
668        .rev()
669        .take(tail)
670        .collect::<Vec<_>>()
671        .into_iter()
672        .rev()
673        .collect();
674    format!("{start}{marker}{end}")
675}
676
677fn extract_tagged_summary(text: &str) -> Option<&str> {
678    let start_tag = "<summary>";
679    let end_tag = "</summary>";
680    let start = text.find(start_tag)? + start_tag.len();
681    let end = text[start..].find(end_tag)? + start;
682    Some(&text[start..end])
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
690        ChatRequest {
691            model_id: "ollama/test".to_string(),
692            messages,
693            system_prompt: "system".to_string(),
694            instructions: None,
695            reasoning: ReasoningLevel::Medium,
696            temperature: 0.7,
697            max_tokens: 4096,
698            tools: Vec::new(),
699            ollama_num_ctx: None,
700            ollama_allow_ram_offload: None,
701        }
702    }
703
704    #[test]
705    fn auto_compaction_triggers_by_percent() {
706        let snapshot = ContextUsageSnapshot::from_estimate(
707            super::super::state::PromptTokenBreakdown {
708                system_tokens: 0,
709                instructions_tokens: 0,
710                message_tokens: 86,
711                tool_schema_tokens: 0,
712                image_count: 0,
713                message_count: 2,
714                tool_count: 0,
715            },
716            Some(100),
717        );
718        let req = request_with(vec![ChatMessage::user("hello")]);
719        assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
720    }
721
722    #[test]
723    fn prepare_preserves_recent_two_user_turns() {
724        let messages = vec![
725            ChatMessage::user("one"),
726            ChatMessage::assistant("one answer"),
727            ChatMessage::user("two"),
728            ChatMessage::assistant("two answer"),
729            ChatMessage::user("three"),
730        ];
731        let request = CompactionRequest::manual(request_with(messages), None);
732        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
733        assert_eq!(prepared.archived_messages.len(), 2);
734        assert_eq!(prepared.preserved_messages.len(), 3);
735        assert_eq!(prepared.preserved_messages[0].content, "two");
736    }
737
738    fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
739        crate::models::tool_call::ToolCall {
740            id: Some(id.to_string()),
741            function: crate::models::tool_call::FunctionCall {
742                name: name.to_string(),
743                arguments: serde_json::json!({}),
744            },
745        }
746    }
747
748    #[test]
749    fn prepare_strips_orphan_tool_call_from_preserved_tail() {
750        // A tail that inherits an assistant(tool_calls) with no matching result
751        // (e.g. a cancelled tool turn) must not forward the unpaired tool_use (#71).
752        let mut orphan = ChatMessage::assistant("calling a tool");
753        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
754        let messages = vec![
755            ChatMessage::user("one"),
756            ChatMessage::assistant("one answer"),
757            ChatMessage::user("two"),
758            orphan,
759            ChatMessage::user("three"),
760        ];
761        let request = CompactionRequest::manual(request_with(messages), None);
762        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
763        let has_orphan = prepared
764            .preserved_messages
765            .iter()
766            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
767        assert!(
768            !has_orphan,
769            "orphan tool_use must be stripped from the tail"
770        );
771        // The message itself (its text) is preserved — only the calls are dropped.
772        assert!(
773            prepared
774                .preserved_messages
775                .iter()
776                .any(|m| m.content == "calling a tool")
777        );
778    }
779
780    #[test]
781    fn prepare_keeps_paired_tool_call_in_tail() {
782        // The mirror case: a tool_call whose result is also in the tail survives.
783        let mut asst = ChatMessage::assistant("calling");
784        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
785        let messages = vec![
786            ChatMessage::user("one"),
787            ChatMessage::assistant("one answer"),
788            ChatMessage::user("two"),
789            asst,
790            ChatMessage::tool("call_1", "do_thing", "ok"),
791            ChatMessage::user("three"),
792        ];
793        let request = CompactionRequest::manual(request_with(messages), None);
794        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
795        let kept = prepared
796            .preserved_messages
797            .iter()
798            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
799        assert!(kept, "a tool_call paired with its result must be preserved");
800    }
801
802    #[test]
803    fn prepare_drops_reverse_orphan_tool_result_from_tail() {
804        // The mirror of #71 (#F64): the assistant `tool_use` is archived (split
805        // out of the tail) while its `tool_result` lands in the preserved tail.
806        // A lone `tool_result` with no preceding `tool_use` 400s Anthropic, so it
807        // must be dropped symmetrically.
808        let mut asst = ChatMessage::assistant("calling");
809        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
810        let messages = vec![
811            ChatMessage::user("one"),
812            asst,
813            ChatMessage::user("two"),
814            ChatMessage::tool("call_1", "do_thing", "result"),
815            ChatMessage::user("three"),
816        ];
817        // Tail keeps the last two user turns ("two".., "three"), so the assistant
818        // tool_use is archived but the tool_result survives into the tail.
819        let request = CompactionRequest::manual(request_with(messages), None);
820        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
821        assert!(
822            prepared
823                .preserved_messages
824                .iter()
825                .all(|m| m.role != MessageRole::Tool),
826            "an orphan tool_result whose tool_use was archived must be dropped"
827        );
828    }
829
830    #[test]
831    fn prepare_keeps_pending_trailing_tool_use_on_retry() {
832        // #F65: a context-limit retry / truncation recovery compacts mid-tool.
833        // The trailing assistant tool_use is genuinely pending — the run resumes
834        // and appends the result — so its calls must survive compaction.
835        let mut pending = ChatMessage::assistant("calling a tool");
836        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
837        let messages = vec![
838            ChatMessage::user("one"),
839            ChatMessage::assistant("a1"),
840            ChatMessage::user("two"),
841            ChatMessage::assistant("a2"),
842            ChatMessage::user("three"),
843            pending,
844        ];
845        let request =
846            CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
847        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
848        let last = prepared
849            .preserved_messages
850            .last()
851            .expect("non-empty preserved tail");
852        assert!(
853            last.tool_calls
854                .as_ref()
855                .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
856            "a pending trailing tool_use must be preserved across a retry compaction"
857        );
858    }
859
860    #[test]
861    fn prepare_drops_trailing_tool_use_on_manual_compaction() {
862        // Same trailing shape, but a manual compaction is not a resume: the tool
863        // is treated as abandoned/cancelled, so the unpaired call is still
864        // scrubbed (#71) — only the assistant's text is kept.
865        let mut pending = ChatMessage::assistant("calling a tool");
866        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
867        let messages = vec![
868            ChatMessage::user("one"),
869            ChatMessage::assistant("a1"),
870            ChatMessage::user("two"),
871            ChatMessage::assistant("a2"),
872            ChatMessage::user("three"),
873            pending,
874        ];
875        let request = CompactionRequest::manual(request_with(messages), None);
876        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
877        assert!(
878            !prepared
879                .preserved_messages
880                .iter()
881                .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
882            "manual compaction must scrub the trailing orphan tool_use"
883        );
884        assert!(
885            prepared
886                .preserved_messages
887                .iter()
888                .any(|m| m.content == "calling a tool"),
889            "the assistant text is kept even though the orphan call is dropped"
890        );
891    }
892
893    #[test]
894    fn replacement_starts_with_checkpoint_and_ack() {
895        let prepared = PreparedCompaction {
896            archived_messages: vec![ChatMessage::user("old")],
897            preserved_messages: vec![ChatMessage::user("new")],
898            previous_summary: None,
899            history_excerpt: "old".to_string(),
900        };
901        let record = CompactionRecord {
902            id: "c1".to_string(),
903            trigger: CompactionTrigger::Manual,
904            created_at: Local::now(),
905            before_tokens: 100,
906            after_tokens: 25,
907            archived_message_count: 1,
908            preserved_message_count: 1,
909            summary_tokens: 10,
910            duration_secs: 1.0,
911            verified: true,
912            verification_error: None,
913            focus: None,
914            archive_path: None,
915        };
916        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
917        assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
918        assert!(messages[0].content.contains(CHECKPOINT_MARKER));
919        assert_eq!(messages[2].content, "new");
920    }
921
922    #[test]
923    fn replacement_metadata_records_verification_status() {
924        let prepared = PreparedCompaction {
925            archived_messages: vec![ChatMessage::user("old")],
926            preserved_messages: vec![ChatMessage::user("new")],
927            previous_summary: None,
928            history_excerpt: "old".to_string(),
929        };
930        let record = CompactionRecord {
931            id: "c1".to_string(),
932            trigger: CompactionTrigger::Manual,
933            created_at: Local::now(),
934            before_tokens: 100,
935            after_tokens: 25,
936            archived_message_count: 1,
937            preserved_message_count: 1,
938            summary_tokens: 10,
939            duration_secs: 1.0,
940            verified: false,
941            verification_error: Some("provider overloaded".to_string()),
942            focus: None,
943            archive_path: None,
944        };
945        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
946        let metadata = messages[0].metadata.as_ref().expect("metadata");
947        assert_eq!(
948            metadata.get("verified").and_then(|v| v.as_bool()),
949            Some(false)
950        );
951        assert_eq!(
952            metadata.get("verification_error").and_then(|v| v.as_str()),
953            Some("provider overloaded")
954        );
955        assert!(messages[1].content.contains("Used draft summary"));
956    }
957}