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    last_failed_call: Option<(String, String)>,
33    last_error_message: Option<String>,
34    suppressed_count: u8,
35}
36
37impl AgentLoop {
38    pub fn new(max_turns: usize) -> Self {
39        Self {
40            state: ReactState::Idle,
41            turn_count: 0,
42            max_turns,
43            idle_count: 0,
44            recent_calls: VecDeque::with_capacity(LOOP_DETECTION_WINDOW + 1),
45            last_failed_call: None,
46            last_error_message: None,
47            suppressed_count: 0,
48        }
49    }
50
51    pub fn transition(&mut self, action: ReactAction) -> ReactState {
52        match action {
53            ReactAction::Think => {
54                // Only count logical turns (Think phase starts a new turn).
55                // Previously every transition incremented, inflating count 2-3x.
56                self.turn_count += 1;
57                if self.turn_count > self.max_turns {
58                    self.state = ReactState::Done;
59                    return self.state;
60                }
61                self.idle_count = 0;
62                self.state = ReactState::Thinking;
63            }
64            ReactAction::Act { tool_name, params } => {
65                self.idle_count = 0;
66                // Evaluate against prior calls only; this avoids counting the
67                // current call inside the detection window.
68                if self.is_looping(&tool_name, &params) {
69                    tracing::warn!(tool = %tool_name, "agent loop detected, forcing Done");
70                    self.state = ReactState::Done;
71                } else {
72                    self.state = ReactState::Acting;
73                }
74                self.recent_calls
75                    .push_back((tool_name.clone(), params.clone()));
76                if self.recent_calls.len() > LOOP_DETECTION_WINDOW {
77                    self.recent_calls.pop_front();
78                }
79            }
80            ReactAction::Observe => {
81                self.idle_count = 0;
82                self.state = ReactState::Observing;
83            }
84            ReactAction::Persist => {
85                self.idle_count = 0;
86                self.state = ReactState::Persisting;
87            }
88            ReactAction::NoOp => {
89                self.idle_count += 1;
90                if self.idle_count >= IDLE_THRESHOLD {
91                    self.state = ReactState::Idle;
92                }
93            }
94            ReactAction::Finish => {
95                self.state = ReactState::Done;
96            }
97        }
98
99        self.state
100    }
101
102    pub fn is_idle(&self) -> bool {
103        self.idle_count >= IDLE_THRESHOLD
104    }
105
106    /// Returns true if the same tool+params combination has appeared
107    /// `LOOP_DETECTION_WINDOW` consecutive times.
108    pub fn is_looping(&self, tool_name: &str, params: &str) -> bool {
109        if self.recent_calls.len() < LOOP_DETECTION_WINDOW {
110            return false;
111        }
112
113        self.recent_calls
114            .iter()
115            .all(|(t, p)| t == tool_name && p == params)
116    }
117
118    /// Record a tool call failure so subsequent identical calls can be suppressed.
119    pub fn record_tool_error(&mut self, tool: &str, params: &str, error: &str) {
120        self.last_failed_call = Some((tool.to_string(), params.to_string()));
121        self.last_error_message = Some(error.to_string());
122        self.suppressed_count = 0;
123    }
124
125    /// Returns true if the given tool+params matches the most recent failure.
126    pub fn should_suppress_duplicate(&self, tool: &str, params: &str) -> bool {
127        self.last_failed_call
128            .as_ref()
129            .map(|(t, p)| t == tool && p == params)
130            .unwrap_or(false)
131    }
132
133    pub fn increment_suppressed(&mut self) {
134        self.suppressed_count += 1;
135    }
136
137    /// After 2 suppressions the model is stuck — caller should abort the batch.
138    pub fn should_abort_error_loop(&self) -> bool {
139        self.suppressed_count >= 2
140    }
141
142    pub fn last_error(&self) -> Option<&str> {
143        self.last_error_message.as_deref()
144    }
145
146    /// Clear error dedup state after a successful tool call.
147    pub fn clear_error_state(&mut self) {
148        self.last_failed_call = None;
149        self.last_error_message = None;
150        self.suppressed_count = 0;
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn state_transitions() {
160        let mut agent = AgentLoop::new(100);
161        assert_eq!(agent.state, ReactState::Idle);
162
163        let s = agent.transition(ReactAction::Think);
164        assert_eq!(s, ReactState::Thinking);
165
166        let s = agent.transition(ReactAction::Act {
167            tool_name: "echo".into(),
168            params: "{}".into(),
169        });
170        assert_eq!(s, ReactState::Acting);
171
172        let s = agent.transition(ReactAction::Observe);
173        assert_eq!(s, ReactState::Observing);
174
175        let s = agent.transition(ReactAction::Persist);
176        assert_eq!(s, ReactState::Persisting);
177
178        let s = agent.transition(ReactAction::Finish);
179        assert_eq!(s, ReactState::Done);
180    }
181
182    #[test]
183    fn idle_detection() {
184        let mut agent = AgentLoop::new(100);
185
186        assert!(!agent.is_idle());
187        agent.transition(ReactAction::NoOp);
188        assert!(!agent.is_idle());
189        agent.transition(ReactAction::NoOp);
190        assert!(!agent.is_idle());
191        agent.transition(ReactAction::NoOp);
192        assert!(agent.is_idle());
193        assert_eq!(agent.state, ReactState::Idle);
194
195        agent.transition(ReactAction::Think);
196        assert!(!agent.is_idle());
197    }
198
199    #[test]
200    fn loop_detection() {
201        let mut agent = AgentLoop::new(100);
202
203        for _ in 0..3 {
204            let s = agent.transition(ReactAction::Act {
205                tool_name: "echo".into(),
206                params: r#"{"msg":"hi"}"#.into(),
207            });
208            assert_eq!(s, ReactState::Acting);
209        }
210
211        assert!(agent.is_looping("echo", r#"{"msg":"hi"}"#));
212        assert!(!agent.is_looping("echo", r#"{"msg":"bye"}"#));
213        assert!(!agent.is_looping("other", r#"{"msg":"hi"}"#));
214
215        let s = agent.transition(ReactAction::Act {
216            tool_name: "echo".into(),
217            params: r#"{"msg":"hi"}"#.into(),
218        });
219        assert_eq!(s, ReactState::Done);
220
221        agent.transition(ReactAction::Act {
222            tool_name: "read".into(),
223            params: "{}".into(),
224        });
225        assert!(!agent.is_looping("echo", r#"{"msg":"hi"}"#));
226    }
227
228    #[test]
229    fn agent_loop_detects_repeated_failed_tool_call() {
230        let mut loop_state = AgentLoop::new(10);
231        loop_state.record_tool_error(
232            "compose-subagent",
233            r#"{"name":"foo"}"#,
234            "no skills available",
235        );
236        assert!(loop_state.should_suppress_duplicate("compose-subagent", r#"{"name":"foo"}"#));
237        assert!(!loop_state.should_suppress_duplicate("compose-subagent", r#"{"name":"bar"}"#));
238        assert!(!loop_state.should_suppress_duplicate("compose-skill", r#"{"name":"foo"}"#));
239    }
240
241    #[test]
242    fn agent_loop_abort_after_repeated_suppression() {
243        let mut loop_state = AgentLoop::new(10);
244        loop_state.record_tool_error("compose-subagent", r#"{"name":"foo"}"#, "no skills");
245        assert!(loop_state.should_suppress_duplicate("compose-subagent", r#"{"name":"foo"}"#));
246        loop_state.increment_suppressed();
247        assert!(loop_state.should_suppress_duplicate("compose-subagent", r#"{"name":"foo"}"#));
248        loop_state.increment_suppressed();
249        assert!(loop_state.should_abort_error_loop());
250    }
251
252    #[test]
253    fn max_turns_forces_done() {
254        let mut agent = AgentLoop::new(2);
255
256        agent.transition(ReactAction::Think);
257        // Non-Think transitions don't inflate the turn count
258        agent.transition(ReactAction::Act {
259            tool_name: "echo".into(),
260            params: "{}".into(),
261        });
262        agent.transition(ReactAction::Observe);
263        assert_eq!(agent.turn_count, 1);
264
265        agent.transition(ReactAction::Think);
266        assert_eq!(agent.turn_count, 2);
267
268        // Third Think exceeds max_turns=2
269        let s = agent.transition(ReactAction::Think);
270        assert_eq!(s, ReactState::Done);
271    }
272}