Skip to main content

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}