Skip to main content

zeph_core/goal/
autonomous.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Autonomous goal execution types and driver.
5//!
6//! An [`AutonomousSession`] tracks one active multi-turn run. At most one session can
7//! exist at a time (invariant A1). The [`AutonomousDriver`] lives on `Agent` and is polled
8//! cooperatively from within the existing `tokio::select!` loop — no separate task is spawned.
9
10use std::sync::Arc;
11use std::time::Instant;
12
13use parking_lot::Mutex;
14use tokio::time::{Duration, Instant as TokioInstant, Interval};
15use tokio_util::sync::CancellationToken;
16
17pub use zeph_config::autonomous::AutonomousState;
18
19/// Structured verdict returned by the supervisor verifier after a single LLM call.
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct SupervisorVerdict {
22    /// Whether the goal condition is considered satisfied.
23    pub achieved: bool,
24    /// Short natural-language explanation of the verdict.
25    pub reasoning: String,
26    /// Confidence score in `[0.0, 1.0]`.
27    pub confidence: f32,
28    /// Optional suggestions for continuing when `achieved = false`.
29    pub suggestions: Vec<String>,
30}
31
32/// Live state for one autonomous goal execution session.
33///
34/// At most one instance exists at a time, held inside [`AutonomousDriver::session`].
35pub struct AutonomousSession {
36    /// UUID of the [`crate::goal::Goal`] being executed.
37    pub goal_id: String,
38    /// Cached goal text used for synthetic message injection.
39    pub goal_text: String,
40    /// Current FSM state.
41    pub state: AutonomousState,
42    /// Total turns executed so far in this session.
43    pub turns_executed: u32,
44    /// Turn limit for this session (from config or per-invocation `--turns N`).
45    pub max_turns: u32,
46    /// Number of consecutive turns the stuck heuristic fired with no progress.
47    pub stuck_count: u32,
48    /// Number of consecutive supervisor verification failures (timeout, 429, auth error).
49    pub supervisor_fail_count: u32,
50    /// Cooperative cancellation signal; checked before each tool call inside a turn.
51    pub cancel: CancellationToken,
52    /// Last verdict received from the supervisor, if any.
53    pub last_verdict: Option<SupervisorVerdict>,
54    /// Wall-clock time this session started.
55    pub started_at: Instant,
56    /// When set, the supervisor retry (after 429 backoff) should fire at or after this instant.
57    pub supervisor_retry_at: Option<TokioInstant>,
58}
59
60impl AutonomousSession {
61    /// Create a new session in [`AutonomousState::Running`].
62    #[must_use]
63    pub fn new(goal_id: impl Into<String>, goal_text: impl Into<String>, max_turns: u32) -> Self {
64        Self {
65            goal_id: goal_id.into(),
66            goal_text: goal_text.into(),
67            state: AutonomousState::Running,
68            turns_executed: 0,
69            max_turns,
70            stuck_count: 0,
71            supervisor_fail_count: 0,
72            cancel: CancellationToken::new(),
73            last_verdict: None,
74            started_at: Instant::now(),
75            supervisor_retry_at: None,
76        }
77    }
78
79    /// `true` when the session is in a terminal state.
80    #[must_use]
81    pub fn is_terminal(&self) -> bool {
82        matches!(
83            self.state,
84            AutonomousState::Achieved
85                | AutonomousState::Stuck
86                | AutonomousState::Aborted
87                | AutonomousState::Failed
88        )
89    }
90
91    /// Elapsed wall-clock time since session start.
92    #[must_use]
93    pub fn elapsed(&self) -> Duration {
94        self.started_at.elapsed()
95    }
96}
97
98/// Drives autonomous turns cooperatively from within `Agent::run`'s `tokio::select!` loop.
99///
100/// When `session` is `Some` and the session is [`AutonomousState::Running`], `should_tick()`
101/// returns `true` and `next_tick().await` completes after the configured inter-turn delay.
102/// The agent's main loop calls `run_autonomous_turn()` in a dedicated select branch —
103/// no `tokio::spawn` is used, preserving exclusive `&mut Agent` access.
104///
105/// The `Interval` is created lazily on the first `start_session` call so that `AutonomousDriver`
106/// can be constructed outside a Tokio runtime context (e.g., during unit-test builder setup).
107pub struct AutonomousDriver {
108    /// Active session, if any. At most one at a time (invariant A1).
109    pub session: Option<AutonomousSession>,
110    /// Configured delay between autonomous turns. Used to (re-)create `turn_interval`.
111    turn_delay: Duration,
112    /// Periodic interval that paces autonomous turns. `None` before the first session starts.
113    pub(crate) turn_interval: Option<Interval>,
114    /// Pending session start requested by a command handler.
115    ///
116    /// `handle_goal` runs inside `Box::pin(async move)` and cannot call `start_session`
117    /// directly (borrow conflict with `&mut Agent`). Instead it writes
118    /// `(goal_id, goal_text, max_turns)` here. The main agent loop calls
119    /// `flush_pending_start()` after each command handler returns to actually start the session.
120    pub pending_start_arc: Arc<Mutex<Option<(String, String, u32)>>>,
121}
122
123impl AutonomousDriver {
124    /// Create a driver with the given inter-turn delay.
125    ///
126    /// No Tokio runtime is required at construction time — the interval is created lazily.
127    ///
128    /// # Panics
129    ///
130    /// Panics if `turn_delay` is zero (would busy-loop the reactor).
131    #[must_use]
132    pub fn new(turn_delay: Duration) -> Self {
133        assert!(
134            !turn_delay.is_zero(),
135            "autonomous turn delay must be non-zero"
136        );
137        Self {
138            session: None,
139            turn_delay,
140            turn_interval: None,
141            pending_start_arc: Arc::new(Mutex::new(None)),
142        }
143    }
144
145    /// Drain any pending session start that was queued by a command handler.
146    ///
147    /// Returns the cancelled session's goal ID (if one was pre-empted) and the goal ID of
148    /// the newly started session (if a start was pending). Call this from the main agent loop
149    /// after each command handler returns and `&mut self` is exclusively available.
150    ///
151    /// Returns `None` when no pending start was queued.
152    #[must_use]
153    pub fn flush_pending_start(&mut self) -> Option<(Option<String>, String)> {
154        let pending = self.pending_start_arc.lock().take()?;
155        let (goal_id, goal_text, max_turns) = pending;
156        let new_id = goal_id.clone();
157        let cancelled = self.start_session(goal_id, goal_text, max_turns);
158        Some((cancelled, new_id))
159    }
160
161    /// Returns `true` when there is a running session that should produce the next tick.
162    #[must_use]
163    pub fn should_tick(&self) -> bool {
164        self.session
165            .as_ref()
166            .is_some_and(|s| s.state == AutonomousState::Running)
167    }
168
169    /// Await the next tick of the inter-turn interval.
170    ///
171    /// Requires a Tokio runtime. Creates the interval on first call. The caller is responsible
172    /// for checking [`should_tick`] first (typically via the `if` guard on the `select!` branch).
173    ///
174    /// [`should_tick`]: Self::should_tick
175    pub async fn next_tick(&mut self) {
176        let interval = self.turn_interval.get_or_insert_with(|| {
177            let mut iv = tokio::time::interval(self.turn_delay);
178            iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
179            iv
180        });
181        interval.tick().await;
182    }
183
184    /// Start a new autonomous session, cancelling any previously active session.
185    ///
186    /// Returns the goal ID of the cancelled session if one was aborted, so the caller can
187    /// notify the user.
188    pub fn start_session(
189        &mut self,
190        goal_id: impl Into<String>,
191        goal_text: impl Into<String>,
192        max_turns: u32,
193    ) -> Option<String> {
194        let prev = self.session.take();
195        let cancelled_id = prev.map(|s| {
196            s.cancel.cancel();
197            s.goal_id
198        });
199        self.session = Some(AutonomousSession::new(goal_id, goal_text, max_turns));
200        cancelled_id
201    }
202
203    /// Abort the current session (if any), setting state to [`AutonomousState::Aborted`].
204    ///
205    /// Returns the goal ID that was aborted, or `None` if no session was active.
206    pub fn abort(&mut self) -> Option<String> {
207        let s = self.session.as_mut()?;
208        s.cancel.cancel();
209        s.state = AutonomousState::Aborted;
210        let id = s.goal_id.clone();
211        self.session = None;
212        Some(id)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn initial_state_is_running() {
222        let s = AutonomousSession::new("id1", "do something", 10);
223        assert_eq!(s.state, AutonomousState::Running);
224        assert_eq!(s.turns_executed, 0);
225        assert!(!s.is_terminal());
226    }
227
228    #[test]
229    fn terminal_states() {
230        for terminal in [
231            AutonomousState::Achieved,
232            AutonomousState::Stuck,
233            AutonomousState::Aborted,
234            AutonomousState::Failed,
235        ] {
236            let mut s = AutonomousSession::new("id", "text", 5);
237            s.state = terminal;
238            assert!(s.is_terminal(), "{terminal} should be terminal");
239        }
240    }
241
242    #[test]
243    fn running_and_verifying_are_not_terminal() {
244        for non_terminal in [AutonomousState::Running, AutonomousState::Verifying] {
245            let mut s = AutonomousSession::new("id", "text", 5);
246            s.state = non_terminal;
247            assert!(!s.is_terminal(), "{non_terminal} should not be terminal");
248        }
249    }
250
251    #[test]
252    fn driver_should_tick_only_when_running() {
253        let mut driver = AutonomousDriver::new(Duration::from_millis(100));
254        assert!(!driver.should_tick(), "no session → no tick");
255
256        driver.start_session("id", "text", 5);
257        assert!(driver.should_tick(), "Running session → tick");
258
259        if let Some(ref mut s) = driver.session {
260            s.state = AutonomousState::Verifying;
261        }
262        assert!(!driver.should_tick(), "Verifying session → no tick");
263
264        if let Some(ref mut s) = driver.session {
265            s.state = AutonomousState::Achieved;
266        }
267        assert!(!driver.should_tick(), "Achieved session → no tick");
268    }
269
270    #[test]
271    fn start_session_cancels_previous() {
272        let mut driver = AutonomousDriver::new(Duration::from_millis(100));
273        driver.start_session("id1", "first", 10);
274        let prev_cancel = driver.session.as_ref().unwrap().cancel.clone();
275        assert!(!prev_cancel.is_cancelled());
276
277        let cancelled = driver.start_session("id2", "second", 5);
278        assert_eq!(cancelled.as_deref(), Some("id1"));
279        assert!(
280            prev_cancel.is_cancelled(),
281            "previous token must be cancelled"
282        );
283        assert_eq!(driver.session.as_ref().unwrap().goal_id, "id2");
284    }
285
286    #[test]
287    fn abort_clears_session_and_returns_id() {
288        let mut driver = AutonomousDriver::new(Duration::from_millis(100));
289        assert_eq!(driver.abort(), None);
290
291        driver.start_session("id3", "text", 5);
292        let id = driver.abort();
293        assert_eq!(id.as_deref(), Some("id3"));
294        assert!(driver.session.is_none());
295    }
296
297    #[test]
298    fn stuck_detection_logic() {
299        let mut s = AutonomousSession::new("id", "text", 10);
300        assert_eq!(s.stuck_count, 0);
301
302        // Simulate two stuck turns, not yet at limit.
303        s.stuck_count = 2;
304        assert!(!s.is_terminal());
305
306        // Third stuck turn → caller sets state to Stuck.
307        s.stuck_count = 3;
308        s.state = AutonomousState::Stuck;
309        assert!(s.is_terminal());
310    }
311
312    #[test]
313    fn supervisor_fail_count_resets_on_success() {
314        let mut s = AutonomousSession::new("id", "text", 10);
315        s.supervisor_fail_count = 2;
316        // Simulate a successful verification call resetting the counter.
317        s.supervisor_fail_count = 0;
318        assert_eq!(s.supervisor_fail_count, 0);
319    }
320
321    #[test]
322    fn display_covers_all_variants() {
323        assert_eq!(AutonomousState::Running.to_string(), "running");
324        assert_eq!(AutonomousState::Verifying.to_string(), "verifying");
325        assert_eq!(AutonomousState::Achieved.to_string(), "achieved");
326        assert_eq!(AutonomousState::Stuck.to_string(), "stuck");
327        assert_eq!(AutonomousState::Aborted.to_string(), "aborted");
328        assert_eq!(AutonomousState::Failed.to_string(), "failed");
329    }
330
331    #[test]
332    fn driver_new_panics_on_zero_delay() {
333        let result = std::panic::catch_unwind(|| {
334            let _ = AutonomousDriver::new(Duration::ZERO);
335        });
336        assert!(result.is_err(), "zero delay must panic");
337    }
338
339    #[test]
340    fn no_cancelled_id_on_first_start() {
341        let mut driver = AutonomousDriver::new(Duration::from_millis(100));
342        let prev = driver.start_session("id1", "text", 5);
343        assert!(prev.is_none(), "no prior session → no cancelled id");
344    }
345}