use std::collections::VecDeque;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone)]
pub(crate) struct LoopDetectorConfig {
pub enabled: bool,
pub window_size: usize,
pub max_repeats: usize,
}
impl Default for LoopDetectorConfig {
fn default() -> Self {
Self {
enabled: true,
window_size: 20,
max_repeats: 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LoopDetectionResult {
Ok,
Warning(String),
Block(String),
Break(String),
}
#[derive(Debug, Clone)]
struct ToolCallRecord {
name: String,
args_hash: u64,
result_hash: u64,
}
fn hash_value(value: &serde_json::Value) -> u64 {
let mut hasher = DefaultHasher::new();
let canonical = serde_json::to_string(&canonicalise(value)).unwrap_or_default();
canonical.hash(&mut hasher);
hasher.finish()
}
fn canonicalise(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
let new_map: serde_json::Map<String, serde_json::Value> = sorted
.into_iter()
.map(|(k, v)| (k.clone(), canonicalise(v)))
.collect();
serde_json::Value::Object(new_map)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(canonicalise).collect())
}
other => other.clone(),
}
}
fn hash_str(s: &str) -> u64 {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
pub(crate) struct LoopDetector {
config: LoopDetectorConfig,
window: VecDeque<ToolCallRecord>,
}
impl LoopDetector {
pub fn new(config: LoopDetectorConfig) -> Self {
Self {
window: VecDeque::with_capacity(config.window_size),
config,
}
}
pub fn record(
&mut self,
name: &str,
args: &serde_json::Value,
result: &str,
) -> LoopDetectionResult {
if !self.config.enabled {
return LoopDetectionResult::Ok;
}
let record = ToolCallRecord {
name: name.to_string(),
args_hash: hash_value(args),
result_hash: hash_str(result),
};
if self.window.len() >= self.config.window_size {
self.window.pop_front();
}
self.window.push_back(record);
if let Some(result) = self.detect_exact_repeat() {
return result;
}
if let Some(result) = self.detect_ping_pong() {
return result;
}
if let Some(result) = self.detect_no_progress() {
return result;
}
LoopDetectionResult::Ok
}
fn detect_exact_repeat(&self) -> Option<LoopDetectionResult> {
let max = self.config.max_repeats;
if self.window.len() < max {
return None;
}
let last = self.window.back()?;
let consecutive = self
.window
.iter()
.rev()
.take_while(|r| r.name == last.name && r.args_hash == last.args_hash)
.count();
if consecutive >= max + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tool '{}' called {} times consecutively with identical arguments",
last.name, consecutive
)))
} else if consecutive > max {
Some(LoopDetectionResult::Block(format!(
"Blocked: tool '{}' called {} times consecutively with identical arguments",
last.name, consecutive
)))
} else if consecutive >= max {
Some(LoopDetectionResult::Warning(format!(
"Warning: tool '{}' has been called {} times consecutively with identical arguments. \
Try a different approach.",
last.name, consecutive
)))
} else {
None
}
}
fn detect_ping_pong(&self) -> Option<LoopDetectionResult> {
const MIN_CYCLES: usize = 4;
let needed = MIN_CYCLES * 2;
if self.window.len() < needed {
return None;
}
let tail: Vec<&ToolCallRecord> = self.window.iter().rev().take(needed).collect();
let a_name = &tail[0].name;
let b_name = &tail[1].name;
if a_name == b_name {
return None;
}
let is_ping_pong = tail.iter().enumerate().all(|(i, r)| {
if i % 2 == 0 {
&r.name == a_name
} else {
&r.name == b_name
}
});
if !is_ping_pong {
return None;
}
let mut cycles = MIN_CYCLES;
let extended: Vec<&ToolCallRecord> = self.window.iter().rev().collect();
for extra_pair in extended.chunks(2).skip(MIN_CYCLES) {
if extra_pair.len() == 2
&& &extra_pair[0].name == a_name
&& &extra_pair[1].name == b_name
{
cycles += 1;
} else {
break;
}
}
if cycles >= MIN_CYCLES + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tools '{}' and '{}' have been alternating for {} cycles",
a_name, b_name, cycles
)))
} else if cycles > MIN_CYCLES {
Some(LoopDetectionResult::Block(format!(
"Blocked: tools '{}' and '{}' have been alternating for {} cycles",
a_name, b_name, cycles
)))
} else {
Some(LoopDetectionResult::Warning(format!(
"Warning: tools '{}' and '{}' appear to be alternating ({} cycles). \
Consider a different strategy.",
a_name, b_name, cycles
)))
}
}
fn detect_no_progress(&self) -> Option<LoopDetectionResult> {
const MIN_CALLS: usize = 5;
if self.window.len() < MIN_CALLS {
return None;
}
let last = self.window.back()?;
let same_tool_same_result: Vec<&ToolCallRecord> = self
.window
.iter()
.rev()
.take_while(|r| r.name == last.name && r.result_hash == last.result_hash)
.collect();
let count = same_tool_same_result.len();
if count < MIN_CALLS {
return None;
}
let unique_args: std::collections::HashSet<u64> =
same_tool_same_result.iter().map(|r| r.args_hash).collect();
if unique_args.len() < 2 {
return None;
}
if count >= MIN_CALLS + 2 {
Some(LoopDetectionResult::Break(format!(
"Circuit breaker: tool '{}' called {} times with different arguments but identical results — no progress",
last.name, count
)))
} else if count > MIN_CALLS {
Some(LoopDetectionResult::Block(format!(
"Blocked: tool '{}' called {} times with different arguments but identical results",
last.name, count
)))
} else {
Some(LoopDetectionResult::Warning(format!(
"Warning: tool '{}' called {} times with different arguments but identical results. \
The current approach may not be making progress.",
last.name, count
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn default_config() -> LoopDetectorConfig {
LoopDetectorConfig::default()
}
fn config_with_repeats(max_repeats: usize) -> LoopDetectorConfig {
LoopDetectorConfig {
enabled: true,
window_size: 20,
max_repeats,
}
}
#[test]
fn exact_repeat_warning_at_threshold() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"path": "/tmp/foo"});
assert_eq!(
det.record("file_read", &args, "contents"),
LoopDetectionResult::Ok
);
assert_eq!(
det.record("file_read", &args, "contents"),
LoopDetectionResult::Ok
);
match det.record("file_read", &args, "contents") {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("file_read"));
assert!(msg.contains("3 times"));
}
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn exact_repeat_block_at_threshold_plus_one() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"cmd": "ls"});
for _ in 0..3 {
det.record("shell", &args, "output");
}
match det.record("shell", &args, "output") {
LoopDetectionResult::Block(msg) => {
assert!(msg.contains("shell"));
assert!(msg.contains("4 times"));
}
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn exact_repeat_break_at_threshold_plus_two() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"q": "test"});
for _ in 0..4 {
det.record("search", &args, "no results");
}
match det.record("search", &args, "no results") {
LoopDetectionResult::Break(msg) => {
assert!(msg.contains("Circuit breaker"));
assert!(msg.contains("search"));
}
other => panic!("expected Break, got {other:?}"),
}
}
#[test]
fn exact_repeat_resets_on_different_call() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"x": 1});
det.record("tool_a", &args, "r1");
det.record("tool_a", &args, "r1");
det.record("tool_b", &json!({}), "r2");
det.record("tool_a", &args, "r1");
det.record("tool_a", &args, "r1");
assert_eq!(
det.record("tool_a", &json!({"x": 999}), "r1"),
LoopDetectionResult::Ok
);
}
#[test]
fn ping_pong_warning_at_four_cycles() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
for i in 0..8 {
let name = if i % 2 == 0 { "read" } else { "write" };
let result = det.record(name, &args, &format!("r{i}"));
if i < 7 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("read"));
assert!(msg.contains("write"));
assert!(msg.contains("4 cycles"));
}
other => panic!("expected Warning at cycle 4, got {other:?}"),
}
}
}
}
#[test]
fn ping_pong_escalates_with_more_cycles() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
for i in 0..10 {
let name = if i % 2 == 0 { "fetch" } else { "parse" };
det.record(name, &args, &format!("r{i}"));
}
let r = det.record("fetch", &args, "r10");
match r {
LoopDetectionResult::Block(msg) => {
assert!(msg.contains("fetch"));
assert!(msg.contains("parse"));
assert!(msg.contains("5 cycles"));
}
other => panic!("expected Block at 5 cycles, got {other:?}"),
}
}
#[test]
fn ping_pong_not_triggered_for_same_tool() {
let mut det = LoopDetector::new(default_config());
let args = json!({});
for _ in 0..10 {
det.record("read", &args, "data");
}
let r = det.record("read", &args, "data");
if let LoopDetectionResult::Break(msg) | LoopDetectionResult::Block(msg) = r {
assert!(
!msg.contains("alternating"),
"should be exact-repeat, not ping-pong"
);
}
}
#[test]
fn no_progress_warning_at_five_different_args_same_result() {
let mut det = LoopDetector::new(default_config());
for i in 0..5 {
let args = json!({"query": format!("attempt_{i}")});
let result = det.record("search", &args, "no results found");
if i < 4 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("search"));
assert!(msg.contains("identical results"));
}
other => panic!("expected Warning, got {other:?}"),
}
}
}
}
#[test]
fn no_progress_escalates_to_block_and_break() {
let mut det = LoopDetector::new(default_config());
for i in 0..6 {
let args = json!({"q": format!("v{i}")});
det.record("web_fetch", &args, "timeout");
}
let r7 = det.record("web_fetch", &json!({"q": "v6"}), "timeout");
match r7 {
LoopDetectionResult::Break(msg) => {
assert!(msg.contains("web_fetch"));
assert!(msg.contains("7 times"));
assert!(msg.contains("no progress"));
}
other => panic!("expected Break at 7 calls, got {other:?}"),
}
}
#[test]
fn no_progress_not_triggered_when_results_differ() {
let mut det = LoopDetector::new(default_config());
for i in 0..8 {
let args = json!({"q": format!("v{i}")});
let result = det.record("search", &args, &format!("result_{i}"));
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
}
}
#[test]
fn no_progress_not_triggered_when_all_args_identical() {
let mut det = LoopDetector::new(config_with_repeats(6));
let args = json!({"q": "same"});
for _ in 0..5 {
det.record("search", &args, "no results");
}
let r = det.record("search", &args, "no results");
match r {
LoopDetectionResult::Warning(msg) => {
assert!(
msg.contains("identical arguments"),
"should be exact-repeat Warning, got: {msg}"
);
}
other => panic!("expected exact-repeat Warning, got {other:?}"),
}
}
#[test]
fn disabled_detector_always_returns_ok() {
let config = LoopDetectorConfig {
enabled: false,
..Default::default()
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
for _ in 0..20 {
assert_eq!(det.record("tool", &args, "same"), LoopDetectionResult::Ok);
}
}
#[test]
fn window_size_limits_memory() {
let config = LoopDetectorConfig {
enabled: true,
window_size: 5,
max_repeats: 3,
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
for i in 0..5 {
det.record(&format!("tool_{i}"), &args, "result");
}
assert_eq!(det.window.len(), 5);
det.record("tool_5", &args, "result");
assert_eq!(det.window.len(), 5);
assert_eq!(det.window.front().unwrap().name, "tool_1");
}
#[test]
fn ping_pong_detects_alternation_with_varying_args() {
let mut det = LoopDetector::new(default_config());
for i in 0..8 {
let name = if i % 2 == 0 { "read" } else { "write" };
let args = json!({"attempt": i});
let result = det.record(name, &args, &format!("r{i}"));
if i < 7 {
assert_eq!(result, LoopDetectionResult::Ok, "iteration {i}");
} else {
match result {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("read"));
assert!(msg.contains("write"));
assert!(msg.contains("4 cycles"));
}
other => panic!("expected Warning at cycle 4, got {other:?}"),
}
}
}
}
#[test]
fn window_eviction_prevents_stale_pattern_detection() {
let config = LoopDetectorConfig {
enabled: true,
window_size: 6,
max_repeats: 3,
};
let mut det = LoopDetector::new(config);
let args = json!({"x": 1});
det.record("tool_a", &args, "r");
det.record("tool_a", &args, "r");
for i in 0..5 {
det.record(&format!("other_{i}"), &json!({}), "ok");
}
let r = det.record("tool_a", &args, "r");
assert_eq!(
r,
LoopDetectionResult::Ok,
"stale entries should be evicted"
);
}
#[test]
fn hash_value_is_key_order_independent() {
let a = json!({"alpha": 1, "beta": 2});
let b = json!({"beta": 2, "alpha": 1});
assert_eq!(
hash_value(&a),
hash_value(&b),
"hash_value must produce identical hashes regardless of JSON key order"
);
}
#[test]
fn hash_value_nested_key_order_independent() {
let a = json!({"outer": {"x": 1, "y": 2}, "z": [1, 2]});
let b = json!({"z": [1, 2], "outer": {"y": 2, "x": 1}});
assert_eq!(
hash_value(&a),
hash_value(&b),
"nested objects must also be key-order independent"
);
}
#[test]
fn exact_repeat_takes_priority_over_no_progress() {
let mut det = LoopDetector::new(config_with_repeats(3));
let args = json!({"q": "same"});
det.record("s", &args, "r");
det.record("s", &args, "r");
let r = det.record("s", &args, "r");
match r {
LoopDetectionResult::Warning(msg) => {
assert!(msg.contains("identical arguments"));
}
other => panic!("expected exact-repeat Warning, got {other:?}"),
}
}
}