tool-loop-guard 0.1.0

Detect when an LLM agent gets stuck calling the same tool with the same args. Sliding-window guard that raises on repeated (tool_name, args) pairs. Custom key function support, zero heavy deps.
Documentation
# tool-loop-guard

[![Crates.io](https://img.shields.io/crates/v/tool-loop-guard.svg)](https://crates.io/crates/tool-loop-guard)
[![Documentation](https://docs.rs/tool-loop-guard/badge.svg)](https://docs.rs/tool-loop-guard)
[![License](https://img.shields.io/crates/l/tool-loop-guard.svg)](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 stable_key = |tool_name: &str, args: &Value| {
    let cleaned = if let Some(map) = args.as_object() {
        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