Skip to main content

behest_runtime/
doom_loop.rs

1//! Doom loop detection for agent runs.
2//!
3//! Detects when an agent is stuck in repetitive tool call patterns:
4//! - Consecutive duplicate tool calls (same tool, same arguments)
5//! - Cyclic patterns (repeating sequences of tool calls)
6
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Configuration for doom loop detection.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[non_exhaustive]
16pub struct DoomLoopConfig {
17    /// Enable doom loop detection. Default: `true`.
18    #[serde(default = "default_true")]
19    pub enabled: bool,
20    /// Number of consecutive identical tool calls to trigger detection. Default: `3`.
21    #[serde(default = "default_consecutive_threshold")]
22    pub consecutive_threshold: usize,
23    /// Minimum cycle length to detect. Default: `2`.
24    #[serde(default = "default_min_cycle_length")]
25    pub min_cycle_length: usize,
26    /// Maximum cycle length to detect. Default: `4`.
27    #[serde(default = "default_max_cycle_length")]
28    pub max_cycle_length: usize,
29    /// Number of cycle repetitions to trigger detection. Default: `2`.
30    #[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    /// Creates a new config with defaults.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Disables doom loop detection.
74    #[must_use]
75    pub fn with_disabled(mut self) -> Self {
76        self.enabled = false;
77        self
78    }
79
80    /// Sets the consecutive duplicate threshold.
81    #[must_use]
82    pub fn with_consecutive_threshold(mut self, threshold: usize) -> Self {
83        self.consecutive_threshold = threshold;
84        self
85    }
86
87    /// Sets the minimum cycle length.
88    #[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    /// Sets the maximum cycle length.
95    #[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    /// Sets the cycle repetition count.
102    #[must_use]
103    pub fn with_cycle_repetitions(mut self, repetitions: usize) -> Self {
104        self.cycle_repetitions = repetitions;
105        self
106    }
107}
108
109/// A fingerprint representing a unique tool call.
110///
111/// Combines tool name and canonicalized arguments into a hash
112/// for efficient comparison and pattern detection.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct ToolCallFingerprint(u64);
115
116impl ToolCallFingerprint {
117    /// Creates a fingerprint from a tool name and arguments.
118    #[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        // Canonicalize arguments by sorting object keys
125        let canonical = Self::canonicalize(arguments);
126        canonical.hash(&mut hasher);
127
128        Self(hasher.finish())
129    }
130
131    /// Canonicalizes a JSON value by sorting object keys recursively.
132    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/// The type of doom loop detected.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum DoomLoopType {
152    /// Consecutive identical tool calls.
153    ConsecutiveDuplicate {
154        /// The tool name being repeated.
155        tool_name: String,
156        /// Number of consecutive occurrences.
157        count: usize,
158    },
159    /// A cyclic pattern of tool calls.
160    Cycle {
161        /// The sequence of tool names forming the cycle.
162        pattern: Vec<String>,
163        /// Number of times the cycle repeated.
164        repetitions: usize,
165    },
166}
167
168/// Detects doom loops in agent tool call sequences.
169#[derive(Debug)]
170pub struct DoomLoopDetector {
171    config: DoomLoopConfig,
172    history: Vec<(ToolCallFingerprint, String)>,
173}
174
175impl DoomLoopDetector {
176    /// Creates a new detector with the given configuration.
177    #[must_use]
178    pub fn new(config: DoomLoopConfig) -> Self {
179        Self {
180            config,
181            history: Vec::new(),
182        }
183    }
184
185    /// Records a tool call into the detection history and checks for doom
186    /// loops against the current and past tool calls.
187    ///
188    /// Two detection strategies are applied in order:
189    /// 1. **Consecutive duplicates** — the same tool with the same canonical
190    ///    arguments called N times in a row (configured by
191    ///    [`DoomLoopConfig::consecutive_threshold`]).
192    /// 2. **Cyclic patterns** — a repeating sequence of tool calls of length
193    ///    between `min_cycle_length` and `max_cycle_length` that repeats at
194    ///    least `cycle_repetitions` times.
195    ///
196    /// Returns `Some(DoomLoopType)` if a doom loop is detected, `None` otherwise.
197    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        // Check for consecutive duplicates
206        if let Some(result) = self.check_consecutive_duplicate() {
207            return Some(result);
208        }
209
210        // Check for cycles
211        self.check_cycle()
212    }
213
214    /// Checks if the last N tool calls are identical.
215    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    /// Checks for cyclic patterns in the tool call history.
240    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        // Try different cycle lengths
253        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            // Extract the candidate pattern from the end
260            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            // Check if this pattern repeats
267            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    /// Clears the detection history.
299    pub fn clear(&mut self) {
300        self.history.clear();
301    }
302
303    /// Returns the current history length.
304    #[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        // Pattern: read -> write -> read -> write -> read -> write
399        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}