Skip to main content

batuta/agent/
phase.rs

1//! Agent loop phase tracking.
2//!
3//! Defines the discrete phases of the perceive-reason-act loop.
4//! Modeled as a finite state machine — transitions are deterministic
5//! and bounded (arXiv:2512.10350 — contractive dynamics).
6
7use serde::{Deserialize, Serialize};
8
9/// Phase of the agent loop FSM.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub enum LoopPhase {
12    /// Recalling memories and context for the current query.
13    Perceive,
14    /// Generating a completion via the LLM driver.
15    Reason,
16    /// Executing a tool call.
17    Act {
18        /// Name of the tool being executed.
19        tool_name: String,
20    },
21    /// Agent has produced a final response.
22    Done,
23    /// Agent encountered an unrecoverable error.
24    Error {
25        /// Human-readable error description.
26        message: String,
27    },
28}
29
30impl std::fmt::Display for LoopPhase {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::Perceive => write!(f, "perceive"),
34            Self::Reason => write!(f, "reason"),
35            Self::Act { tool_name } => write!(f, "act:{tool_name}"),
36            Self::Done => write!(f, "done"),
37            Self::Error { message } => write!(f, "error:{message}"),
38        }
39    }
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45
46    #[test]
47    fn test_phase_display() {
48        assert_eq!(LoopPhase::Perceive.to_string(), "perceive");
49        assert_eq!(LoopPhase::Reason.to_string(), "reason");
50        assert_eq!(LoopPhase::Act { tool_name: "rag".to_string() }.to_string(), "act:rag");
51        assert_eq!(LoopPhase::Done.to_string(), "done");
52        assert_eq!(LoopPhase::Error { message: "budget".to_string() }.to_string(), "error:budget");
53    }
54
55    #[test]
56    fn test_phase_equality() {
57        assert_eq!(LoopPhase::Perceive, LoopPhase::Perceive);
58        assert_ne!(LoopPhase::Perceive, LoopPhase::Reason);
59        assert_ne!(
60            LoopPhase::Act { tool_name: "a".into() },
61            LoopPhase::Act { tool_name: "b".into() }
62        );
63    }
64
65    #[test]
66    fn test_phase_serialization_roundtrip() {
67        let phases = vec![
68            LoopPhase::Perceive,
69            LoopPhase::Reason,
70            LoopPhase::Act { tool_name: "memory".into() },
71            LoopPhase::Done,
72            LoopPhase::Error { message: "out of budget".into() },
73        ];
74        for phase in &phases {
75            let json = serde_json::to_string(phase).expect("serialize failed");
76            let back: LoopPhase = serde_json::from_str(&json).expect("deserialize failed");
77            assert_eq!(*phase, back);
78        }
79    }
80}