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
  • Coverage
  • 100%
    30 out of 30 items documented1 out of 16 items with examples
  • Size
  • Source code size: 43.13 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 981.8 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 8s Average build duration of successful builds.
  • all releases: 8s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • MukundaKatta/tool-loop-guard-rs
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • MukundaKatta

tool-loop-guard

Crates.io Documentation License

Detect when an LLM agent gets stuck calling the same tool with the same args. One dep (serde_json). One struct.

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

[dependencies]
tool-loop-guard = "0.1"
serde_json = "1"

API

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:

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:

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 — opens on provider errors, not on agent confusion.
  • llm-retry — exponential backoff retry. Pair with this guard: retry handles transient errors, guard catches "looping fast."
  • token-budget-pool — shared token/USD budget. Catches looping eventually; this guard catches it sooner.

License

MIT OR Apache-2.0