use crate::providers::ToolCall;
use std::collections::VecDeque;
pub const MAX_ITERATIONS_DEFAULT: u32 = 200;
pub const MAX_SUB_AGENT_ITERATIONS: usize = 20;
const CONSECUTIVE_REPEAT_THRESHOLD: usize = 5;
const DISPLAY_RECENT: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoopAction {
Ok,
InjectFeedback(String),
HardStop(String),
}
pub struct LoopDetector {
last_fingerprint: Option<String>,
consecutive_count: usize,
detection_count: u32,
recent: VecDeque<String>,
}
impl Default for LoopDetector {
fn default() -> Self {
Self::new()
}
}
impl LoopDetector {
pub fn new() -> Self {
Self {
last_fingerprint: None,
consecutive_count: 0,
detection_count: 0,
recent: VecDeque::new(),
}
}
pub fn record(&mut self, tool_calls: &[ToolCall]) -> LoopAction {
for tc in tool_calls {
let fp = fingerprint(&tc.function_name, &tc.arguments);
if self.last_fingerprint.as_ref() == Some(&fp) {
self.consecutive_count += 1;
} else {
self.last_fingerprint = Some(fp);
self.consecutive_count = 1;
}
self.recent.push_back(tc.function_name.clone());
if self.recent.len() > DISPLAY_RECENT {
self.recent.pop_front();
}
}
self.check()
}
pub fn clear_after_feedback(&mut self) {
self.detection_count += 1;
self.last_fingerprint = None;
self.consecutive_count = 0;
}
pub fn recent_names(&self) -> Vec<String> {
self.recent.iter().cloned().collect()
}
fn check(&self) -> LoopAction {
if self.consecutive_count < CONSECUTIVE_REPEAT_THRESHOLD {
return LoopAction::Ok;
}
let fp = self.last_fingerprint.as_deref().unwrap_or("unknown");
let tool_name = fp.split(':').next().unwrap_or(fp);
let detail = format!(
"'{tool_name}' called {n} times consecutively with identical arguments",
n = self.consecutive_count,
);
if self.detection_count == 0 {
LoopAction::InjectFeedback(detail)
} else {
LoopAction::HardStop(detail)
}
}
}
fn fingerprint(name: &str, args: &str) -> String {
let prefix = &args[..args.len().min(200)];
format!("{name}:{prefix}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoopContinuation {
Stop,
Continue50,
Continue200,
}
impl LoopContinuation {
pub fn extra_iterations(self) -> u32 {
match self {
Self::Stop => 0,
Self::Continue50 => 50,
Self::Continue200 => 200,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn call(name: &str, args: &str) -> ToolCall {
ToolCall {
id: "x".into(),
function_name: name.into(),
arguments: args.into(),
thought_signature: None,
}
}
#[test]
fn no_loop_on_unique_calls() {
let mut d = LoopDetector::new();
assert_eq!(
d.record(&[call("Edit", "{\"path\":\"a.rs\"}")]),
LoopAction::Ok
);
assert_eq!(
d.record(&[call("Edit", "{\"path\":\"b.rs\"}")]),
LoopAction::Ok
);
assert_eq!(
d.record(&[call("Bash", "{\"cmd\":\"ls\"}")]),
LoopAction::Ok
);
}
#[test]
fn detects_consecutive_identical_calls() {
let mut d = LoopDetector::new();
let tc = call("Edit", "{\"path\":\"src/main.rs\"}");
for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 1 {
assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
}
assert!(matches!(
d.record(std::slice::from_ref(&tc)),
LoopAction::InjectFeedback(_)
));
}
#[test]
fn different_tool_resets_consecutive_count() {
let mut d = LoopDetector::new();
let tc = call("Edit", "{\"path\":\"src/main.rs\"}");
for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 2 {
assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
}
assert_eq!(
d.record(&[call("Bash", "{\"cmd\":\"test\"}")]),
LoopAction::Ok
);
for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD - 1 {
assert_eq!(d.record(std::slice::from_ref(&tc)), LoopAction::Ok);
}
assert!(matches!(
d.record(std::slice::from_ref(&tc)),
LoopAction::InjectFeedback(_)
));
}
#[test]
fn read_edit_test_cycle_never_triggers() {
let mut d = LoopDetector::new();
let test_cmd = "{\"command\":\"cargo test\"}";
let read_args = "{\"path\":\"src/lib.rs\"}";
for cycle in 0..20 {
assert_eq!(
d.record(&[call("Read", read_args)]),
LoopAction::Ok,
"read should not trigger at cycle {cycle}"
);
let edit_args = format!("{{\"path\":\"src/lib.rs\",\"old\":\"v{cycle}\"}}");
assert_eq!(
d.record(&[call("Edit", &edit_args)]),
LoopAction::Ok,
"edit should not trigger at cycle {cycle}"
);
assert_eq!(
d.record(&[call("Bash", test_cmd)]),
LoopAction::Ok,
"test should not trigger at cycle {cycle}"
);
}
}
#[test]
fn feedback_then_hard_stop() {
let mut d = LoopDetector::new();
let tc = call("Read", "{\"path\":\"stuck.rs\"}");
for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD {
d.record(std::slice::from_ref(&tc));
}
d.detection_count = 1; d.clear_after_feedback();
for _ in 0..CONSECUTIVE_REPEAT_THRESHOLD {
d.record(std::slice::from_ref(&tc));
}
assert!(matches!(d.check(), LoopAction::HardStop(_)));
}
#[test]
fn parallel_calls_same_tool_not_a_loop() {
let mut d = LoopDetector::new();
let batch: Vec<ToolCall> = (0..10)
.map(|i| call("Read", &format!("{{\"path\":\"file{i}.rs\"}}")))
.collect();
assert_eq!(d.record(&batch), LoopAction::Ok);
}
#[test]
fn same_tool_different_args_not_consecutive() {
let mut d = LoopDetector::new();
for i in 0..20 {
let args = format!("{{\"command\":\"ls -variant-{i}\"}}");
assert_eq!(
d.record(&[call("Bash", &args)]),
LoopAction::Ok,
"different args should not trigger at call {i}"
);
}
}
#[test]
fn recent_names_tracks_last_five() {
let mut d = LoopDetector::new();
for i in 0..8 {
let name = format!("Tool{i}");
d.record(&[call(&name, "{}")]);
}
let names = d.recent_names();
assert_eq!(names.len(), 5);
assert_eq!(names[0], "Tool3");
assert_eq!(names[4], "Tool7");
}
}