1use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15#[non_exhaustive]
16pub struct DoomLoopConfig {
17 #[serde(default = "default_true")]
19 pub enabled: bool,
20 #[serde(default = "default_consecutive_threshold")]
22 pub consecutive_threshold: usize,
23 #[serde(default = "default_min_cycle_length")]
25 pub min_cycle_length: usize,
26 #[serde(default = "default_max_cycle_length")]
28 pub max_cycle_length: usize,
29 #[serde(default = "default_cycle_repetitions")]
31 pub cycle_repetitions: usize,
32}
33
34const fn default_true() -> bool {
35 true
36}
37
38const fn default_consecutive_threshold() -> usize {
39 3
40}
41
42const fn default_min_cycle_length() -> usize {
43 2
44}
45
46const fn default_max_cycle_length() -> usize {
47 4
48}
49
50const fn default_cycle_repetitions() -> usize {
51 2
52}
53
54impl Default for DoomLoopConfig {
55 fn default() -> Self {
56 Self {
57 enabled: true,
58 consecutive_threshold: 3,
59 min_cycle_length: 2,
60 max_cycle_length: 4,
61 cycle_repetitions: 2,
62 }
63 }
64}
65
66impl DoomLoopConfig {
67 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 #[must_use]
75 pub fn with_disabled(mut self) -> Self {
76 self.enabled = false;
77 self
78 }
79
80 #[must_use]
82 pub fn with_consecutive_threshold(mut self, threshold: usize) -> Self {
83 self.consecutive_threshold = threshold;
84 self
85 }
86
87 #[must_use]
89 pub fn with_min_cycle_length(mut self, length: usize) -> Self {
90 self.min_cycle_length = length;
91 self
92 }
93
94 #[must_use]
96 pub fn with_max_cycle_length(mut self, length: usize) -> Self {
97 self.max_cycle_length = length;
98 self
99 }
100
101 #[must_use]
103 pub fn with_cycle_repetitions(mut self, repetitions: usize) -> Self {
104 self.cycle_repetitions = repetitions;
105 self
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct ToolCallFingerprint(u64);
115
116impl ToolCallFingerprint {
117 #[must_use]
119 pub fn from_tool_call(name: &str, arguments: &Value) -> Self {
120 let mut hasher = DefaultHasher::new();
121
122 name.hash(&mut hasher);
123
124 let canonical = Self::canonicalize(arguments);
126 canonical.hash(&mut hasher);
127
128 Self(hasher.finish())
129 }
130
131 fn canonicalize(value: &Value) -> Value {
133 match value {
134 Value::Object(map) => {
135 let mut sorted: Vec<_> = map.iter().collect();
136 sorted.sort_by(|a, b| a.0.cmp(b.0));
137 let canonical_map: serde_json::Map<String, Value> = sorted
138 .into_iter()
139 .map(|(k, v)| (k.clone(), Self::canonicalize(v)))
140 .collect();
141 Value::Object(canonical_map)
142 }
143 Value::Array(arr) => Value::Array(arr.iter().map(Self::canonicalize).collect()),
144 other => other.clone(),
145 }
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum DoomLoopType {
152 ConsecutiveDuplicate {
154 tool_name: String,
156 count: usize,
158 },
159 Cycle {
161 pattern: Vec<String>,
163 repetitions: usize,
165 },
166}
167
168#[derive(Debug)]
170pub struct DoomLoopDetector {
171 config: DoomLoopConfig,
172 history: Vec<(ToolCallFingerprint, String)>,
173}
174
175impl DoomLoopDetector {
176 #[must_use]
178 pub fn new(config: DoomLoopConfig) -> Self {
179 Self {
180 config,
181 history: Vec::new(),
182 }
183 }
184
185 pub fn record_and_check(&mut self, name: &str, arguments: &Value) -> Option<DoomLoopType> {
198 if !self.config.enabled {
199 return None;
200 }
201
202 let fingerprint = ToolCallFingerprint::from_tool_call(name, arguments);
203 self.history.push((fingerprint, name.to_string()));
204
205 if let Some(result) = self.check_consecutive_duplicate() {
207 return Some(result);
208 }
209
210 self.check_cycle()
212 }
213
214 fn check_consecutive_duplicate(&self) -> Option<DoomLoopType> {
216 let threshold = self.config.consecutive_threshold;
217 if self.history.len() < threshold {
218 return None;
219 }
220
221 let last = self.history.last()?;
222 let all_same = self
223 .history
224 .iter()
225 .rev()
226 .take(threshold)
227 .all(|(fp, _)| fp == &last.0);
228
229 if all_same {
230 Some(DoomLoopType::ConsecutiveDuplicate {
231 tool_name: last.1.clone(),
232 count: threshold,
233 })
234 } else {
235 None
236 }
237 }
238
239 fn check_cycle(&self) -> Option<DoomLoopType> {
241 let min_len = self.config.min_cycle_length;
242 let max_len = self.config.max_cycle_length;
243 let repetitions = self.config.cycle_repetitions;
244
245 let history_len = self.history.len();
246 let min_required = min_len * (repetitions + 1);
247
248 if history_len < min_required {
249 return None;
250 }
251
252 for cycle_len in min_len..=max_len {
254 let required_len = cycle_len * (repetitions + 1);
255 if history_len < required_len {
256 continue;
257 }
258
259 let pattern_start = history_len - required_len;
261 let candidate: Vec<_> = self.history[pattern_start..pattern_start + cycle_len]
262 .iter()
263 .map(|(fp, _)| *fp)
264 .collect();
265
266 let mut matches = true;
268 for rep in 1..=repetitions {
269 let rep_start = pattern_start + rep * cycle_len;
270 for (i, cand_fp) in candidate.iter().enumerate() {
271 if self.history[rep_start + i].0 != *cand_fp {
272 matches = false;
273 break;
274 }
275 }
276 if !matches {
277 break;
278 }
279 }
280
281 if matches {
282 let pattern_names: Vec<String> = self.history
283 [pattern_start..pattern_start + cycle_len]
284 .iter()
285 .map(|(_, name)| name.clone())
286 .collect();
287
288 return Some(DoomLoopType::Cycle {
289 pattern: pattern_names,
290 repetitions: repetitions + 1,
291 });
292 }
293 }
294
295 None
296 }
297
298 pub fn clear(&mut self) {
300 self.history.clear();
301 }
302
303 #[must_use]
305 pub fn history_len(&self) -> usize {
306 self.history.len()
307 }
308}
309
310#[cfg(test)]
311#[allow(clippy::unwrap_used)]
312mod tests {
313 use super::*;
314 use serde_json::json;
315
316 #[test]
317 fn fingerprint_same_tool_same_args() {
318 let fp1 = ToolCallFingerprint::from_tool_call("read", &json!({"path": "/foo"}));
319 let fp2 = ToolCallFingerprint::from_tool_call("read", &json!({"path": "/foo"}));
320 assert_eq!(fp1, fp2);
321 }
322
323 #[test]
324 fn fingerprint_same_tool_different_args() {
325 let fp1 = ToolCallFingerprint::from_tool_call("read", &json!({"path": "/foo"}));
326 let fp2 = ToolCallFingerprint::from_tool_call("read", &json!({"path": "/bar"}));
327 assert_ne!(fp1, fp2);
328 }
329
330 #[test]
331 fn fingerprint_different_tools() {
332 let fp1 = ToolCallFingerprint::from_tool_call("read", &json!({"path": "/foo"}));
333 let fp2 = ToolCallFingerprint::from_tool_call("write", &json!({"path": "/foo"}));
334 assert_ne!(fp1, fp2);
335 }
336
337 #[test]
338 fn fingerprint_canonicalizes_object_keys() {
339 let fp1 = ToolCallFingerprint::from_tool_call("test", &json!({"a": 1, "b": 2}));
340 let fp2 = ToolCallFingerprint::from_tool_call("test", &json!({"b": 2, "a": 1}));
341 assert_eq!(fp1, fp2);
342 }
343
344 #[test]
345 fn detect_consecutive_duplicate() {
346 let config = DoomLoopConfig::new().with_consecutive_threshold(3);
347 let mut detector = DoomLoopDetector::new(config);
348
349 assert!(
350 detector
351 .record_and_check("read", &json!({"path": "/foo"}))
352 .is_none()
353 );
354 assert!(
355 detector
356 .record_and_check("read", &json!({"path": "/foo"}))
357 .is_none()
358 );
359
360 let result = detector.record_and_check("read", &json!({"path": "/foo"}));
361 assert!(matches!(
362 result,
363 Some(DoomLoopType::ConsecutiveDuplicate { tool_name, count })
364 if tool_name == "read" && count == 3
365 ));
366 }
367
368 #[test]
369 fn no_false_positive_on_different_args() {
370 let config = DoomLoopConfig::new().with_consecutive_threshold(3);
371 let mut detector = DoomLoopDetector::new(config);
372
373 assert!(
374 detector
375 .record_and_check("read", &json!({"path": "/foo"}))
376 .is_none()
377 );
378 assert!(
379 detector
380 .record_and_check("read", &json!({"path": "/bar"}))
381 .is_none()
382 );
383 assert!(
384 detector
385 .record_and_check("read", &json!({"path": "/foo"}))
386 .is_none()
387 );
388 }
389
390 #[test]
391 fn detect_cycle() {
392 let config = DoomLoopConfig::new()
393 .with_min_cycle_length(2)
394 .with_max_cycle_length(2)
395 .with_cycle_repetitions(2);
396 let mut detector = DoomLoopDetector::new(config);
397
398 assert!(detector.record_and_check("read", &json!({})).is_none());
400 assert!(detector.record_and_check("write", &json!({})).is_none());
401 assert!(detector.record_and_check("read", &json!({})).is_none());
402 assert!(detector.record_and_check("write", &json!({})).is_none());
403 assert!(detector.record_and_check("read", &json!({})).is_none());
404
405 let result = detector.record_and_check("write", &json!({}));
406 assert!(matches!(
407 result,
408 Some(DoomLoopType::Cycle { pattern, repetitions })
409 if pattern == vec!["read", "write"] && repetitions == 3
410 ));
411 }
412
413 #[test]
414 fn disabled_detection_returns_none() {
415 let config = DoomLoopConfig::new().with_disabled();
416 let mut detector = DoomLoopDetector::new(config);
417
418 for _ in 0..10 {
419 assert!(detector.record_and_check("read", &json!({})).is_none());
420 }
421 }
422
423 #[test]
424 fn clear_resets_history() {
425 let config = DoomLoopConfig::new();
426 let mut detector = DoomLoopDetector::new(config);
427
428 detector.record_and_check("read", &json!({}));
429 detector.record_and_check("write", &json!({}));
430 assert_eq!(detector.history_len(), 2);
431
432 detector.clear();
433 assert_eq!(detector.history_len(), 0);
434 }
435}