Skip to main content

roboticus_agent/
loop.rs

1use std::collections::VecDeque;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ReactState {
5    Thinking,
6    Acting,
7    Observing,
8    Persisting,
9    Idle,
10    Done,
11}
12
13#[derive(Debug, Clone)]
14pub enum ReactAction {
15    Think,
16    Act { tool_name: String, params: String },
17    Observe,
18    Persist,
19    NoOp,
20    Finish,
21}
22
23const IDLE_THRESHOLD: usize = 3;
24const LOOP_DETECTION_WINDOW: usize = 3;
25
26pub struct AgentLoop {
27    pub state: ReactState,
28    pub turn_count: usize,
29    pub max_turns: usize,
30    idle_count: usize,
31    recent_calls: VecDeque<(String, String)>,
32}
33
34impl AgentLoop {
35    pub fn new(max_turns: usize) -> Self {
36        Self {
37            state: ReactState::Idle,
38            turn_count: 0,
39            max_turns,
40            idle_count: 0,
41            recent_calls: VecDeque::with_capacity(LOOP_DETECTION_WINDOW + 1),
42        }
43    }
44
45    pub fn transition(&mut self, action: ReactAction) -> ReactState {
46        match action {
47            ReactAction::Think => {
48                // Only count logical turns (Think phase starts a new turn).
49                // Previously every transition incremented, inflating count 2-3x.
50                self.turn_count += 1;
51                if self.turn_count > self.max_turns {
52                    self.state = ReactState::Done;
53                    return self.state;
54                }
55                self.idle_count = 0;
56                self.state = ReactState::Thinking;
57            }
58            ReactAction::Act { tool_name, params } => {
59                self.idle_count = 0;
60                // Evaluate against prior calls only; this avoids counting the
61                // current call inside the detection window.
62                if self.is_looping(&tool_name, &params) {
63                    tracing::warn!(tool = %tool_name, "agent loop detected, forcing Done");
64                    self.state = ReactState::Done;
65                } else {
66                    self.state = ReactState::Acting;
67                }
68                self.recent_calls
69                    .push_back((tool_name.clone(), params.clone()));
70                if self.recent_calls.len() > LOOP_DETECTION_WINDOW {
71                    self.recent_calls.pop_front();
72                }
73            }
74            ReactAction::Observe => {
75                self.idle_count = 0;
76                self.state = ReactState::Observing;
77            }
78            ReactAction::Persist => {
79                self.idle_count = 0;
80                self.state = ReactState::Persisting;
81            }
82            ReactAction::NoOp => {
83                self.idle_count += 1;
84                if self.idle_count >= IDLE_THRESHOLD {
85                    self.state = ReactState::Idle;
86                }
87            }
88            ReactAction::Finish => {
89                self.state = ReactState::Done;
90            }
91        }
92
93        self.state
94    }
95
96    pub fn is_idle(&self) -> bool {
97        self.idle_count >= IDLE_THRESHOLD
98    }
99
100    /// Returns true if the same tool+params combination has appeared
101    /// `LOOP_DETECTION_WINDOW` consecutive times.
102    pub fn is_looping(&self, tool_name: &str, params: &str) -> bool {
103        if self.recent_calls.len() < LOOP_DETECTION_WINDOW {
104            return false;
105        }
106
107        self.recent_calls
108            .iter()
109            .all(|(t, p)| t == tool_name && p == params)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn state_transitions() {
119        let mut agent = AgentLoop::new(100);
120        assert_eq!(agent.state, ReactState::Idle);
121
122        let s = agent.transition(ReactAction::Think);
123        assert_eq!(s, ReactState::Thinking);
124
125        let s = agent.transition(ReactAction::Act {
126            tool_name: "echo".into(),
127            params: "{}".into(),
128        });
129        assert_eq!(s, ReactState::Acting);
130
131        let s = agent.transition(ReactAction::Observe);
132        assert_eq!(s, ReactState::Observing);
133
134        let s = agent.transition(ReactAction::Persist);
135        assert_eq!(s, ReactState::Persisting);
136
137        let s = agent.transition(ReactAction::Finish);
138        assert_eq!(s, ReactState::Done);
139    }
140
141    #[test]
142    fn idle_detection() {
143        let mut agent = AgentLoop::new(100);
144
145        assert!(!agent.is_idle());
146        agent.transition(ReactAction::NoOp);
147        assert!(!agent.is_idle());
148        agent.transition(ReactAction::NoOp);
149        assert!(!agent.is_idle());
150        agent.transition(ReactAction::NoOp);
151        assert!(agent.is_idle());
152        assert_eq!(agent.state, ReactState::Idle);
153
154        agent.transition(ReactAction::Think);
155        assert!(!agent.is_idle());
156    }
157
158    #[test]
159    fn loop_detection() {
160        let mut agent = AgentLoop::new(100);
161
162        for _ in 0..3 {
163            let s = agent.transition(ReactAction::Act {
164                tool_name: "echo".into(),
165                params: r#"{"msg":"hi"}"#.into(),
166            });
167            assert_eq!(s, ReactState::Acting);
168        }
169
170        assert!(agent.is_looping("echo", r#"{"msg":"hi"}"#));
171        assert!(!agent.is_looping("echo", r#"{"msg":"bye"}"#));
172        assert!(!agent.is_looping("other", r#"{"msg":"hi"}"#));
173
174        let s = agent.transition(ReactAction::Act {
175            tool_name: "echo".into(),
176            params: r#"{"msg":"hi"}"#.into(),
177        });
178        assert_eq!(s, ReactState::Done);
179
180        agent.transition(ReactAction::Act {
181            tool_name: "read".into(),
182            params: "{}".into(),
183        });
184        assert!(!agent.is_looping("echo", r#"{"msg":"hi"}"#));
185    }
186
187    #[test]
188    fn max_turns_forces_done() {
189        let mut agent = AgentLoop::new(2);
190
191        agent.transition(ReactAction::Think);
192        // Non-Think transitions don't inflate the turn count
193        agent.transition(ReactAction::Act {
194            tool_name: "echo".into(),
195            params: "{}".into(),
196        });
197        agent.transition(ReactAction::Observe);
198        assert_eq!(agent.turn_count, 1);
199
200        agent.transition(ReactAction::Think);
201        assert_eq!(agent.turn_count, 2);
202
203        // Third Think exceeds max_turns=2
204        let s = agent.transition(ReactAction::Think);
205        assert_eq!(s, ReactState::Done);
206    }
207}