Skip to main content

everruns_core/capabilities/
compaction.rs

1//! Compaction Capability
2//!
3//! Configurable context compaction strategy. Users choose between native provider
4//! compaction (e.g., OpenAI /responses/compact) and our own strategies (observation
5//! masking, LLM summarization). See specs/compaction.md.
6//!
7//! Design decisions:
8//! - Strategy selection is per-agent/harness via `AgentCapabilityConfig`
9//! - Native and our own strategies coexist as first-class options
10//! - The `auto` cascade: observation masking → native → summarization
11//! - Proactive compaction at a configurable budget threshold, not just on error
12
13use super::{
14    Capability, CapabilityLocalization, CapabilityStatus, ModelViewContext, ModelViewProvider,
15};
16use crate::events::TokenUsage;
17use crate::message::{ContentPart, Message, MessageRole};
18use crate::message_filter::MessageFilterProvider;
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23/// Capability ID for compaction.
24pub const COMPACTION_CAPABILITY_ID: &str = "compaction";
25const MAX_RELATED_RECENT_READ_RESULTS: usize = 4;
26
27/// Compaction strategy selection.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum CompactionStrategy {
31    /// Cascade: observation masking → native → summarization → aggressive trim.
32    #[default]
33    Auto,
34    /// Use provider's native compact endpoint only (e.g., OpenAI /responses/compact).
35    Native,
36    /// Strip old tool outputs, replace with one-line summaries.
37    ObservationMasking,
38    /// Use LLM to summarize older turns.
39    Summarization,
40}
41
42impl std::fmt::Display for CompactionStrategy {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Auto => write!(f, "auto"),
46            Self::Native => write!(f, "native"),
47            Self::ObservationMasking => write!(f, "observation_masking"),
48            Self::Summarization => write!(f, "summarization"),
49        }
50    }
51}
52
53/// Format for masked tool output summaries.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum MaskingSummaryFormat {
57    /// `[tool_name(args_truncated) → OK]`
58    #[default]
59    OneLine,
60    /// Keep first and last 3 lines of output.
61    HeadTail,
62}
63
64/// Observation masking settings.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ObservationMaskingConfig {
67    /// Number of recent tool outputs to keep verbatim.
68    #[serde(default = "default_keep_recent_tool_outputs")]
69    pub keep_recent_tool_outputs: usize,
70
71    /// Format for masked tool output summaries.
72    #[serde(default)]
73    pub summary_format: MaskingSummaryFormat,
74}
75
76impl Default for ObservationMaskingConfig {
77    fn default() -> Self {
78        Self {
79            keep_recent_tool_outputs: default_keep_recent_tool_outputs(),
80            summary_format: MaskingSummaryFormat::default(),
81        }
82    }
83}
84
85fn default_keep_recent_tool_outputs() -> usize {
86    // Lowered from 5 to 2 (EVE-224). With EVE-221 capping exec output at 16 KiB,
87    // keeping 2 recent (~8K tokens) instead of 5 (~20K tokens) significantly reduces
88    // stale exec output accumulation. Older tool results are masked to one-line summaries.
89    2
90}
91
92/// Cost-control masking settings.
93///
94/// Unlike proactive compaction, this is cost-oriented rather than
95/// context-window-oriented: old bulky tool results should not stay verbatim in
96/// every request just because the model still has room for them.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CostControlConfig {
99    /// Enable low-cost tool-result masking before every LLM call.
100    #[serde(default = "default_cost_control_enabled")]
101    pub enabled: bool,
102
103    /// Number of most-recent tool results to always keep verbatim.
104    #[serde(default = "default_cost_control_keep_recent_tool_results")]
105    pub keep_recent_tool_results: usize,
106
107    /// Start masking once this many tool results are present.
108    #[serde(default = "default_cost_control_mask_after_tool_results")]
109    pub mask_after_tool_results: usize,
110
111    /// Start masking once aggregate live tool-result payload exceeds this many bytes.
112    #[serde(default = "default_cost_control_max_live_tool_result_bytes")]
113    pub max_live_tool_result_bytes: usize,
114
115    /// If cumulative/session usage is available, mask when uncached input exceeds this.
116    #[serde(default = "default_cost_control_max_uncached_input_tokens")]
117    pub max_uncached_input_tokens: u32,
118
119    /// If cumulative/session usage is available, mask when cache read ratio falls below this.
120    #[serde(default = "default_cost_control_min_cache_read_ratio")]
121    pub min_cache_read_ratio: f32,
122}
123
124impl Default for CostControlConfig {
125    fn default() -> Self {
126        Self {
127            enabled: default_cost_control_enabled(),
128            keep_recent_tool_results: default_cost_control_keep_recent_tool_results(),
129            mask_after_tool_results: default_cost_control_mask_after_tool_results(),
130            max_live_tool_result_bytes: default_cost_control_max_live_tool_result_bytes(),
131            max_uncached_input_tokens: default_cost_control_max_uncached_input_tokens(),
132            min_cache_read_ratio: default_cost_control_min_cache_read_ratio(),
133        }
134    }
135}
136
137fn default_cost_control_enabled() -> bool {
138    true
139}
140
141fn default_cost_control_keep_recent_tool_results() -> usize {
142    2
143}
144
145fn default_cost_control_mask_after_tool_results() -> usize {
146    4
147}
148
149fn default_cost_control_max_live_tool_result_bytes() -> usize {
150    24 * 1024
151}
152
153fn default_cost_control_max_uncached_input_tokens() -> u32 {
154    100_000
155}
156
157fn default_cost_control_min_cache_read_ratio() -> f32 {
158    0.35
159}
160
161/// Summarization settings.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct SummarizationConfig {
164    /// Model to use for summarization. None = same model as agent.
165    #[serde(default)]
166    pub model: Option<String>,
167
168    /// What to preserve in summaries.
169    #[serde(default = "default_preserve")]
170    pub preserve: Vec<String>,
171
172    /// Custom instructions appended to summarization prompt.
173    #[serde(default)]
174    pub instructions: Option<String>,
175}
176
177impl Default for SummarizationConfig {
178    fn default() -> Self {
179        Self {
180            model: None,
181            preserve: default_preserve(),
182            instructions: None,
183        }
184    }
185}
186
187fn default_preserve() -> Vec<String> {
188    vec![
189        "decisions".to_string(),
190        "files_modified".to_string(),
191        "errors".to_string(),
192        "current_plan".to_string(),
193        "skill_instructions".to_string(),
194    ]
195}
196
197/// Compaction capability configuration.
198///
199/// Configured per agent/harness via `AgentCapabilityConfig`:
200/// ```json
201/// { "ref": "compaction", "config": { "strategy": "auto", "proactive": true } }
202/// ```
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct CompactionConfig {
205    /// Which strategy to use.
206    #[serde(default)]
207    pub strategy: CompactionStrategy,
208
209    /// Compact proactively at budget_percent, not just on RequestTooLarge.
210    #[serde(default = "default_proactive")]
211    pub proactive: bool,
212
213    /// Trigger proactive compaction at this fraction of context budget.
214    #[serde(default = "default_budget_percent")]
215    pub budget_percent: f32,
216
217    /// Observation masking settings.
218    #[serde(default)]
219    pub observation_masking: ObservationMaskingConfig,
220
221    /// Summarization settings.
222    #[serde(default)]
223    pub summarization: SummarizationConfig,
224
225    /// Hierarchical memory tier settings for hot/warm/cold management.
226    #[serde(default)]
227    pub memory_tiers: HierarchicalMemoryConfig,
228
229    /// Always-on cost-oriented masking for stale tool results.
230    #[serde(default)]
231    pub cost_control: CostControlConfig,
232}
233
234impl Default for CompactionConfig {
235    fn default() -> Self {
236        Self {
237            strategy: CompactionStrategy::default(),
238            proactive: default_proactive(),
239            budget_percent: default_budget_percent(),
240            observation_masking: ObservationMaskingConfig::default(),
241            summarization: SummarizationConfig::default(),
242            memory_tiers: HierarchicalMemoryConfig::default(),
243            cost_control: CostControlConfig::default(),
244        }
245    }
246}
247
248fn default_proactive() -> bool {
249    true
250}
251
252fn default_budget_percent() -> f32 {
253    0.85
254}
255
256impl CompactionConfig {
257    /// Parse from JSON value, falling back to defaults for invalid config.
258    pub fn from_json(value: &serde_json::Value) -> Self {
259        serde_json::from_value(value.clone()).unwrap_or_default()
260    }
261}
262
263/// Compaction capability.
264pub struct CompactionCapability;
265
266impl Capability for CompactionCapability {
267    fn id(&self) -> &str {
268        COMPACTION_CAPABILITY_ID
269    }
270
271    fn name(&self) -> &str {
272        "Compaction"
273    }
274
275    fn description(&self) -> &str {
276        r#"Configurable context compaction when conversations exceed LLM context windows.
277
278Choose between native provider compaction (e.g., OpenAI /responses/compact), observation masking (strip old tool outputs), or LLM summarization. The `auto` strategy cascades through all available options."#
279    }
280
281    fn status(&self) -> CapabilityStatus {
282        CapabilityStatus::Available
283    }
284
285    fn icon(&self) -> Option<&str> {
286        Some("shrink")
287    }
288
289    fn category(&self) -> Option<&str> {
290        Some("Optimization")
291    }
292
293    fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
294        Some(Arc::new(CompactionFilterProvider))
295    }
296
297    fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
298        Some(Arc::new(CompactionModelViewProvider))
299    }
300
301    /// Only the top-level knobs users meaningfully tune are exposed:
302    /// `strategy`, `proactive`, and `budget_percent`. The nested
303    /// `observation_masking` / `summarization` / `memory_tiers` /
304    /// `cost_control` objects are advanced tuning with safe defaults and stay
305    /// out of the schema, but `validate_config` still accepts them via the
306    /// typed `CompactionConfig` parse.
307    fn config_schema(&self) -> Option<serde_json::Value> {
308        Some(serde_json::json!({
309            "type": "object",
310            "properties": {
311                "strategy": {
312                    "type": "string",
313                    "title": "Strategy",
314                    "description": "Compaction strategy used when the conversation approaches the context window.",
315                    "oneOf": [
316                        { "const": "auto", "title": "Automatic" },
317                        { "const": "native", "title": "Provider-native" },
318                        { "const": "observation_masking", "title": "Observation masking" },
319                        { "const": "summarization", "title": "LLM summarization" }
320                    ],
321                    "default": "auto"
322                },
323                "proactive": {
324                    "type": "boolean",
325                    "title": "Proactive compaction",
326                    "description": "Compact at the budget threshold instead of waiting for a request-too-large error.",
327                    "default": true
328                },
329                "budget_percent": {
330                    "type": "number",
331                    "title": "Context budget threshold",
332                    "description": "Fraction of the model context window at which proactive compaction triggers.",
333                    "minimum": 0.1,
334                    "maximum": 1.0,
335                    // Keep in sync with `default_budget_percent()`; the f32
336                    // value is not used directly to avoid noisy f32->f64
337                    // widening in the serialized schema.
338                    "default": 0.85
339                }
340            }
341        }))
342    }
343
344    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
345        if config.is_null() {
346            return Ok(());
347        }
348        let typed: CompactionConfig = serde_json::from_value(config.clone())
349            .map_err(|e| format!("invalid compaction config: {e}"))?;
350        if !(0.1..=1.0).contains(&typed.budget_percent) {
351            return Err(format!(
352                "budget_percent must be between 0.1 and 1.0, got {}",
353                typed.budget_percent
354            ));
355        }
356        Ok(())
357    }
358
359    fn localizations(&self) -> Vec<CapabilityLocalization> {
360        vec![
361            CapabilityLocalization {
362                locale: "en",
363                name: None,
364                description: None,
365                config_description: Some(
366                    "Controls the compaction strategy, proactive triggering, and the \
367                     context-budget threshold.",
368                ),
369                config_overlay: None,
370            },
371            CapabilityLocalization {
372                locale: "uk",
373                name: Some("Ущільнення контексту"),
374                description: Some(
375                    "Налаштовуване ущільнення контексту, коли розмова перевищує контекстне \
376                     вікно LLM. Доступні стратегії: нативне ущільнення провайдера, маскування \
377                     результатів інструментів і підсумовування через LLM; стратегія auto \
378                     перебирає всі доступні варіанти.",
379                ),
380                config_description: Some(
381                    "Визначає стратегію ущільнення контексту, проактивний запуск і поріг \
382                     бюджету контексту.",
383                ),
384                config_overlay: Some(serde_json::json!({
385                    "properties": {
386                        "strategy": {
387                            "title": "Стратегія",
388                            "description": "Стратегія ущільнення, коли розмова наближається до межі контекстного вікна.",
389                            "enum_labels": {
390                                "auto": "Автоматично",
391                                "native": "Нативна (провайдер)",
392                                "observation_masking": "Маскування результатів інструментів",
393                                "summarization": "Підсумовування через LLM"
394                            }
395                        },
396                        "proactive": {
397                            "title": "Проактивне ущільнення",
398                            "description": "Ущільнювати контекст при досягненні порогу бюджету, а не лише після помилки про завеликий запит."
399                        },
400                        "budget_percent": {
401                            "title": "Поріг бюджету контексту",
402                            "description": "Частка контекстного вікна моделі, після якої запускається проактивне ущільнення."
403                        }
404                    }
405                })),
406            },
407        ]
408    }
409}
410
411struct CompactionModelViewProvider;
412
413impl ModelViewProvider for CompactionModelViewProvider {
414    fn apply_model_view(
415        &self,
416        messages: Vec<Message>,
417        config: &serde_json::Value,
418        context: &ModelViewContext<'_>,
419    ) -> Vec<Message> {
420        let config = CompactionConfig::from_json(config);
421        let masking = build_model_view_messages_owned(messages, &config, context.prior_usage);
422        if masking.masked_count > 0 {
423            tracing::info!(
424                session_id = %context.session_id,
425                masked_count = masking.masked_count,
426                tool_result_bytes_before = masking.tool_result_bytes_before,
427                tool_result_bytes_after = masking.tool_result_bytes_after,
428                "CompactionCapability: masked stale tool results for model view"
429            );
430        }
431        masking.messages
432    }
433
434    fn priority(&self) -> i32 {
435        50
436    }
437}
438
439// ============================================================================
440// Message Filter Provider (proactive observation masking at message load time)
441// ============================================================================
442
443/// Applies observation masking as a message filter during message loading.
444///
445/// This runs *before* the LLM call, proactively reducing context size
446/// by masking old tool outputs. Lower priority than infinity context (50 vs 100)
447/// so it runs first — masking happens before trimming.
448struct CompactionFilterProvider;
449
450impl MessageFilterProvider for CompactionFilterProvider {
451    fn apply_filters(
452        &self,
453        _query: &mut crate::message_filter::MessageQuery,
454        _config: &serde_json::Value,
455    ) {
456        // The filter provider signals that compaction is active on this session.
457        // Actual observation masking is applied at LLM message construction time
458        // (in ReasonAtom) rather than at message query time, because masking
459        // operates on LlmMessage format, not the storage Message format.
460        //
461        // The proactive compaction check in ReasonAtom reads the compaction config
462        // and applies masking + budget checks before the LLM call.
463    }
464
465    fn priority(&self) -> i32 {
466        50 // Before infinity context (100)
467    }
468}
469
470// ============================================================================
471// Token Estimation
472// ============================================================================
473
474/// Estimate token count for an LLM message using char/4 approximation.
475///
476/// This is intentionally simple. More accurate estimation (tiktoken, etc.) can
477/// be swapped in later, but char/4 is sufficient for budget decisions.
478pub fn estimate_tokens(msg: &LlmMessage) -> usize {
479    let text_len = match &msg.content {
480        LlmMessageContent::Text(t) => t.len(),
481        LlmMessageContent::Parts(parts) => parts
482            .iter()
483            .map(|p| match p {
484                LlmContentPart::Text { text } => text.len(),
485                _ => 50, // images, etc. — rough estimate
486            })
487            .sum(),
488    };
489
490    // Add tool call overhead
491    let tool_call_len = msg
492        .tool_calls
493        .as_ref()
494        .map(|calls| {
495            calls
496                .iter()
497                .map(|tc| tc.name.len() + tc.arguments.to_string().len() + 20)
498                .sum::<usize>()
499        })
500        .unwrap_or(0);
501
502    (text_len + tool_call_len) / 4
503}
504
505/// Estimate total tokens for a slice of messages.
506pub fn estimate_total_tokens(messages: &[LlmMessage]) -> usize {
507    messages.iter().map(estimate_tokens).sum()
508}
509
510/// Check whether proactive compaction should trigger.
511///
512/// Returns `true` if the estimated tokens exceed `budget_percent` of the model's
513/// context window.
514pub fn should_compact_proactively(
515    messages: &[LlmMessage],
516    config: &CompactionConfig,
517    context_window_tokens: usize,
518) -> bool {
519    if !config.proactive {
520        return false;
521    }
522    let budget = (context_window_tokens as f32 * config.budget_percent) as usize;
523    let estimated = estimate_total_tokens(messages);
524    estimated > budget
525}
526
527// ============================================================================
528// Aggressive Trim (last resort in cascade)
529// ============================================================================
530
531/// Drop oldest messages to fit within a target token count.
532///
533/// Preserves the system prompt (index 0 if present), protected messages
534/// (e.g. `activate_skill` results and their tool call messages), and the
535/// most recent messages. This is the last resort — lossy, no recovery.
536pub fn aggressive_trim(
537    messages: &[LlmMessage],
538    target_tokens: usize,
539    has_system_prompt: bool,
540) -> Vec<LlmMessage> {
541    let mut result = Vec::new();
542    let mut token_budget = target_tokens;
543
544    // Always keep system prompt
545    let start_idx = if has_system_prompt && !messages.is_empty() {
546        let sys_tokens = estimate_tokens(&messages[0]);
547        if sys_tokens < token_budget {
548            result.push(messages[0].clone());
549            token_budget -= sys_tokens;
550        }
551        1
552    } else {
553        0
554    };
555
556    let conversation = &messages[start_idx..];
557
558    // Identify protected messages (skill tool results and their call messages).
559    // Reserve budget for them first so they are never dropped.
560    let mut protected_indices: std::collections::HashSet<usize> = conversation
561        .iter()
562        .enumerate()
563        .filter(|(_, m)| {
564            is_protected_tool_result(conversation, m) || is_protected_tool_call_message(m)
565        })
566        .map(|(i, _)| i)
567        .collect();
568
569    // Anchor the first conversation message (the original task / goal) by
570    // adding it to the protected set, so its budget is reserved before any
571    // non-protected message — like infinity context's head anchor, this is the
572    // eviction we most want to avoid once the window slides. Under extreme
573    // pressure (protected messages alone exceed the budget) the oldest
574    // protected messages, including this one, may still be dropped, matching how
575    // protected tool results are handled.
576    if !conversation.is_empty() {
577        protected_indices.insert(0);
578    }
579
580    let mut protected_budget: usize = 0;
581    for &idx in &protected_indices {
582        protected_budget += estimate_tokens(&conversation[idx]);
583    }
584
585    // If protected messages alone exceed the remaining budget, keep as many
586    // protected messages as possible (newest first) and skip non-protected.
587    if protected_budget > token_budget {
588        let mut protected_with_indices: Vec<(usize, LlmMessage)> = protected_indices
589            .iter()
590            .map(|&idx| (idx, conversation[idx].clone()))
591            .collect();
592        protected_with_indices.sort_by_key(|(i, _)| *i);
593
594        let mut remaining = token_budget;
595        let mut kept: Vec<(usize, LlmMessage)> = Vec::new();
596        for (idx, msg) in protected_with_indices.into_iter().rev() {
597            let t = estimate_tokens(&msg);
598            if t <= remaining {
599                kept.push((idx, msg));
600                remaining -= t;
601            }
602        }
603        kept.sort_by_key(|(i, _)| *i);
604        result.extend(kept.into_iter().map(|(_, m)| m));
605        return result;
606    }
607
608    token_budget -= protected_budget;
609
610    // Walk from newest to oldest, collecting non-protected messages that fit
611    let mut keep_from_end = Vec::new();
612    for (i, msg) in conversation.iter().enumerate().rev() {
613        if protected_indices.contains(&i) {
614            continue; // handled separately
615        }
616        let msg_tokens = estimate_tokens(msg);
617        if msg_tokens <= token_budget {
618            keep_from_end.push((i, msg.clone()));
619            token_budget -= msg_tokens;
620        } else {
621            break;
622        }
623    }
624
625    // Merge protected + kept messages in original order
626    let mut all_kept: Vec<(usize, LlmMessage)> = Vec::new();
627    for &idx in &protected_indices {
628        all_kept.push((idx, conversation[idx].clone()));
629    }
630    all_kept.extend(keep_from_end);
631    all_kept.sort_by_key(|(i, _)| *i);
632
633    result.extend(all_kept.into_iter().map(|(_, m)| m));
634    result
635}
636
637// ============================================================================
638// Session Compaction Metrics
639// ============================================================================
640
641/// Per-session compaction metrics, stored as session metadata.
642#[derive(Debug, Clone, Default, Serialize, Deserialize)]
643pub struct SessionCompactionMetrics {
644    /// Total number of compaction events in this session.
645    pub compaction_count: u32,
646    /// Total messages saved across all compactions.
647    pub total_messages_saved: u64,
648    /// Breakdown by strategy.
649    pub strategy_counts: HashMap<String, u32>,
650    /// Total time spent compacting (ms).
651    pub total_duration_ms: u64,
652}
653
654impl SessionCompactionMetrics {
655    /// Record a completed compaction step.
656    pub fn record(
657        &mut self,
658        strategy_used: &str,
659        messages_before: usize,
660        messages_after: usize,
661        duration_ms: u64,
662    ) {
663        self.compaction_count += 1;
664        self.total_messages_saved += (messages_before.saturating_sub(messages_after)) as u64;
665        self.total_duration_ms += duration_ms;
666
667        for strategy in strategy_used.split('+') {
668            *self
669                .strategy_counts
670                .entry(strategy.to_string())
671                .or_insert(0) += 1;
672        }
673    }
674}
675
676// ============================================================================
677// Hierarchical Memory Tiers
678// ============================================================================
679
680/// Memory tier for a message in the hierarchy.
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum MemoryTier {
684    /// Full verbatim text, always in context.
685    Hot,
686    /// Observation-masked (tool outputs replaced with summaries).
687    Warm,
688    /// Summarized to key facts. Queryable via `query_history` if Infinity Context enabled.
689    Cold,
690}
691
692/// Configuration for hierarchical memory tiers.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct HierarchicalMemoryConfig {
695    /// Number of most recent messages to keep in the hot tier (full verbatim).
696    #[serde(default = "default_hot_messages")]
697    pub hot_messages: usize,
698    /// Number of messages in the warm tier (observation-masked).
699    #[serde(default = "default_warm_messages")]
700    pub warm_messages: usize,
701    // Everything older → cold tier (summarized / queryable)
702}
703
704impl Default for HierarchicalMemoryConfig {
705    fn default() -> Self {
706        Self {
707            hot_messages: default_hot_messages(),
708            warm_messages: default_warm_messages(),
709        }
710    }
711}
712
713fn default_hot_messages() -> usize {
714    20
715}
716
717fn default_warm_messages() -> usize {
718    100
719}
720
721/// Classify messages into memory tiers based on position (newest-first).
722///
723/// Returns a vec of (tier, message) pairs in original order.
724pub fn classify_memory_tiers<'a>(
725    messages: &'a [LlmMessage],
726    config: &HierarchicalMemoryConfig,
727) -> Vec<(MemoryTier, &'a LlmMessage)> {
728    let len = messages.len();
729    messages
730        .iter()
731        .enumerate()
732        .map(|(i, msg)| {
733            let from_end = len - 1 - i;
734            let tier = if from_end < config.hot_messages {
735                MemoryTier::Hot
736            } else if from_end < config.hot_messages + config.warm_messages {
737                MemoryTier::Warm
738            } else {
739                MemoryTier::Cold
740            };
741            (tier, msg)
742        })
743        .collect()
744}
745
746/// Apply hierarchical memory: mask warm-tier tool outputs, summarize cold tier.
747///
748/// Returns the processed messages ready for LLM context. Cold-tier messages are
749/// replaced with a `[CONVERSATION_SUMMARY]` if a summary is provided.
750///
751/// Protected messages (e.g. `activate_skill` results) in cold/warm tiers are
752/// promoted to the output verbatim — they are never dropped or masked.
753pub fn apply_hierarchical_memory(
754    messages: &[LlmMessage],
755    config: &HierarchicalMemoryConfig,
756    masking_config: &ObservationMaskingConfig,
757    cold_summary: Option<&str>,
758) -> Vec<LlmMessage> {
759    let len = messages.len();
760    let hot_start = len.saturating_sub(config.hot_messages);
761    let warm_start = hot_start.saturating_sub(config.warm_messages);
762
763    let mut result = Vec::new();
764
765    // Cold tier: replace with summary if available, but rescue protected messages
766    if warm_start > 0 {
767        // Extract protected messages from cold tier before dropping
768        let cold_msgs = &messages[..warm_start];
769        let protected_cold: Vec<LlmMessage> = cold_msgs
770            .iter()
771            .filter(|m| is_protected_tool_result(cold_msgs, m) || is_protected_tool_call_message(m))
772            .cloned()
773            .collect();
774
775        if let Some(summary) = cold_summary {
776            result.push(build_summary_message(summary));
777        }
778
779        // Re-insert protected messages after the summary
780        result.extend(protected_cold);
781    }
782
783    // Warm tier: apply observation masking to tool outputs.
784    // Use the full message slice for protected-tool detection so that a tool
785    // result in warm tier whose assistant call is in cold tier is still recognized.
786    if warm_start < hot_start {
787        let warm_msgs = &messages[warm_start..hot_start];
788
789        // Pre-identify protected tool_call_ids using the full message list
790        let protected_call_ids: std::collections::HashSet<String> = warm_msgs
791            .iter()
792            .filter(|m| is_protected_tool_result(messages, m))
793            .filter_map(|m| m.tool_call_id.clone())
794            .collect();
795
796        let masked = apply_observation_masking_with_protected(
797            warm_msgs,
798            masking_config,
799            &protected_call_ids,
800        );
801        result.extend(masked.messages);
802    }
803
804    // Hot tier: verbatim
805    if hot_start < len {
806        result.extend_from_slice(&messages[hot_start..]);
807    }
808
809    result
810}
811
812// ============================================================================
813// Protected Tool Detection
814// ============================================================================
815
816use crate::driver_registry::{LlmContentPart, LlmMessage, LlmMessageContent, LlmMessageRole};
817
818/// Tool names whose results must be protected from compaction.
819///
820/// Skill activation results contain durable behavioral instructions that silently
821/// degrade agent behavior when masked, summarized, or trimmed. The agentskills.io
822/// client implementation guide recommends exempting skill content from pruning.
823///
824/// See: specs/compaction.md (Tier 3: tool-aware masking), specs/skills-registry.md
825const PROTECTED_TOOL_NAMES: &[&str] = &["activate_skill"];
826
827/// Check if a tool result message corresponds to a protected tool.
828///
829/// Looks up the tool_call_id in preceding assistant messages to find the tool name.
830/// Returns `true` if the tool name is in `PROTECTED_TOOL_NAMES`.
831fn is_protected_tool_result(messages: &[LlmMessage], tool_msg: &LlmMessage) -> bool {
832    if tool_msg.role != LlmMessageRole::Tool {
833        return false;
834    }
835    let tool_name = find_tool_call_name(messages, tool_msg);
836    PROTECTED_TOOL_NAMES.contains(&tool_name.as_str())
837}
838
839/// Check if an assistant message contains a tool call to a protected tool.
840///
841/// Returns `true` if any tool call in the message targets a protected tool name.
842fn is_protected_tool_call_message(msg: &LlmMessage) -> bool {
843    if msg.role != LlmMessageRole::Assistant {
844        return false;
845    }
846    msg.tool_calls.as_ref().is_some_and(|calls| {
847        calls
848            .iter()
849            .any(|tc| PROTECTED_TOOL_NAMES.contains(&tc.name.as_str()))
850    })
851}
852
853// ============================================================================
854// Observation Masking
855// ============================================================================
856
857/// Result of applying observation masking to a message list.
858#[derive(Debug)]
859pub struct ObservationMaskingResult {
860    /// The masked messages.
861    pub messages: Vec<LlmMessage>,
862    /// Number of tool outputs that were masked.
863    pub masked_count: usize,
864}
865
866/// Apply observation masking: replace old tool outputs with one-line summaries.
867///
868/// Keeps the last `keep_recent_tool_outputs` tool results verbatim and replaces
869/// older ones with compact summaries. Message count is preserved (replace, not remove).
870///
871/// Protected tool results (e.g. `activate_skill`) are never masked — they contain
872/// durable behavioral instructions that must survive compaction.
873pub fn apply_observation_masking(
874    messages: &[LlmMessage],
875    config: &ObservationMaskingConfig,
876) -> ObservationMaskingResult {
877    apply_observation_masking_with_protected(messages, config, &std::collections::HashSet::new())
878}
879
880/// Result of cost-control masking applied before provider serialization.
881#[derive(Debug)]
882pub struct CostControlMaskingResult {
883    /// Messages after stale bulky tool results were replaced by summaries.
884    pub messages: Vec<Message>,
885    /// Number of tool-result messages that were masked.
886    pub masked_count: usize,
887    /// Tool-result payload bytes before masking.
888    pub tool_result_bytes_before: usize,
889    /// Tool-result payload bytes after masking.
890    pub tool_result_bytes_after: usize,
891}
892
893/// Build the bounded model-view messages from lossless stored messages.
894///
895/// Storage keeps full tool results. This helper defines the cheaper prompt
896/// view used for provider serialization when the compaction capability is
897/// configured.
898pub fn build_model_view_messages(
899    stored_messages: &[Message],
900    compaction_config: &CompactionConfig,
901    prior_usage: Option<&TokenUsage>,
902) -> CostControlMaskingResult {
903    apply_cost_control_masking(stored_messages, compaction_config, prior_usage)
904}
905
906/// Build the bounded model-view messages from owned stored messages.
907///
908/// This avoids cloning the message list when masking does not apply.
909pub fn build_model_view_messages_owned(
910    stored_messages: Vec<Message>,
911    compaction_config: &CompactionConfig,
912    prior_usage: Option<&TokenUsage>,
913) -> CostControlMaskingResult {
914    apply_cost_control_masking_owned(stored_messages, compaction_config, prior_usage)
915}
916
917/// Apply cheap, generic cost-control masking to stored messages.
918///
919/// This runs before converting messages to provider-specific LLM messages, so
920/// the llm.generation event can reflect the context actually sent. It is
921/// deliberately separate from observation masking: observation masking is part
922/// of the context-window compaction cascade, while this keeps stale tool output
923/// from being paid for repeatedly even when a large-context model still has
924/// room.
925pub fn apply_cost_control_masking(
926    messages: &[Message],
927    config: &CompactionConfig,
928    prior_usage: Option<&TokenUsage>,
929) -> CostControlMaskingResult {
930    apply_cost_control_masking_owned(messages.to_vec(), config, prior_usage)
931}
932
933fn apply_cost_control_masking_owned(
934    messages: Vec<Message>,
935    config: &CompactionConfig,
936    prior_usage: Option<&TokenUsage>,
937) -> CostControlMaskingResult {
938    let cost_config = &config.cost_control;
939    let tool_indices: Vec<usize> = messages
940        .iter()
941        .enumerate()
942        .filter(|(_, message)| {
943            message.role == MessageRole::ToolResult
944                && !is_protected_message_tool_result(&messages, message)
945        })
946        .map(|(index, _)| index)
947        .collect();
948    let tool_result_bytes_before = tool_indices
949        .iter()
950        .map(|index| message_tool_result_len(&messages[*index]))
951        .sum();
952
953    if !cost_config.enabled
954        || tool_indices.len() <= cost_config.keep_recent_tool_results
955        || !should_apply_cost_control_masking(
956            tool_indices.len(),
957            tool_result_bytes_before,
958            cost_config,
959            prior_usage,
960        )
961    {
962        return CostControlMaskingResult {
963            messages,
964            masked_count: 0,
965            tool_result_bytes_before,
966            tool_result_bytes_after: tool_result_bytes_before,
967        };
968    }
969
970    let keep_recent = cost_config.keep_recent_tool_results;
971    let to_mask_count = tool_indices.len().saturating_sub(keep_recent);
972    let related_recent_reads =
973        related_recent_paginated_read_results(&messages, &tool_indices, keep_recent);
974    let indices_to_mask: HashSet<usize> = tool_indices[..to_mask_count]
975        .iter()
976        .copied()
977        .filter(|index| !related_recent_reads.contains(index))
978        .collect();
979    let tool_names: std::collections::HashMap<usize, String> = indices_to_mask
980        .iter()
981        .map(|index| {
982            (
983                *index,
984                find_message_tool_call_name(&messages, &messages[*index]),
985            )
986        })
987        .collect();
988
989    let mut masked_count = 0;
990    let mut masked_messages = Vec::with_capacity(messages.len());
991    for (index, message) in messages.into_iter().enumerate() {
992        if let Some(tool_name) = tool_names.get(&index) {
993            masked_messages.push(mask_tool_result_message(&message, tool_name));
994            masked_count += 1;
995        } else {
996            masked_messages.push(message);
997        }
998    }
999
1000    let tool_result_bytes_after = masked_messages
1001        .iter()
1002        .filter(|message| message.role == MessageRole::ToolResult)
1003        .map(message_tool_result_len)
1004        .sum();
1005
1006    CostControlMaskingResult {
1007        messages: masked_messages,
1008        masked_count,
1009        tool_result_bytes_before,
1010        tool_result_bytes_after,
1011    }
1012}
1013
1014fn should_apply_cost_control_masking(
1015    tool_result_count: usize,
1016    tool_result_bytes: usize,
1017    config: &CostControlConfig,
1018    prior_usage: Option<&TokenUsage>,
1019) -> bool {
1020    if tool_result_count >= config.mask_after_tool_results {
1021        return true;
1022    }
1023    if tool_result_bytes >= config.max_live_tool_result_bytes {
1024        return true;
1025    }
1026    let Some(usage) = prior_usage else {
1027        return false;
1028    };
1029    // Token buckets are disjoint (see `TokenUsage`): `input_tokens` already
1030    // carries only the non-cached prompt, and the cache-read ratio is measured
1031    // against the full prompt (all buckets summed).
1032    let cache_read = usage.cache_read_tokens.unwrap_or(0);
1033    let cache_creation = usage.cache_creation_tokens.unwrap_or(0);
1034    if usage.input_tokens >= config.max_uncached_input_tokens {
1035        return true;
1036    }
1037    let total_prompt = usage.input_tokens + cache_read + cache_creation;
1038    total_prompt > 0 && (cache_read as f32 / total_prompt as f32) < config.min_cache_read_ratio
1039}
1040
1041fn is_protected_message_tool_result(messages: &[Message], tool_msg: &Message) -> bool {
1042    if tool_msg.role != MessageRole::ToolResult {
1043        return false;
1044    }
1045    let tool_name = find_message_tool_call_name(messages, tool_msg);
1046    PROTECTED_TOOL_NAMES.contains(&tool_name.as_str())
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1050struct ReadResultKey {
1051    tool_name: String,
1052    path: String,
1053    content_hash: String,
1054}
1055
1056fn related_recent_paginated_read_results(
1057    messages: &[Message],
1058    tool_indices: &[usize],
1059    keep_recent: usize,
1060) -> HashSet<usize> {
1061    if keep_recent == 0 || tool_indices.len() <= keep_recent {
1062        return HashSet::new();
1063    }
1064
1065    let keep_start = tool_indices.len().saturating_sub(keep_recent);
1066    let recent_keys: HashSet<ReadResultKey> = tool_indices[keep_start..]
1067        .iter()
1068        .filter_map(|index| paginated_read_result_key(messages, &messages[*index]))
1069        .collect();
1070    if recent_keys.is_empty() {
1071        return HashSet::new();
1072    }
1073
1074    let mut protected = HashSet::new();
1075    for index in tool_indices[..keep_start].iter().rev() {
1076        let Some(key) = paginated_read_result_key(messages, &messages[*index]) else {
1077            break;
1078        };
1079        if !recent_keys.contains(&key) {
1080            break;
1081        }
1082        protected.insert(*index);
1083        if protected.len() >= MAX_RELATED_RECENT_READ_RESULTS {
1084            break;
1085        }
1086    }
1087    protected
1088}
1089
1090fn paginated_read_result_key(messages: &[Message], tool_msg: &Message) -> Option<ReadResultKey> {
1091    let tool_name = find_message_tool_call_name(messages, tool_msg);
1092    if !is_read_file_tool_name(&tool_name) {
1093        return None;
1094    }
1095    let value = tool_msg.tool_result_content()?.result.as_ref()?;
1096    let object = value.as_object()?;
1097    object.get("lines_shown").and_then(|v| v.as_object())?;
1098    Some(ReadResultKey {
1099        tool_name,
1100        path: object.get("path")?.as_str()?.to_string(),
1101        content_hash: object.get("content_hash")?.as_str()?.to_string(),
1102    })
1103}
1104
1105fn is_read_file_tool_name(tool_name: &str) -> bool {
1106    matches!(
1107        tool_name,
1108        "read_file"
1109            | "daytona_read_file"
1110            | "sandbox_read_file"
1111            | "e2b_read_file"
1112            | "docker_read_file"
1113            | "deno_read_file"
1114            | "sprites_read_file"
1115            | "read_github_file"
1116    )
1117}
1118
1119fn find_message_tool_call_name(messages: &[Message], tool_msg: &Message) -> String {
1120    let Some(call_id) = tool_msg.tool_call_id() else {
1121        return "unknown_tool".to_string();
1122    };
1123
1124    for msg in messages.iter().rev() {
1125        if msg.role != MessageRole::Agent {
1126            continue;
1127        }
1128        for tool_call in msg.tool_calls() {
1129            if tool_call.id == call_id {
1130                return tool_call.name.clone();
1131            }
1132        }
1133    }
1134
1135    "unknown_tool".to_string()
1136}
1137
1138fn message_tool_result_len(message: &Message) -> usize {
1139    let Some(result) = message.tool_result_content() else {
1140        return 0;
1141    };
1142    result
1143        .result
1144        .as_ref()
1145        .map(estimate_json_value_len)
1146        .unwrap_or(0)
1147        + result.error.as_ref().map_or(0, String::len)
1148}
1149
1150fn mask_tool_result_message(message: &Message, tool_name: &str) -> Message {
1151    let Some(result) = message.tool_result_content() else {
1152        return message.clone();
1153    };
1154    let summary = summarize_tool_result(tool_name, result.result.as_ref(), result.error.as_ref());
1155    let was_error = result.error.is_some();
1156    let mut masked = message.clone();
1157    for part in &mut masked.content {
1158        if let ContentPart::ToolResult(tool_result) = part {
1159            if was_error {
1160                tool_result.result = None;
1161                tool_result.error = Some(summary);
1162            } else {
1163                tool_result.result = Some(serde_json::json!({
1164                    "masked": true,
1165                    "summary": summary,
1166                }));
1167                tool_result.error = None;
1168            }
1169            break;
1170        }
1171    }
1172    masked
1173}
1174
1175fn summarize_tool_result(
1176    tool_name: &str,
1177    result: Option<&serde_json::Value>,
1178    error: Option<&String>,
1179) -> String {
1180    if let Some(error) = error {
1181        return format!("[{tool_name} error: {}]", truncate_inline(error, 160));
1182    }
1183    let Some(value) = result else {
1184        return format!("[{tool_name} returned no result]");
1185    };
1186    let Some(object) = value.as_object() else {
1187        return format!(
1188            "[{tool_name} -> {}, {} bytes]",
1189            value_kind(value),
1190            estimate_json_value_len(value)
1191        );
1192    };
1193
1194    match tool_name {
1195        tool_name if is_read_file_tool_name(tool_name) => {
1196            summarize_read_file_result(tool_name, object, value)
1197        }
1198        "bash" | "daytona_exec" | "sandbox_exec" | "e2b_exec" | "docker_exec" | "deno_exec" => {
1199            summarize_exec_result(tool_name, object, value)
1200        }
1201        "list_directory" => summarize_list_directory_result(tool_name, object, value),
1202        "grep_files" => summarize_grep_files_result(tool_name, object, value),
1203        _ => summarize_generic_tool_result(tool_name, object, value),
1204    }
1205}
1206
1207fn summarize_read_file_result(
1208    tool_name: &str,
1209    object: &serde_json::Map<String, serde_json::Value>,
1210    value: &serde_json::Value,
1211) -> String {
1212    let path = object
1213        .get("path")
1214        .and_then(|v| v.as_str())
1215        .unwrap_or("(unknown path)");
1216    let lines = object.get("lines_shown").and_then(|v| v.as_object());
1217    let line_range = lines
1218        .and_then(|lines| {
1219            let start = lines.get("start")?.as_u64()?;
1220            let end = lines.get("end")?.as_u64()?;
1221            Some(format!(" lines {start}-{end}"))
1222        })
1223        .unwrap_or_default();
1224    let total_lines = object
1225        .get("total_lines")
1226        .and_then(|v| v.as_u64())
1227        .map(|lines| format!(", total_lines={lines}"))
1228        .unwrap_or_default();
1229    let next_offset = object
1230        .get("truncation")
1231        .and_then(|v| v.as_object())
1232        .and_then(|truncation| truncation.get("next_offset"))
1233        .and_then(|v| v.as_u64())
1234        .map(|offset| format!(", next_offset={offset}"))
1235        .unwrap_or_default();
1236    let hash = object
1237        .get("content_hash")
1238        .and_then(|v| v.as_str())
1239        .map(|hash| format!(", hash={hash}"))
1240        .unwrap_or_default();
1241    let truncated = object
1242        .get("truncated")
1243        .and_then(|v| v.as_bool())
1244        .unwrap_or(false);
1245
1246    format!(
1247        "[{tool_name} {path}{line_range}, {} bytes, truncated={truncated}{total_lines}{next_offset}{hash}]",
1248        estimate_json_value_len(value)
1249    )
1250}
1251
1252fn summarize_exec_result(
1253    tool_name: &str,
1254    object: &serde_json::Map<String, serde_json::Value>,
1255    value: &serde_json::Value,
1256) -> String {
1257    let exit = object
1258        .get("exit_code")
1259        .and_then(|v| v.as_i64())
1260        .map(|code| format!(" exit={code}"))
1261        .unwrap_or_default();
1262    let stdout_len = object
1263        .get("stdout")
1264        .and_then(|v| v.as_str())
1265        .map(|stdout| stdout.len())
1266        .unwrap_or(0);
1267    let stderr_len = object
1268        .get("stderr")
1269        .and_then(|v| v.as_str())
1270        .map(|stderr| stderr.len())
1271        .unwrap_or(0);
1272    let full_output = object
1273        .get("full_output")
1274        .and_then(|v| v.as_str())
1275        .map(|path| format!(", full_output={path}"))
1276        .unwrap_or_default();
1277    let total_lines = object
1278        .get("total_lines")
1279        .and_then(|v| v.as_u64())
1280        .map(|lines| format!(", total_lines={lines}"))
1281        .unwrap_or_default();
1282
1283    format!(
1284        "[{tool_name}{exit}, stdout={} bytes, stderr={} bytes, result={} bytes{full_output}{total_lines}]",
1285        stdout_len,
1286        stderr_len,
1287        estimate_json_value_len(value)
1288    )
1289}
1290
1291fn summarize_list_directory_result(
1292    tool_name: &str,
1293    object: &serde_json::Map<String, serde_json::Value>,
1294    value: &serde_json::Value,
1295) -> String {
1296    let path = object
1297        .get("path")
1298        .and_then(|v| v.as_str())
1299        .unwrap_or("(unknown path)");
1300    let count = object
1301        .get("count")
1302        .and_then(|v| v.as_u64())
1303        .or_else(|| {
1304            object
1305                .get("entries")
1306                .and_then(|v| v.as_array())
1307                .map(|v| v.len() as u64)
1308        })
1309        .unwrap_or(0);
1310    format!(
1311        "[{tool_name} {path}, {count} entries, {} bytes]",
1312        estimate_json_value_len(value)
1313    )
1314}
1315
1316fn summarize_grep_files_result(
1317    tool_name: &str,
1318    object: &serde_json::Map<String, serde_json::Value>,
1319    value: &serde_json::Value,
1320) -> String {
1321    let pattern = object
1322        .get("pattern")
1323        .and_then(|v| v.as_str())
1324        .map(|pattern| format!(" pattern={:?}", truncate_inline(pattern, 80)))
1325        .unwrap_or_default();
1326    let match_count = object
1327        .get("match_count")
1328        .and_then(|v| v.as_u64())
1329        .unwrap_or(0);
1330    format!(
1331        "[{tool_name}{pattern}, matches={match_count}, {} bytes]",
1332        estimate_json_value_len(value)
1333    )
1334}
1335
1336fn summarize_generic_tool_result(
1337    tool_name: &str,
1338    object: &serde_json::Map<String, serde_json::Value>,
1339    value: &serde_json::Value,
1340) -> String {
1341    let keys = object.keys().take(5).cloned().collect::<Vec<_>>().join(",");
1342    format!(
1343        "[{tool_name} result, {} bytes, keys={keys}]",
1344        estimate_json_value_len(value)
1345    )
1346}
1347
1348fn value_kind(value: &serde_json::Value) -> &'static str {
1349    match value {
1350        serde_json::Value::Null => "null",
1351        serde_json::Value::Bool(_) => "bool",
1352        serde_json::Value::Number(_) => "number",
1353        serde_json::Value::String(_) => "string",
1354        serde_json::Value::Array(_) => "array",
1355        serde_json::Value::Object(_) => "object",
1356    }
1357}
1358
1359fn estimate_json_value_len(value: &serde_json::Value) -> usize {
1360    let mut writer = CountingWriter::default();
1361    serde_json::to_writer(&mut writer, value)
1362        .map(|_| writer.bytes)
1363        .unwrap_or(0)
1364}
1365
1366#[derive(Default)]
1367struct CountingWriter {
1368    bytes: usize,
1369}
1370
1371impl std::io::Write for CountingWriter {
1372    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1373        self.bytes += buf.len();
1374        Ok(buf.len())
1375    }
1376
1377    fn flush(&mut self) -> std::io::Result<()> {
1378        Ok(())
1379    }
1380}
1381
1382fn truncate_inline(text: &str, max_chars: usize) -> String {
1383    if text.chars().count() <= max_chars {
1384        return text.to_string();
1385    }
1386    let mut truncated = text.chars().take(max_chars).collect::<String>();
1387    truncated.push_str("...");
1388    truncated
1389}
1390
1391/// Like `apply_observation_masking`, but accepts additional pre-identified protected
1392/// tool_call_ids. This is needed when the message slice doesn't contain the
1393/// assistant tool-call message (e.g. warm tier where the call is in cold tier).
1394fn apply_observation_masking_with_protected(
1395    messages: &[LlmMessage],
1396    config: &ObservationMaskingConfig,
1397    extra_protected_call_ids: &std::collections::HashSet<String>,
1398) -> ObservationMaskingResult {
1399    // Separate protected vs maskable tool result indices
1400    let tool_indices: Vec<usize> = messages
1401        .iter()
1402        .enumerate()
1403        .filter(|(_, m)| {
1404            m.role == LlmMessageRole::Tool
1405                && !is_protected_tool_result(messages, m)
1406                && !m
1407                    .tool_call_id
1408                    .as_ref()
1409                    .is_some_and(|id| extra_protected_call_ids.contains(id))
1410        })
1411        .map(|(i, _)| i)
1412        .collect();
1413
1414    if tool_indices.len() <= config.keep_recent_tool_outputs {
1415        return ObservationMaskingResult {
1416            messages: messages.to_vec(),
1417            masked_count: 0,
1418        };
1419    }
1420
1421    let to_mask_count = tool_indices.len() - config.keep_recent_tool_outputs;
1422    let indices_to_mask: std::collections::HashSet<usize> =
1423        tool_indices[..to_mask_count].iter().copied().collect();
1424
1425    let mut result = Vec::with_capacity(messages.len());
1426    let mut masked_count = 0;
1427
1428    for (i, msg) in messages.iter().enumerate() {
1429        if indices_to_mask.contains(&i) {
1430            let tool_name = find_tool_call_name(messages, msg);
1431            let summary = match config.summary_format {
1432                MaskingSummaryFormat::OneLine => format_one_line_summary(&tool_name, &msg.content),
1433                MaskingSummaryFormat::HeadTail => format_head_tail_summary(&msg.content),
1434            };
1435            result.push(LlmMessage {
1436                role: LlmMessageRole::Tool,
1437                content: LlmMessageContent::Text(summary),
1438                tool_calls: msg.tool_calls.clone(),
1439                tool_call_id: msg.tool_call_id.clone(),
1440                phase: msg.phase,
1441                thinking: None,
1442                thinking_signature: None,
1443            });
1444            masked_count += 1;
1445        } else {
1446            result.push(msg.clone());
1447        }
1448    }
1449
1450    ObservationMaskingResult {
1451        messages: result,
1452        masked_count,
1453    }
1454}
1455
1456/// Find the tool name from a preceding assistant message that issued the tool call.
1457fn find_tool_call_name(messages: &[LlmMessage], tool_msg: &LlmMessage) -> String {
1458    let Some(ref call_id) = tool_msg.tool_call_id else {
1459        return "unknown_tool".to_string();
1460    };
1461
1462    for msg in messages.iter().rev() {
1463        if msg.role == LlmMessageRole::Assistant
1464            && let Some(ref tool_calls) = msg.tool_calls
1465        {
1466            for tc in tool_calls {
1467                if tc.id == *call_id {
1468                    return tc.name.clone();
1469                }
1470            }
1471        }
1472    }
1473
1474    "unknown_tool".to_string()
1475}
1476
1477fn extract_text(content: &LlmMessageContent) -> String {
1478    match content {
1479        LlmMessageContent::Text(t) => t.clone(),
1480        LlmMessageContent::Parts(parts) => parts
1481            .iter()
1482            .filter_map(|p| {
1483                if let LlmContentPart::Text { text } = p {
1484                    Some(text.clone())
1485                } else {
1486                    None
1487                }
1488            })
1489            .collect::<Vec<_>>()
1490            .join(" "),
1491    }
1492}
1493
1494fn format_one_line_summary(tool_name: &str, content: &LlmMessageContent) -> String {
1495    let text = extract_text(content);
1496    let line_count = text.lines().count();
1497    let byte_len = text.len();
1498
1499    if byte_len <= 100 {
1500        format!("[{tool_name} → {text}]")
1501    } else {
1502        format!("[{tool_name} → {line_count} lines, {byte_len} bytes]")
1503    }
1504}
1505
1506fn format_head_tail_summary(content: &LlmMessageContent) -> String {
1507    let text = extract_text(content);
1508    let lines: Vec<&str> = text.lines().collect();
1509
1510    if lines.len() <= 6 {
1511        return text;
1512    }
1513
1514    let head: Vec<&str> = lines[..3].to_vec();
1515    let tail: Vec<&str> = lines[lines.len() - 3..].to_vec();
1516
1517    format!(
1518        "{}\n... ({} lines omitted) ...\n{}",
1519        head.join("\n"),
1520        lines.len() - 6,
1521        tail.join("\n")
1522    )
1523}
1524
1525// ============================================================================
1526// Summarization
1527// ============================================================================
1528
1529/// Build the summarization system prompt.
1530pub fn build_summarization_prompt(config: &SummarizationConfig) -> String {
1531    let preserve_items = if config.preserve.is_empty() {
1532        default_preserve()
1533    } else {
1534        config.preserve.clone()
1535    };
1536
1537    let preserve_list = preserve_items
1538        .iter()
1539        .map(|item| format!("- {item}"))
1540        .collect::<Vec<_>>()
1541        .join("\n");
1542
1543    let custom_instructions = config
1544        .instructions
1545        .as_deref()
1546        .map(|instr| format!("\n- {instr}"))
1547        .unwrap_or_default();
1548
1549    format!(
1550        r#"<task>
1551Summarize the following conversation history. The summary replaces these
1552messages in the agent's context window — it must contain everything the
1553agent needs to continue working.
1554</task>
1555
1556<preserve>
1557{preserve_list}{custom_instructions}
1558</preserve>
1559
1560<format>
1561Produce a structured summary. Use sections. Be concise but complete.
1562Do not include tool output verbatim — reference files by path.
1563IMPORTANT: Any activate_skill tool results contain durable skill instructions.
1564Include them verbatim in a dedicated "Active Skills" section — do not summarize
1565or paraphrase skill instructions.
1566</format>"#
1567    )
1568}
1569
1570/// Format messages into a text block for the summarization prompt.
1571pub fn format_messages_for_summarization(messages: &[LlmMessage]) -> String {
1572    let mut parts = Vec::new();
1573    for msg in messages {
1574        let role = match msg.role {
1575            LlmMessageRole::System => "system",
1576            LlmMessageRole::User => "user",
1577            LlmMessageRole::Assistant => "assistant",
1578            LlmMessageRole::Tool => "tool",
1579        };
1580
1581        let content = extract_text(&msg.content);
1582
1583        // Protected tool results (skill instructions) are never truncated —
1584        // the summarizer must see the full text to reproduce them verbatim.
1585        let is_protected = is_protected_tool_result(messages, msg);
1586
1587        // Truncate very long messages to avoid blowing up the summarization prompt
1588        let truncated = if !is_protected && content.len() > 2000 {
1589            let safe_prefix = truncate_at_char_boundary(&content, 2000);
1590            format!(
1591                "{}... [truncated, {} chars total]",
1592                safe_prefix,
1593                content.len()
1594            )
1595        } else {
1596            content
1597        };
1598
1599        parts.push(format!("[{role}]: {truncated}"));
1600    }
1601    parts.join("\n\n")
1602}
1603
1604fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
1605    if content.len() <= max_bytes {
1606        return content;
1607    }
1608
1609    if content.is_char_boundary(max_bytes) {
1610        return &content[..max_bytes];
1611    }
1612
1613    let mut end = max_bytes;
1614    while end > 0 && !content.is_char_boundary(end) {
1615        end -= 1;
1616    }
1617
1618    &content[..end]
1619}
1620
1621/// Build a summary system message that replaces compacted messages in context.
1622pub fn build_summary_message(summary_text: &str) -> LlmMessage {
1623    LlmMessage {
1624        role: LlmMessageRole::System,
1625        content: LlmMessageContent::Text(format!(
1626            "[CONVERSATION_SUMMARY]\n{summary_text}\n[/CONVERSATION_SUMMARY]"
1627        )),
1628        tool_calls: None,
1629        tool_call_id: None,
1630        phase: None,
1631        thinking: None,
1632        thinking_signature: None,
1633    }
1634}
1635
1636// ============================================================================
1637// Compaction Step Tracking
1638// ============================================================================
1639
1640/// Record of a single compaction step in a cascade.
1641#[derive(Debug, Clone, Serialize, Deserialize)]
1642pub struct CompactionStep {
1643    /// Strategy used in this step.
1644    pub strategy: String,
1645    /// Message count after this step.
1646    pub messages_after: usize,
1647    /// Duration of this step in milliseconds.
1648    pub duration_ms: u64,
1649}
1650
1651// ============================================================================
1652// Tests
1653// ============================================================================
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use crate::tool_types::ToolCall;
1659    use serde_json::json;
1660
1661    fn make_user_msg(text: &str) -> LlmMessage {
1662        LlmMessage {
1663            role: LlmMessageRole::User,
1664            content: LlmMessageContent::Text(text.to_string()),
1665            tool_calls: None,
1666            tool_call_id: None,
1667            phase: None,
1668            thinking: None,
1669            thinking_signature: None,
1670        }
1671    }
1672
1673    fn make_assistant_msg(text: &str) -> LlmMessage {
1674        LlmMessage {
1675            role: LlmMessageRole::Assistant,
1676            content: LlmMessageContent::Text(text.to_string()),
1677            tool_calls: None,
1678            tool_call_id: None,
1679            phase: None,
1680            thinking: None,
1681            thinking_signature: None,
1682        }
1683    }
1684
1685    fn make_assistant_with_tool_call(call_id: &str, tool_name: &str) -> LlmMessage {
1686        LlmMessage {
1687            role: LlmMessageRole::Assistant,
1688            content: LlmMessageContent::Text(String::new()),
1689            tool_calls: Some(vec![ToolCall {
1690                id: call_id.to_string(),
1691                name: tool_name.to_string(),
1692                arguments: json!({"path": "src/main.rs"}),
1693            }]),
1694            tool_call_id: None,
1695            phase: None,
1696            thinking: None,
1697            thinking_signature: None,
1698        }
1699    }
1700
1701    fn make_tool_result(call_id: &str, output: &str) -> LlmMessage {
1702        LlmMessage {
1703            role: LlmMessageRole::Tool,
1704            content: LlmMessageContent::Text(output.to_string()),
1705            tool_calls: None,
1706            tool_call_id: Some(call_id.to_string()),
1707            phase: None,
1708            thinking: None,
1709            thinking_signature: None,
1710        }
1711    }
1712
1713    // ====================================================================
1714    // CompactionConfig tests
1715    // ====================================================================
1716
1717    #[test]
1718    fn test_capability_metadata() {
1719        let cap = CompactionCapability;
1720        assert_eq!(cap.id(), COMPACTION_CAPABILITY_ID);
1721        assert_eq!(cap.name(), "Compaction");
1722        assert_eq!(cap.status(), CapabilityStatus::Available);
1723        assert_eq!(cap.category(), Some("Optimization"));
1724        assert!(cap.tools().is_empty());
1725        assert!(cap.message_filter_provider().is_some());
1726    }
1727
1728    #[test]
1729    fn test_config_schema_and_validate_config() {
1730        let cap = CompactionCapability;
1731
1732        let schema = cap.config_schema().expect("config schema");
1733        assert_eq!(schema["type"], "object");
1734        // Only the simple knobs are exposed; advanced nested objects stay out.
1735        assert!(schema["properties"]["strategy"].is_object());
1736        assert!(schema["properties"]["proactive"].is_object());
1737        assert!(schema["properties"]["budget_percent"].is_object());
1738        assert!(schema["properties"].get("observation_masking").is_none());
1739        assert!(schema["properties"].get("cost_control").is_none());
1740
1741        // Null and valid configs are accepted.
1742        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
1743        assert!(
1744            cap.validate_config(&json!({
1745                "strategy": "native",
1746                "proactive": false,
1747                "budget_percent": 0.9
1748            }))
1749            .is_ok()
1750        );
1751        // Advanced nested fields are tolerated even though not in the schema.
1752        assert!(
1753            cap.validate_config(&json!({
1754                "strategy": "observation_masking",
1755                "observation_masking": { "keep_recent_tool_outputs": 4 },
1756                "cost_control": { "enabled": false }
1757            }))
1758            .is_ok()
1759        );
1760
1761        // Invalid values are rejected.
1762        assert!(cap.validate_config(&json!({"strategy": "bogus"})).is_err());
1763        let err = cap
1764            .validate_config(&json!({"budget_percent": 5.0}))
1765            .unwrap_err();
1766        assert!(err.contains("budget_percent"));
1767    }
1768
1769    #[test]
1770    fn test_localizations_resolve_uk() {
1771        let cap = CompactionCapability;
1772        assert_eq!(cap.localized_name(Some("uk-UA")), "Ущільнення контексту");
1773        assert!(cap.describe_schema(None).is_some());
1774    }
1775
1776    #[test]
1777    fn test_default_config() {
1778        let config = CompactionConfig::default();
1779        assert_eq!(config.strategy, CompactionStrategy::Auto);
1780        assert!(config.proactive);
1781        assert!((config.budget_percent - 0.85).abs() < f32::EPSILON);
1782        assert_eq!(config.observation_masking.keep_recent_tool_outputs, 2);
1783        assert_eq!(
1784            config.observation_masking.summary_format,
1785            MaskingSummaryFormat::OneLine
1786        );
1787        assert!(config.summarization.model.is_none());
1788        assert_eq!(config.summarization.preserve.len(), 5);
1789        assert!(config.summarization.instructions.is_none());
1790        assert!(config.cost_control.enabled);
1791        assert_eq!(config.cost_control.keep_recent_tool_results, 2);
1792    }
1793
1794    #[test]
1795    fn test_config_from_empty_json() {
1796        let config = CompactionConfig::from_json(&json!({}));
1797        assert_eq!(config.strategy, CompactionStrategy::Auto);
1798        assert!(config.proactive);
1799    }
1800
1801    #[test]
1802    fn test_config_native_only() {
1803        let config = CompactionConfig::from_json(&json!({"strategy": "native"}));
1804        assert_eq!(config.strategy, CompactionStrategy::Native);
1805        assert!(config.proactive);
1806    }
1807
1808    #[test]
1809    fn test_config_observation_masking_with_custom_settings() {
1810        let config = CompactionConfig::from_json(&json!({
1811            "strategy": "observation_masking",
1812            "proactive": false,
1813            "observation_masking": {
1814                "keep_recent_tool_outputs": 10,
1815                "summary_format": "head_tail"
1816            }
1817        }));
1818        assert_eq!(config.strategy, CompactionStrategy::ObservationMasking);
1819        assert!(!config.proactive);
1820        assert_eq!(config.observation_masking.keep_recent_tool_outputs, 10);
1821        assert_eq!(
1822            config.observation_masking.summary_format,
1823            MaskingSummaryFormat::HeadTail
1824        );
1825    }
1826
1827    #[test]
1828    fn test_config_cost_control_with_custom_settings() {
1829        let config = CompactionConfig::from_json(&json!({
1830            "cost_control": {
1831                "enabled": true,
1832                "keep_recent_tool_results": 1,
1833                "mask_after_tool_results": 2,
1834                "max_live_tool_result_bytes": 4096,
1835                "max_uncached_input_tokens": 50000,
1836                "min_cache_read_ratio": 0.5
1837            }
1838        }));
1839
1840        assert!(config.cost_control.enabled);
1841        assert_eq!(config.cost_control.keep_recent_tool_results, 1);
1842        assert_eq!(config.cost_control.mask_after_tool_results, 2);
1843        assert_eq!(config.cost_control.max_live_tool_result_bytes, 4096);
1844        assert_eq!(config.cost_control.max_uncached_input_tokens, 50000);
1845        assert!((config.cost_control.min_cache_read_ratio - 0.5).abs() < f32::EPSILON);
1846    }
1847
1848    #[test]
1849    fn test_config_summarization_with_custom_model() {
1850        let config = CompactionConfig::from_json(&json!({
1851            "strategy": "summarization",
1852            "summarization": {
1853                "model": "claude-haiku-4-5-20251001",
1854                "instructions": "Focus on API decisions",
1855                "preserve": ["decisions", "errors"]
1856            }
1857        }));
1858        assert_eq!(config.strategy, CompactionStrategy::Summarization);
1859        assert_eq!(
1860            config.summarization.model.as_deref(),
1861            Some("claude-haiku-4-5-20251001")
1862        );
1863        assert_eq!(
1864            config.summarization.instructions.as_deref(),
1865            Some("Focus on API decisions")
1866        );
1867        assert_eq!(config.summarization.preserve.len(), 2);
1868    }
1869
1870    fn make_message_tool_turn(
1871        call_id: &str,
1872        tool_name: &str,
1873        result: serde_json::Value,
1874    ) -> Vec<Message> {
1875        vec![
1876            Message::assistant_with_tools(
1877                "",
1878                vec![ToolCall {
1879                    id: call_id.to_string(),
1880                    name: tool_name.to_string(),
1881                    arguments: json!({"path": "/workspace/src/lib.rs"}),
1882                }],
1883            ),
1884            Message::tool_result(call_id, Some(result), None),
1885        ]
1886    }
1887
1888    #[test]
1889    fn test_cost_control_masks_old_read_file_results() {
1890        let mut messages = vec![Message::user("inspect files")];
1891        for index in 0..5 {
1892            messages.extend(make_message_tool_turn(
1893                &format!("call_{index}"),
1894                "read_file",
1895                json!({
1896                    "path": "/workspace/src/lib.rs",
1897                    "content": format!("{}{}", "line\n".repeat(400), index),
1898                    "total_lines": 900,
1899                    "lines_shown": {"start": 1, "end": 400},
1900                    "truncated": true,
1901                    "content_hash": format!("sha256:{index}"),
1902                    "truncation": {"truncated": true, "next_offset": 400, "reason": "line_cap"}
1903                }),
1904            ));
1905        }
1906
1907        let config = CompactionConfig::from_json(&json!({
1908            "cost_control": {
1909                "keep_recent_tool_results": 2,
1910                "mask_after_tool_results": 4
1911            }
1912        }));
1913        let result = apply_cost_control_masking(&messages, &config, None);
1914
1915        assert_eq!(result.masked_count, 3);
1916        assert!(result.tool_result_bytes_after < result.tool_result_bytes_before);
1917
1918        let first_tool = result.messages[2].tool_result_content().unwrap();
1919        let masked = first_tool.result.as_ref().unwrap();
1920        assert_eq!(masked["masked"], true);
1921        let summary = masked["summary"].as_str().unwrap();
1922        assert!(summary.contains("read_file"));
1923        assert!(summary.contains("/workspace/src/lib.rs"));
1924        assert!(summary.contains("lines 1-400"));
1925        assert!(summary.contains("next_offset=400"));
1926        assert!(!summary.contains("line\nline"));
1927
1928        let last_tool = result
1929            .messages
1930            .last()
1931            .unwrap()
1932            .tool_result_content()
1933            .unwrap();
1934        assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
1935    }
1936
1937    #[test]
1938    fn test_cost_control_keeps_recent_paginated_read_group() {
1939        let mut messages = vec![Message::user("inspect saved output")];
1940        messages.extend(make_message_tool_turn(
1941            "call_bash",
1942            "bash",
1943            json!({
1944                "stdout": "old command output",
1945                "stderr": "",
1946                "exit_code": 0,
1947                "success": true
1948            }),
1949        ));
1950        messages.extend(make_message_tool_turn(
1951            "call_read_first",
1952            "read_file",
1953            json!({
1954                "path": "/workspace/outputs/call_123.stdout",
1955                "content": "first page\n".repeat(200),
1956                "total_lines": 400,
1957                "lines_shown": {"start": 1, "end": 200},
1958                "truncated": true,
1959                "content_hash": "sha256:same-output",
1960                "truncation": {"truncated": true, "next_offset": 200, "reason": "line_cap"}
1961            }),
1962        ));
1963        messages.extend(make_message_tool_turn(
1964            "call_read_second",
1965            "read_file",
1966            json!({
1967                "path": "/workspace/outputs/call_123.stdout",
1968                "content": "second page\n".repeat(200),
1969                "total_lines": 400,
1970                "lines_shown": {"start": 201, "end": 400},
1971                "truncated": false,
1972                "content_hash": "sha256:same-output"
1973            }),
1974        ));
1975
1976        let config = CompactionConfig::from_json(&json!({
1977            "cost_control": {
1978                "keep_recent_tool_results": 1,
1979                "mask_after_tool_results": 2
1980            }
1981        }));
1982        let result = build_model_view_messages(&messages, &config, None);
1983
1984        assert_eq!(result.masked_count, 1);
1985        let bash_result = result.messages[2].tool_result_content().unwrap();
1986        assert_eq!(bash_result.result.as_ref().unwrap()["masked"], true);
1987        let first_page = result.messages[4].tool_result_content().unwrap();
1988        assert!(first_page.result.as_ref().unwrap().get("content").is_some());
1989        let second_page = result.messages[6].tool_result_content().unwrap();
1990        assert!(
1991            second_page
1992                .result
1993                .as_ref()
1994                .unwrap()
1995                .get("content")
1996                .is_some()
1997        );
1998    }
1999
2000    #[test]
2001    fn test_model_view_masks_with_compaction_config() {
2002        let mut messages = vec![Message::user("inspect files repeatedly")];
2003        for index in 0..9 {
2004            messages.extend(make_message_tool_turn(
2005                &format!("call_{index}"),
2006                "read_file",
2007                json!({
2008                    "path": "/workspace/session_019e4c9dd1b17021af70ad3227361b16.jsonl",
2009                    "content": format!("{}{}", "large transcript line\n".repeat(1000), index),
2010                    "total_lines": 1000,
2011                    "lines_shown": {"start": 1, "end": 1000},
2012                    "truncated": false,
2013                    "content_hash": format!("sha256:{index}")
2014                }),
2015            ));
2016        }
2017
2018        let config = CompactionConfig::default();
2019        let result = build_model_view_messages(&messages, &config, None);
2020
2021        assert_eq!(result.masked_count, 7);
2022        assert!(result.tool_result_bytes_after < result.tool_result_bytes_before / 4);
2023        let first_tool = result.messages[2].tool_result_content().unwrap();
2024        let masked = first_tool.result.as_ref().unwrap();
2025        assert_eq!(masked["masked"], true);
2026        assert!(masked["summary"].as_str().unwrap().contains("read_file"));
2027        let last_tool = result
2028            .messages
2029            .last()
2030            .unwrap()
2031            .tool_result_content()
2032            .unwrap();
2033        assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
2034    }
2035
2036    #[test]
2037    fn test_compaction_capability_contributes_model_view_provider() {
2038        let mut messages = vec![Message::user("inspect files repeatedly")];
2039        for index in 0..9 {
2040            messages.extend(make_message_tool_turn(
2041                &format!("call_{index}"),
2042                "read_file",
2043                json!({
2044                    "path": "/workspace/src/lib.rs",
2045                    "content": format!("{}{}", "large file line\n".repeat(1000), index),
2046                    "total_lines": 1000,
2047                    "lines_shown": {"start": 1, "end": 1000},
2048                    "truncated": false
2049                }),
2050            ));
2051        }
2052
2053        let capability = CompactionCapability;
2054        let provider = capability.model_view_provider().unwrap();
2055        let context = ModelViewContext {
2056            session_id: crate::typed_id::SessionId::new(),
2057            prior_usage: None,
2058        };
2059        let result = provider.apply_model_view(messages, &json!({}), &context);
2060
2061        let first_tool = result[2].tool_result_content().unwrap();
2062        assert_eq!(first_tool.result.as_ref().unwrap()["masked"], true);
2063        let last_tool = result.last().unwrap().tool_result_content().unwrap();
2064        assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
2065    }
2066
2067    #[test]
2068    fn test_model_view_respects_disabled_cost_control_config() {
2069        let mut messages = vec![Message::user("inspect files repeatedly")];
2070        for index in 0..5 {
2071            messages.extend(make_message_tool_turn(
2072                &format!("call_{index}"),
2073                "read_file",
2074                json!({
2075                    "path": "/workspace/src/lib.rs",
2076                    "content": "line\n".repeat(400),
2077                    "total_lines": 400,
2078                    "lines_shown": {"start": 1, "end": 400},
2079                    "truncated": false
2080                }),
2081            ));
2082        }
2083
2084        let config = CompactionConfig::from_json(&json!({
2085            "cost_control": {
2086                "enabled": false,
2087                "keep_recent_tool_results": 1,
2088                "mask_after_tool_results": 2
2089            }
2090        }));
2091        let result = build_model_view_messages(&messages, &config, None);
2092
2093        assert_eq!(result.masked_count, 0);
2094        assert_eq!(
2095            result.tool_result_bytes_after,
2096            result.tool_result_bytes_before
2097        );
2098    }
2099
2100    #[test]
2101    fn test_cost_control_uses_prior_usage_signal() {
2102        let mut messages = vec![Message::user("run commands")];
2103        for index in 0..3 {
2104            messages.extend(make_message_tool_turn(
2105                &format!("call_{index}"),
2106                "bash",
2107                json!({
2108                    "stdout": "small output",
2109                    "stderr": "",
2110                    "exit_code": 0,
2111                    "success": true
2112                }),
2113            ));
2114        }
2115
2116        let config = CompactionConfig::from_json(&json!({
2117            "cost_control": {
2118                "keep_recent_tool_results": 1,
2119                "mask_after_tool_results": 99,
2120                "max_live_tool_result_bytes": 999999,
2121                "max_uncached_input_tokens": 1000
2122            }
2123        }));
2124        let usage = TokenUsage::with_cache(10_000, 100, Some(0), None);
2125        let result = apply_cost_control_masking(&messages, &config, Some(&usage));
2126
2127        assert_eq!(result.masked_count, 2);
2128        let first_tool = result.messages[2].tool_result_content().unwrap();
2129        let summary = first_tool.result.as_ref().unwrap()["summary"]
2130            .as_str()
2131            .unwrap();
2132        assert!(summary.contains("bash exit=0"));
2133    }
2134
2135    #[test]
2136    fn test_cost_control_masking_uses_disjoint_cache_buckets() {
2137        // Disjoint convention: `input_tokens` is the non-cached prompt and the
2138        // cache-read ratio is measured against the full prompt (all buckets).
2139        let config = CostControlConfig {
2140            mask_after_tool_results: usize::MAX,
2141            max_live_tool_result_bytes: usize::MAX,
2142            max_uncached_input_tokens: 50_000,
2143            min_cache_read_ratio: 0.35,
2144            ..CostControlConfig::default()
2145        };
2146
2147        // 20K non-cached input + 180K cache reads: a well-cached run (90% hit,
2148        // non-cached under threshold) — usage signals must not trigger masking.
2149        let well_cached = TokenUsage::with_cache(20_000, 100, Some(180_000), None);
2150        assert!(!should_apply_cost_control_masking(
2151            0,
2152            0,
2153            &config,
2154            Some(&well_cached)
2155        ));
2156
2157        // Same total prompt but cache-poor (10% hit): low ratio triggers masking.
2158        let cache_poor = TokenUsage::with_cache(20_000, 100, Some(2_000), None);
2159        assert!(should_apply_cost_control_masking(
2160            0,
2161            0,
2162            &config,
2163            Some(&cache_poor)
2164        ));
2165
2166        // High non-cached input alone triggers masking regardless of cache ratio.
2167        let heavy_uncached = TokenUsage::with_cache(60_000, 100, Some(200_000), None);
2168        assert!(should_apply_cost_control_masking(
2169            0,
2170            0,
2171            &config,
2172            Some(&heavy_uncached)
2173        ));
2174    }
2175
2176    #[test]
2177    fn test_model_view_uses_provider_cache_signal_from_compaction_config() {
2178        let mut messages = vec![Message::user("run commands")];
2179        for index in 0..3 {
2180            messages.extend(make_message_tool_turn(
2181                &format!("call_{index}"),
2182                "bash",
2183                json!({
2184                    "stdout": "small output",
2185                    "stderr": "",
2186                    "exit_code": 0,
2187                    "success": true
2188                }),
2189            ));
2190        }
2191        let usage = TokenUsage::with_cache(150_000, 100, Some(0), None);
2192
2193        let config = CompactionConfig::default();
2194        let result = build_model_view_messages(&messages, &config, Some(&usage));
2195
2196        assert_eq!(result.masked_count, 1);
2197        let first_tool = result.messages[2].tool_result_content().unwrap();
2198        assert_eq!(first_tool.result.as_ref().unwrap()["masked"], true);
2199    }
2200
2201    #[test]
2202    fn test_config_falls_back_to_defaults_for_invalid_json() {
2203        let config = CompactionConfig::from_json(&json!({
2204            "strategy": "nonexistent_strategy",
2205            "budget_percent": "not-a-number"
2206        }));
2207        assert_eq!(config.strategy, CompactionStrategy::Auto);
2208        assert!(config.proactive);
2209    }
2210
2211    #[test]
2212    fn test_config_partial_override() {
2213        let config = CompactionConfig::from_json(&json!({
2214            "budget_percent": 0.7,
2215            "observation_masking": {
2216                "keep_recent_tool_outputs": 3
2217            }
2218        }));
2219        assert_eq!(config.strategy, CompactionStrategy::Auto);
2220        assert!(config.proactive);
2221        assert!((config.budget_percent - 0.7).abs() < f32::EPSILON);
2222        assert_eq!(config.observation_masking.keep_recent_tool_outputs, 3);
2223        assert_eq!(
2224            config.observation_masking.summary_format,
2225            MaskingSummaryFormat::OneLine
2226        );
2227    }
2228
2229    #[test]
2230    fn test_strategy_serialization_roundtrip() {
2231        for strategy in [
2232            CompactionStrategy::Auto,
2233            CompactionStrategy::Native,
2234            CompactionStrategy::ObservationMasking,
2235            CompactionStrategy::Summarization,
2236        ] {
2237            let json = serde_json::to_value(strategy).unwrap();
2238            let deserialized: CompactionStrategy = serde_json::from_value(json).unwrap();
2239            assert_eq!(strategy, deserialized);
2240        }
2241    }
2242
2243    #[test]
2244    fn test_strategy_display() {
2245        assert_eq!(CompactionStrategy::Auto.to_string(), "auto");
2246        assert_eq!(CompactionStrategy::Native.to_string(), "native");
2247        assert_eq!(
2248            CompactionStrategy::ObservationMasking.to_string(),
2249            "observation_masking"
2250        );
2251        assert_eq!(
2252            CompactionStrategy::Summarization.to_string(),
2253            "summarization"
2254        );
2255    }
2256
2257    #[test]
2258    fn test_masking_format_serialization_roundtrip() {
2259        for format in [
2260            MaskingSummaryFormat::OneLine,
2261            MaskingSummaryFormat::HeadTail,
2262        ] {
2263            let json = serde_json::to_value(format).unwrap();
2264            let deserialized: MaskingSummaryFormat = serde_json::from_value(json).unwrap();
2265            assert_eq!(format, deserialized);
2266        }
2267    }
2268
2269    #[test]
2270    fn test_budget_percent_boundary_values() {
2271        let config = CompactionConfig::from_json(&json!({"budget_percent": 0.1}));
2272        assert!((config.budget_percent - 0.1).abs() < f32::EPSILON);
2273
2274        let config = CompactionConfig::from_json(&json!({"budget_percent": 0.99}));
2275        assert!((config.budget_percent - 0.99).abs() < f32::EPSILON);
2276    }
2277
2278    #[test]
2279    fn test_keep_recent_tool_outputs_zero() {
2280        let config = CompactionConfig::from_json(&json!({
2281            "observation_masking": {"keep_recent_tool_outputs": 0}
2282        }));
2283        assert_eq!(config.observation_masking.keep_recent_tool_outputs, 0);
2284    }
2285
2286    // ====================================================================
2287    // Observation masking tests
2288    // ====================================================================
2289
2290    #[test]
2291    fn test_masking_no_tool_messages() {
2292        let messages = vec![make_user_msg("hello"), make_assistant_msg("hi")];
2293        let config = ObservationMaskingConfig::default();
2294        let result = apply_observation_masking(&messages, &config);
2295        assert_eq!(result.masked_count, 0);
2296        assert_eq!(result.messages.len(), 2);
2297    }
2298
2299    #[test]
2300    fn test_masking_fewer_than_keep_recent() {
2301        let messages = vec![
2302            make_user_msg("read file"),
2303            make_assistant_with_tool_call("call_1", "read_file"),
2304            make_tool_result("call_1", "file contents"),
2305            make_assistant_msg("done"),
2306        ];
2307        let config = ObservationMaskingConfig {
2308            keep_recent_tool_outputs: 5,
2309            summary_format: MaskingSummaryFormat::OneLine,
2310        };
2311        let result = apply_observation_masking(&messages, &config);
2312        assert_eq!(result.masked_count, 0);
2313    }
2314
2315    #[test]
2316    fn test_masking_masks_old_outputs() {
2317        let messages = vec![
2318            make_user_msg("start"),
2319            make_assistant_with_tool_call("call_1", "read_file"),
2320            make_tool_result(
2321                "call_1",
2322                "old file contents that are very long and should be masked by the observation masking strategy because it exceeds 100 chars",
2323            ),
2324            make_assistant_msg("got it"),
2325            make_user_msg("next"),
2326            make_assistant_with_tool_call("call_2", "search"),
2327            make_tool_result("call_2", "search results"),
2328            make_assistant_msg("found it"),
2329            make_user_msg("more"),
2330            make_assistant_with_tool_call("call_3", "bash"),
2331            make_tool_result("call_3", "command output"),
2332        ];
2333
2334        let config = ObservationMaskingConfig {
2335            keep_recent_tool_outputs: 2,
2336            summary_format: MaskingSummaryFormat::OneLine,
2337        };
2338        let result = apply_observation_masking(&messages, &config);
2339
2340        assert_eq!(result.masked_count, 1);
2341
2342        // First tool result should be masked
2343        let masked = &result.messages[2];
2344        assert_eq!(masked.role, LlmMessageRole::Tool);
2345        let text = extract_text(&masked.content);
2346        assert!(
2347            text.starts_with('['),
2348            "Expected masked summary, got: {text}"
2349        );
2350        assert!(text.contains("read_file"), "Expected tool name: {text}");
2351
2352        // Last 2 tool results should be verbatim
2353        assert_eq!(extract_text(&result.messages[6].content), "search results");
2354        assert_eq!(extract_text(&result.messages[10].content), "command output");
2355    }
2356
2357    #[test]
2358    fn test_masking_preserves_tool_call_id() {
2359        let messages = vec![
2360            make_assistant_with_tool_call("call_1", "read_file"),
2361            make_tool_result("call_1", "content"),
2362            make_assistant_with_tool_call("call_2", "bash"),
2363            make_tool_result("call_2", "output"),
2364        ];
2365
2366        let config = ObservationMaskingConfig {
2367            keep_recent_tool_outputs: 1,
2368            summary_format: MaskingSummaryFormat::OneLine,
2369        };
2370        let result = apply_observation_masking(&messages, &config);
2371        assert_eq!(result.messages[1].tool_call_id, Some("call_1".to_string()));
2372    }
2373
2374    #[test]
2375    fn test_masking_head_tail_format() {
2376        let long_output = (0..20)
2377            .map(|i| format!("line {i}"))
2378            .collect::<Vec<_>>()
2379            .join("\n");
2380
2381        let messages = vec![
2382            make_assistant_with_tool_call("call_1", "bash"),
2383            make_tool_result("call_1", &long_output),
2384            make_assistant_with_tool_call("call_2", "bash"),
2385            make_tool_result("call_2", "recent output"),
2386        ];
2387
2388        let config = ObservationMaskingConfig {
2389            keep_recent_tool_outputs: 1,
2390            summary_format: MaskingSummaryFormat::HeadTail,
2391        };
2392        let result = apply_observation_masking(&messages, &config);
2393
2394        let text = extract_text(&result.messages[1].content);
2395        assert!(text.contains("line 0"), "Should contain first lines");
2396        assert!(text.contains("line 19"), "Should contain last lines");
2397        assert!(text.contains("lines omitted"), "Should indicate omissions");
2398    }
2399
2400    #[test]
2401    fn test_masking_short_output_inline() {
2402        let messages = vec![
2403            make_assistant_with_tool_call("call_1", "get_time"),
2404            make_tool_result("call_1", "2024-01-01"),
2405            make_assistant_with_tool_call("call_2", "bash"),
2406            make_tool_result("call_2", "ok"),
2407        ];
2408
2409        let config = ObservationMaskingConfig {
2410            keep_recent_tool_outputs: 1,
2411            summary_format: MaskingSummaryFormat::OneLine,
2412        };
2413        let result = apply_observation_masking(&messages, &config);
2414        let text = extract_text(&result.messages[1].content);
2415        assert!(text.contains("2024-01-01"), "Short output included: {text}");
2416    }
2417
2418    #[test]
2419    fn test_masking_all_when_keep_zero() {
2420        let messages = vec![
2421            make_assistant_with_tool_call("call_1", "a"),
2422            make_tool_result("call_1", "output1"),
2423            make_assistant_with_tool_call("call_2", "b"),
2424            make_tool_result("call_2", "output2"),
2425        ];
2426
2427        let config = ObservationMaskingConfig {
2428            keep_recent_tool_outputs: 0,
2429            summary_format: MaskingSummaryFormat::OneLine,
2430        };
2431        let result = apply_observation_masking(&messages, &config);
2432        assert_eq!(result.masked_count, 2);
2433    }
2434
2435    #[test]
2436    fn test_masking_empty_messages() {
2437        let result = apply_observation_masking(&[], &ObservationMaskingConfig::default());
2438        assert_eq!(result.masked_count, 0);
2439        assert!(result.messages.is_empty());
2440    }
2441
2442    #[test]
2443    fn test_masking_preserves_message_count() {
2444        let messages = vec![
2445            make_user_msg("start"),
2446            make_assistant_with_tool_call("c1", "read_file"),
2447            make_tool_result("c1", "content 1"),
2448            make_assistant_msg("ok"),
2449            make_user_msg("next"),
2450            make_assistant_with_tool_call("c2", "bash"),
2451            make_tool_result("c2", "content 2"),
2452            make_assistant_msg("done"),
2453        ];
2454
2455        let config = ObservationMaskingConfig {
2456            keep_recent_tool_outputs: 1,
2457            summary_format: MaskingSummaryFormat::OneLine,
2458        };
2459        let result = apply_observation_masking(&messages, &config);
2460        assert_eq!(result.messages.len(), messages.len());
2461    }
2462
2463    #[test]
2464    fn test_masking_unknown_tool_call_id() {
2465        let messages = vec![
2466            make_tool_result("orphan", "some output"),
2467            make_assistant_with_tool_call("call_2", "bash"),
2468            make_tool_result("call_2", "recent"),
2469        ];
2470
2471        let config = ObservationMaskingConfig {
2472            keep_recent_tool_outputs: 1,
2473            summary_format: MaskingSummaryFormat::OneLine,
2474        };
2475        let result = apply_observation_masking(&messages, &config);
2476        assert_eq!(result.masked_count, 1);
2477        let text = extract_text(&result.messages[0].content);
2478        assert!(text.contains("unknown_tool"), "Fallback name: {text}");
2479    }
2480
2481    #[test]
2482    fn test_masking_many_tool_calls_keeps_exactly_n() {
2483        let mut messages = Vec::new();
2484        for i in 0..10 {
2485            let id = format!("call_{i}");
2486            messages.push(make_assistant_with_tool_call(&id, &format!("tool_{i}")));
2487            messages.push(make_tool_result(&id, &format!("output {i}")));
2488        }
2489
2490        let config = ObservationMaskingConfig {
2491            keep_recent_tool_outputs: 3,
2492            summary_format: MaskingSummaryFormat::OneLine,
2493        };
2494        let result = apply_observation_masking(&messages, &config);
2495        assert_eq!(result.masked_count, 7);
2496
2497        // Last 3 tool results at indices 15, 17, 19 should be verbatim
2498        assert_eq!(extract_text(&result.messages[15].content), "output 7");
2499        assert_eq!(extract_text(&result.messages[17].content), "output 8");
2500        assert_eq!(extract_text(&result.messages[19].content), "output 9");
2501    }
2502
2503    // ====================================================================
2504    // Summarization tests
2505    // ====================================================================
2506
2507    #[test]
2508    fn test_summarization_prompt_default() {
2509        let config = SummarizationConfig::default();
2510        let prompt = build_summarization_prompt(&config);
2511        assert!(prompt.contains("<task>"));
2512        assert!(prompt.contains("decisions"));
2513        assert!(prompt.contains("files_modified"));
2514        assert!(prompt.contains("errors"));
2515        assert!(prompt.contains("current_plan"));
2516    }
2517
2518    #[test]
2519    fn test_summarization_prompt_custom_instructions() {
2520        let config = SummarizationConfig {
2521            instructions: Some("Focus on API changes".to_string()),
2522            ..Default::default()
2523        };
2524        let prompt = build_summarization_prompt(&config);
2525        assert!(prompt.contains("Focus on API changes"));
2526    }
2527
2528    #[test]
2529    fn test_summarization_prompt_custom_preserve() {
2530        let config = SummarizationConfig {
2531            preserve: vec!["auth_tokens".to_string(), "database_schema".to_string()],
2532            ..Default::default()
2533        };
2534        let prompt = build_summarization_prompt(&config);
2535        assert!(prompt.contains("auth_tokens"));
2536        assert!(prompt.contains("database_schema"));
2537        assert!(!prompt.contains("decisions"));
2538    }
2539
2540    #[test]
2541    fn test_summarization_prompt_empty_preserve_uses_defaults() {
2542        let config = SummarizationConfig {
2543            preserve: vec![],
2544            ..Default::default()
2545        };
2546        let prompt = build_summarization_prompt(&config);
2547        assert!(prompt.contains("decisions"));
2548    }
2549
2550    #[test]
2551    fn test_format_messages_for_summarization() {
2552        let messages = vec![
2553            make_user_msg("What is 2+2?"),
2554            make_assistant_msg("The answer is 4."),
2555        ];
2556        let formatted = format_messages_for_summarization(&messages);
2557        assert!(formatted.contains("[user]: What is 2+2?"));
2558        assert!(formatted.contains("[assistant]: The answer is 4."));
2559    }
2560
2561    #[test]
2562    fn test_format_messages_truncates_long_content() {
2563        let long_content = "x".repeat(5000);
2564        let messages = vec![make_user_msg(&long_content)];
2565        let formatted = format_messages_for_summarization(&messages);
2566        assert!(formatted.contains("truncated"));
2567        assert!(formatted.len() < long_content.len());
2568    }
2569
2570    #[test]
2571    fn test_format_messages_truncates_utf8_without_panic() {
2572        let multibyte = "é".repeat(1001); // 2002 bytes, 1001 chars
2573        let messages = vec![make_user_msg(&multibyte)];
2574        let formatted = format_messages_for_summarization(&messages);
2575        assert!(formatted.contains("truncated"));
2576        assert!(formatted.contains("[truncated, 2002 chars total]"));
2577    }
2578
2579    #[test]
2580    fn test_build_summary_message() {
2581        let msg = build_summary_message("The user asked about APIs.");
2582        assert_eq!(msg.role, LlmMessageRole::System);
2583        let text = extract_text(&msg.content);
2584        assert!(text.contains("[CONVERSATION_SUMMARY]"));
2585        assert!(text.contains("The user asked about APIs."));
2586        assert!(text.contains("[/CONVERSATION_SUMMARY]"));
2587    }
2588
2589    // ====================================================================
2590    // Head-tail format edge cases
2591    // ====================================================================
2592
2593    #[test]
2594    fn test_head_tail_short_content_unchanged() {
2595        let content = LlmMessageContent::Text("line1\nline2\nline3".to_string());
2596        assert_eq!(format_head_tail_summary(&content), "line1\nline2\nline3");
2597    }
2598
2599    #[test]
2600    fn test_head_tail_exactly_six_lines() {
2601        let content = LlmMessageContent::Text("1\n2\n3\n4\n5\n6".to_string());
2602        assert_eq!(format_head_tail_summary(&content), "1\n2\n3\n4\n5\n6");
2603    }
2604
2605    #[test]
2606    fn test_head_tail_seven_lines() {
2607        let content = LlmMessageContent::Text("1\n2\n3\n4\n5\n6\n7".to_string());
2608        let result = format_head_tail_summary(&content);
2609        assert!(result.contains("1\n2\n3"));
2610        assert!(result.contains("5\n6\n7"));
2611        assert!(result.contains("1 lines omitted"));
2612    }
2613
2614    // ====================================================================
2615    // One-line format edge cases
2616    // ====================================================================
2617
2618    #[test]
2619    fn test_one_line_empty_output() {
2620        let result = format_one_line_summary("bash", &LlmMessageContent::Text(String::new()));
2621        assert_eq!(result, "[bash → ]");
2622    }
2623
2624    #[test]
2625    fn test_one_line_exactly_100_chars() {
2626        let text = "x".repeat(100);
2627        let result = format_one_line_summary("bash", &LlmMessageContent::Text(text.clone()));
2628        assert!(result.contains(&text));
2629    }
2630
2631    #[test]
2632    fn test_one_line_101_chars_summarized() {
2633        let text = "x".repeat(101);
2634        let result = format_one_line_summary("bash", &LlmMessageContent::Text(text));
2635        assert!(result.contains("lines"));
2636        assert!(result.contains("bytes"));
2637    }
2638
2639    #[test]
2640    fn test_one_line_multipart_content() {
2641        let content = LlmMessageContent::Parts(vec![
2642            LlmContentPart::Text {
2643                text: "part1".to_string(),
2644            },
2645            LlmContentPart::Text {
2646                text: "part2".to_string(),
2647            },
2648        ]);
2649        let result = format_one_line_summary("tool", &content);
2650        assert!(result.contains("part1"));
2651        assert!(result.contains("part2"));
2652    }
2653
2654    // ====================================================================
2655    // CompactionStep tests
2656    // ====================================================================
2657
2658    #[test]
2659    fn test_compaction_step_serialization() {
2660        let step = CompactionStep {
2661            strategy: "observation_masking".to_string(),
2662            messages_after: 42,
2663            duration_ms: 12,
2664        };
2665        let json = serde_json::to_value(&step).unwrap();
2666        assert_eq!(json["strategy"], "observation_masking");
2667        assert_eq!(json["messages_after"], 42);
2668        assert_eq!(json["duration_ms"], 12);
2669    }
2670
2671    // ====================================================================
2672    // Token estimation tests
2673    // ====================================================================
2674
2675    #[test]
2676    fn test_estimate_tokens_text() {
2677        let msg = make_user_msg("hello world"); // 11 chars → ~2 tokens
2678        let tokens = estimate_tokens(&msg);
2679        assert_eq!(tokens, 11 / 4);
2680    }
2681
2682    #[test]
2683    fn test_estimate_tokens_empty() {
2684        let msg = make_user_msg("");
2685        assert_eq!(estimate_tokens(&msg), 0);
2686    }
2687
2688    #[test]
2689    fn test_estimate_total_tokens() {
2690        let messages = vec![
2691            make_user_msg("a".repeat(400).as_str()),      // 100 tokens
2692            make_assistant_msg("b".repeat(200).as_str()), // 50 tokens
2693        ];
2694        assert_eq!(estimate_total_tokens(&messages), 150);
2695    }
2696
2697    #[test]
2698    fn test_estimate_tokens_with_tool_calls() {
2699        let msg = make_assistant_with_tool_call("call_1", "read_file");
2700        let tokens = estimate_tokens(&msg);
2701        assert!(tokens > 0, "Tool call should contribute tokens");
2702    }
2703
2704    // ====================================================================
2705    // Proactive compaction check tests
2706    // ====================================================================
2707
2708    #[test]
2709    fn test_should_compact_proactively_under_budget() {
2710        let messages = vec![make_user_msg("short")];
2711        let config = CompactionConfig::default(); // 85% budget
2712        assert!(!should_compact_proactively(&messages, &config, 128_000));
2713    }
2714
2715    #[test]
2716    fn test_should_compact_proactively_over_budget() {
2717        // Create messages that exceed 85% of 1000 tokens = 850 tokens
2718        let big_text = "x".repeat(4000); // ~1000 tokens
2719        let messages = vec![make_user_msg(&big_text)];
2720        let config = CompactionConfig::default();
2721        assert!(should_compact_proactively(&messages, &config, 1000));
2722    }
2723
2724    #[test]
2725    fn test_should_compact_proactively_disabled() {
2726        let big_text = "x".repeat(4000);
2727        let messages = vec![make_user_msg(&big_text)];
2728        let config = CompactionConfig {
2729            proactive: false,
2730            ..Default::default()
2731        };
2732        assert!(!should_compact_proactively(&messages, &config, 1000));
2733    }
2734
2735    // ====================================================================
2736    // Aggressive trim tests
2737    // ====================================================================
2738
2739    #[test]
2740    fn test_aggressive_trim_keeps_newest() {
2741        // Use big messages so budget matters
2742        let messages = vec![
2743            make_user_msg(&"s".repeat(400)),      // system: 100 tokens
2744            make_user_msg(&"a".repeat(400)),      // old: 100 tokens
2745            make_assistant_msg(&"b".repeat(400)), // old: 100 tokens
2746            make_user_msg(&"c".repeat(400)),      // recent: 100 tokens
2747            make_assistant_msg(&"d".repeat(400)), // recent: 100 tokens
2748        ];
2749        // Target: enough for system + 2 recent messages only (300 tokens)
2750        let target_tokens = 300;
2751        let result = aggressive_trim(&messages, target_tokens, true);
2752        assert!(
2753            result.len() < messages.len(),
2754            "Expected trim, got {} messages",
2755            result.len()
2756        );
2757        // Should keep system prompt (first)
2758        assert_eq!(result[0].role, LlmMessageRole::User);
2759    }
2760
2761    #[test]
2762    fn test_aggressive_trim_empty() {
2763        let result = aggressive_trim(&[], 100, false);
2764        assert!(result.is_empty());
2765    }
2766
2767    #[test]
2768    fn test_aggressive_trim_anchors_first_conversation_message() {
2769        // messages[0] = system prompt; messages[1] = the original task (old, big);
2770        // the rest are newer. Under a tight budget the task must still survive so
2771        // the model does not lose track of what it is doing.
2772        let messages = vec![
2773            make_user_msg("sys"),                 // system prompt (small)
2774            make_user_msg(&"TASK ".repeat(80)),   // the task: old + big
2775            make_assistant_msg(&"x".repeat(400)), // filler old
2776            make_user_msg(&"c".repeat(400)),      // recent
2777            make_assistant_msg(&"d".repeat(400)), // recent
2778        ];
2779        let result = aggressive_trim(&messages, 250, true);
2780
2781        assert!(
2782            result.len() < messages.len(),
2783            "expected a trim, got {} messages",
2784            result.len()
2785        );
2786        let kept_task = result.iter().any(|m| match &m.content {
2787            LlmMessageContent::Text(t) => t.contains("TASK"),
2788            _ => false,
2789        });
2790        assert!(kept_task, "the original task must be anchored, not dropped");
2791    }
2792
2793    #[test]
2794    fn test_aggressive_trim_everything_fits() {
2795        let messages = vec![make_user_msg("hi"), make_assistant_msg("hello")];
2796        let result = aggressive_trim(&messages, 100_000, false);
2797        assert_eq!(result.len(), 2);
2798    }
2799
2800    // ====================================================================
2801    // Session compaction metrics tests
2802    // ====================================================================
2803
2804    #[test]
2805    fn test_session_metrics_record() {
2806        let mut metrics = SessionCompactionMetrics::default();
2807        metrics.record("observation_masking+native", 100, 50, 200);
2808
2809        assert_eq!(metrics.compaction_count, 1);
2810        assert_eq!(metrics.total_messages_saved, 50);
2811        assert_eq!(metrics.total_duration_ms, 200);
2812        assert_eq!(metrics.strategy_counts["observation_masking"], 1);
2813        assert_eq!(metrics.strategy_counts["native"], 1);
2814    }
2815
2816    #[test]
2817    fn test_session_metrics_accumulate() {
2818        let mut metrics = SessionCompactionMetrics::default();
2819        metrics.record("observation_masking", 100, 80, 10);
2820        metrics.record("summarization", 80, 40, 500);
2821
2822        assert_eq!(metrics.compaction_count, 2);
2823        assert_eq!(metrics.total_messages_saved, 60);
2824        assert_eq!(metrics.total_duration_ms, 510);
2825        assert_eq!(metrics.strategy_counts["observation_masking"], 1);
2826        assert_eq!(metrics.strategy_counts["summarization"], 1);
2827    }
2828
2829    #[test]
2830    fn test_session_metrics_serialization() {
2831        let mut metrics = SessionCompactionMetrics::default();
2832        metrics.record("auto", 50, 30, 100);
2833        let json = serde_json::to_value(&metrics).unwrap();
2834        assert_eq!(json["compaction_count"], 1);
2835        assert_eq!(json["total_messages_saved"], 20);
2836    }
2837
2838    // ====================================================================
2839    // Hierarchical memory tier tests
2840    // ====================================================================
2841
2842    #[test]
2843    fn test_classify_memory_tiers_basic() {
2844        let messages: Vec<LlmMessage> = (0..30)
2845            .map(|i| make_user_msg(&format!("msg {i}")))
2846            .collect();
2847
2848        let config = HierarchicalMemoryConfig {
2849            hot_messages: 5,
2850            warm_messages: 10,
2851        };
2852
2853        let classified = classify_memory_tiers(&messages, &config);
2854        assert_eq!(classified.len(), 30);
2855
2856        // Last 5 = hot
2857        assert_eq!(classified[29].0, MemoryTier::Hot);
2858        assert_eq!(classified[25].0, MemoryTier::Hot);
2859
2860        // Next 10 = warm
2861        assert_eq!(classified[24].0, MemoryTier::Warm);
2862        assert_eq!(classified[15].0, MemoryTier::Warm);
2863
2864        // Rest = cold
2865        assert_eq!(classified[14].0, MemoryTier::Cold);
2866        assert_eq!(classified[0].0, MemoryTier::Cold);
2867    }
2868
2869    #[test]
2870    fn test_classify_memory_tiers_all_hot() {
2871        let messages: Vec<LlmMessage> =
2872            (0..3).map(|i| make_user_msg(&format!("msg {i}"))).collect();
2873
2874        let config = HierarchicalMemoryConfig::default(); // 20 hot
2875
2876        let classified = classify_memory_tiers(&messages, &config);
2877        assert!(classified.iter().all(|(tier, _)| *tier == MemoryTier::Hot));
2878    }
2879
2880    #[test]
2881    fn test_apply_hierarchical_memory_basic() {
2882        let mut messages = Vec::new();
2883
2884        // Cold: old tool interactions
2885        for i in 0..5 {
2886            let id = format!("old_{i}");
2887            messages.push(make_assistant_with_tool_call(&id, "read_file"));
2888            messages.push(make_tool_result(&id, &format!("old content {i}")));
2889        }
2890
2891        // Warm: mid tool interactions
2892        for i in 0..3 {
2893            let id = format!("mid_{i}");
2894            messages.push(make_assistant_with_tool_call(&id, "bash"));
2895            messages.push(make_tool_result(&id, &format!("mid output {i}")));
2896        }
2897
2898        // Hot: recent
2899        messages.push(make_user_msg("what now?"));
2900        messages.push(make_assistant_msg("let me check"));
2901
2902        let config = HierarchicalMemoryConfig {
2903            hot_messages: 2,
2904            warm_messages: 6,
2905        };
2906        let masking_config = ObservationMaskingConfig::default();
2907
2908        let result = apply_hierarchical_memory(
2909            &messages,
2910            &config,
2911            &masking_config,
2912            Some("Summary of old work"),
2913        );
2914
2915        // Should have: 1 summary + 6 warm messages + 2 hot messages
2916        assert!(result.len() <= 9);
2917        // First should be the summary
2918        let first_text = extract_text(&result[0].content);
2919        assert!(first_text.contains("CONVERSATION_SUMMARY"));
2920        // Last 2 should be hot (verbatim)
2921        let last = extract_text(&result[result.len() - 1].content);
2922        assert!(last.contains("let me check"));
2923    }
2924
2925    #[test]
2926    fn test_apply_hierarchical_memory_no_cold() {
2927        let messages = vec![make_user_msg("hello"), make_assistant_msg("hi")];
2928
2929        let config = HierarchicalMemoryConfig {
2930            hot_messages: 5,
2931            warm_messages: 5,
2932        };
2933
2934        let result = apply_hierarchical_memory(
2935            &messages,
2936            &config,
2937            &ObservationMaskingConfig::default(),
2938            None,
2939        );
2940        // All hot, no summary needed
2941        assert_eq!(result.len(), 2);
2942    }
2943
2944    #[test]
2945    fn test_memory_tier_config_from_json() {
2946        let config: HierarchicalMemoryConfig = serde_json::from_value(json!({
2947            "hot_messages": 10,
2948            "warm_messages": 50
2949        }))
2950        .unwrap();
2951        assert_eq!(config.hot_messages, 10);
2952        assert_eq!(config.warm_messages, 50);
2953    }
2954
2955    #[test]
2956    fn test_memory_tier_config_defaults() {
2957        let config = HierarchicalMemoryConfig::default();
2958        assert_eq!(config.hot_messages, 20);
2959        assert_eq!(config.warm_messages, 100);
2960    }
2961
2962    #[test]
2963    fn test_compaction_config_with_memory_tiers() {
2964        let config = CompactionConfig::from_json(&json!({
2965            "strategy": "auto",
2966            "memory_tiers": {
2967                "hot_messages": 15,
2968                "warm_messages": 80
2969            }
2970        }));
2971        assert_eq!(config.memory_tiers.hot_messages, 15);
2972        assert_eq!(config.memory_tiers.warm_messages, 80);
2973    }
2974
2975    #[test]
2976    fn test_memory_tier_serialization() {
2977        assert_eq!(serde_json::to_value(MemoryTier::Hot).unwrap(), json!("hot"));
2978        assert_eq!(
2979            serde_json::to_value(MemoryTier::Warm).unwrap(),
2980            json!("warm")
2981        );
2982        assert_eq!(
2983            serde_json::to_value(MemoryTier::Cold).unwrap(),
2984            json!("cold")
2985        );
2986    }
2987
2988    // ====================================================================
2989    // Skill content protection tests
2990    // ====================================================================
2991
2992    #[test]
2993    fn test_masking_skips_activate_skill_results() {
2994        // 3 tool results: activate_skill (protected), read_file, bash
2995        // With keep_recent=1, only read_file should be masked (activate_skill exempt)
2996        let messages = vec![
2997            make_assistant_with_tool_call("call_skill", "activate_skill"),
2998            make_tool_result(
2999                "call_skill",
3000                "You are a code review agent. Follow these instructions...",
3001            ),
3002            make_assistant_msg("Skill activated"),
3003            make_assistant_with_tool_call("call_read", "read_file"),
3004            make_tool_result(
3005                "call_read",
3006                "file contents that are long enough to be masked by observation masking because they exceed one hundred characters easily",
3007            ),
3008            make_assistant_msg("got it"),
3009            make_assistant_with_tool_call("call_bash", "bash"),
3010            make_tool_result("call_bash", "command output"),
3011        ];
3012
3013        let config = ObservationMaskingConfig {
3014            keep_recent_tool_outputs: 1,
3015            summary_format: MaskingSummaryFormat::OneLine,
3016        };
3017        let result = apply_observation_masking(&messages, &config);
3018
3019        // activate_skill result should be verbatim
3020        assert_eq!(
3021            extract_text(&result.messages[1].content),
3022            "You are a code review agent. Follow these instructions..."
3023        );
3024        // read_file result should be masked (it's the only maskable old one)
3025        assert!(extract_text(&result.messages[4].content).starts_with('['));
3026        // bash result should be verbatim (most recent maskable)
3027        assert_eq!(extract_text(&result.messages[7].content), "command output");
3028        assert_eq!(result.masked_count, 1);
3029    }
3030
3031    #[test]
3032    fn test_masking_all_activate_skill_exempt_from_count() {
3033        // 2 activate_skill results + 1 regular tool result
3034        // With keep_recent=0, only the regular one should be masked
3035        let messages = vec![
3036            make_assistant_with_tool_call("s1", "activate_skill"),
3037            make_tool_result("s1", "Skill 1 instructions"),
3038            make_assistant_with_tool_call("s2", "activate_skill"),
3039            make_tool_result("s2", "Skill 2 instructions"),
3040            make_assistant_with_tool_call("c1", "bash"),
3041            make_tool_result("c1", "output"),
3042        ];
3043
3044        let config = ObservationMaskingConfig {
3045            keep_recent_tool_outputs: 0,
3046            summary_format: MaskingSummaryFormat::OneLine,
3047        };
3048        let result = apply_observation_masking(&messages, &config);
3049
3050        assert_eq!(result.masked_count, 1);
3051        // Both skill results preserved
3052        assert_eq!(
3053            extract_text(&result.messages[1].content),
3054            "Skill 1 instructions"
3055        );
3056        assert_eq!(
3057            extract_text(&result.messages[3].content),
3058            "Skill 2 instructions"
3059        );
3060    }
3061
3062    #[test]
3063    fn test_aggressive_trim_preserves_skill_messages() {
3064        // Create messages where budget only fits ~2 messages, but skill messages
3065        // should always be preserved
3066        let messages = vec![
3067            make_user_msg(&"s".repeat(400)), // system: 100 tokens
3068            make_assistant_with_tool_call("skill1", "activate_skill"),
3069            make_tool_result("skill1", "Important skill instructions"),
3070            make_user_msg(&"a".repeat(400)),      // old: 100 tokens
3071            make_assistant_msg(&"b".repeat(400)), // old: 100 tokens
3072            make_user_msg(&"c".repeat(400)),      // recent: 100 tokens
3073            make_assistant_msg(&"d".repeat(400)), // recent: 100 tokens
3074        ];
3075
3076        // Budget for system + skill call + skill result + 1 recent = ~400 tokens
3077        // Should keep: system, skill call, skill result, and as many recent as fit
3078        let target_tokens = 400;
3079        let result = aggressive_trim(&messages, target_tokens, true);
3080
3081        // Verify skill messages are preserved
3082        let has_skill_result = result.iter().any(|m| {
3083            m.role == LlmMessageRole::Tool
3084                && extract_text(&m.content) == "Important skill instructions"
3085        });
3086        assert!(
3087            has_skill_result,
3088            "Skill tool result must survive aggressive trim"
3089        );
3090
3091        let has_skill_call = result.iter().any(|m| {
3092            m.tool_calls
3093                .as_ref()
3094                .is_some_and(|calls| calls.iter().any(|tc| tc.name == "activate_skill"))
3095        });
3096        assert!(
3097            has_skill_call,
3098            "Skill tool call must survive aggressive trim"
3099        );
3100    }
3101
3102    #[test]
3103    fn test_hierarchical_memory_rescues_skill_from_cold_tier() {
3104        let mut messages = Vec::new();
3105
3106        // Cold tier: old messages including a skill activation
3107        messages.push(make_assistant_with_tool_call("skill1", "activate_skill"));
3108        messages.push(make_tool_result(
3109            "skill1",
3110            "You must always validate input.",
3111        ));
3112        for i in 0..8 {
3113            let id = format!("old_{i}");
3114            messages.push(make_assistant_with_tool_call(&id, "read_file"));
3115            messages.push(make_tool_result(&id, &format!("old content {i}")));
3116        }
3117
3118        // Warm tier
3119        for i in 0..3 {
3120            let id = format!("mid_{i}");
3121            messages.push(make_assistant_with_tool_call(&id, "bash"));
3122            messages.push(make_tool_result(&id, &format!("mid output {i}")));
3123        }
3124
3125        // Hot tier
3126        messages.push(make_user_msg("what now?"));
3127        messages.push(make_assistant_msg("let me check"));
3128
3129        let config = HierarchicalMemoryConfig {
3130            hot_messages: 2,
3131            warm_messages: 6,
3132        };
3133        let masking_config = ObservationMaskingConfig::default();
3134
3135        let result = apply_hierarchical_memory(
3136            &messages,
3137            &config,
3138            &masking_config,
3139            Some("Summary of old work"),
3140        );
3141
3142        // The protected skill messages from cold tier should be rescued
3143        let has_skill_instructions = result
3144            .iter()
3145            .any(|m| extract_text(&m.content).contains("You must always validate input."));
3146        assert!(
3147            has_skill_instructions,
3148            "Skill instructions from cold tier must be rescued into output"
3149        );
3150
3151        // Summary should still be present
3152        assert!(extract_text(&result[0].content).contains("CONVERSATION_SUMMARY"));
3153    }
3154
3155    #[test]
3156    fn test_is_protected_tool_result_detection() {
3157        let messages = vec![
3158            make_assistant_with_tool_call("s1", "activate_skill"),
3159            make_tool_result("s1", "skill content"),
3160            make_assistant_with_tool_call("r1", "read_file"),
3161            make_tool_result("r1", "file content"),
3162        ];
3163
3164        // activate_skill result is protected
3165        assert!(is_protected_tool_result(&messages, &messages[1]));
3166        // read_file result is not
3167        assert!(!is_protected_tool_result(&messages, &messages[3]));
3168        // non-tool message is not
3169        assert!(!is_protected_tool_result(&messages, &messages[0]));
3170    }
3171
3172    #[test]
3173    fn test_is_protected_tool_call_message_detection() {
3174        let skill_call = make_assistant_with_tool_call("s1", "activate_skill");
3175        let regular_call = make_assistant_with_tool_call("r1", "read_file");
3176        let user_msg = make_user_msg("hello");
3177
3178        assert!(is_protected_tool_call_message(&skill_call));
3179        assert!(!is_protected_tool_call_message(&regular_call));
3180        assert!(!is_protected_tool_call_message(&user_msg));
3181    }
3182
3183    #[test]
3184    fn test_default_preserve_includes_skill_instructions() {
3185        let config = SummarizationConfig::default();
3186        assert!(
3187            config.preserve.contains(&"skill_instructions".to_string()),
3188            "Default preserve list must include skill_instructions"
3189        );
3190    }
3191
3192    #[test]
3193    fn test_summarization_prompt_mentions_skill_protection() {
3194        let config = SummarizationConfig::default();
3195        let prompt = build_summarization_prompt(&config);
3196        assert!(
3197            prompt.contains("activate_skill"),
3198            "Summarization prompt must instruct LLM to preserve skill content"
3199        );
3200    }
3201
3202    #[test]
3203    fn test_aggressive_trim_protected_exceed_budget() {
3204        // When protected messages alone exceed the budget, keep as many as
3205        // fit (newest first) and drop non-protected entirely.
3206        let messages = vec![
3207            make_user_msg(&"s".repeat(400)), // system ~100 tokens
3208            make_assistant_with_tool_call("skill1", "activate_skill"), // protected
3209            make_tool_result("skill1", &"x".repeat(800)), // protected ~200 tokens
3210            make_assistant_with_tool_call("skill2", "activate_skill"), // protected
3211            make_tool_result("skill2", &"y".repeat(800)), // protected ~200 tokens
3212            make_user_msg(&"z".repeat(400)), // non-protected
3213        ];
3214
3215        // Budget only fits system + ~1 protected pair
3216        let result = aggressive_trim(&messages, 200, true);
3217
3218        // Must not exceed budget — non-protected messages dropped
3219        let has_non_protected = result
3220            .iter()
3221            .any(|m| m.role == LlmMessageRole::User && extract_text(&m.content).contains('z'));
3222        assert!(
3223            !has_non_protected,
3224            "Non-protected messages must be dropped when protected exceed budget"
3225        );
3226    }
3227
3228    #[test]
3229    fn test_format_messages_no_truncate_protected_tool_result() {
3230        // Protected tool results should not be truncated at 2000 chars
3231        let long_instructions = "a".repeat(5000);
3232        let messages = vec![
3233            make_assistant_with_tool_call("s1", "activate_skill"),
3234            make_tool_result("s1", &long_instructions),
3235            make_assistant_with_tool_call("r1", "read_file"),
3236            make_tool_result("r1", &"b".repeat(5000)),
3237        ];
3238
3239        let formatted = format_messages_for_summarization(&messages);
3240
3241        // Skill result: full 5000-char content present, not truncated
3242        assert!(
3243            formatted.contains(&long_instructions),
3244            "Protected tool result must not be truncated"
3245        );
3246        // Regular result: should be truncated
3247        assert!(
3248            formatted.contains("[truncated, 5000 chars total]"),
3249            "Non-protected tool result should be truncated"
3250        );
3251    }
3252
3253    #[test]
3254    fn test_hierarchical_memory_cross_tier_boundary_protection() {
3255        // The activate_skill tool-call is in cold tier, but its tool-result
3256        // lands in warm tier. The result must still be protected from masking.
3257        let mut messages = Vec::new();
3258
3259        // Cold tier: skill call + filler to push result into warm tier
3260        messages.push(make_assistant_with_tool_call("skill1", "activate_skill"));
3261        for i in 0..9 {
3262            let id = format!("cold_{i}");
3263            messages.push(make_assistant_with_tool_call(&id, "read_file"));
3264            messages.push(make_tool_result(&id, &format!("cold content {i}")));
3265        }
3266
3267        // Warm tier starts here — skill result is first warm message
3268        messages.push(make_tool_result(
3269            "skill1",
3270            "Cross-tier skill instructions that must survive",
3271        ));
3272        for i in 0..2 {
3273            let id = format!("warm_{i}");
3274            messages.push(make_assistant_with_tool_call(&id, "bash"));
3275            messages.push(make_tool_result(&id, &format!("warm output {i}")));
3276        }
3277
3278        // Hot tier
3279        messages.push(make_user_msg("continue"));
3280        messages.push(make_assistant_msg("ok"));
3281
3282        let config = HierarchicalMemoryConfig {
3283            hot_messages: 2,
3284            warm_messages: 5, // skill result + 2 bash pairs
3285        };
3286        let masking_config = ObservationMaskingConfig {
3287            keep_recent_tool_outputs: 0,
3288            summary_format: MaskingSummaryFormat::OneLine,
3289        };
3290
3291        let result = apply_hierarchical_memory(&messages, &config, &masking_config, None);
3292
3293        let has_skill_instructions = result.iter().any(|m| {
3294            extract_text(&m.content).contains("Cross-tier skill instructions that must survive")
3295        });
3296        assert!(
3297            has_skill_instructions,
3298            "Skill result in warm tier with call in cold tier must be protected"
3299        );
3300    }
3301}