Skip to main content

phi_core/context/
tracker.rs

1use super::token::{message_tokens, total_tokens};
2use crate::types::*;
3
4// ---------------------------------------------------------------------------
5// Context tracking (real usage + estimates)
6// ---------------------------------------------------------------------------
7
8/// Tracks context size using real token counts from provider responses
9/// combined with estimates for messages added after the last response.
10///
11/// This gives more accurate context size tracking than pure estimation,
12/// since providers report actual token counts in their usage data.
13///
14/// # Example
15///
16/// ```rust
17/// use phi_core::context::ContextTracker;
18/// use phi_core::types::Usage;
19///
20/// let mut tracker = ContextTracker::new();
21/// // After receiving an assistant response with usage data:
22/// tracker.record_usage(&Usage { input: 1500, output: 200, ..Default::default() }, 3);
23/// ```
24/*
25RUST QUIRK: Using `Option<usize>` for "not yet known" state
26
27`last_usage_tokens: Option<usize>` means "either we have a real token count
28(Some(n)), or we haven't received one yet (None)".
29
30This is Rust's way of representing nullable data without null pointers.
31There is no `null` or `None` in Rust — you must use `Option<T>` explicitly.
32The compiler forces you to handle both cases, preventing null pointer exceptions.
33
34Python analogy: last_usage_tokens: Optional[int] = None
35
36The hybrid design strategy:
37  - After each LLM response, record the REAL token count from provider usage data
38  - For messages added after the last response, ESTIMATE with chars/4
39  - Combine: real_base + estimated_trailing = accurate context size
40
41This beats pure estimation because real token counts account for:
42  - Unicode characters (multi-byte)
43  - Special tokens (BOS, EOS, system prompt formatting)
44  - Provider-specific tokenization differences
45*/
46pub struct ContextTracker {
47    /// Last known total token count from provider usage (None = no response yet)
48    last_usage_tokens: Option<usize>,
49    /// Index into the message list of the last assistant response with usage (None = no response yet)
50    last_usage_index: Option<usize>,
51}
52
53impl ContextTracker {
54    pub fn new() -> Self {
55        Self {
56            last_usage_tokens: None,
57            last_usage_index: None,
58        }
59    }
60
61    /// Record usage from an assistant response.
62    ///
63    /// Call this after each assistant message to update the tracker
64    /// with real token counts from the provider.
65    pub fn record_usage(&mut self, usage: &Usage, message_index: usize) {
66        let total = usage.input + usage.output + usage.cache_read + usage.cache_write;
67        if total > 0 {
68            self.last_usage_tokens = Some(total as usize);
69            self.last_usage_index = Some(message_index);
70        }
71    }
72
73    /// Estimate current context size.
74    ///
75    /// Uses real usage from the last assistant response as a baseline,
76    /// then adds estimates (chars/4) for any messages added since.
77    /// Falls back to pure estimation if no usage data is available.
78    pub fn estimate_context_tokens(&self, messages: &[AgentMessage]) -> usize {
79        match (self.last_usage_tokens, self.last_usage_index) {
80            (Some(usage_tokens), Some(idx)) if idx < messages.len() => {
81                let trailing: usize = messages[idx + 1..].iter().map(message_tokens).sum();
82                usage_tokens + trailing
83            }
84            _ => total_tokens(messages),
85        }
86    }
87
88    /// Reset tracking (e.g. after compaction replaces messages).
89    pub fn reset(&mut self) {
90        self.last_usage_tokens = None;
91        self.last_usage_index = None;
92    }
93}
94
95impl Default for ContextTracker {
96    fn default() -> Self {
97        Self::new()
98    }
99}