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 observed_constant_reuse_plateau_reports_weighted_session_rate() {
208        // First six requests in the 2026-10-15T06:35:33 session: the reused
209        // prefix plateaus while input grows. This measures reuse, not churn.
210        let mut monitor = PromptCacheHealthMonitor::new();
211        for (input, cached) in [
212            (19126, 0),
213            (20309, 13708),
214            (25677, 13708),
215            (30443, 13708),
216            (30686, 13708),
217        ] {
218            assert!(monitor.record_turn(&usage(input, cached, input - cached - 3)).is_none());
219        }
220        let alert = monitor
221            .record_turn(&usage(30932, 13708, 17221))
222            .expect("three low-reuse requests");
223        let CacheHealthAlert::SustainedMisses { consecutive, hit_rate, measured_turns } = &alert else {
224            panic!("expected sustained low reuse");
225        };
226        assert_eq!((*consecutive, *measured_turns), (3, 6));
227        assert!((hit_rate - 43.6079988293).abs() < 0.000001);
228        assert!(alert.message().contains("44% over 6 measured requests"));
229        assert!(
230            alert
231                .message()
232                .contains("Cache misses alone do not establish client-side prefix churn")
233        );
234    }
235
236    #[test]
237    fn ignores_turns_without_cache_metrics() {
238        let mut monitor = PromptCacheHealthMonitor::new();
239        for _ in 0..10 {
240            assert_eq!(monitor.record_turn(&usage(50_000, 0, 0)), None);
241        }
242        assert_eq!(monitor.measured_turns, 0);
243    }
244
245    #[test]
246    fn ignores_small_turns_below_volume_floor() {
247        let mut monitor = PromptCacheHealthMonitor::new();
248        for _ in 0..10 {
249            assert_eq!(monitor.record_turn(&usage(100, 0, 200)), None);
250        }
251        assert_eq!(monitor.measured_turns, 0);
252    }
253
254    #[test]
255    fn fires_sustained_miss_after_consecutive_degraded_turns() {
256        let mut monitor = PromptCacheHealthMonitor::new();
257        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
258        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
259        let alert = monitor.record_turn(&usage(50_000, 0, 5_000));
260        assert!(matches!(alert, Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })));
261    }
262
263    #[test]
264    fn healthy_turn_resets_consecutive_counter() {
265        let mut monitor = PromptCacheHealthMonitor::new();
266        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
267        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
268        assert_eq!(monitor.record_turn(&usage(50_000, 45_000, 5_000)), None);
269        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
270        assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
271        assert!(matches!(
272            monitor.record_turn(&usage(50_000, 0, 5_000)),
273            Some(CacheHealthAlert::SustainedMisses { consecutive: 3, .. })
274        ));
275    }
276
277    #[test]
278    fn alerts_are_one_shot_per_session() {
279        let mut monitor = PromptCacheHealthMonitor::new();
280        for _ in 0..3 {
281            monitor.record_turn(&usage(50_000, 0, 5_000));
282        }
283        // Stay below the hit-rate window so only the sustained-miss alert
284        // (already fired) could recur; it must not.
285        for _ in 0..4 {
286            assert_eq!(monitor.record_turn(&usage(50_000, 0, 5_000)), None);
287        }
288    }
289
290    #[test]
291    fn no_alert_when_rolling_rate_stays_above_floor() {
292        let mut monitor = PromptCacheHealthMonitor::new();
293        // Interleaved healthy turns keep the consecutive counter (needs 3 in a
294        // row) at bay: D,D,H,D,D,H,D,D → max streak 2, rolling rate
295        // (6*10000 + 2*30000) / (8*50000) = 30%, above the 25% floor.
296        let mut alert = None;
297        for index in 0..8 {
298            let turn = if index % 3 == 2 {
299                usage(50_000, 30_000, 20_000)
300            } else {
301                usage(50_000, 10_000, 40_000)
302            };
303            alert = monitor.record_turn(&turn).or(alert);
304        }
305        assert_eq!(alert, None);
306    }
307
308    #[test]
309    fn low_hit_rate_fires_below_floor() {
310        let mut monitor = PromptCacheHealthMonitor::new();
311        // Barely-healthy turns (60%) reset the consecutive counter while the
312        // rolling rate stays near zero: max streak 2, rolling rate after 8
313        // measured turns (60000 read / 400000) = 15%.
314        let mut alert = None;
315        for index in 0..10 {
316            let turn = if index % 3 == 2 {
317                usage(50_000, 30_000, 20_000)
318            } else {
319                usage(50_000, 0, 50_000)
320            };
321            alert = monitor.record_turn(&turn).or(alert);
322        }
323        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
324    }
325
326    #[test]
327    fn rolling_rate_uses_total_input_not_cache_traffic() {
328        // Asymmetric uncached tail: cached/(cached+creation) looks healthy
329        // while cached/input is degraded. Rolling must follow cached/input.
330        let mut monitor = PromptCacheHealthMonitor::new();
331        // D,D,H x2 + D,D → max streak 2 (no sustained miss), rolling
332        // (6*5000 + 2*26000) / (8*50000) = 20.5%, below the 25% floor.
333        // Old read/(read+creation) math gave 68% and missed this.
334        let mut alert = None;
335        for index in 0..8 {
336            let turn = if index % 3 == 2 {
337                usage(50_000, 26_000, 4_000)
338            } else {
339                usage(50_000, 5_000, 5_000)
340            };
341            alert = monitor.record_turn(&turn).or(alert);
342        }
343        assert!(matches!(alert, Some(CacheHealthAlert::LowHitRate { .. })));
344        assert!(
345            monitor.rolling_hit_rate() < HIT_RATE_FLOOR_PERCENT,
346            "rolling {} should be below floor",
347            monitor.rolling_hit_rate()
348        );
349    }
350
351    #[test]
352    fn alert_messages_mention_likely_causes() {
353        let sustained =
354            CacheHealthAlert::SustainedMisses { consecutive: 3, hit_rate: 5.0, measured_turns: 3 }.message();
355        assert!(sustained.contains("consecutive"));
356        assert!(sustained.contains("5%"));
357        assert!(sustained.contains("trajectory log"));
358        let low = CacheHealthAlert::LowHitRate { hit_rate: 10.0, measured_turns: 9 }.message();
359        assert!(low.contains("10%"));
360        assert!(low.contains("idle gaps"));
361    }
362}