# tool-loop-guard
[](https://crates.io/crates/tool-loop-guard)
[](https://docs.rs/tool-loop-guard)
[](https://crates.io/crates/tool-loop-guard)
**Detect when an LLM agent gets stuck calling the same tool with the same args.** One dep (`serde_json`). One struct.
```rust
use serde_json::json;
use tool_loop_guard::{LoopGuard, LoopDetectedError};
let mut guard = LoopGuard::with_capacity(8, 3);
for step in agent_loop() {
match guard.record(&step.tool_name, &step.tool_args) {
Ok(()) => run_tool(&step),
Err(LoopDetectedError { tool_name, tool_args, count, window, .. }) => {
eprintln!("agent looping on {tool_name}({tool_args})");
eprintln!(" occurred {count} times in the last {window} calls");
abort_or_replan();
break;
}
}
}
```
## Why
The most common agent failure isn't a runtime error. It's a `search_web("anthropic prompt cache")` that returns nothing useful, an agent that decides to "try the same search one more time," and the loop running until your wallet notices.
`tool-loop-guard` is a sliding-window detector that returns an error when the same `(tool_name, args)` tuple shows up more than `threshold` times within the last `window` calls. That's the entire library.
A circuit breaker won't catch this (no errors). A deadline won't catch it (each call is fast). A budget will *eventually* catch it (after blowing through cost). The guard catches it on the third repeat.
## Install
```toml
[dependencies]
tool-loop-guard = "0.1"
serde_json = "1"
```
## API
```rust
use serde_json::json;
use tool_loop_guard::LoopGuard;
let mut guard = LoopGuard::with_capacity(
8, // window: how many recent calls to consider
3, // threshold: how many matches in that window are allowed
);
guard.record("search", &json!({"q": "x"}))?; // Err on the offending call
let _ = guard.would_raise("search", &json!({"q": "x"})); // peek without mutating
guard.reset(); // clear the buffer
let _len = guard.len(); // calls currently buffered
let _keys = guard.recent_keys(); // borrowed slice, oldest first
# Ok::<(), tool_loop_guard::LoopDetectedError>(())
```
`record` returns `Err(LoopDetectedError { tool_name, tool_args, count, window, threshold })` so you can route on the structured fields rather than parsing a message.
The default key is `(tool_name, canonical_json(args))`. Argument object keys are sorted recursively so `{"a":1,"b":2}` and `{"b":2,"a":1}` collide. To ignore noisy fields (`request_id`, `timestamp`) before comparison, pass your own key function:
```rust
use serde_json::{json, Value};
use tool_loop_guard::{LoopGuard, default_key_fn};
let mut clone = map.clone();
clone.remove("request_id");
clone.remove("ts");
Value::Object(clone)
} else {
args.clone()
};
default_key_fn(tool_name, &cleaned)
};
let mut guard = LoopGuard::with_key_fn(10, 3, stable_key);
```
## Construction errors
`LoopGuard::with_capacity` panics on invalid input because misconfiguring a guard is a programming bug, not a runtime condition. Use `LoopGuard::try_new` if you want a `Result<Self, ConfigError>` instead:
```rust
use tool_loop_guard::{ConfigError, LoopGuard};
assert!(matches!(
LoopGuard::try_new(3, 4),
Err(ConfigError::ThresholdExceedsWindow { .. })
));
```
Validation rules: `window >= 2`, `threshold >= 2`, `threshold <= window`.
## Companion libraries
- [`llm-circuit-breaker`](https://github.com/MukundaKatta/llm-circuit-breaker) — opens on *provider* errors, not on agent confusion.
- [`llm-retry`](https://github.com/MukundaKatta/llm-retry) — exponential backoff retry. Pair with this guard: retry handles transient errors, guard catches "looping fast."
- [`token-budget-pool`](https://github.com/MukundaKatta/token-budget-pool) — shared token/USD budget. Catches looping eventually; this guard catches it sooner.
## License
MIT OR Apache-2.0