Skip to main content

lean_ctx/core/
budget_tracker.rs

1//! Runtime budget tracking against role limits.
2//!
3//! Compares accumulated session counters with the active role's `RoleLimits`
4//! and produces `BudgetStatus` verdicts (Ok / Warning / Exhausted).
5
6use std::sync::OnceLock;
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8
9use serde::Serialize;
10
11use crate::core::roles::{self, RoleLimits};
12
13static TRACKER: OnceLock<BudgetTracker> = OnceLock::new();
14
15pub struct BudgetTracker {
16    context_tokens: AtomicU64,
17    shell_invocations: AtomicUsize,
18    cost_millicents: AtomicU64,
19    tool_calls: AtomicUsize,
20}
21
22impl BudgetTracker {
23    fn new() -> Self {
24        Self {
25            context_tokens: AtomicU64::new(0),
26            shell_invocations: AtomicUsize::new(0),
27            cost_millicents: AtomicU64::new(0),
28            tool_calls: AtomicUsize::new(0),
29        }
30    }
31
32    pub fn global() -> &'static BudgetTracker {
33        TRACKER.get_or_init(BudgetTracker::new)
34    }
35
36    pub fn record_tokens(&self, tokens: u64) {
37        self.context_tokens.fetch_add(tokens, Ordering::Relaxed);
38    }
39
40    pub fn record_shell(&self) {
41        self.shell_invocations.fetch_add(1, Ordering::Relaxed);
42    }
43
44    pub fn record_tool_call(&self) {
45        self.tool_calls.fetch_add(1, Ordering::Relaxed);
46    }
47
48    pub fn tool_calls_count(&self) -> usize {
49        self.tool_calls.load(Ordering::Relaxed)
50    }
51
52    pub fn record_cost_usd(&self, usd: f64) {
53        let mc = (usd * 100_000.0) as u64;
54        self.cost_millicents.fetch_add(mc, Ordering::Relaxed);
55    }
56
57    pub fn tokens_used(&self) -> u64 {
58        self.context_tokens.load(Ordering::Relaxed)
59    }
60
61    pub fn shell_used(&self) -> usize {
62        self.shell_invocations.load(Ordering::Relaxed)
63    }
64
65    pub fn cost_usd(&self) -> f64 {
66        self.cost_millicents.load(Ordering::Relaxed) as f64 / 100_000.0
67    }
68
69    /// Returns `Some(message)` when the session cost cap is exceeded (#794).
70    /// Returns `None` when no cap is configured, the cap isn't reached, or
71    /// `LEAN_CTX_COST_CAP_OVERRIDE=1` is set.
72    pub fn cost_cap_message(&self) -> Option<String> {
73        let cfg = crate::core::config::Config::load();
74        let cap = cfg.cost.max_session_cost_usd;
75        if cap <= 0.0 {
76            return None;
77        }
78        if std::env::var("LEAN_CTX_COST_CAP_OVERRIDE")
79            .ok()
80            .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
81        {
82            return None;
83        }
84        let used = self.cost_usd();
85        if used < cap {
86            return None;
87        }
88        Some(format!(
89            "[COST CAP] Session cost ${used:.2} reached ${cap:.2} limit. Use ctx_session(action=budget, override=true) or LEAN_CTX_COST_CAP_OVERRIDE=1 to continue."
90        ))
91    }
92
93    pub fn reset(&self) {
94        self.context_tokens.store(0, Ordering::Relaxed);
95        self.shell_invocations.store(0, Ordering::Relaxed);
96        self.cost_millicents.store(0, Ordering::Relaxed);
97        self.tool_calls.store(0, Ordering::Relaxed);
98    }
99
100    /// A context policy pack may **tighten** (never loosen) the per-session
101    /// token ceiling (#673). Pure so it can be unit-tested without globals.
102    fn capped_token_limit(role_limit: usize, policy_cap: Option<u32>) -> usize {
103        match policy_cap {
104            Some(cap) => role_limit.min(cap as usize),
105            None => role_limit,
106        }
107    }
108
109    pub fn check(&self) -> BudgetSnapshot {
110        let mut limits = roles::active_role().limits;
111        let role_name = roles::active_role_name();
112
113        // #673 — apply the active context policy pack's token cap (Local-Free:
114        // this only affects agent budget accounting, never a human's own reads).
115        let policy_cap =
116            crate::core::policy::runtime::active().and_then(|p| p.resolved.max_context_tokens);
117        limits.max_context_tokens = Self::capped_token_limit(limits.max_context_tokens, policy_cap);
118
119        let tokens = self.tokens_used();
120        let shell = self.shell_used();
121        let cost = self.cost_usd();
122
123        BudgetSnapshot {
124            role: role_name,
125            tokens: DimensionStatus::evaluate(tokens as usize, limits.max_context_tokens, &limits),
126            shell: DimensionStatus::evaluate(shell, limits.max_shell_invocations, &limits),
127            cost: CostStatus::evaluate(cost, limits.max_cost_usd, &limits),
128        }
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub enum BudgetLevel {
134    Ok,
135    Warning,
136    Exhausted,
137}
138
139impl std::fmt::Display for BudgetLevel {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::Ok => write!(f, "OK"),
143            Self::Warning => write!(f, "WARNING"),
144            Self::Exhausted => write!(f, "EXHAUSTED"),
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize)]
150pub struct DimensionStatus {
151    pub used: usize,
152    pub limit: usize,
153    pub percent: u8,
154    pub level: BudgetLevel,
155}
156
157impl DimensionStatus {
158    fn evaluate(used: usize, limit: usize, limits: &RoleLimits) -> Self {
159        if limit == 0 {
160            // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks)
161            return Self {
162                used,
163                limit,
164                percent: 0,
165                level: if used > 0 {
166                    BudgetLevel::Warning
167                } else {
168                    BudgetLevel::Ok
169                },
170            };
171        }
172        let percent = ((used as f64 / limit as f64) * 100.0).min(254.0) as u8;
173        // block_at_percent == 255 means blocking is disabled (LeanCTX default)
174        let level = if limits.block_at_percent < 255 && percent >= limits.block_at_percent {
175            BudgetLevel::Exhausted
176        } else if percent >= limits.warn_at_percent {
177            BudgetLevel::Warning
178        } else {
179            BudgetLevel::Ok
180        };
181        Self {
182            used,
183            limit,
184            percent,
185            level,
186        }
187    }
188}
189
190#[derive(Debug, Clone, Serialize)]
191pub struct CostStatus {
192    pub used_usd: f64,
193    pub limit_usd: f64,
194    pub percent: u8,
195    pub level: BudgetLevel,
196}
197
198impl CostStatus {
199    fn evaluate(used: f64, limit: f64, limits: &RoleLimits) -> Self {
200        if limit <= 0.0 {
201            // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks)
202            return Self {
203                used_usd: used,
204                limit_usd: limit,
205                percent: 0,
206                level: if used > 0.0 {
207                    BudgetLevel::Warning
208                } else {
209                    BudgetLevel::Ok
210                },
211            };
212        }
213        let pct = ((used / limit) * 100.0).min(254.0) as u8;
214        // block_at_percent == 255 means blocking is disabled (LeanCTX default)
215        let level = if limits.block_at_percent < 255 && pct >= limits.block_at_percent {
216            BudgetLevel::Exhausted
217        } else if pct >= limits.warn_at_percent {
218            BudgetLevel::Warning
219        } else {
220            BudgetLevel::Ok
221        };
222        Self {
223            used_usd: used,
224            limit_usd: limit,
225            percent: pct,
226            level,
227        }
228    }
229}
230
231#[derive(Debug, Clone, Serialize)]
232pub struct BudgetSnapshot {
233    pub role: String,
234    pub tokens: DimensionStatus,
235    pub shell: DimensionStatus,
236    pub cost: CostStatus,
237}
238
239impl BudgetSnapshot {
240    pub fn worst_level(&self) -> &BudgetLevel {
241        for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
242            if *level == BudgetLevel::Exhausted {
243                return level;
244            }
245        }
246        for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
247            if *level == BudgetLevel::Warning {
248                return level;
249            }
250        }
251        &BudgetLevel::Ok
252    }
253
254    pub fn format_compact(&self) -> String {
255        format!(
256            "Budget[role:{}]: tokens {}/{} ({}%) | shell {}/{} ({}%) | cost ${:.2}/${:.2} ({}%) → {}",
257            self.role,
258            self.tokens.used,
259            self.tokens.limit,
260            self.tokens.percent,
261            self.shell.used,
262            self.shell.limit,
263            self.shell.percent,
264            self.cost.used_usd,
265            self.cost.limit_usd,
266            self.cost.percent,
267            self.worst_level(),
268        )
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn tracker_starts_at_zero() {
278        let t = BudgetTracker::new();
279        assert_eq!(t.tokens_used(), 0);
280        assert_eq!(t.shell_used(), 0);
281        assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
282    }
283
284    #[test]
285    fn record_and_read() {
286        let t = BudgetTracker::new();
287        t.record_tokens(5000);
288        t.record_tokens(3000);
289        t.record_shell();
290        t.record_shell();
291        t.record_cost_usd(0.50);
292        assert_eq!(t.tokens_used(), 8000);
293        assert_eq!(t.shell_used(), 2);
294        assert!((t.cost_usd() - 0.50).abs() < 0.001);
295    }
296
297    #[test]
298    fn reset_clears_all() {
299        let t = BudgetTracker::new();
300        t.record_tokens(10_000);
301        t.record_shell();
302        t.record_cost_usd(1.0);
303        t.reset();
304        assert_eq!(t.tokens_used(), 0);
305        assert_eq!(t.shell_used(), 0);
306        assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
307    }
308
309    #[test]
310    fn dimension_status_ok() {
311        let limits = RoleLimits::default();
312        let s = DimensionStatus::evaluate(50_000, 200_000, &limits);
313        assert_eq!(s.level, BudgetLevel::Ok);
314        assert_eq!(s.percent, 25);
315    }
316
317    #[test]
318    fn policy_cap_tightens_but_never_loosens() {
319        // #673: a policy may only lower the ceiling, and a None cap is a no-op.
320        assert_eq!(
321            BudgetTracker::capped_token_limit(200_000, Some(5_000)),
322            5_000
323        );
324        assert_eq!(
325            BudgetTracker::capped_token_limit(4_000, Some(50_000)),
326            4_000
327        );
328        assert_eq!(BudgetTracker::capped_token_limit(10_000, None), 10_000);
329    }
330
331    #[test]
332    fn dimension_status_warning() {
333        let limits = RoleLimits::default();
334        let s = DimensionStatus::evaluate(170_000, 200_000, &limits);
335        assert_eq!(s.level, BudgetLevel::Warning);
336        assert_eq!(s.percent, 85);
337    }
338
339    #[test]
340    fn dimension_status_at_100_percent_is_warning_by_default() {
341        // With block_at_percent=255 (default), 100% usage is Warning, not Exhausted
342        let limits = RoleLimits::default();
343        assert_eq!(limits.block_at_percent, 255); // Default = never block
344        let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
345        assert_eq!(s.level, BudgetLevel::Warning);
346        assert_eq!(s.percent, 100);
347    }
348
349    #[test]
350    fn dimension_status_exhausted_when_blocking_enabled() {
351        // Exhausted only happens when block_at_percent is explicitly set low
352        let limits = RoleLimits {
353            block_at_percent: 100,
354            ..Default::default()
355        };
356        let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
357        assert_eq!(s.level, BudgetLevel::Exhausted);
358    }
359
360    #[test]
361    fn zero_limit_warns_usage() {
362        // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks by default)
363        let limits = RoleLimits::default();
364        let s = DimensionStatus::evaluate(1, 0, &limits);
365        assert_eq!(s.level, BudgetLevel::Warning);
366    }
367
368    #[test]
369    fn cost_cap_no_limit_returns_none() {
370        let t = BudgetTracker::new();
371        t.record_cost_usd(100.0);
372        // Without a configured cap (default 0), no message is returned.
373        // We test the pure logic; the config defaults to 0.
374        assert!(t.cost_cap_message().is_none());
375    }
376
377    #[test]
378    fn cost_cap_blocks_when_exceeded() {
379        let _env_lock = crate::core::data_dir::test_env_lock();
380        let t = BudgetTracker::new();
381        t.record_cost_usd(6.0);
382        // SAFETY: single-threaded test — no concurrent env access.
383        unsafe {
384            std::env::set_var("LEAN_CTX_COST_CAP_OVERRIDE", "1");
385        }
386        assert!(
387            t.cost_cap_message().is_none(),
388            "override=1 must bypass cost cap"
389        );
390        // SAFETY: single-threaded test — no concurrent env access.
391        unsafe {
392            std::env::remove_var("LEAN_CTX_COST_CAP_OVERRIDE");
393        }
394    }
395
396    #[test]
397    fn cost_status_warning() {
398        let limits = RoleLimits::default();
399        let s = CostStatus::evaluate(4.5, 5.0, &limits);
400        assert_eq!(s.level, BudgetLevel::Warning);
401    }
402
403    #[test]
404    fn snapshot_worst_level() {
405        let limits = RoleLimits::default();
406        let snap = BudgetSnapshot {
407            role: "test".into(),
408            tokens: DimensionStatus::evaluate(50_000, 200_000, &limits),
409            shell: DimensionStatus::evaluate(90, 100, &limits),
410            cost: CostStatus::evaluate(1.0, 5.0, &limits),
411        };
412        assert_eq!(*snap.worst_level(), BudgetLevel::Warning);
413    }
414
415    #[test]
416    fn format_compact_includes_all() {
417        let s = BudgetSnapshot {
418            role: "coder".into(),
419            tokens: DimensionStatus {
420                used: 1000,
421                limit: 200_000,
422                percent: 0,
423                level: BudgetLevel::Ok,
424            },
425            shell: DimensionStatus {
426                used: 5,
427                limit: 100,
428                percent: 5,
429                level: BudgetLevel::Ok,
430            },
431            cost: CostStatus {
432                used_usd: 0.25,
433                limit_usd: 5.0,
434                percent: 5,
435                level: BudgetLevel::Ok,
436            },
437        };
438        let out = s.format_compact();
439        assert!(out.contains("role:coder"));
440        assert!(out.contains("tokens"));
441        assert!(out.contains("shell"));
442        assert!(out.contains("cost"));
443        assert!(out.contains("OK"));
444    }
445}