phi_core/context/execution.rs
1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// Execution limits
5// ---------------------------------------------------------------------------
6
7/*
8ExecutionLimits — a safety net against runaway agent loops.
9
10Without limits, a poorly-designed tool or a confused LLM could loop forever,
11burning tokens and money. These three limits provide defense-in-depth:
12
13 max_turns — catches infinite tool-call loops
14 max_total_tokens — catches token budget overruns (cost control)
15 max_duration — catches wall-clock hangs (e.g., a bash tool that blocks)
16
17The agent loop checks these BEFORE each turn (in ExecutionTracker::check_limits).
18When a limit is hit, it injects a "[Agent stopped: ...]" user message into the
19conversation so the LLM (and user) can see what happened, then returns.
20
21RUST QUIRK: `std::time::Duration`
22
23Duration is Rust's type for a span of time (not a point in time — that's Instant/SystemTime).
24Constructors:
25 Duration::from_secs(600) → 10 minutes
26 Duration::from_millis(100) → 100ms
27 Duration::from_nanos(1) → 1 nanosecond
28
29Internally, Duration is stored as (seconds: u64, nanoseconds: u32) — no floating point,
30no overflow risk for reasonable values.
31
32The full path `std::time::Duration` is used instead of a `use` import because it appears
33only in this one struct — no need to pollute the module namespace.
34*/
35/// Execution limits for the agent loop — guards against infinite loops and budget overruns.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ExecutionLimits {
38 /// Maximum number of LLM turns (one turn = one LLM call + its tool results)
39 pub max_turns: usize,
40 /// Maximum total tokens consumed across all turns (input + output)
41 pub max_total_tokens: usize,
42 /// Maximum wall-clock duration. Uses std::time::Duration (not f64 seconds) for precision.
43 pub max_duration: std::time::Duration,
44 /// Maximum cumulative dollar cost for the run. `None` means no cost cap.
45 /// Requires `AgentLoopConfig.cost_config` to be set — without pricing rates the
46 /// accumulated cost is always 0.0 and this limit has no effect.
47 #[serde(default)]
48 pub max_cost: Option<f64>,
49}
50
51impl Default for ExecutionLimits {
52 fn default() -> Self {
53 Self {
54 max_turns: 50,
55 max_total_tokens: 1_000_000,
56 max_duration: std::time::Duration::from_secs(600),
57 max_cost: None,
58 }
59 }
60}
61
62/// Tracks execution state against limits
63pub struct ExecutionTracker {
64 pub limits: ExecutionLimits,
65 pub turns: usize,
66 pub tokens_used: usize,
67 /// Accumulated dollar cost across all turns. Updated via `record_cost()`.
68 /// Only non-zero when `AgentLoopConfig.cost_config` is set.
69 pub cost_accumulated: f64,
70 pub started_at: std::time::Instant,
71}
72
73impl ExecutionTracker {
74 pub fn new(limits: ExecutionLimits) -> Self {
75 Self {
76 limits,
77 turns: 0,
78 tokens_used: 0,
79 cost_accumulated: 0.0,
80 started_at: std::time::Instant::now(),
81 }
82 }
83
84 pub fn record_turn(&mut self, tokens: usize) {
85 self.turns += 1;
86 self.tokens_used += tokens;
87 }
88
89 /// Accumulate incremental cost for the current turn.
90 pub fn record_cost(&mut self, cost: f64) {
91 self.cost_accumulated += cost;
92 }
93
94 /// Check if any limit has been exceeded. Returns the reason if so.
95 /*
96 RUST QUIRK: `Option<String>` as "either an error reason, or nothing"
97
98 `check_limits()` returns:
99 Some("Max turns reached (50/50)") ← a limit was hit
100 None ← all limits OK
101
102 This is the Rust way to return "optional data" — no exceptions, no sentinel values (-1, ""),
103 no separate boolean + string pair. The caller pattern-matches to handle both cases.
104
105 RUST QUIRK: `Instant::elapsed()` for wall-clock timing
106
107 `std::time::Instant` records a moment in time (monotonic clock, not wall clock).
108 Monotonic means it never goes backwards — safe to use for durations.
109 `started_at.elapsed()` returns a `Duration` = current time - started_at.
110
111 The `>=` comparison between two Durations works because Duration implements PartialOrd.
112
113 RUST QUIRK: `{:.0}` format specifier — zero decimal places for f64
114
115 `format!("Max duration reached ({:.0}s/{:.0}s)", elapsed.as_secs_f64(), ...)`
116 `{:.0}` means "format as float with 0 decimal places" → "42" not "42.000000"
117 Other examples: {:.2} = 2 decimal places, {:>10.3} = right-aligned, 10 wide, 3 decimal places
118 */
119 pub fn check_limits(&self) -> Option<String> {
120 if self.turns >= self.limits.max_turns {
121 return Some(format!(
122 "Max turns reached ({}/{})",
123 self.turns, self.limits.max_turns
124 ));
125 }
126 if self.tokens_used >= self.limits.max_total_tokens {
127 return Some(format!(
128 "Max tokens reached ({}/{})",
129 self.tokens_used, self.limits.max_total_tokens
130 ));
131 }
132 let elapsed = self.started_at.elapsed(); // Duration since ExecutionTracker::new()
133 if elapsed >= self.limits.max_duration {
134 return Some(format!(
135 "Max duration reached ({:.0}s/{:.0}s)", // {:.0} = 0 decimal places
136 elapsed.as_secs_f64(),
137 self.limits.max_duration.as_secs_f64()
138 ));
139 }
140 if let Some(max) = self.limits.max_cost {
141 if self.cost_accumulated >= max {
142 return Some(format!(
143 "Max cost reached (${:.4}/${:.4})",
144 self.cost_accumulated, max
145 ));
146 }
147 }
148 None // All limits OK — return None (no reason to stop)
149 }
150}