use std::collections::VecDeque;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug)]
pub struct LoopDetectorConfig {
pub window_size: usize,
pub repeat_threshold: usize,
pub min_output_len: usize,
}
impl Default for LoopDetectorConfig {
fn default() -> Self {
Self {
window_size: 10,
repeat_threshold: 3,
min_output_len: 20,
}
}
}
#[derive(Clone, Debug)]
pub struct LoopDetector {
config: LoopDetectorConfig,
output_hashes: VecDeque<u64>,
total_outputs: usize,
loops_detected: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LoopStatus {
Ok,
LoopDetected {
repeats: usize,
action: LoopAction,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum LoopAction {
InjectWarning,
ForceAlternative,
HaltAgent,
}
impl LoopDetector {
pub fn new() -> Self {
Self::with_config(LoopDetectorConfig::default())
}
pub fn with_config(config: LoopDetectorConfig) -> Self {
Self {
output_hashes: VecDeque::with_capacity(config.window_size),
config,
total_outputs: 0,
loops_detected: 0,
}
}
fn hash_output(output: &str) -> u64 {
let normalized = output
.chars()
.take(500)
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_lowercase();
let mut hasher = DefaultHasher::new();
normalized.hash(&mut hasher);
hasher.finish()
}
pub fn check(&mut self, output: &str) -> LoopStatus {
self.total_outputs += 1;
if output.len() < self.config.min_output_len {
return LoopStatus::Ok;
}
let hash = Self::hash_output(output);
let repeats = self.output_hashes.iter().filter(|&&h| h == hash).count();
if self.output_hashes.len() >= self.config.window_size {
self.output_hashes.pop_front();
}
self.output_hashes.push_back(hash);
if repeats >= self.config.repeat_threshold {
self.loops_detected += 1;
let action = if repeats >= self.config.repeat_threshold * 2 {
LoopAction::HaltAgent
} else if repeats >= self.config.repeat_threshold + 1 {
LoopAction::ForceAlternative
} else {
LoopAction::InjectWarning
};
LoopStatus::LoopDetected { repeats, action }
} else {
LoopStatus::Ok
}
}
pub fn reset(&mut self) {
self.output_hashes.clear();
self.total_outputs = 0;
}
pub fn stats(&self) -> (usize, usize) {
(self.total_outputs, self.loops_detected)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_loop() {
let mut detector = LoopDetector::new();
assert_eq!(detector.check("Hello, how can I help?"), LoopStatus::Ok);
assert_eq!(detector.check("I can assist with that."), LoopStatus::Ok);
assert_eq!(detector.check("Here's what I found."), LoopStatus::Ok);
}
#[test]
fn test_loop_detected() {
let mut detector = LoopDetector::new();
let repeated = "I'm sorry, I cannot help with that request at this time.";
assert_eq!(detector.check(repeated), LoopStatus::Ok);
assert_eq!(detector.check(repeated), LoopStatus::Ok);
assert_eq!(detector.check(repeated), LoopStatus::Ok);
match detector.check(repeated) {
LoopStatus::LoopDetected { repeats, action } => {
assert!(repeats >= 3);
assert_eq!(action, LoopAction::InjectWarning);
}
_ => panic!("should detect loop"),
}
}
#[test]
fn test_short_output_ignored() {
let mut detector = LoopDetector::new();
assert_eq!(detector.check("ok"), LoopStatus::Ok);
assert_eq!(detector.check("ok"), LoopStatus::Ok);
assert_eq!(detector.check("ok"), LoopStatus::Ok);
assert_eq!(detector.check("ok"), LoopStatus::Ok);
}
#[test]
fn test_escalation() {
let mut detector = LoopDetector::with_config(LoopDetectorConfig {
window_size: 20,
repeat_threshold: 2,
min_output_len: 10,
});
let repeated = "This is a repeated response that keeps coming back.";
detector.check(repeated); detector.check(repeated); match detector.check(repeated) {
LoopStatus::LoopDetected { action, .. } => assert_eq!(action, LoopAction::InjectWarning),
_ => panic!("should warn"),
}
match detector.check(repeated) {
LoopStatus::LoopDetected { action, .. } => assert_eq!(action, LoopAction::ForceAlternative),
_ => panic!("should force alternative"),
}
}
#[test]
fn test_reset() {
let mut detector = LoopDetector::new();
let repeated = "A repeated output that should trigger detection.";
detector.check(repeated);
detector.check(repeated);
detector.check(repeated);
detector.reset();
assert_eq!(detector.check(repeated), LoopStatus::Ok);
}
#[test]
fn test_stats() {
let mut detector = LoopDetector::new();
detector.check("First unique output here and now.");
detector.check("Second unique output here and now.");
let (total, loops) = detector.stats();
assert_eq!(total, 2);
assert_eq!(loops, 0);
}
#[test]
fn test_whitespace_normalization() {
let mut detector = LoopDetector::with_config(LoopDetectorConfig {
repeat_threshold: 2,
..Default::default()
});
detector.check("Hello world, how are you doing today?");
detector.check("Hello world, how are you doing today?");
match detector.check("Hello\n\tworld,\thow are you doing today?") {
LoopStatus::LoopDetected { .. } => {} _ => panic!("whitespace-normalized duplicates should match"),
}
}
}