#![deny(missing_docs)]
use serde_json::Value;
use std::collections::VecDeque;
use std::error::Error;
use std::fmt::{self, Write as _};
pub type Key = (String, String);
pub type KeyFn = Box<dyn Fn(&str, &Value) -> Key + Send + Sync + 'static>;
pub fn default_key_fn(tool_name: &str, args: &Value) -> Key {
let canon = if args.is_null() {
"{}".to_string()
} else {
canonical_json(args)
};
(tool_name.to_string(), canon)
}
fn write_canonical(out: &mut String, value: &Value) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => {
let _ = write!(out, "{n}");
}
Value::String(s) => {
let encoded = serde_json::to_string(s).unwrap_or_else(|_| String::from("\"\""));
out.push_str(&encoded);
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(out, item);
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
let encoded_key =
serde_json::to_string(k.as_str()).unwrap_or_else(|_| String::from("\"\""));
out.push_str(&encoded_key);
out.push(':');
write_canonical(out, &map[*k]);
}
out.push('}');
}
}
}
fn canonical_json(value: &Value) -> String {
let mut out = String::new();
write_canonical(&mut out, value);
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoopDetectedError {
pub tool_name: String,
pub tool_args: Value,
pub count: usize,
pub window: usize,
pub threshold: usize,
}
impl fmt::Display for LoopDetectedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"tool '{}' called {} times in the last {} calls (threshold={})",
self.tool_name, self.count, self.window, self.threshold
)
}
}
impl Error for LoopDetectedError {}
#[derive(Clone, Debug)]
struct Recent {
key: Key,
#[allow(dead_code)]
args: Value,
}
pub struct LoopGuard {
window: usize,
threshold: usize,
key_fn: KeyFn,
buf: VecDeque<Recent>,
}
impl LoopGuard {
pub fn with_capacity(window: usize, threshold: usize) -> Self {
Self::try_new(window, threshold).expect("invalid LoopGuard configuration")
}
pub fn try_new(window: usize, threshold: usize) -> Result<Self, ConfigError> {
if window < 2 {
return Err(ConfigError::WindowTooSmall { window });
}
if threshold < 2 {
return Err(ConfigError::ThresholdTooSmall { threshold });
}
if threshold > window {
return Err(ConfigError::ThresholdExceedsWindow { window, threshold });
}
Ok(Self {
window,
threshold,
key_fn: Box::new(default_key_fn),
buf: VecDeque::with_capacity(window),
})
}
pub fn with_key_fn<F>(window: usize, threshold: usize, key_fn: F) -> Self
where
F: Fn(&str, &Value) -> Key + Send + Sync + 'static,
{
let mut g = Self::with_capacity(window, threshold);
g.key_fn = Box::new(key_fn);
g
}
pub fn window(&self) -> usize {
self.window
}
pub fn threshold(&self) -> usize {
self.threshold
}
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.buf.len()
}
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub fn recent_keys(&self) -> Vec<&Key> {
self.buf.iter().map(|r| &r.key).collect()
}
pub fn record(&mut self, tool_name: &str, args: &Value) -> Result<(), LoopDetectedError> {
let key = (self.key_fn)(tool_name, args);
if self.buf.len() == self.window {
self.buf.pop_front();
}
self.buf.push_back(Recent {
key: key.clone(),
args: args.clone(),
});
let count = self.buf.iter().filter(|r| r.key == key).count();
if count >= self.threshold {
return Err(LoopDetectedError {
tool_name: tool_name.to_string(),
tool_args: args.clone(),
count,
window: self.window,
threshold: self.threshold,
});
}
Ok(())
}
pub fn would_raise(&self, tool_name: &str, args: &Value) -> bool {
let key = (self.key_fn)(tool_name, args);
let skip_oldest = self.buf.len() == self.window;
let projected = self
.buf
.iter()
.enumerate()
.filter(|(i, _)| !(skip_oldest && *i == 0))
.filter(|(_, r)| r.key == key)
.count()
+ 1;
projected >= self.threshold
}
pub fn reset(&mut self) {
self.buf.clear();
}
}
impl fmt::Debug for LoopGuard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoopGuard")
.field("window", &self.window)
.field("threshold", &self.threshold)
.field("len", &self.buf.len())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
WindowTooSmall {
window: usize,
},
ThresholdTooSmall {
threshold: usize,
},
ThresholdExceedsWindow {
window: usize,
threshold: usize,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::WindowTooSmall { window } => {
write!(f, "window must be >= 2 (got {window})")
}
ConfigError::ThresholdTooSmall { threshold } => {
write!(f, "threshold must be >= 2 (got {threshold})")
}
ConfigError::ThresholdExceedsWindow { window, threshold } => write!(
f,
"threshold must be <= window (got threshold={threshold}, window={window})"
),
}
}
}
impl Error for ConfigError {}