use std::collections::{HashMap, VecDeque};
const DEFAULT_WINDOW: usize = 25;
const DEFAULT_WARNING_THRESHOLD: usize = 5;
const DEFAULT_CRITICAL_THRESHOLD: usize = 10;
fn builtin_overrides() -> HashMap<String, (usize, usize)> {
HashMap::new()
}
pub fn hash_tool_call(tool_name: &str, params: &serde_json::Value) -> String {
let stable = stable_stringify(params);
let hash = simple_hash(&stable);
format!("{tool_name}:{hash}")
}
fn stable_stringify(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => format!("\"{}\"", escape_json_string(s)),
serde_json::Value::Array(arr) => {
format!("[{}]", arr.iter().map(stable_stringify).collect::<Vec<_>>().join(","))
}
serde_json::Value::Object(obj) => {
let keys: Vec<_> = obj.keys().collect();
let sorted_keys = sort_keys(&keys);
let entries: Vec<String> = sorted_keys
.iter()
.map(|k| {
let v = obj.get(*k).unwrap_or(&serde_json::Value::Null);
format!("\"{}\":{}", escape_json_string(k), stable_stringify(v))
})
.collect();
format!("{{{}}}", entries.join(","))
}
}
}
fn escape_json_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn sort_keys<'a>(keys: &[&'a String]) -> Vec<&'a String> {
let mut sorted = keys.to_vec();
sorted.sort();
sorted
}
fn simple_hash(s: &str) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in s.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[derive(Debug, Clone)]
pub enum LoopCheckResult {
Ok,
Warning {
tool_name: String,
count: usize,
message: String,
},
Critical {
tool_name: String,
count: usize,
message: String,
},
}
impl LoopCheckResult {
pub fn is_critical(&self) -> bool {
matches!(self, LoopCheckResult::Critical { .. })
}
pub fn warning_message(&self) -> Option<String> {
match self {
LoopCheckResult::Warning { message, .. } => Some(message.clone()),
_ => None,
}
}
pub fn to_result(&self) -> anyhow::Result<Option<String>> {
match self {
LoopCheckResult::Ok => Ok(None),
LoopCheckResult::Warning { message, .. } => Ok(Some(message.clone())),
LoopCheckResult::Critical { message, .. } => Err(anyhow::anyhow!("{}", message)),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolCallRecord {
pub tool_name: String,
pub args_hash: String,
pub result_hash: Option<String>,
}
const MAX_SAME_ERROR_STREAK: usize = 5;
const MAX_ANY_FAILURE_STREAK: usize = 8;
fn normalize_error(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut run = String::new();
let flush = |run: &mut String, out: &mut String| {
if run.len() >= 3 {
out.push('N');
} else {
out.push_str(run);
}
run.clear();
};
for c in s.chars() {
if c.is_ascii_digit() {
run.push(c);
} else {
flush(&mut run, &mut out);
out.push(c);
}
}
flush(&mut run, &mut out);
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[derive(Debug, Clone)]
pub struct LoopDetector {
window: usize,
warning_threshold: usize,
critical_threshold: usize,
overrides: HashMap<String, (usize, usize)>, history: VecDeque<ToolCallRecord>,
error_streak: HashMap<String, (String, usize)>,
any_failure_streak: HashMap<String, usize>,
}
fn is_result_failure(result: &serde_json::Value) -> bool {
if let Some(code) = result.get("exit_code").and_then(|v| v.as_i64()) {
if code != 0 {
return true;
}
}
if let Some(err) = result.get("error").and_then(|v| v.as_str()) {
if !err.is_empty() {
return true;
}
}
if result.get("success").and_then(|v| v.as_bool()) == Some(false) {
return true;
}
if result.get("ok").and_then(|v| v.as_bool()) == Some(false) {
return true;
}
false
}
impl LoopDetector {
pub fn new(window: usize, default_threshold: usize) -> Self {
Self::with_dual_thresholds(
window,
default_threshold,
default_threshold.saturating_add(1),
)
}
pub fn with_dual_thresholds(
window: usize,
warning_threshold: usize,
critical_threshold: usize,
) -> Self {
Self {
window,
warning_threshold,
critical_threshold,
overrides: builtin_overrides(),
history: VecDeque::new(),
error_streak: HashMap::new(),
any_failure_streak: HashMap::new(),
}
}
pub fn with_overrides(
window: usize,
warning_threshold: usize,
critical_threshold: usize,
extra_overrides: HashMap<String, (usize, usize)>,
) -> Self {
let mut overrides = builtin_overrides();
overrides.extend(extra_overrides);
Self {
window,
warning_threshold,
critical_threshold,
overrides,
history: VecDeque::new(),
error_streak: HashMap::new(),
any_failure_streak: HashMap::new(),
}
}
pub fn from_single_threshold(window: usize, threshold: usize) -> Self {
let critical = threshold.saturating_add(10).max(threshold + 1);
Self::with_dual_thresholds(window, threshold, critical)
}
fn thresholds_for(&self, tool_name: &str) -> (usize, usize) {
self.overrides
.get(tool_name)
.copied()
.unwrap_or((self.warning_threshold, self.critical_threshold))
}
pub fn check_with_params(&mut self, tool_name: &str, params: &serde_json::Value) -> LoopCheckResult {
let args_hash = hash_tool_call(tool_name, params);
self.history.push_back(ToolCallRecord {
tool_name: tool_name.to_owned(),
args_hash: args_hash.clone(),
result_hash: None,
});
if self.history.len() > self.window {
self.history.pop_front();
}
let same_args_records: Vec<_> = self
.history
.iter()
.filter(|r| r.args_hash == args_hash)
.collect();
let result_hashes: Vec<_> = same_args_records
.iter()
.filter_map(|r| r.result_hash.as_ref())
.collect();
let has_progress = result_hashes.len() >= 2 && {
let first = result_hashes.first();
result_hashes.iter().any(|h| h != first.unwrap())
};
let count = if has_progress {
same_args_records
.iter()
.filter(|r| r.result_hash.is_none())
.count()
} else {
same_args_records.len()
};
if let Some((err_hash, streak)) = self.error_streak.get(tool_name) {
if *streak >= MAX_SAME_ERROR_STREAK {
return LoopCheckResult::Critical {
tool_name: tool_name.to_owned(),
count: *streak,
message: format!(
"CRITICAL: tool `{tool_name}` returned the same (normalized) error {streak} times in a row \
(error hash {err_hash}). Different arguments, same failure — the approach \
is wrong. Stop and report the problem to the user.",
),
};
}
}
if let Some(streak) = self.any_failure_streak.get(tool_name) {
if *streak >= MAX_ANY_FAILURE_STREAK {
return LoopCheckResult::Critical {
tool_name: tool_name.to_owned(),
count: *streak,
message: format!(
"CRITICAL: tool `{tool_name}` failed {streak} times consecutively with no success. \
The approach is stuck. Stop and report the problem to the user.",
),
};
}
}
let (warning_threshold, critical_threshold) = self.thresholds_for(tool_name);
if count >= critical_threshold {
return LoopCheckResult::Critical {
tool_name: tool_name.to_owned(),
count,
message: format!(
"CRITICAL: tool `{tool_name}` called {count} times in the last {} calls with identical arguments and results. \
No progress detected. Session execution blocked to prevent runaway loops.",
self.history.len(),
),
};
}
if count >= warning_threshold {
return LoopCheckResult::Warning {
tool_name: tool_name.to_owned(),
count,
message: format!(
"WARNING: You have called `{tool_name}` {count} times in the last {} \
calls with identical arguments and results. If this is not making progress, \
stop retrying and report the task as failed.",
self.history.len(),
),
};
}
LoopCheckResult::Ok
}
pub fn check(&mut self, tool_name: &str) -> LoopCheckResult {
self.check_with_params(tool_name, &serde_json::Value::Object(serde_json::Map::new()))
}
pub fn record_result(&mut self, result: &serde_json::Value) {
let tool_name = self.history.back().map(|r| r.tool_name.clone());
if let Some(last) = self.history.back_mut() {
let result_str = stable_stringify(result);
last.result_hash = Some(format!("{}", simple_hash(&result_str)));
}
let Some(name) = tool_name else { return };
let failure = is_result_failure(result);
if failure {
let raw_sig = result
.get("error")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| result.get("stderr").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_else(|| stable_stringify(result));
let err_sig = normalize_error(&raw_sig);
let err_hash = format!("{}", simple_hash(&err_sig));
self.error_streak
.entry(name.clone())
.and_modify(|(h, c)| {
if *h == err_hash {
*c += 1;
} else {
*h = err_hash.clone();
*c = 1;
}
})
.or_insert((err_hash, 1));
*self.any_failure_streak.entry(name.clone()).or_insert(0) += 1;
} else {
self.error_streak.remove(&name);
self.any_failure_streak.remove(&name);
}
}
pub fn reset(&mut self) {
self.history.clear();
self.error_streak.clear();
self.any_failure_streak.clear();
}
}
impl Default for LoopDetector {
fn default() -> Self {
Self::with_dual_thresholds(
DEFAULT_WINDOW,
DEFAULT_WARNING_THRESHOLD,
DEFAULT_CRITICAL_THRESHOLD,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn is_ok(r: &LoopCheckResult) -> bool {
matches!(r, LoopCheckResult::Ok)
}
fn is_warning(r: &LoopCheckResult) -> bool {
matches!(r, LoopCheckResult::Warning { .. })
}
fn is_critical(r: &LoopCheckResult) -> bool {
matches!(r, LoopCheckResult::Critical { .. })
}
#[test]
fn no_loop_for_varied_tools() {
let mut d = LoopDetector::default();
assert!(is_ok(&d.check("read")));
assert!(is_ok(&d.check("write")));
assert!(is_ok(&d.check("exec")));
assert!(is_ok(&d.check("read")));
}
#[test]
fn detects_warning_before_critical() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
assert!(is_ok(&d.check("read"))); assert!(is_ok(&d.check("read"))); assert!(is_warning(&d.check("read"))); assert!(is_warning(&d.check("read"))); assert!(is_critical(&d.check("read"))); }
#[test]
fn single_threshold_constructor_sets_critical_above() {
let mut d = LoopDetector::new(10, 3);
assert!(is_ok(&d.check("exec"))); assert!(is_ok(&d.check("exec"))); assert!(is_warning(&d.check("exec"))); assert!(is_critical(&d.check("exec"))); }
#[test]
fn default_has_warning_at_5_critical_at_10() {
let mut d = LoopDetector::default();
for i in 0..4 {
assert!(is_ok(&d.check("exec")), "call {} should be ok", i + 1);
}
assert!(is_warning(&d.check("exec")), "5th call should be warning");
for i in 5..9 {
assert!(
is_warning(&d.check("exec")),
"call {} should be warning",
i + 1
);
}
assert!(
is_critical(&d.check("exec")),
"10th call should be critical"
);
}
#[test]
fn custom_override_takes_priority() {
let mut overrides = HashMap::new();
overrides.insert("my_tool".into(), (2, 3)); let mut d = LoopDetector::with_overrides(10, 10, 20, overrides);
assert!(is_ok(&d.check("my_tool")));
assert!(is_warning(&d.check("my_tool"))); assert!(is_critical(&d.check("my_tool"))); }
#[test]
fn window_slides_correctly() {
let mut d = LoopDetector::with_dual_thresholds(4, 3, 5);
assert!(is_ok(&d.check("a")));
assert!(is_ok(&d.check("b")));
assert!(is_ok(&d.check("a")));
assert!(is_ok(&d.check("b")));
assert!(is_ok(&d.check("a")));
assert!(is_warning(&d.check("a")));
}
#[test]
fn reset_clears_loop_state() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
assert!(is_ok(&d.check("read")));
assert!(is_ok(&d.check("read")));
assert!(is_warning(&d.check("read")));
d.reset();
assert!(is_ok(&d.check("read")), "after reset, should be ok");
}
#[test]
fn warning_message_contains_info() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
for _ in 0..3 {
d.check("exec");
}
let result = d.check("exec");
if let LoopCheckResult::Warning {
tool_name,
count,
message,
} = result
{
assert_eq!(tool_name, "exec");
assert_eq!(count, 4);
assert!(message.contains("exec"));
} else {
panic!("expected Warning, got {:?}", result);
}
}
#[test]
fn different_params_count_as_different_calls() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
let params_a = serde_json::json!({"command": "ls"});
let params_b = serde_json::json!({"command": "pwd"});
assert!(is_ok(&d.check_with_params("exec", ¶ms_a)));
assert!(is_ok(&d.check_with_params("exec", ¶ms_b)));
assert!(is_ok(&d.check_with_params("exec", ¶ms_a)));
assert!(is_ok(&d.check_with_params("exec", ¶ms_b)));
}
#[test]
fn same_params_trigger_warning() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
let params = serde_json::json!({"command": "ls -la"});
assert!(is_ok(&d.check_with_params("exec", ¶ms))); assert!(is_ok(&d.check_with_params("exec", ¶ms))); assert!(is_warning(&d.check_with_params("exec", ¶ms))); assert!(is_warning(&d.check_with_params("exec", ¶ms))); assert!(is_critical(&d.check_with_params("exec", ¶ms))); }
#[test]
fn hash_tool_call_includes_params() {
let params_a = serde_json::json!({"command": "ls"});
let params_b = serde_json::json!({"command": "pwd"});
let hash_a = hash_tool_call("exec", ¶ms_a);
let hash_b = hash_tool_call("exec", ¶ms_b);
assert_ne!(hash_a, hash_b);
let hash_a2 = hash_tool_call("exec", ¶ms_a);
assert_eq!(hash_a, hash_a2);
}
#[test]
fn stable_stringify_sorts_keys() {
let obj1 = serde_json::json!({"b": 2, "a": 1});
let obj2 = serde_json::json!({"a": 1, "b": 2});
let hash1 = simple_hash(&stable_stringify(&obj1));
let hash2 = simple_hash(&stable_stringify(&obj2));
assert_eq!(hash1, hash2);
}
#[test]
fn different_results_means_progress() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
let params = serde_json::json!({"command": "ls"});
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "file1.txt"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "file1.txt file2.txt"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "file1.txt file2.txt file3.txt"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "file1.txt file2.txt file3.txt file4.txt"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
}
#[test]
fn same_results_means_no_progress() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
let params = serde_json::json!({"command": "ls"});
assert!(is_ok(&d.check_with_params("exec", ¶ms))); d.record_result(&serde_json::json!({"stdout": "same_output"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms))); d.record_result(&serde_json::json!({"stdout": "same_output"}));
assert!(is_warning(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "same_output"}));
assert!(is_warning(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "same_output"}));
assert!(is_critical(&d.check_with_params("exec", ¶ms)));
}
#[test]
fn mixed_results_progres_detection() {
let mut d = LoopDetector::with_dual_thresholds(10, 4, 6);
let params = serde_json::json!({"command": "ls"});
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "a"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "a"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "b"}));
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": "c"}));
for i in 0..20 {
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
d.record_result(&serde_json::json!({"stdout": format!("result_{}", i)}));
}
}
#[test]
fn no_result_hash_yet_counts_as_potential_loop() {
let mut d = LoopDetector::with_dual_thresholds(10, 3, 5);
let params = serde_json::json!({"command": "ls"});
assert!(is_ok(&d.check_with_params("exec", ¶ms)));
assert!(is_ok(&d.check_with_params("exec", ¶ms))); assert!(is_warning(&d.check_with_params("exec", ¶ms))); assert!(is_warning(&d.check_with_params("exec", ¶ms))); assert!(is_critical(&d.check_with_params("exec", ¶ms))); }
}