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 still counts as healthy; only turns that re-pay (nearly)
36/// everything move the needle.
37pub const DEGRADED_TURN_HIT_RATE_PERCENT: f64 = 50.0;
38
39/// Session-scoped prompt-cache health alert.
40#[derive(Debug, Clone, PartialEq)]
41pub enum CacheHealthAlert {
42    /// Several consecutive measured turns re-paid (nearly) full input cost.
43    SustainedMisses {
44        consecutive: u32,
45        hit_rate: f64,
46        measured_turns: u32,
47    },
48    /// The hit rate over recent measured turns fell below the floor.
49    LowHitRate { hit_rate: f64, measured_turns: u32 },
50}
51
52impl CacheHealthAlert {
53    /// User-facing warning text. Mirrors the tone of the existing cache-gap
54    /// and reasoning-effort advisories: what happened, what it costs, and the
55    /// most likely causes to check.
56    #[must_use]
57    pub fn message(&self) -> String {
58        match self {
59            Self::SustainedMisses { consecutive, hit_rate, measured_turns } => format!(
60                "Prompt cache suffered {consecutive} consecutive near-full misses (hit rate {hit_rate:.0}% over {measured_turns} measured turns); recent requests re-paid full input cost. \
61                 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."
62            ),
63            Self::LowHitRate { hit_rate, measured_turns } => format!(
64                "Prompt cache hit rate is {hit_rate:.0}% over {measured_turns} measured turns (floor {HIT_RATE_FLOOR_PERCENT:.0}%). \
65                 Requests are re-paying input cost; check for prompt/tool-catalog churn or idle gaps expiring the provider cache."
66            ),
67        }
68    }
69}
70
71/// Rolling prompt-cache health for one session.
72///
73/// Fed with normalized per-turn [`Usage`]; turns without provider cache
74/// metrics (providers that do not report them) or below the volume floor are
75/// ignored so unmeasurable providers never trip the detector. Each alert
76/// fires at most once per session, matching the one-shot style of the budget
77/// and cache-gap warnings.
78#[derive(Debug, Clone, Default)]
79pub struct PromptCacheHealthMonitor {
80    measured_turns: u32,
81    window_input_tokens: u64,
82    window_read_tokens: u64,
83    consecutive_degraded: u32,
84    sustained_miss_fired: bool,
85    low_rate_fired: bool,
86}
87
88impl PromptCacheHealthMonitor {
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Record one turn's normalized usage. Returns an alert the first time a
95    /// degraded pattern is confirmed, `None` otherwise.
96    pub fn record_turn(&mut self, usage: &Usage) -> Option<CacheHealthAlert> {
97        if !Self::is_measured(usage) {
98            return None;
99        }
100        self.measured_turns = self.measured_turns.saturating_add(1);
101        self.window_input_tokens = self.window_input_tokens.saturating_add(usage.input_tokens);
102        self.window_read_tokens = self.window_read_tokens.saturating_add(usage.cached_input_tokens);
103
104        if Self::turn_hit_rate(usage) < DEGRADED_TURN_HIT_RATE_PERCENT {
105            self.consecutive_degraded = self.consecutive_degraded.saturating_add(1);
106        } else {
107            self.consecutive_degraded = 0;
108        }
109
110        if !self.sustained_miss_fired && self.consecutive_degraded >= SUSTAINED_MISS_TURNS {
111            self.sustained_miss_fired = true;
112            return Some(CacheHealthAlert::SustainedMisses {
113                consecutive: self.consecutive_degraded,
114                hit_rate: self.rolling_hit_rate(),
115                measured_turns: self.measured_turns,
116            });
117        }
118
119        if !self.low_rate_fired
120            && self.measured_turns >= HIT_RATE_WINDOW_TURNS
121            && self.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT
122        {
123            self.low_rate_fired = true;
124            return Some(CacheHealthAlert::LowHitRate {
125                hit_rate: self.rolling_hit_rate(),
126                measured_turns: self.measured_turns,
127            });
128        }
129
130        None
131    }
132
133    /// Hit rate over all measured turns this session: cached input over total
134    /// input. Uses the same `cached/input` definition as the per-turn check so
135    /// turns with large uncached tails cannot inflate the rolling figure.
136    #[must_use]
137    pub fn rolling_hit_rate(&self) -> f64 {
138        if self.window_input_tokens == 0 {
139            return 100.0;
140        }
141        (self.window_read_tokens as f64 / self.window_input_tokens as f64) * 100.0
142    }
143
144    fn is_measured(usage: &Usage) -> bool {
145        usage.input_tokens >= MIN_INPUT_TOKENS_FOR_SIGNAL
146            && (usage.cached_input_tokens > 0 || usage.cache_creation_tokens > 0)
147    }
148
149    fn turn_hit_rate(usage: &Usage) -> f64 {
150        usage.cache_hit_rate().map_or(100.0, |rate| rate * 100.0)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn usage(input_tokens: u64, cached: u64, creation: u64) -> Usage {
159        Usage {
160            input_tokens,
161            cached_input_tokens: cached,
162            cache_creation_tokens: creation,
163            output_tokens: 10,
164        }
165    }
166
167    #[test]
168    fn ignores_turns_without_cache_metrics() {
169        let mut monitor = PromptCacheHealthMonitor::new();
170        for _ in 0..10 {
171            assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
172        }
173        assert_eq!(monitor.measured_turns, 0);
174    }
175
176    #[test]
177    fn ignores_small_turns_below_volume_floor() {
178        let mut monitor = PromptCacheHealthMonitor::new();
179        for _ in 0..10 {
180            assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
181        }
182        assert_eq!(monitor.measured_turns, 0);
183    }
184
185    #[test]
186    fn fires_sustained_miss_after_consecutive_degraded_turns() {
187        let mut monitor = PromptCacheHealthMonitor::new();
188        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
189        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
190        let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
191        assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
192    }
193
194    #[test]
195    fn healthy_turn_resets_consecutive_counter() {
196        let mut monitor = PromptCacheHealthMonitor::new();
197        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
198        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
199        assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
200        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
201        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
202        assert!(matches!(
203            monitor.record_turn(&usage(50_000, 0, 5_000)),
204            Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
205        ));
206    }
207
208    #[test]
209    fn alerts_are_one_shot_per_session() {
210        let mut monitor = PromptCacheHealthMonitor::new();
211        for _ in 0..3 {
212            monitor.record_turn(&usage(50_000, 0, 5_000));
213        }
214        // Stay below the hit-rate window so only the sustained-miss alert
215        // (already fired) could recur; it must not.
216        for _ in 0..4 {
217            assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
218        }
219    }
220
221    #[test]
222    fn no_alert_when_rolling_rate_stays_above_floor() {
223        let mut monitor = PromptCacheHealthMonitor::new();
224        // Interleaved healthy turns keep the consecutive counter (needs 3 in a
225        // row) at bay: D,D,H,D,D,H,D,D → max streak 2, rolling rate
226        // (6*10000 + 2*30000) / (8*50000) = 30%, above the 25% floor.
227        let mut alert = None;
228        for index in 0..8 {
229            let turn = if index % 3 == 2 {
230                usage(50_000, 30_000, 20_000)
231            } else {
232                usage(50_000, 10_000, 40_000)
233            };
234            alert = monitor.record_turn(&turn).or(alert);
235        }
236        assert_eq!(alert, None);
237    }
238
239    #[test]
240    fn low_hit_rate_fires_below_floor() {
241        let mut monitor = PromptCacheHealthMonitor::new();
242        // Barely-healthy turns (60%) reset the consecutive counter while the
243        // rolling rate stays near zero: max streak 2, rolling rate after 8
244        // measured turns (60000 read / 400000) = 15%.
245        let mut alert = None;
246        for index in 0..10 {
247            let turn = if index % 3 == 2 {
248                usage(50_000, 30_000, 20_000)
249            } else {
250                usage(50_000, 0, 50_000)
251            };
252            alert = monitor.record_turn(&turn).or(alert);
253        }
254        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
255    }
256
257    #[test]
258    fn rolling_rate_uses_total_input_not_cache_traffic() {
259        // Asymmetric uncached tail: cached/(cached+creation) looks healthy
260        // while cached/input is degraded. Rolling must follow cached/input.
261        let mut monitor = PromptCacheHealthMonitor::new();
262        // D,D,H x2 + D,D → max streak 2 (no sustained miss), rolling
263        // (6*5000 + 2*26000) / (8*50000) = 20.5%, below the 25% floor.
264        // Old read/(read+creation) math gave 68% and missed this.
265        let mut alert = None;
266        for index in 0..8 {
267            let turn = if index % 3 == 2 {
268                usage(50_000, 26_000, 4_000)
269            } else {
270                usage(50_000, 5_000, 5_000)
271            };
272            alert = monitor.record_turn(&turn).or(alert);
273        }
274        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
275        assert!(
276            monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
277            "rolling {} should be below floor",
278            monitor.rolling_hit_rate()
279        );
280    }
281
282    #[test]
283    fn alert_messages_mention_likely_causes() {
284        let sustained =
285            CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
286        assert!(sustained.contains("consecutive"));
287        assert!(sustained.contains("5%"));
288        assert!(sustained.contains("trajectory log"));
289        let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
290        assert!(low.contains("10%"));
291        assert!(low.contains("idle gaps"));
292    }
293}