Skip to main content

tool_loop_guard/
lib.rs

1//! # tool-loop-guard
2//!
3//! Detect when an LLM agent gets stuck calling the same tool with the same
4//! args.
5//!
6//! When an LLM agent gets confused, the most common failure mode is calling
7//! the same tool with the same args over and over. A circuit breaker won't
8//! catch this (no errors), a deadline won't catch it (single calls are fast),
9//! but the wall clock keeps ticking and the bill keeps growing.
10//!
11//! [`LoopGuard`] watches a sliding window of recent tool calls and returns
12//! [`LoopDetectedError`] the moment a `(tool_name, args)` pair repeats more
13//! than `threshold` times within the last `window` calls.
14//!
15//! ```rust
16//! use serde_json::json;
17//! use tool_loop_guard::{LoopGuard, LoopDetectedError};
18//!
19//! let mut guard = LoopGuard::with_capacity(8, 3);
20//!
21//! let args = json!({"q": "anthropic prompt cache"});
22//! guard.record("search", &args).unwrap();
23//! guard.record("search", &args).unwrap();
24//! match guard.record("search", &args) {
25//!     Ok(()) => unreachable!(),
26//!     Err(LoopDetectedError { tool_name, count, window, .. }) => {
27//!         assert_eq!(tool_name, "search");
28//!         assert_eq!(count, 3);
29//!         assert_eq!(window, 8);
30//!     }
31//! }
32//! ```
33//!
34//! The default key is `(tool_name, canonical_json(args))`. For more exotic
35//! matching (for example, ignore a `request_id` field in args) pass a custom
36//! `key_fn` via [`LoopGuard::with_key_fn`].
37//!
38//! ## Why not just `serde_json::to_string`?
39//!
40//! `serde_json::to_string` does not sort object keys, so
41//! `{"a":1,"b":2}` and `{"b":2,"a":1}` would hash differently. This crate
42//! re-walks the [`serde_json::Value`] and emits a stable canonical form
43//! (sorted object keys, compact separators) so semantically identical
44//! argument blobs collide as expected. That matches Python's
45//! `json.dumps(args, sort_keys=True, separators=(",", ":"))`.
46
47#![deny(missing_docs)]
48
49use serde_json::Value;
50use std::collections::VecDeque;
51use std::error::Error;
52use std::fmt::{self, Write as _};
53
54/// A canonical (tool_name, args) key used by the default key function.
55///
56/// First element is the tool name. Second element is the canonical JSON
57/// representation of the args (sorted object keys, compact separators).
58pub type Key = (String, String);
59
60/// A user-supplied key function.
61///
62/// Given a tool name and its args, return a [`Key`] that two semantically
63/// identical calls should produce. The default implementation is
64/// [`default_key_fn`]. Override it to ignore noisy fields (`request_id`,
65/// `timestamp`, etc.) before comparison.
66pub type KeyFn = Box<dyn Fn(&str, &Value) -> Key + Send + Sync + 'static>;
67
68/// Compute the canonical default key for a `(tool_name, args)` pair.
69///
70/// `args` is serialized to JSON with sorted object keys and compact
71/// separators so that `{"a":1,"b":2}` and `{"b":2,"a":1}` collide. A
72/// [`Value::Null`] is treated as an empty object (`"{}"`) to match the
73/// Python library's behavior, where `None` args are also normalized to an
74/// empty dict.
75pub fn default_key_fn(tool_name: &str, args: &Value) -> Key {
76    let canon = if args.is_null() {
77        "{}".to_string()
78    } else {
79        canonical_json(args)
80    };
81    (tool_name.to_string(), canon)
82}
83
84/// Write `value` to `out` in canonical form (sorted keys, compact).
85fn write_canonical(out: &mut String, value: &Value) {
86    match value {
87        Value::Null => out.push_str("null"),
88        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
89        Value::Number(n) => {
90            // serde_json::Number's Display already produces a stable form.
91            let _ = write!(out, "{n}");
92        }
93        Value::String(s) => {
94            // Use serde_json to handle escapes correctly.
95            let encoded = serde_json::to_string(s).unwrap_or_else(|_| String::from("\"\""));
96            out.push_str(&encoded);
97        }
98        Value::Array(items) => {
99            out.push('[');
100            for (i, item) in items.iter().enumerate() {
101                if i > 0 {
102                    out.push(',');
103                }
104                write_canonical(out, item);
105            }
106            out.push(']');
107        }
108        Value::Object(map) => {
109            // Sort keys for canonical form.
110            let mut keys: Vec<&String> = map.keys().collect();
111            keys.sort();
112            out.push('{');
113            for (i, k) in keys.iter().enumerate() {
114                if i > 0 {
115                    out.push(',');
116                }
117                let encoded_key =
118                    serde_json::to_string(k.as_str()).unwrap_or_else(|_| String::from("\"\""));
119                out.push_str(&encoded_key);
120                out.push(':');
121                write_canonical(out, &map[*k]);
122            }
123            out.push('}');
124        }
125    }
126}
127
128fn canonical_json(value: &Value) -> String {
129    let mut out = String::new();
130    write_canonical(&mut out, value);
131    out
132}
133
134/// Raised when the same `(tool_name, args)` pair fires too often.
135///
136/// Carries the full context needed to surface a useful diagnostic to the
137/// caller: which tool was looping, what args it was called with on the
138/// offending call, how many times the key has been seen inside the window,
139/// and the guard's configured `window` / `threshold`.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct LoopDetectedError {
142    /// Name of the offending tool.
143    pub tool_name: String,
144    /// The args object that was matched (last recorded form).
145    pub tool_args: Value,
146    /// How many times the key was seen inside the window.
147    pub count: usize,
148    /// Configured window size.
149    pub window: usize,
150    /// Configured threshold.
151    pub threshold: usize,
152}
153
154impl fmt::Display for LoopDetectedError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(
157            f,
158            "tool '{}' called {} times in the last {} calls (threshold={})",
159            self.tool_name, self.count, self.window, self.threshold
160        )
161    }
162}
163
164impl Error for LoopDetectedError {}
165
166#[derive(Clone, Debug)]
167struct Recent {
168    key: Key,
169    // Kept for symmetry with the Python implementation; not currently read
170    // back, but cheap and aids future Debug/inspection use.
171    #[allow(dead_code)]
172    args: Value,
173}
174
175/// Sliding-window detector for repeated tool calls.
176///
177/// The guard keeps the last `window` `(tool_name, args)` records in a
178/// FIFO ring. On every [`LoopGuard::record`], if the new key already appears
179/// `threshold` times (including the call being recorded) in the buffer,
180/// [`LoopDetectedError`] is returned. The new call is *still* recorded so
181/// repeated calls to `record` after a detection stay deterministic.
182///
183/// Use [`LoopGuard::would_raise`] to peek at the next decision without
184/// mutating the buffer.
185pub struct LoopGuard {
186    window: usize,
187    threshold: usize,
188    key_fn: KeyFn,
189    buf: VecDeque<Recent>,
190}
191
192impl LoopGuard {
193    /// Construct a guard.
194    ///
195    /// # Panics
196    /// Panics on invalid construction (matches the spirit of Python's
197    /// `ValueError`, but in Rust idiom we treat invalid construction as a
198    /// programming bug rather than a recoverable error):
199    /// * `window < 2`
200    /// * `threshold < 2`
201    /// * `threshold > window`
202    ///
203    /// If you want a non-panicking constructor, see [`LoopGuard::try_new`].
204    pub fn with_capacity(window: usize, threshold: usize) -> Self {
205        Self::try_new(window, threshold).expect("invalid LoopGuard configuration")
206    }
207
208    /// Fallible constructor. Returns `Err(ConfigError)` instead of panicking.
209    pub fn try_new(window: usize, threshold: usize) -> Result<Self, ConfigError> {
210        if window < 2 {
211            return Err(ConfigError::WindowTooSmall { window });
212        }
213        if threshold < 2 {
214            return Err(ConfigError::ThresholdTooSmall { threshold });
215        }
216        if threshold > window {
217            return Err(ConfigError::ThresholdExceedsWindow { window, threshold });
218        }
219        Ok(Self {
220            window,
221            threshold,
222            key_fn: Box::new(default_key_fn),
223            buf: VecDeque::with_capacity(window),
224        })
225    }
226
227    /// Construct a guard with a custom key function.
228    ///
229    /// The key function decides which calls should be considered "the
230    /// same". Two calls that produce equal [`Key`]s count toward the
231    /// threshold. Use this to ignore noisy fields like `request_id` or
232    /// `timestamp` that change every call but don't represent real intent.
233    pub fn with_key_fn<F>(window: usize, threshold: usize, key_fn: F) -> Self
234    where
235        F: Fn(&str, &Value) -> Key + Send + Sync + 'static,
236    {
237        let mut g = Self::with_capacity(window, threshold);
238        g.key_fn = Box::new(key_fn);
239        g
240    }
241
242    /// Configured window size.
243    pub fn window(&self) -> usize {
244        self.window
245    }
246
247    /// Configured threshold.
248    pub fn threshold(&self) -> usize {
249        self.threshold
250    }
251
252    /// Number of calls currently in the buffer.
253    #[allow(clippy::len_without_is_empty)]
254    pub fn len(&self) -> usize {
255        self.buf.len()
256    }
257
258    /// True if no calls have been recorded since construction or last reset.
259    pub fn is_empty(&self) -> bool {
260        self.buf.is_empty()
261    }
262
263    /// Recent keys, oldest first.
264    ///
265    /// Returned as borrowed references so callers do not pay an allocation
266    /// cost just to inspect the ring. Clone individual entries with
267    /// `key.clone()` if you need to keep them past the next `record` call.
268    pub fn recent_keys(&self) -> Vec<&Key> {
269        self.buf.iter().map(|r| &r.key).collect()
270    }
271
272    /// Record a call. Returns `Err(LoopDetectedError)` if the threshold is met.
273    ///
274    /// The call is recorded into the ring before the count is checked, so
275    /// subsequent calls observe the same offending entry until evicted.
276    pub fn record(&mut self, tool_name: &str, args: &Value) -> Result<(), LoopDetectedError> {
277        let key = (self.key_fn)(tool_name, args);
278        if self.buf.len() == self.window {
279            self.buf.pop_front();
280        }
281        self.buf.push_back(Recent {
282            key: key.clone(),
283            args: args.clone(),
284        });
285        let count = self.buf.iter().filter(|r| r.key == key).count();
286        if count >= self.threshold {
287            return Err(LoopDetectedError {
288                tool_name: tool_name.to_string(),
289                tool_args: args.clone(),
290                count,
291                window: self.window,
292                threshold: self.threshold,
293            });
294        }
295        Ok(())
296    }
297
298    /// Return `true` if the *next* `record` for this key would raise.
299    ///
300    /// Lets callers preview the outcome without mutating the buffer. The
301    /// simulation accounts for buffer eviction: if the buffer is already
302    /// full and the oldest entry has the same key, the projected count
303    /// loses one before gaining one.
304    pub fn would_raise(&self, tool_name: &str, args: &Value) -> bool {
305        let key = (self.key_fn)(tool_name, args);
306        // Simulate appending without overflow side effects.
307        let skip_oldest = self.buf.len() == self.window;
308        let projected = self
309            .buf
310            .iter()
311            .enumerate()
312            .filter(|(i, _)| !(skip_oldest && *i == 0))
313            .filter(|(_, r)| r.key == key)
314            .count()
315            + 1;
316        projected >= self.threshold
317    }
318
319    /// Forget all recorded calls.
320    pub fn reset(&mut self) {
321        self.buf.clear();
322    }
323}
324
325impl fmt::Debug for LoopGuard {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        f.debug_struct("LoopGuard")
328            .field("window", &self.window)
329            .field("threshold", &self.threshold)
330            .field("len", &self.buf.len())
331            .finish()
332    }
333}
334
335/// Errors from invalid [`LoopGuard`] construction.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum ConfigError {
338    /// `window` was less than 2.
339    WindowTooSmall {
340        /// The offending value.
341        window: usize,
342    },
343    /// `threshold` was less than 2.
344    ThresholdTooSmall {
345        /// The offending value.
346        threshold: usize,
347    },
348    /// `threshold` was greater than `window`.
349    ThresholdExceedsWindow {
350        /// Configured window size.
351        window: usize,
352        /// Configured threshold.
353        threshold: usize,
354    },
355}
356
357impl fmt::Display for ConfigError {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        match self {
360            ConfigError::WindowTooSmall { window } => {
361                write!(f, "window must be >= 2 (got {window})")
362            }
363            ConfigError::ThresholdTooSmall { threshold } => {
364                write!(f, "threshold must be >= 2 (got {threshold})")
365            }
366            ConfigError::ThresholdExceedsWindow { window, threshold } => write!(
367                f,
368                "threshold must be <= window (got threshold={threshold}, window={window})"
369            ),
370        }
371    }
372}
373
374impl Error for ConfigError {}