Skip to content

Commit 6e0fa2f

Browse files
AlexMikhalevclaude
andcommitted
feat(rlm): add session management and budget tracking
Phase 2 implementation progress: - Add SessionManager for VM affinity, context variables, extensions - Add BudgetTracker for token/time/recursion limits - Update to use jiff instead of chrono for timestamps - Update to use ulid instead of uuid for identifiers - Apply config-based budget limits to new sessions All 38 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cb3b1b2 commit 6e0fa2f

7 files changed

Lines changed: 893 additions & 42 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/terraphim_rlm/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ categories = ["development-tools", "asynchronous"]
1414
tokio.workspace = true
1515
serde.workspace = true
1616
serde_json.workspace = true
17-
uuid.workspace = true
18-
chrono.workspace = true
17+
ulid = { version = "1.1", features = ["serde"] }
18+
jiff = { version = "0.2", features = ["serde"] }
1919
async-trait.workspace = true
2020
thiserror.workspace = true
2121
anyhow.workspace = true

crates/terraphim_rlm/src/budget.rs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
//! Budget tracking for RLM execution.
2+
//!
3+
//! The BudgetTracker enforces:
4+
//! - Token budget: Maximum LLM tokens consumed per session
5+
//! - Time budget: Maximum wall-clock time per session
6+
//! - Recursion depth: Maximum nested LLM calls
7+
8+
use std::sync::atomic::{AtomicU64, Ordering};
9+
use std::time::Instant;
10+
11+
use crate::config::RlmConfig;
12+
use crate::error::{RlmError, RlmResult};
13+
use crate::types::BudgetStatus;
14+
15+
/// Tracks resource consumption for a session.
16+
///
17+
/// Thread-safe budget tracker using atomic operations.
18+
pub struct BudgetTracker {
19+
/// Token budget limit.
20+
token_budget: u64,
21+
/// Tokens consumed.
22+
tokens_used: AtomicU64,
23+
24+
/// Time budget in milliseconds.
25+
time_budget_ms: u64,
26+
/// Start time for time tracking.
27+
start_time: Instant,
28+
29+
/// Maximum recursion depth.
30+
max_recursion_depth: u32,
31+
/// Current recursion depth (not atomic - managed by SessionManager).
32+
current_depth: std::sync::atomic::AtomicU32,
33+
}
34+
35+
impl BudgetTracker {
36+
/// Create a new budget tracker with default limits.
37+
pub fn new(config: &RlmConfig) -> Self {
38+
Self {
39+
token_budget: config.token_budget,
40+
tokens_used: AtomicU64::new(0),
41+
time_budget_ms: config.time_budget_ms,
42+
start_time: Instant::now(),
43+
max_recursion_depth: config.max_recursion_depth,
44+
current_depth: std::sync::atomic::AtomicU32::new(0),
45+
}
46+
}
47+
48+
/// Create a child budget tracker for recursive calls.
49+
///
50+
/// The child inherits remaining budget from parent.
51+
pub fn child(&self, remaining_tokens: u64, remaining_time_ms: u64) -> Self {
52+
Self {
53+
token_budget: remaining_tokens,
54+
tokens_used: AtomicU64::new(0),
55+
time_budget_ms: remaining_time_ms,
56+
start_time: Instant::now(),
57+
max_recursion_depth: self.max_recursion_depth,
58+
current_depth: std::sync::atomic::AtomicU32::new(
59+
self.current_depth.load(Ordering::Relaxed) + 1,
60+
),
61+
}
62+
}
63+
64+
/// Add tokens to the consumption count.
65+
pub fn add_tokens(&self, tokens: u64) -> RlmResult<()> {
66+
let new_total = self.tokens_used.fetch_add(tokens, Ordering::Relaxed) + tokens;
67+
68+
if new_total > self.token_budget {
69+
return Err(RlmError::TokenBudgetExceeded {
70+
used: new_total,
71+
budget: self.token_budget,
72+
});
73+
}
74+
75+
Ok(())
76+
}
77+
78+
/// Check if token budget is exhausted.
79+
pub fn check_token_budget(&self) -> RlmResult<()> {
80+
let used = self.tokens_used.load(Ordering::Relaxed);
81+
if used >= self.token_budget {
82+
return Err(RlmError::TokenBudgetExceeded {
83+
used,
84+
budget: self.token_budget,
85+
});
86+
}
87+
Ok(())
88+
}
89+
90+
/// Check if time budget is exhausted.
91+
pub fn check_time_budget(&self) -> RlmResult<()> {
92+
let elapsed_ms = self.start_time.elapsed().as_millis() as u64;
93+
if elapsed_ms >= self.time_budget_ms {
94+
return Err(RlmError::TimeBudgetExceeded {
95+
used_ms: elapsed_ms,
96+
budget_ms: self.time_budget_ms,
97+
});
98+
}
99+
Ok(())
100+
}
101+
102+
/// Check if recursion depth is exhausted.
103+
pub fn check_recursion_depth(&self) -> RlmResult<()> {
104+
let depth = self.current_depth.load(Ordering::Relaxed);
105+
if depth > self.max_recursion_depth {
106+
return Err(RlmError::RecursionDepthExceeded {
107+
depth,
108+
max_depth: self.max_recursion_depth,
109+
});
110+
}
111+
Ok(())
112+
}
113+
114+
/// Check all budgets at once.
115+
pub fn check_all(&self) -> RlmResult<()> {
116+
self.check_token_budget()?;
117+
self.check_time_budget()?;
118+
self.check_recursion_depth()?;
119+
Ok(())
120+
}
121+
122+
/// Increment recursion depth.
123+
pub fn push_recursion(&self) -> RlmResult<u32> {
124+
let depth = self.current_depth.fetch_add(1, Ordering::Relaxed) + 1;
125+
if depth > self.max_recursion_depth {
126+
// Rollback
127+
self.current_depth.fetch_sub(1, Ordering::Relaxed);
128+
return Err(RlmError::RecursionDepthExceeded {
129+
depth,
130+
max_depth: self.max_recursion_depth,
131+
});
132+
}
133+
Ok(depth)
134+
}
135+
136+
/// Decrement recursion depth.
137+
pub fn pop_recursion(&self) -> u32 {
138+
let depth = self.current_depth.fetch_sub(1, Ordering::Relaxed);
139+
depth.saturating_sub(1)
140+
}
141+
142+
/// Get tokens used.
143+
pub fn tokens_used(&self) -> u64 {
144+
self.tokens_used.load(Ordering::Relaxed)
145+
}
146+
147+
/// Get remaining tokens.
148+
pub fn tokens_remaining(&self) -> u64 {
149+
self.token_budget
150+
.saturating_sub(self.tokens_used.load(Ordering::Relaxed))
151+
}
152+
153+
/// Get elapsed time in milliseconds.
154+
pub fn elapsed_ms(&self) -> u64 {
155+
self.start_time.elapsed().as_millis() as u64
156+
}
157+
158+
/// Get remaining time in milliseconds.
159+
pub fn time_remaining_ms(&self) -> u64 {
160+
self.time_budget_ms.saturating_sub(self.elapsed_ms())
161+
}
162+
163+
/// Get current recursion depth.
164+
pub fn current_depth(&self) -> u32 {
165+
self.current_depth.load(Ordering::Relaxed)
166+
}
167+
168+
/// Get remaining recursion depth.
169+
pub fn depth_remaining(&self) -> u32 {
170+
self.max_recursion_depth
171+
.saturating_sub(self.current_depth.load(Ordering::Relaxed))
172+
}
173+
174+
/// Get current budget status.
175+
pub fn status(&self) -> BudgetStatus {
176+
BudgetStatus {
177+
token_budget: self.token_budget,
178+
tokens_used: self.tokens_used.load(Ordering::Relaxed),
179+
time_budget_ms: self.time_budget_ms,
180+
time_used_ms: self.elapsed_ms(),
181+
max_recursion_depth: self.max_recursion_depth,
182+
current_recursion_depth: self.current_depth.load(Ordering::Relaxed),
183+
}
184+
}
185+
186+
/// Check if any budget is close to exhaustion (>80% used).
187+
pub fn is_near_exhaustion(&self) -> bool {
188+
let token_ratio =
189+
self.tokens_used.load(Ordering::Relaxed) as f64 / self.token_budget as f64;
190+
let time_ratio = self.elapsed_ms() as f64 / self.time_budget_ms as f64;
191+
let depth_ratio =
192+
self.current_depth.load(Ordering::Relaxed) as f64 / self.max_recursion_depth as f64;
193+
194+
token_ratio > 0.8 || time_ratio > 0.8 || depth_ratio > 0.8
195+
}
196+
197+
/// Reset the tracker (for testing).
198+
#[cfg(test)]
199+
pub fn reset(&self) {
200+
self.tokens_used.store(0, Ordering::Relaxed);
201+
self.current_depth.store(0, Ordering::Relaxed);
202+
}
203+
}
204+
205+
impl Default for BudgetTracker {
206+
fn default() -> Self {
207+
Self::new(&RlmConfig::default())
208+
}
209+
}
210+
211+
#[cfg(test)]
212+
mod tests {
213+
use super::*;
214+
215+
fn test_config() -> RlmConfig {
216+
RlmConfig {
217+
token_budget: 1000,
218+
time_budget_ms: 60_000, // 1 minute
219+
max_recursion_depth: 5,
220+
..Default::default()
221+
}
222+
}
223+
224+
#[test]
225+
fn test_token_tracking() {
226+
let tracker = BudgetTracker::new(&test_config());
227+
228+
// Add tokens within budget
229+
assert!(tracker.add_tokens(500).is_ok());
230+
assert_eq!(tracker.tokens_used(), 500);
231+
assert_eq!(tracker.tokens_remaining(), 500);
232+
233+
// Add more tokens within budget
234+
assert!(tracker.add_tokens(400).is_ok());
235+
assert_eq!(tracker.tokens_used(), 900);
236+
237+
// Exceed budget
238+
let result = tracker.add_tokens(200);
239+
assert!(matches!(result, Err(RlmError::TokenBudgetExceeded { .. })));
240+
}
241+
242+
#[test]
243+
fn test_recursion_tracking() {
244+
let tracker = BudgetTracker::new(&test_config());
245+
246+
// Push within limits
247+
assert_eq!(tracker.push_recursion().unwrap(), 1);
248+
assert_eq!(tracker.push_recursion().unwrap(), 2);
249+
assert_eq!(tracker.current_depth(), 2);
250+
251+
// Pop
252+
assert_eq!(tracker.pop_recursion(), 1);
253+
assert_eq!(tracker.current_depth(), 1);
254+
255+
// Push to limit
256+
tracker.push_recursion().unwrap();
257+
tracker.push_recursion().unwrap();
258+
tracker.push_recursion().unwrap();
259+
tracker.push_recursion().unwrap();
260+
261+
// Should fail at max depth
262+
let result = tracker.push_recursion();
263+
assert!(matches!(result, Err(RlmError::RecursionDepthExceeded { .. })));
264+
}
265+
266+
#[test]
267+
fn test_budget_status() {
268+
let tracker = BudgetTracker::new(&test_config());
269+
270+
tracker.add_tokens(250).unwrap();
271+
tracker.push_recursion().unwrap();
272+
273+
let status = tracker.status();
274+
assert_eq!(status.tokens_used, 250);
275+
assert_eq!(status.token_budget, 1000);
276+
assert_eq!(status.current_recursion_depth, 1);
277+
assert_eq!(status.max_recursion_depth, 5);
278+
}
279+
280+
#[test]
281+
fn test_child_budget() {
282+
let parent = BudgetTracker::new(&test_config());
283+
parent.add_tokens(400).unwrap();
284+
parent.push_recursion().unwrap();
285+
286+
let child = parent.child(parent.tokens_remaining(), parent.time_remaining_ms());
287+
288+
// Child starts with remaining budget
289+
assert_eq!(child.token_budget, 600);
290+
assert_eq!(child.current_depth(), 2);
291+
292+
// Child can use its own budget
293+
assert!(child.add_tokens(300).is_ok());
294+
assert_eq!(child.tokens_remaining(), 300);
295+
}
296+
297+
#[test]
298+
fn test_near_exhaustion() {
299+
let config = RlmConfig {
300+
token_budget: 100,
301+
time_budget_ms: 60_000,
302+
max_recursion_depth: 5,
303+
..Default::default()
304+
};
305+
let tracker = BudgetTracker::new(&config);
306+
307+
assert!(!tracker.is_near_exhaustion());
308+
309+
tracker.add_tokens(85).unwrap();
310+
assert!(tracker.is_near_exhaustion());
311+
}
312+
313+
#[test]
314+
fn test_check_all() {
315+
let tracker = BudgetTracker::new(&test_config());
316+
317+
// All should pass initially
318+
assert!(tracker.check_all().is_ok());
319+
320+
// Exhaust tokens
321+
tracker.add_tokens(1001).ok();
322+
assert!(tracker.check_all().is_err());
323+
}
324+
}

0 commit comments

Comments
 (0)