use crate::types::{ArgType, ModelOutput};
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone)]
pub struct LoopDetectorConfig {
pub window_size: usize,
pub similarity_threshold: f32,
pub tool_call_max_repeats: usize,
pub max_redirects: usize,
pub try_model_change: bool,
pub temperature_delta: f32,
pub judge_bare_numbers_against_question: bool,
pub detect_vacuous_answers: bool,
}
impl Default for LoopDetectorConfig {
fn default() -> Self {
Self {
window_size: 5,
similarity_threshold: 0.9,
tool_call_max_repeats: 3,
max_redirects: 3,
try_model_change: true,
temperature_delta: 0.3,
detect_vacuous_answers: true,
judge_bare_numbers_against_question: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCallSignature {
pub tool_name: String,
pub argument_keys: Vec<String>,
pub argument_hash: u64,
}
impl ToolCallSignature {
#[must_use]
pub fn from_tool_call(
name: &str,
args: &Option<std::collections::HashMap<String, ArgType>>,
) -> Self {
let mut keys: Vec<String> = args
.as_ref()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
keys.sort();
let mut hasher = ahash::AHasher::default();
name.hash(&mut hasher);
if let Some(map) = args {
for k in &keys {
k.hash(&mut hasher);
if let Some(v) = map.get(k) {
format!("{v:?}").hash(&mut hasher);
}
}
}
Self {
tool_name: name.to_string(),
argument_keys: keys,
argument_hash: hasher.finish(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoopDetection {
NoLoop,
ExactLoop {
repetitions: usize,
},
FuzzyLoop {
similarity: f32,
},
ToolCallLoop {
pattern: Vec<ToolCallSignature>,
repetitions: usize,
},
VacuousAnswer {
text: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Escalation {
Redirect,
SwitchModelOrTemperature { delta: f32 },
Terminate,
}
fn simhash_text(text: &str) -> u64 {
let mut counts = [0i32; 64];
for token in text.split_whitespace() {
let mut hasher = ahash::AHasher::default();
token.hash(&mut hasher);
let h = hasher.finish();
for (bit, count) in counts.iter_mut().enumerate() {
if (h >> bit) & 1 == 1 {
*count += 1;
} else {
*count -= 1;
}
}
}
let mut hash = 0u64;
for (bit, count) in counts.iter().enumerate() {
if *count > 0 {
hash |= 1 << bit;
}
}
hash
}
#[must_use]
pub fn hamming_similarity(a: u64, b: u64) -> f32 {
let diff = (a ^ b).count_ones();
#[allow(clippy::cast_precision_loss)]
let result = 1.0 - (diff as f32 / 64.0);
result
}
fn extract_text(output: &ModelOutput) -> Option<&str> {
match output {
ModelOutput::Text(tc) => Some(&tc.content),
ModelOutput::ThinkingContent { thinking, .. } => Some(thinking),
_ => None,
}
}
fn extract_tool_signatures(output: &ModelOutput) -> Option<ToolCallSignature> {
match output {
ModelOutput::ToolCall {
name, arguments, ..
} => Some(ToolCallSignature::from_tool_call(name, arguments)),
_ => None,
}
}
#[must_use]
pub fn is_vacuous_answer(text: &str) -> bool {
!text.chars().any(char::is_alphanumeric)
}
#[must_use]
pub fn is_bare_number(text: &str) -> bool {
text.chars().any(|c| c.is_ascii_digit()) && !text.chars().any(char::is_alphabetic)
}
const QUANTITY_PHRASES: [&str; 19] = [
"how many",
"how much",
"how old",
"how long",
"how far",
"how tall",
"number of",
"number",
"integer",
"float",
"date",
"count",
"total",
"sum",
"percent",
"average",
"quantity",
"calculate",
"compute",
];
#[must_use]
pub fn question_expects_a_number(question: &str) -> bool {
if question.chars().any(|c| c.is_ascii_digit()) {
return true;
}
let lowered = question.to_lowercase();
QUANTITY_PHRASES
.iter()
.any(|phrase| lowered.contains(phrase))
}
pub struct LoopDetector {
window: VecDeque<ModelOutput>,
tool_signatures: VecDeque<ToolCallSignature>,
redirect_count: usize,
config: LoopDetectorConfig,
}
impl LoopDetector {
#[must_use]
pub fn new(config: LoopDetectorConfig) -> Self {
Self {
window: VecDeque::with_capacity(config.window_size + 1),
tool_signatures: VecDeque::with_capacity(config.window_size + 1),
redirect_count: 0,
config,
}
}
pub fn check(&mut self, output: &ModelOutput) -> LoopDetection {
self.window.push_back(output.clone());
if self.window.len() > self.config.window_size {
self.window.pop_front();
}
if let Some(sig) = extract_tool_signatures(output) {
self.tool_signatures.push_back(sig);
if self.tool_signatures.len() > self.config.window_size {
self.tool_signatures.pop_front();
}
}
if let Some(detection) = self.check_tool_call_pattern() {
return detection;
}
if let Some(detection) = self.check_exact() {
return detection;
}
if let Some(detection) = self.check_simhash() {
return detection;
}
LoopDetection::NoLoop
}
fn check_exact(&self) -> Option<LoopDetection> {
if self.window.len() < 2 {
return None;
}
let last = self.window.back()?;
let mut count = 0usize;
for item in self.window.iter().rev().skip(1) {
if item == last {
count += 1;
} else {
break;
}
}
if count >= 1 {
Some(LoopDetection::ExactLoop {
repetitions: count + 1,
})
} else {
None
}
}
fn check_tool_call_pattern(&self) -> Option<LoopDetection> {
if self.tool_signatures.len() < self.config.tool_call_max_repeats {
return None;
}
let last = self.tool_signatures.back()?;
let mut count = 0usize;
for sig in self.tool_signatures.iter().rev().skip(1) {
if sig == last {
count += 1;
} else {
break;
}
}
let total = count + 1;
if total >= self.config.tool_call_max_repeats {
Some(LoopDetection::ToolCallLoop {
pattern: vec![last.clone()],
repetitions: total,
})
} else {
None
}
}
fn check_simhash(&self) -> Option<LoopDetection> {
if self.window.len() < 2 {
return None;
}
let last = self.window.back()?;
let last_text = extract_text(last)?;
if last_text.split_whitespace().count() < 3 {
return None;
}
let last_hash = simhash_text(last_text);
let prev = self.window.iter().rev().nth(1)?;
let prev_text = extract_text(prev)?;
let prev_hash = simhash_text(prev_text);
let sim = hamming_similarity(last_hash, prev_hash);
if sim >= self.config.similarity_threshold {
Some(LoopDetection::FuzzyLoop { similarity: sim })
} else {
None
}
}
pub fn check_answer(&mut self, answer: &str, question: &str) -> LoopDetection {
if !self.config.detect_vacuous_answers {
return LoopDetection::NoLoop;
}
let vacuous = is_vacuous_answer(answer)
|| (self.config.judge_bare_numbers_against_question
&& is_bare_number(answer)
&& !question_expects_a_number(question));
if vacuous {
return LoopDetection::VacuousAnswer {
text: answer.to_string(),
};
}
LoopDetection::NoLoop
}
pub fn escalate(&mut self) -> Escalation {
self.redirect_count += 1;
if self.redirect_count == 1 {
Escalation::Redirect
} else if self.redirect_count <= self.config.max_redirects {
Escalation::SwitchModelOrTemperature {
delta: self.config.temperature_delta,
}
} else {
Escalation::Terminate
}
}
pub fn reset(&mut self) {
self.redirect_count = 0;
}
#[must_use]
pub fn redirect_count(&self) -> usize {
self.redirect_count
}
#[must_use]
pub fn config(&self) -> &LoopDetectorConfig {
&self.config
}
}