Skip to main content

vtcode_core/core/agent/
cache_health.rs

1//! Session-level prompt-cache health monitoring.
2//!
3//! The harness already warns about individual cache-breaking events (reasoning
4//! effort changes, idle-gap expiry, planning transitions). This module adds the
5//! missing aggregate view: it observes normalized per-turn usage and fires a
6//! session-scoped alert when the *hit rate* stays degraded, mirroring the
7//! "monitor cache hit rate like uptime" discipline. Both runloops (the
8//! `vtcode-core` `AgentRunner` and the binary unified runloop) feed the same
9//! monitor through their usage-merge paths so thresholds and wording stay
10//! identical.
11//!
12//! The monitor consumes the canonical harness [`Usage`] (as produced by
13//! `normalized_turn_usage`), where `input_tokens` is always the total prompt
14//! volume and `cached_input_tokens`/`cache_creation_tokens` carry the cache
15//! signal, so provider reporting differences are already normalized away.
16
17use crate::exec::events::Usage;
18
19/// Minimum input tokens for a turn to count as cache signal.
20///
21/// Tiny turns re-pay little even on a full miss; counting them would let
22/// cheap noise trip (or mask) the sustained-miss detector.
23pub const MIN_INPUT_TOKENS_FOR_SIGNAL: u64 = 1_024;
24
25/// Consecutive degraded turns that fire the sustained-miss alert.
26pub const SUSTAINED_MISS_TURNS: u32 = 3;
27
28/// Measured turns before the hit-rate alert arms.
29pub const HIT_RATE_WINDOW_TURNS: u32 = 8;
30
31/// Hit-rate floor (percent) that fires the low-hit-rate alert.
32pub const HIT_RATE_FLOOR_PERCENT: f64 = 25.0;
33
34/// A turn counts as degraded when its cache hit rate is below this floor.
35/// Partial reuse below this threshold counts as degraded, too.
36pub const DEGRADED_TURN_HIT_RATE_PERCENT: f64 = 50.0;
37
38/// Session-scoped prompt-cache health alert.
39#[derive(Debug, Clone, PartialEq)]
40pub enum CacheHealthAlert {
41    /// Several consecutive measured requests reused less than half their input.
42    SustainedMisses {
43        consecutive: u32,
44        hit_rate: f64,
45        measured_turns: u32,
46    },
47    /// The hit rate over recent measured turns fell below the floor.
48    LowHitRate { hit_rate: f64, measured_turns: u32 },
49}
50
51impl CacheHealthAlert {
52    /// User-facing warning text. Mirrors the tone of the existing cache-gap
53    /// and reasoning-effort advisories: what happened, what it costs, and the
54    /// most likely causes to check.
55    #[must_use]
56    pub fn message(&self) -> String {
57        match self {
58            Self::SustainedMisses { consecutive, hit_rate, measured_turns } => format!(
59                "Prompt cache reuse stayed below {DEGRADED_TURN_HIT_RATE_PERCENT:.0}% for {consecutive} consecutive requests (overall hit rate {hit_rate:.0}% over {measured_turns} measured requests). \
60                 Check earlier warnings for model/reasoning switches, MCP refreshes, planning toggles, or idle-gap expiry, or inspect the trajectory log for prefix/catalog hash changes. Cache misses alone do not establish client-side prefix churn."
61            ),
62            Self::LowHitRate { hit_rate, measured_turns } => format!(
63                "Prompt cache hit rate is {hit_rate:.0}% over {measured_turns} measured turns (floor {HIT_RATE_FLOOR_PERCENT:.0}%). \
64                 Requests are re-paying input cost; check for prompt/tool-catalog churn or idle gaps expiring the provider cache."
65            ),
66        }
67    }
68}
69
70/// Rolling prompt-cache health for one session.
71///
72/// Fed with normalized per-turn [`Usage`]; turns without provider cache
73/// metrics (providers that do not report them) or below the volume floor are
74/// ignored so unmeasurable providers never trip the detector. Each alert
75/// fires at most once per session, matching the one-shot style of the budget
76/// and cache-gap warnings.
77#[derive(Debug, Clone, Default)]
78pub struct PromptCacheHealthMonitor {
79    measured_turns: u32,
80    window_input_tokens: u64,
81    window_read_tokens: u64,
82    consecutive_degraded: u32,
83    sustained_miss_fired: bool,
84    low_rate_fired: bool,
85}
86
87impl PromptCacheHealthMonitor {
88    #[must_use]
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Successful compaction replaces the reusable history. Start a new
94    /// consecutive streak while retaining session totals and one-shot alerts.
95    pub fn record_compaction_boundary(&mut self) {
96        self.consecutive_degraded = 0;
97    }
98
99    /// Record one turn's normalized usage. Returns an alert the first time a
100    /// degraded pattern is confirmed, `None` otherwise.
101    pub fn record_turn(&mut self, usage: &Usage) -> Option<CacheHealthAlert> {
102        if !Self::is_measured(usage) {
103            return None;
104        }
105        self.measured_turns = self.measured_turns.saturating_add(1);
106        self.window_input_tokens = self.window_input_tokens.saturating_add(usage.input_tokens);
107        self.window_read_tokens = self.window_read_tokens.saturating_add(usage.cached_input_tokens);
108
109        if Self::turn_hit_rate(usage) < DEGRADED_TURN_HIT_RATE_PERCENT {
110            self.consecutive_degraded = self.consecutive_degraded.saturating_add(1);
111        } else {
112            self.consecutive_degraded = 0;
113        }
114
115        if !self.sustained_miss_fired && self.consecutive_degraded >= SUSTAINED_MISS_TURNS {
116            self.sustained_miss_fired = true;
117            return Some(CacheHealthAlert::SustainedMisses {
118                consecutive: self.consecutive_degraded,
119                hit_rate: self.rolling_hit_rate(),
120                measured_turns: self.measured_turns,
121            });
122        }
123
124        if !self.low_rate_fired
125            && self.measured_turns >= HIT_RATE_WINDOW_TURNS
126            && self.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT
127        {
128            self.low_rate_fired = true;
129            return Some(CacheHealthAlert::LowHitRate {
130                hit_rate: self.rolling_hit_rate(),
131                measured_turns: self.measured_turns,
132            });
133        }
134
135        None
136    }
137
138    /// Hit rate over all measured turns this session: cached input over total
139    /// input. Uses the same `cached/input` definition as the per-turn check so
140    /// turns with large uncached tails cannot inflate the rolling figure.
141    #[must_use]
142    pub fn rolling_hit_rate(&self) -> f64 {
143        if self.window_input_tokens == 0 {
144            return 100.0;
145        }
146        (self.window_read_tokens as f64 / self.window_input_tokens as f64) * 100.0
147    }
148
149    fn is_measured(usage: &Usage) -> bool {
150        usage.input_tokens >= MIN_INPUT_TOKENS_FOR_SIGNAL
151            && (usage.cached_input_tokens > 0 || usage.cache_creation_tokens > 0)
152    }
153
154    fn turn_hit_rate(usage: &Usage) -> f64 {
155        usage.cache_hit_rate().map_or(100.0, |rate| rate * 100.0)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn usage(input_tokens: u64, cached: u64, creation: u64) -> Usage {
164        Usage {
165            input_tokens,
166            cached_input_tokens: cached,
167            cache_creation_tokens: creation,
168            output_tokens: 10,
169        }
170    }
171
172    #[test]
173    fn compaction_separates_observed_session_misses_without_losing_totals() {
174        let mut monitor = PromptCacheHealthMonitor::new();
175        assert!(monitor.record_turn(&usage(12645, 0, 12642)).is_none());
176        assert!(monitor.record_turn(&usage(12669, 5710, 5575)).is_none());
177        monitor.record_compaction_boundary();
178        let miss = usage(12740, 0, 5212);
179        assert!(monitor.record_turn(&miss).is_none());
180        assert_eq!(monitor.window_input_tokens, 38054);
181        assert_eq!(monitor.window_read_tokens, 5710);
182        assert!(monitor.record_turn(&miss).is_none());
183        assert!(matches!(
184            monitor.record_turn(&miss),
185            Some(CacheHealthAlert::SustainedMisses { consecutive: 3, measured_turns: 5, .. })
186        ));
187        monitor.record_compaction_boundary();
188        assert!(monitor.record_turn(&miss).is_none());
189        assert!(monitor.record_turn(&miss).is_none());
190        assert!(matches!(monitor.record_turn(&miss), Some(CacheHealthAlert::LowHitRate { measured_turns: 8, .. })));
191        assert!(monitor.record_turn(&miss).is_none());
192    }
193
194    #[test]
195    fn partial_hits_are_reported_as_low_reuse_not_full_cost_misses() {
196        let mut monitor = PromptCacheHealthMonitor::new();
197        assert!(monitor.record_turn(&usage(40000, 12000, 0)).is_none());
198        assert!(monitor.record_turn(&usage(40000, 18000, 0)).is_none());
199        let message = monitor.record_turn(&usage(40000, 16000, 0)).expect("degraded streak").message();
200        assert!(message.contains("below 50%"));
201        assert!(message.contains("38%"));
202        assert!(!message.contains("near-full"));
203        assert!(!message.contains("full input cost"));
204    }
205
206    #[test]
207    fn ignores_turns_without_cache_metrics() {
208        let mut monitor = PromptCacheHealthMonitor::new();
209        for _ in 0..10 {
210            assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
211        }
212        assert_eq!(monitor.measured_turns, 0);
213    }
214
215    #[test]
216    fn ignores_small_turns_below_volume_floor() {
217        let mut monitor = PromptCacheHealthMonitor::new();
218        for _ in 0..10 {
219            assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
220        }
221        assert_eq!(monitor.measured_turns, 0);
222    }
223
224    #[test]
225    fn fires_sustained_miss_after_consecutive_degraded_turns() {
226        let mut monitor = PromptCacheHealthMonitor::new();
227        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
228        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
229        let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
230        assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
231    }
232
233    #[test]
234    fn healthy_turn_resets_consecutive_counter() {
235        let mut monitor = PromptCacheHealthMonitor::new();
236        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
237        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
238        assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
239        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
240        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
241        assert!(matches!(
242            monitor.record_turn(&usage(50_000, 0, 5_000)),
243            Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
244        ));
245    }
246
247    #[test]
248    fn alerts_are_one_shot_per_session() {
249        let mut monitor = PromptCacheHealthMonitor::new();
250        for _ in 0..3 {
251            monitor.record_turn(&usage(50_000, 0, 5_000));
252        }
253        // Stay below the hit-rate window so only the sustained-miss alert
254        // (already fired) could recur; it must not.
255        for _ in 0..4 {
256            assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
257        }
258    }
259
260    #[test]
261    fn no_alert_when_rolling_rate_stays_above_floor() {
262        let mut monitor = PromptCacheHealthMonitor::new();
263        // Interleaved healthy turns keep the consecutive counter (needs 3 in a
264        // row) at bay: D,D,H,D,D,H,D,D → max streak 2, rolling rate
265        // (6*10000 + 2*30000) / (8*50000) = 30%, above the 25% floor.
266        let mut alert = None;
267        for index in 0..8 {
268            let turn = if index % 3 == 2 {
269                usage(50_000, 30_000, 20_000)
270            } else {
271                usage(50_000, 10_000, 40_000)
272            };
273            alert = monitor.record_turn(&turn).or(alert);
274        }
275        assert_eq!(alert, None);
276    }
277
278    #[test]
279    fn low_hit_rate_fires_below_floor() {
280        let mut monitor = PromptCacheHealthMonitor::new();
281        // Barely-healthy turns (60%) reset the consecutive counter while the
282        // rolling rate stays near zero: max streak 2, rolling rate after 8
283        // measured turns (60000 read / 400000) = 15%.
284        let mut alert = None;
285        for index in 0..10 {
286            let turn = if index % 3 == 2 {
287                usage(50_000, 30_000, 20_000)
288            } else {
289                usage(50_000, 0, 50_000)
290            };
291            alert = monitor.record_turn(&turn).or(alert);
292        }
293        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
294    }
295
296    #[test]
297    fn rolling_rate_uses_total_input_not_cache_traffic() {
298        // Asymmetric uncached tail: cached/(cached+creation) looks healthy
299        // while cached/input is degraded. Rolling must follow cached/input.
300        let mut monitor = PromptCacheHealthMonitor::new();
301        // D,D,H x2 + D,D → max streak 2 (no sustained miss), rolling
302        // (6*5000 + 2*26000) / (8*50000) = 20.5%, below the 25% floor.
303        // Old read/(read+creation) math gave 68% and missed this.
304        let mut alert = None;
305        for index in 0..8 {
306            let turn = if index % 3 == 2 {
307                usage(50_000, 26_000, 4_000)
308            } else {
309                usage(50_000, 5_000, 5_000)
310            };
311            alert = monitor.record_turn(&turn).or(alert);
312        }
313        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
314        assert!(
315            monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
316            "rolling {} should be below floor",
317            monitor.rolling_hit_rate()
318        );
319    }
320
321    #[test]
322    fn alert_messages_mention_likely_causes() {
323        let sustained =
324            CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
325        assert!(sustained.contains("consecutive"));
326        assert!(sustained.contains("5%"));
327        assert!(sustained.contains("trajectory log"));
328        let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
329        assert!(low.contains("10%"));
330        assert!(low.contains("idle gaps"));
331    }
332}