1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crate::llm::types::ToolCall;
/// Tracks consecutive identical tool-call turns to detect doom loops.
///
/// Each turn's tool calls are hashed as a sorted set of `(name, input_json)` pairs.
/// When N consecutive turns produce the same hash, the tracker signals a doom loop.
pub(super) struct DoomLoopTracker {
/// Hash of the previous turn's tool calls, and its consecutive count.
last_hash: Option<u64>,
count: u32,
/// Tracks fuzzy matches (same tool names, different inputs).
last_names_hash: Option<u64>,
fuzzy_count: u32,
}
impl DoomLoopTracker {
pub(super) fn new() -> Self {
Self {
last_hash: None,
count: 0,
last_names_hash: None,
fuzzy_count: 0,
}
}
/// Consecutive exact-match count.
pub(super) fn count(&self) -> u32 {
self.count
}
/// Consecutive fuzzy-match count (same tool names, different inputs).
pub(super) fn fuzzy_count(&self) -> u32 {
self.fuzzy_count
}
/// Hash a set of tool calls for the current turn. Tool calls are sorted by
/// name so that ordering differences don't produce different hashes.
fn hash_tool_calls(calls: &[ToolCall]) -> u64 {
let mut sorted: Vec<(String, String)> = calls
.iter()
.map(|tc| (tc.name.clone(), tc.input.to_string()))
.collect();
sorted.sort();
let mut hasher = DefaultHasher::new();
for (name, input) in &sorted {
name.hash(&mut hasher);
input.hash(&mut hasher);
}
hasher.finish()
}
/// Hash only the tool names (sorted) for fuzzy matching.
fn hash_tool_names(calls: &[ToolCall]) -> u64 {
let mut names: Vec<&str> = calls.iter().map(|tc| tc.name.as_str()).collect();
names.sort();
let mut hasher = DefaultHasher::new();
for name in &names {
name.hash(&mut hasher);
}
hasher.finish()
}
/// Record the current turn's tool calls. Returns `(exact_match, fuzzy_match)`.
/// `exact_match` is true if exact same calls repeated >= threshold times.
/// `fuzzy_match` is true if same tool names (different inputs) repeated >= fuzzy_threshold times.
pub(super) fn record(
&mut self,
calls: &[ToolCall],
threshold: u32,
fuzzy_threshold: Option<u32>,
) -> (bool, bool) {
let hash = Self::hash_tool_calls(calls);
match self.last_hash {
Some(prev) if prev == hash => {
self.count += 1;
}
_ => {
self.last_hash = Some(hash);
self.count = 1;
}
}
// Fuzzy tracking: same tool names, possibly different inputs
if let Some(_ft) = fuzzy_threshold {
let names_hash = Self::hash_tool_names(calls);
match self.last_names_hash {
Some(prev) if prev == names_hash => {
// Only count fuzzy if NOT an exact match (avoid double-counting)
if self.count < threshold {
self.fuzzy_count += 1;
}
}
_ => {
self.last_names_hash = Some(names_hash);
self.fuzzy_count = 1;
}
}
}
let exact = self.count >= threshold;
// Fuzzy only fires when exact does not (avoid double-counting)
let fuzzy = !exact && fuzzy_threshold.is_some_and(|ft| self.fuzzy_count >= ft);
(exact, fuzzy)
}
}