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 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 if self.is_looping(&tool_name, ¶ms) {
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 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 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 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 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 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 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 let s = agent.transition(ReactAction::Think);
270 assert_eq!(s, ReactState::Done);
271 }
272}