use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
const THRESHOLD: usize = 3;
const HARD_THRESHOLD: usize = 6;
const WINDOW: usize = 32;
const STATE_CHANGING: &[&str] =
&["edit_file", "write_file", "create_file", "search_replace"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry {
key: String,
output_hash: u64,
success: bool,
}
#[derive(Default, Debug)]
pub struct LoopGuardState {
recent: Vec<Entry>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum LoopGuardDecision {
Allow,
Block(String),
}
impl LoopGuardState {
pub fn clear(&mut self) {
self.recent.clear();
}
pub fn check(&self, name: &str, arguments: &str) -> LoopGuardDecision {
let key = make_key(name, arguments);
let matches: Vec<&Entry> = self.recent.iter().filter(|e| e.key == key).collect();
if matches.len() >= HARD_THRESHOLD - 1 {
return LoopGuardDecision::Block(format!(
"[Loop guard] `{}` with identical arguments has run {} time(s) \
already in this turn. Output varies slightly each run but you \
keep issuing the same query — that's a loop. Try a different \
approach: change the command (different filter / different \
file), edit code to make progress, or stop and ask the user. \
Continuing to repeat will keep getting blocked.",
name,
matches.len()
));
}
if matches.len() < THRESHOLD - 1 {
return LoopGuardDecision::Allow;
}
let first = matches[0];
let all_same =
matches.iter().all(|e| e.output_hash == first.output_hash && e.success == first.success);
if !all_same {
return LoopGuardDecision::Allow;
}
LoopGuardDecision::Block(format!(
"[Loop guard] This exact tool call (`{}` with identical arguments) has \
run {} time(s) already in this turn with the same output and no \
intervening state change. Try a different approach: change the \
command, read different files, edit code to make progress, or stop \
and ask the user. Repeating the same query will not change the \
result.",
name,
matches.len()
))
}
pub fn record(&mut self, name: &str, arguments: &str, output: &str, success: bool) {
let key = make_key(name, arguments);
let is_state_changing = STATE_CHANGING.contains(&name);
let key_is_new = !self.recent.iter().any(|e| e.key == key);
if is_state_changing && success && key_is_new {
self.recent.clear();
}
self.recent.push(Entry {
key,
output_hash: hash_str(output),
success,
});
if self.recent.len() > WINDOW {
self.recent.remove(0);
}
}
}
fn make_key(name: &str, arguments: &str) -> String {
let normalised = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(v) => serde_json::to_string(&v).unwrap_or_else(|_| arguments.to_string()),
Err(_) => arguments.to_string(),
};
let mut s = String::with_capacity(name.len() + 1 + normalised.len());
s.push_str(name);
s.push('\0');
s.push_str(&normalised);
s
}
fn hash_str(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn allow(d: &LoopGuardDecision) -> bool {
matches!(d, LoopGuardDecision::Allow)
}
fn block(d: &LoopGuardDecision) -> bool {
matches!(d, LoopGuardDecision::Block(_))
}
#[test]
fn third_identical_call_is_blocked() {
let mut g = LoopGuardState::default();
let args = r#"{"command":"cargo check"}"#;
let out = "error[E0599]: no method named `foo`";
assert!(allow(&g.check("bash", args)));
g.record("bash", args, out, false);
assert!(allow(&g.check("bash", args)));
g.record("bash", args, out, false);
let d = g.check("bash", args);
assert!(block(&d), "expected Block, got {:?}", d);
}
#[test]
fn rotating_arguments_do_not_trigger() {
let mut g = LoopGuardState::default();
let cmds = [
r#"{"command":"cargo check 2>&1 | head -50"}"#,
r#"{"command":"cargo check 2>&1 | head -100"}"#,
r#"{"command":"cargo check 2>&1 | head -200"}"#,
r#"{"command":"cargo check --message-format=short"}"#,
];
for c in cmds {
assert!(allow(&g.check("bash", c)));
g.record("bash", c, "compile error", false);
}
assert!(allow(&g.check("bash", r#"{"command":"cargo build"}"#)));
}
#[test]
fn ssh_batch_with_distinct_commands_does_not_trigger() {
let mut g = LoopGuardState::default();
let cmds = [
r#"{"command":"sshpass -p ... ssh user@host echo ok"}"#,
r#"{"command":"sshpass -p ... ssh user@host ls /var/log"}"#,
r#"{"command":"sshpass -p ... ssh user@host cat /etc/hostname"}"#,
r#"{"command":"sshpass -p ... ssh user@host uname -a"}"#,
r#"{"command":"sshpass -p ... ssh user@host df -h"}"#,
];
for c in cmds {
let d = g.check("bash", c);
assert!(allow(&d), "expected Allow for {:?}, got {:?}", c, d);
g.record("bash", c, "ok", true);
}
}
#[test]
fn changing_output_does_not_trigger() {
let mut g = LoopGuardState::default();
let args = r#"{"command":"curl -s http://service/health"}"#;
g.record("bash", args, "starting up", false);
g.record("bash", args, "starting up", false);
let mut g2 = LoopGuardState::default();
g2.record("bash", args, "starting up", false);
g2.record("bash", args, "ready", true);
assert!(allow(&g2.check("bash", args)));
}
#[test]
fn intervening_state_change_resets_window() {
let mut g = LoopGuardState::default();
let bash_args = r#"{"command":"cargo test"}"#;
let edit_args = r#"{"file_path":"src/lib.rs","old":"a","new":"b"}"#;
let test_out = "test failed: assertion 1 != 2";
g.record("bash", bash_args, test_out, false);
g.record("bash", bash_args, test_out, false);
g.record("edit_file", edit_args, "ok", true);
assert!(allow(&g.check("bash", bash_args)));
g.record("bash", bash_args, test_out, false);
assert!(allow(&g.check("bash", bash_args)));
g.record("bash", bash_args, test_out, false);
assert!(block(&g.check("bash", bash_args)));
}
#[test]
fn state_change_with_repeated_key_does_not_reset() {
let mut g = LoopGuardState::default();
let edit_args = r#"{"file_path":"a.rs","old":"x","new":"y"}"#;
g.record("edit_file", edit_args, "ok", true);
g.record("edit_file", edit_args, "ok", true);
assert!(block(&g.check("edit_file", edit_args)));
}
#[test]
fn clear_resets_state() {
let mut g = LoopGuardState::default();
let args = r#"{"command":"ls"}"#;
g.record("bash", args, "out", true);
g.record("bash", args, "out", true);
assert!(block(&g.check("bash", args)));
g.clear();
assert!(allow(&g.check("bash", args)));
}
#[test]
fn hard_cap_blocks_loops_even_when_output_drifts() {
let mut g = LoopGuardState::default();
let args = r#"{"command":"cargo check 2>&1 | tail -20"}"#;
for i in 0..5 {
assert!(
allow(&g.check("bash", args)),
"call {} should still allow",
i + 1
);
g.record("bash", args, &format!("warning #{}: drift", i), false);
}
let d = g.check("bash", args);
assert!(block(&d), "expected hard-cap Block on 6th call, got {:?}", d);
if let LoopGuardDecision::Block(msg) = d {
assert!(
msg.contains("Output varies slightly"),
"hard-cap message expected, got: {}",
msg
);
}
}
#[test]
fn polling_below_hard_cap_still_allowed() {
let mut g = LoopGuardState::default();
let args = r#"{"command":"curl http://service/health"}"#;
for i in 0..5 {
let d = g.check("bash", args);
assert!(
allow(&d),
"polling attempt {} should be allowed, got {:?}",
i + 1,
d
);
g.record("bash", args, &format!("attempt {}: starting", i), false);
}
assert!(block(&g.check("bash", args)));
}
#[test]
fn json_whitespace_variants_collapse_across_batches() {
let mut g = LoopGuardState::default();
let v1 = r#"{"pattern":"**/*.rs"}"#;
let v2 = r#"{"pattern": "**/*.rs"}"#; let v3 = r#"{ "pattern":"**/*.rs" }"#; let out = "203 files found";
g.record("glob", v1, out, true);
g.record("glob", v2, out, true);
let d = g.check("glob", v3);
assert!(block(&d), "expected Block on JSON-variant repeat, got {:?}", d);
}
#[test]
fn name_collision_is_not_a_match() {
let mut g = LoopGuardState::default();
g.record("foo", "bar", "out", true);
g.record("foo", "bar", "out", true);
assert!(allow(&g.check("foob", "ar")));
}
}