Skip to main content

cersei_agent/
context_analyzer.rs

1//! Context analyzer: token breakdown by category with compaction recommendations.
2
3use cersei_types::*;
4
5// ─── Context categories ─────────────────────────────────────────────────────
6
7/// Token breakdown categories.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ContextCategory {
10    SystemPrompt,
11    ToolDefinitions,
12    ConversationHistory,
13    ToolResults,
14    Attachments,
15    Unknown,
16}
17
18impl ContextCategory {
19    pub fn label(&self) -> &'static str {
20        match self {
21            Self::SystemPrompt => "System Prompt",
22            Self::ToolDefinitions => "Tool Definitions",
23            Self::ConversationHistory => "Conversation",
24            Self::ToolResults => "Tool Results",
25            Self::Attachments => "Attachments",
26            Self::Unknown => "Other",
27        }
28    }
29}
30
31// ─── Compaction strategy ─────────────────────────────────────────────────────
32
33/// Recommendation for what to compact.
34#[derive(Debug, Clone)]
35pub enum CompactionStrategy {
36    /// Compact all messages.
37    FullCompact { expected_reduction_pct: f64 },
38    /// Compact only the oldest N messages.
39    PartialCompact {
40        messages_to_compact: usize,
41        expected_reduction_pct: f64,
42    },
43    /// Collapse repeated file reads to save space.
44    CollapseReads { expected_reduction_pct: f64 },
45    /// No compaction needed.
46    None,
47}
48
49// ─── Context analysis ────────────────────────────────────────────────────────
50
51/// Token count breakdown of the current context.
52#[derive(Debug, Clone, Default)]
53pub struct ContextAnalysis {
54    pub system_prompt_tokens: u64,
55    pub tool_definitions_tokens: u64,
56    pub conversation_history_tokens: u64,
57    pub tool_results_tokens: u64,
58    pub attachments_tokens: u64,
59    pub total_tokens: u64,
60    /// 0.0 (not compressible) to 1.0 (highly compressible).
61    pub compressibility: f64,
62}
63
64impl ContextAnalysis {
65    /// Percentage of context used by a category.
66    pub fn category_pct(&self, cat: ContextCategory) -> f64 {
67        if self.total_tokens == 0 {
68            return 0.0;
69        }
70        let tokens = match cat {
71            ContextCategory::SystemPrompt => self.system_prompt_tokens,
72            ContextCategory::ToolDefinitions => self.tool_definitions_tokens,
73            ContextCategory::ConversationHistory => self.conversation_history_tokens,
74            ContextCategory::ToolResults => self.tool_results_tokens,
75            ContextCategory::Attachments => self.attachments_tokens,
76            ContextCategory::Unknown => 0,
77        };
78        tokens as f64 / self.total_tokens as f64
79    }
80}
81
82// ─── Analysis ────────────────────────────────────────────────────────────────
83
84fn estimate_tokens(text: &str) -> u64 {
85    (text.len() as u64) / 4
86}
87
88/// Analyze context window usage by category.
89pub fn analyze_context(
90    system_prompt: Option<&str>,
91    tool_defs_json: Option<&str>,
92    messages: &[Message],
93) -> ContextAnalysis {
94    let system_prompt_tokens = system_prompt.map(estimate_tokens).unwrap_or(0);
95    let tool_definitions_tokens = tool_defs_json.map(estimate_tokens).unwrap_or(0);
96
97    let mut conversation_tokens: u64 = 0;
98    let mut tool_result_tokens: u64 = 0;
99
100    for msg in messages {
101        match &msg.content {
102            MessageContent::Text(t) => {
103                conversation_tokens += estimate_tokens(t);
104            }
105            MessageContent::Blocks(blocks) => {
106                for block in blocks {
107                    match block {
108                        ContentBlock::ToolResult { content, .. } => {
109                            let text = match content {
110                                ToolResultContent::Text(t) => t.len(),
111                                ToolResultContent::Blocks(b) => b
112                                    .iter()
113                                    .map(|bb| {
114                                        if let ContentBlock::Text { text } = bb {
115                                            text.len()
116                                        } else {
117                                            50
118                                        }
119                                    })
120                                    .sum(),
121                            };
122                            tool_result_tokens += (text as u64) / 4;
123                        }
124                        ContentBlock::Text { text } => {
125                            conversation_tokens += estimate_tokens(text);
126                        }
127                        ContentBlock::ToolUse { input, .. } => {
128                            conversation_tokens +=
129                                estimate_tokens(&serde_json::to_string(input).unwrap_or_default());
130                        }
131                        ContentBlock::Thinking { thinking, .. } => {
132                            conversation_tokens += estimate_tokens(thinking);
133                        }
134                        _ => {
135                            conversation_tokens += 10; // small overhead for other types
136                        }
137                    }
138                }
139            }
140        }
141    }
142
143    let total =
144        system_prompt_tokens + tool_definitions_tokens + conversation_tokens + tool_result_tokens;
145
146    // Compressibility: tool results are ~90% compressible, conversation is ~50%
147    let compressibility = if total > 0 {
148        (tool_result_tokens as f64 * 0.9 + conversation_tokens as f64 * 0.5) / total as f64
149    } else {
150        0.0
151    };
152
153    ContextAnalysis {
154        system_prompt_tokens,
155        tool_definitions_tokens,
156        conversation_history_tokens: conversation_tokens,
157        tool_results_tokens: tool_result_tokens,
158        attachments_tokens: 0,
159        total_tokens: total,
160        compressibility,
161    }
162}
163
164/// Suggest a compaction strategy based on the analysis.
165pub fn suggest_compaction(analysis: &ContextAnalysis, context_limit: u64) -> CompactionStrategy {
166    if context_limit == 0 || analysis.total_tokens == 0 {
167        return CompactionStrategy::None;
168    }
169
170    let usage_pct = analysis.total_tokens as f64 / context_limit as f64;
171
172    if usage_pct < 0.75 {
173        return CompactionStrategy::None;
174    }
175
176    // If tool results dominate (>40%) and we're not critical, collapse reads first
177    if analysis.category_pct(ContextCategory::ToolResults) > 0.4 && usage_pct < 0.90 {
178        return CompactionStrategy::CollapseReads {
179            expected_reduction_pct: analysis.category_pct(ContextCategory::ToolResults) * 0.5,
180        };
181    }
182
183    // Critical: full compact
184    if usage_pct >= 0.90 {
185        return CompactionStrategy::FullCompact {
186            expected_reduction_pct: analysis.compressibility * 0.7,
187        };
188    }
189
190    // Moderate: partial compact (oldest half)
191    CompactionStrategy::PartialCompact {
192        messages_to_compact: 0, // caller determines based on message count
193        expected_reduction_pct: analysis.compressibility * 0.5,
194    }
195}
196
197/// Format a human-readable context visualization.
198pub fn format_ctx_viz(analysis: &ContextAnalysis, context_limit: u64) -> String {
199    let usage_pct = if context_limit > 0 {
200        (analysis.total_tokens as f64 / context_limit as f64) * 100.0
201    } else {
202        0.0
203    };
204
205    let bar_width = 40;
206    let filled = ((usage_pct / 100.0) * bar_width as f64).min(bar_width as f64) as usize;
207    let bar: String = format!("[{}{}]", "#".repeat(filled), ".".repeat(bar_width - filled));
208
209    let categories = [
210        (ContextCategory::SystemPrompt, analysis.system_prompt_tokens),
211        (
212            ContextCategory::ToolDefinitions,
213            analysis.tool_definitions_tokens,
214        ),
215        (
216            ContextCategory::ConversationHistory,
217            analysis.conversation_history_tokens,
218        ),
219        (ContextCategory::ToolResults, analysis.tool_results_tokens),
220    ];
221
222    let mut lines = vec![
223        format!(
224            "Context: {} {:.1}% of {} tokens",
225            bar, usage_pct, context_limit
226        ),
227        String::new(),
228    ];
229
230    for (cat, tokens) in &categories {
231        if *tokens > 0 {
232            let pct = (*tokens as f64 / analysis.total_tokens.max(1) as f64) * 100.0;
233            lines.push(format!(
234                "  {:<20} {:>8} tokens ({:.1}%)",
235                cat.label(),
236                tokens,
237                pct
238            ));
239        }
240    }
241
242    lines.push(format!(
243        "\n  Compressibility: {:.0}%",
244        analysis.compressibility * 100.0
245    ));
246    lines.join("\n")
247}
248
249// ─── Tests ───────────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn sample_messages() -> Vec<Message> {
256        vec![
257            Message::user("Read the file src/main.rs"),
258            Message::assistant_blocks(vec![
259                ContentBlock::Text {
260                    text: "Here's the file:".into(),
261                },
262                ContentBlock::ToolUse {
263                    id: "t1".into(),
264                    name: "Read".into(),
265                    input: serde_json::json!({"file_path": "src/main.rs"}),
266                },
267            ]),
268            Message::user_blocks(vec![ContentBlock::ToolResult {
269                tool_use_id: "t1".into(),
270                content: ToolResultContent::Text("fn main() { println!(\"hello\"); }".repeat(100)),
271                is_error: Some(false),
272            }]),
273            Message::assistant("The main function prints hello."),
274        ]
275    }
276
277    #[test]
278    fn test_analyze_context() {
279        let messages = sample_messages();
280        let analysis = analyze_context(
281            Some("You are a helpful assistant."),
282            Some(r#"[{"name":"Read","description":"Read files"}]"#),
283            &messages,
284        );
285
286        assert!(analysis.system_prompt_tokens > 0);
287        assert!(analysis.tool_definitions_tokens > 0);
288        assert!(analysis.conversation_history_tokens > 0);
289        assert!(analysis.tool_results_tokens > 0);
290        assert!(analysis.total_tokens > 0);
291        assert!(analysis.compressibility > 0.0);
292        assert!(analysis.compressibility <= 1.0);
293    }
294
295    #[test]
296    fn test_category_pct() {
297        let analysis = ContextAnalysis {
298            system_prompt_tokens: 100,
299            tool_definitions_tokens: 200,
300            conversation_history_tokens: 300,
301            tool_results_tokens: 400,
302            attachments_tokens: 0,
303            total_tokens: 1000,
304            compressibility: 0.5,
305        };
306
307        assert!((analysis.category_pct(ContextCategory::SystemPrompt) - 0.1).abs() < 0.01);
308        assert!((analysis.category_pct(ContextCategory::ToolResults) - 0.4).abs() < 0.01);
309    }
310
311    #[test]
312    fn test_suggest_none_under_75() {
313        let analysis = ContextAnalysis {
314            total_tokens: 50_000,
315            ..Default::default()
316        };
317        assert!(matches!(
318            suggest_compaction(&analysis, 200_000),
319            CompactionStrategy::None
320        ));
321    }
322
323    #[test]
324    fn test_suggest_full_over_90() {
325        let analysis = ContextAnalysis {
326            total_tokens: 185_000,
327            conversation_history_tokens: 100_000,
328            tool_results_tokens: 80_000,
329            compressibility: 0.7,
330            ..Default::default()
331        };
332        assert!(matches!(
333            suggest_compaction(&analysis, 200_000),
334            CompactionStrategy::FullCompact { .. }
335        ));
336    }
337
338    #[test]
339    fn test_suggest_collapse_reads() {
340        let analysis = ContextAnalysis {
341            total_tokens: 170_000,       // 85%
342            tool_results_tokens: 90_000, // >40% of total
343            conversation_history_tokens: 70_000,
344            compressibility: 0.6,
345            ..Default::default()
346        };
347        assert!(matches!(
348            suggest_compaction(&analysis, 200_000),
349            CompactionStrategy::CollapseReads { .. }
350        ));
351    }
352
353    #[test]
354    fn test_format_ctx_viz() {
355        let analysis = ContextAnalysis {
356            system_prompt_tokens: 5000,
357            tool_definitions_tokens: 3000,
358            conversation_history_tokens: 20000,
359            tool_results_tokens: 10000,
360            attachments_tokens: 0,
361            total_tokens: 38000,
362            compressibility: 0.5,
363        };
364        let viz = format_ctx_viz(&analysis, 200_000);
365        assert!(viz.contains("Context:"));
366        assert!(viz.contains("System Prompt"));
367        assert!(viz.contains("Compressibility"));
368    }
369
370    #[test]
371    fn test_empty_analysis() {
372        let analysis = analyze_context(None, None, &[]);
373        assert_eq!(analysis.total_tokens, 0);
374        assert_eq!(analysis.compressibility, 0.0);
375    }
376}