1use crate::providers::ToolCall;
32use std::collections::VecDeque;
33
34pub const MAX_ITERATIONS_DEFAULT: u32 = 200;
36
37pub const MAX_SUB_AGENT_ITERATIONS: usize = 20;
39
40const CONSECUTIVE_REPEAT_THRESHOLD: usize = 5;
48
49const DISPLAY_RECENT: usize = 5;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum LoopAction {
57 Ok,
59 InjectFeedback(String),
62 HardStop(String),
64}
65
66pub struct LoopDetector {
73 last_fingerprint: Option<String>,
75 consecutive_count: usize,
77 detection_count: u32,
79 recent: VecDeque<String>,
81}
82
83impl Default for LoopDetector {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl LoopDetector {
90 pub fn new() -> Self {
92 Self {
93 last_fingerprint: None,
94 consecutive_count: 0,
95 detection_count: 0,
96 recent: VecDeque::new(),
97 }
98 }
99
100 pub fn record(&mut self, tool_calls: &[ToolCall]) -> LoopAction {
104 for tc in tool_calls {
105 let fp = fingerprint(&tc.function_name, &tc.arguments);
106
107 if self.last_fingerprint.as_ref() == Some(&fp) {
109 self.consecutive_count += 1;
110 } else {
111 self.last_fingerprint = Some(fp);
112 self.consecutive_count = 1;
113 }
114
115 self.recent.push_back(tc.function_name.clone());
117 if self.recent.len() > DISPLAY_RECENT {
118 self.recent.pop_front();
119 }
120 }
121
122 self.check()
123 }
124
125 pub fn clear_after_feedback(&mut self) {
129 self.detection_count += 1;
130 self.last_fingerprint = None;
131 self.consecutive_count = 0;
132 }
133
134 pub fn recent_names(&self) -> Vec<String> {
136 self.recent.iter().cloned().collect()
137 }
138
139 fn check(&self) -> LoopAction {
140 if self.consecutive_count < CONSECUTIVE_REPEAT_THRESHOLD {
141 return LoopAction::Ok;
142 }
143
144 let fp = self.last_fingerprint.as_deref().unwrap_or("unknown");
145 let tool_name = fp.split(':').next().unwrap_or(fp);
146 let detail = format!(
147 "'{tool_name}' called {n} times consecutively with identical arguments",
148 n = self.consecutive_count,
149 );
150
151 if self.detection_count == 0 {
152 LoopAction::InjectFeedback(detail)
154 } else {
155 LoopAction::HardStop(detail)
157 }
158 }
159}
160
161fn fingerprint(name: &str, args: &str) -> String {
163 let prefix = &args[..args.len().min(200)];
164 format!("{name}:{prefix}")
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum LoopContinuation {
173 Stop,
175 Continue50,
177 Continue200,
179}
180
181impl LoopContinuation {
182 pub fn extra_iterations(self) -> u32 {
184 match self {
185 Self::Stop => 0,
186 Self::Continue50 => 50,
187 Self::Continue200 => 200,
188 }
189 }
190}
191
192#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn call(name: &str, args: &str) -> ToolCall {
199 ToolCall {
200 id: "x".into(),
201 function_name: name.into(),
202 arguments: args.into(),
203 thought_signature: None,
204 }
205 }
206
207 #[test]
208 fn no_loop_on_unique_calls() {
209 let mut d = LoopDetector::new();
210 assert_eq!(
211 d.record(&[call("Edit", "{\"path\":\"a.rs\"}")]),
212 LoopAction::Ok
213 );
214 assert_eq!(
215 d.record(&[call("Edit", "{\"path\":\"b.rs\"}")]),
216 LoopAction::Ok
217 );
218 assert_eq!(
219 d.record(&[call("Bash", "{\"cmd\":\"ls\"}")]),
220 LoopAction::Ok
221 );
222 }
223
224 #[test]
225 fn detects_consecutive_identical_calls() {
226 let mut d = LoopDetector::new();
227 let tc = call("Edit", "{\"path\":\"src/main.rs\"}");
228 for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 1 {
229 assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
230 }
231 assert!(matches!(
233 d.record(std::slice::from_ref(&tc)),
234 LoopAction::InjectFeedback(_)
235 ));
236 }
237
238 #[test]
239 fn different_tool_resets_consecutive_count() {
240 let mut d = LoopDetector::new();
241 let tc = call("Edit", "{\"path\":\"src/main.rs\"}");
242 for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 2 {
244 assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
245 }
246 assert_eq!(
248 d.record(&[call("Bash", "{\"cmd\":\"test\"}")]),
249 LoopAction::Ok
250 );
251 for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 1 {
253 assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
254 }
255 assert!(matches!(
256 d.record(std::slice::from_ref(&tc)),
257 LoopAction::InjectFeedback(_)
258 ));
259 }
260
261 #[test]
262 fn read_edit_test_cycle_never_triggers() {
263 let mut d = LoopDetector::new();
265 let test_cmd = "{\"command\":\"cargo test\"}";
266 let read_args = "{\"path\":\"src/lib.rs\"}";
267
268 for cycle in 0..20 {
269 assert_eq!(
270 d.record(&[call("Read", read_args)]),
271 LoopAction::Ok,
272 "read should not trigger at cycle {cycle}"
273 );
274 let edit_args = format!("{{\"path\":\"src/lib.rs\",\"old\":\"v{cycle}\"}}");
275 assert_eq!(
276 d.record(&[call("Edit", &edit_args)]),
277 LoopAction::Ok,
278 "edit should not trigger at cycle {cycle}"
279 );
280 assert_eq!(
281 d.record(&[call("Bash", test_cmd)]),
282 LoopAction::Ok,
283 "test should not trigger at cycle {cycle}"
284 );
285 }
286 }
287
288 #[test]
289 fn feedback_then_hard_stop() {
290 let mut d = LoopDetector::new();
291 let tc = call("Read", "{\"path\":\"stuck.rs\"}");
292
293 for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD {
295 d.record(std::slice::from_ref(&tc));
296 }
297 d.detection_count = 1; d.clear_after_feedback();
301
302 for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD {
304 d.record(std::slice::from_ref(&tc));
305 }
306 assert!(matches!(d.check(), LoopAction::HardStop(_)));
307 }
308
309 #[test]
310 fn parallel_calls_same_tool_not_a_loop() {
311 let mut d = LoopDetector::new();
313 let batch: Vec<ToolCall> = (0..10)
314 .map(|i| call("Read", &format!("{{\"path\":\"file{i}.rs\"}}")))
315 .collect();
316 assert_eq!(d.record(&batch), LoopAction::Ok);
317 }
318
319 #[test]
320 fn same_tool_different_args_not_consecutive() {
321 let mut d = LoopDetector::new();
323 for i in 0..20 {
324 let args = format!("{{\"command\":\"ls -variant-{i}\"}}");
325 assert_eq!(
326 d.record(&[call("Bash", &args)]),
327 LoopAction::Ok,
328 "different args should not trigger at call {i}"
329 );
330 }
331 }
332
333 #[test]
334 fn recent_names_tracks_last_five() {
335 let mut d = LoopDetector::new();
336 for i in 0..8 {
337 let name = format!("Tool{i}");
338 d.record(&[call(&name, "{}")]);
339 }
340 let names = d.recent_names();
341 assert_eq!(names.len(), 5);
342 assert_eq!(names[0], "Tool3");
343 assert_eq!(names[4], "Tool7");
344 }
345}