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    pub fn reset(&self) {
70        self.context_tokens.store(0, Ordering::Relaxed);
71        self.shell_invocations.store(0, Ordering::Relaxed);
72        self.cost_millicents.store(0, Ordering::Relaxed);
73        self.tool_calls.store(0, Ordering::Relaxed);
74    }
75
76    /// A context policy pack may **tighten** (never loosen) the per-session
77    /// token ceiling (#673). Pure so it can be unit-tested without globals.
78    fn capped_token_limit(role_limit: usize, policy_cap: Option<u32>) -> usize {
79        match policy_cap {
80            Some(cap) => role_limit.min(cap as usize),
81            None => role_limit,
82        }
83    }
84
85    pub fn check(&self) -> BudgetSnapshot {
86        let mut limits = roles::active_role().limits;
87        let role_name = roles::active_role_name();
88
89        // #673 — apply the active context policy pack's token cap (Local-Free:
90        // this only affects agent budget accounting, never a human's own reads).
91        let policy_cap =
92            crate::core::policy::runtime::active().and_then(|p| p.resolved.max_context_tokens);
93        limits.max_context_tokens = Self::capped_token_limit(limits.max_context_tokens, policy_cap);
94
95        let tokens = self.tokens_used();
96        let shell = self.shell_used();
97        let cost = self.cost_usd();
98
99        BudgetSnapshot {
100            role: role_name,
101            tokens: DimensionStatus::evaluate(tokens as usize, limits.max_context_tokens, &limits),
102            shell: DimensionStatus::evaluate(shell, limits.max_shell_invocations, &limits),
103            cost: CostStatus::evaluate(cost, limits.max_cost_usd, &limits),
104        }
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub enum BudgetLevel {
110    Ok,
111    Warning,
112    Exhausted,
113}
114
115impl std::fmt::Display for BudgetLevel {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::Ok => write!(f, "OK"),
119            Self::Warning => write!(f, "WARNING"),
120            Self::Exhausted => write!(f, "EXHAUSTED"),
121        }
122    }
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct DimensionStatus {
127    pub used: usize,
128    pub limit: usize,
129    pub percent: u8,
130    pub level: BudgetLevel,
131}
132
133impl DimensionStatus {
134    fn evaluate(used: usize, limit: usize, limits: &RoleLimits) -> Self {
135        if limit == 0 {
136            // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks)
137            return Self {
138                used,
139                limit,
140                percent: 0,
141                level: if used > 0 {
142                    BudgetLevel::Warning
143                } else {
144                    BudgetLevel::Ok
145                },
146            };
147        }
148        let percent = ((used as f64 / limit as f64) * 100.0).min(254.0) as u8;
149        // block_at_percent == 255 means blocking is disabled (LeanCTX default)
150        let level = if limits.block_at_percent < 255 && percent >= limits.block_at_percent {
151            BudgetLevel::Exhausted
152        } else if percent >= limits.warn_at_percent {
153            BudgetLevel::Warning
154        } else {
155            BudgetLevel::Ok
156        };
157        Self {
158            used,
159            limit,
160            percent,
161            level,
162        }
163    }
164}
165
166#[derive(Debug, Clone, Serialize)]
167pub struct CostStatus {
168    pub used_usd: f64,
169    pub limit_usd: f64,
170    pub percent: u8,
171    pub level: BudgetLevel,
172}
173
174impl CostStatus {
175    fn evaluate(used: f64, limit: f64, limits: &RoleLimits) -> Self {
176        if limit <= 0.0 {
177            // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks)
178            return Self {
179                used_usd: used,
180                limit_usd: limit,
181                percent: 0,
182                level: if used > 0.0 {
183                    BudgetLevel::Warning
184                } else {
185                    BudgetLevel::Ok
186                },
187            };
188        }
189        let pct = ((used / limit) * 100.0).min(254.0) as u8;
190        // block_at_percent == 255 means blocking is disabled (LeanCTX default)
191        let level = if limits.block_at_percent < 255 && pct >= limits.block_at_percent {
192            BudgetLevel::Exhausted
193        } else if pct >= limits.warn_at_percent {
194            BudgetLevel::Warning
195        } else {
196            BudgetLevel::Ok
197        };
198        Self {
199            used_usd: used,
200            limit_usd: limit,
201            percent: pct,
202            level,
203        }
204    }
205}
206
207#[derive(Debug, Clone, Serialize)]
208pub struct BudgetSnapshot {
209    pub role: String,
210    pub tokens: DimensionStatus,
211    pub shell: DimensionStatus,
212    pub cost: CostStatus,
213}
214
215impl BudgetSnapshot {
216    pub fn worst_level(&self) -> &BudgetLevel {
217        for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
218            if *level == BudgetLevel::Exhausted {
219                return level;
220            }
221        }
222        for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
223            if *level == BudgetLevel::Warning {
224                return level;
225            }
226        }
227        &BudgetLevel::Ok
228    }
229
230    pub fn format_compact(&self) -> String {
231        format!(
232            "Budget[{}]: tokens {}/{} ({}%) | shell {}/{} ({}%) | cost ${:.2}/${:.2} ({}%) → {}",
233            self.role,
234            self.tokens.used,
235            self.tokens.limit,
236            self.tokens.percent,
237            self.shell.used,
238            self.shell.limit,
239            self.shell.percent,
240            self.cost.used_usd,
241            self.cost.limit_usd,
242            self.cost.percent,
243            self.worst_level(),
244        )
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn tracker_starts_at_zero() {
254        let t = BudgetTracker::new();
255        assert_eq!(t.tokens_used(), 0);
256        assert_eq!(t.shell_used(), 0);
257        assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
258    }
259
260    #[test]
261    fn record_and_read() {
262        let t = BudgetTracker::new();
263        t.record_tokens(5000);
264        t.record_tokens(3000);
265        t.record_shell();
266        t.record_shell();
267        t.record_cost_usd(0.50);
268        assert_eq!(t.tokens_used(), 8000);
269        assert_eq!(t.shell_used(), 2);
270        assert!((t.cost_usd() - 0.50).abs() < 0.001);
271    }
272
273    #[test]
274    fn reset_clears_all() {
275        let t = BudgetTracker::new();
276        t.record_tokens(10_000);
277        t.record_shell();
278        t.record_cost_usd(1.0);
279        t.reset();
280        assert_eq!(t.tokens_used(), 0);
281        assert_eq!(t.shell_used(), 0);
282        assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
283    }
284
285    #[test]
286    fn dimension_status_ok() {
287        let limits = RoleLimits::default();
288        let s = DimensionStatus::evaluate(50_000, 200_000, &limits);
289        assert_eq!(s.level, BudgetLevel::Ok);
290        assert_eq!(s.percent, 25);
291    }
292
293    #[test]
294    fn policy_cap_tightens_but_never_loosens() {
295        // #673: a policy may only lower the ceiling, and a None cap is a no-op.
296        assert_eq!(
297            BudgetTracker::capped_token_limit(200_000, Some(5_000)),
298            5_000
299        );
300        assert_eq!(
301            BudgetTracker::capped_token_limit(4_000, Some(50_000)),
302            4_000
303        );
304        assert_eq!(BudgetTracker::capped_token_limit(10_000, None), 10_000);
305    }
306
307    #[test]
308    fn dimension_status_warning() {
309        let limits = RoleLimits::default();
310        let s = DimensionStatus::evaluate(170_000, 200_000, &limits);
311        assert_eq!(s.level, BudgetLevel::Warning);
312        assert_eq!(s.percent, 85);
313    }
314
315    #[test]
316    fn dimension_status_at_100_percent_is_warning_by_default() {
317        // With block_at_percent=255 (default), 100% usage is Warning, not Exhausted
318        let limits = RoleLimits::default();
319        assert_eq!(limits.block_at_percent, 255); // Default = never block
320        let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
321        assert_eq!(s.level, BudgetLevel::Warning);
322        assert_eq!(s.percent, 100);
323    }
324
325    #[test]
326    fn dimension_status_exhausted_when_blocking_enabled() {
327        // Exhausted only happens when block_at_percent is explicitly set low
328        let limits = RoleLimits {
329            block_at_percent: 100,
330            ..Default::default()
331        };
332        let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
333        assert_eq!(s.level, BudgetLevel::Exhausted);
334    }
335
336    #[test]
337    fn zero_limit_warns_usage() {
338        // Zero limit with any usage => Warning (not Exhausted, LeanCTX never blocks by default)
339        let limits = RoleLimits::default();
340        let s = DimensionStatus::evaluate(1, 0, &limits);
341        assert_eq!(s.level, BudgetLevel::Warning);
342    }
343
344    #[test]
345    fn cost_status_warning() {
346        let limits = RoleLimits::default();
347        let s = CostStatus::evaluate(4.5, 5.0, &limits);
348        assert_eq!(s.level, BudgetLevel::Warning);
349    }
350
351    #[test]
352    fn snapshot_worst_level() {
353        let limits = RoleLimits::default();
354        let snap = BudgetSnapshot {
355            role: "test".into(),
356            tokens: DimensionStatus::evaluate(50_000, 200_000, &limits),
357            shell: DimensionStatus::evaluate(90, 100, &limits),
358            cost: CostStatus::evaluate(1.0, 5.0, &limits),
359        };
360        assert_eq!(*snap.worst_level(), BudgetLevel::Warning);
361    }
362
363    #[test]
364    fn format_compact_includes_all() {
365        let s = BudgetSnapshot {
366            role: "coder".into(),
367            tokens: DimensionStatus {
368                used: 1000,
369                limit: 200_000,
370                percent: 0,
371                level: BudgetLevel::Ok,
372            },
373            shell: DimensionStatus {
374                used: 5,
375                limit: 100,
376                percent: 5,
377                level: BudgetLevel::Ok,
378            },
379            cost: CostStatus {
380                used_usd: 0.25,
381                limit_usd: 5.0,
382                percent: 5,
383                level: BudgetLevel::Ok,
384            },
385        };
386        let out = s.format_compact();
387        assert!(out.contains("coder"));
388        assert!(out.contains("tokens"));
389        assert!(out.contains("shell"));
390        assert!(out.contains("cost"));
391        assert!(out.contains("OK"));
392    }
393}