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 {}