tool-loop-break 0.1.0

Detect repeated agent tool invocations to break runaway loops. Tracks recent (tool, args_hash) tuples and signals a loop when count exceeds a threshold. Zero deps.
Documentation
//! # tool-loop-break
//!
//! Detect repeated agent tool invocations and signal "loop" before the
//! agent burns the budget.
//!
//! Approach: maintain a sliding window of the last `window` recorded
//! `(tool_name, args_fingerprint)` tuples. When any tuple has appeared
//! `>= threshold` times inside the window, [`record`] returns `true`.
//!
//! `args_fingerprint` is whatever you decide — typically a short hash
//! of the JSON arguments — so identical arg sets collide.
//!
//! ## Example
//!
//! ```
//! use tool_loop_break::LoopDetector;
//! let mut d = LoopDetector::new(10, 3); // window=10, threshold=3
//! assert!(!d.record("read_file", "abc"));
//! assert!(!d.record("read_file", "abc"));
//! assert!(d.record("read_file", "abc")); // third within window -> loop
//! ```

#![deny(missing_docs)]

use std::collections::{HashMap, VecDeque};

/// Sliding-window loop detector.
#[derive(Debug)]
pub struct LoopDetector {
    window: usize,
    threshold: u32,
    buf: VecDeque<(String, String)>,
    counts: HashMap<(String, String), u32>,
}

impl LoopDetector {
    /// Build a detector with the given sliding window size and trip
    /// threshold.
    pub fn new(window: usize, threshold: u32) -> Self {
        Self {
            window,
            threshold,
            buf: VecDeque::with_capacity(window),
            counts: HashMap::new(),
        }
    }

    /// Record one invocation. Returns true when this invocation pushed
    /// `(tool, args_fingerprint)` to or past the trip threshold.
    pub fn record(&mut self, tool: &str, args_fingerprint: &str) -> bool {
        let key = (tool.to_string(), args_fingerprint.to_string());
        // Evict the oldest if window full.
        if self.buf.len() == self.window {
            if let Some(old) = self.buf.pop_front() {
                if let Some(c) = self.counts.get_mut(&old) {
                    *c -= 1;
                    if *c == 0 {
                        self.counts.remove(&old);
                    }
                }
            }
        }
        self.buf.push_back(key.clone());
        let entry = self.counts.entry(key).or_insert(0);
        *entry += 1;
        *entry >= self.threshold
    }

    /// True when any tuple is currently at or past the threshold.
    pub fn is_looping(&self) -> bool {
        self.counts.values().any(|&v| v >= self.threshold)
    }

    /// Reset the detector.
    pub fn reset(&mut self) {
        self.buf.clear();
        self.counts.clear();
    }
}