Skip to main content

ares_agent/
loop_detector.rs

1//! Loop detection for ARES agents.
2//!
3//! Detects when an agent is producing repetitive outputs and intervenes
4//! to break the loop. Uses a sliding window of recent outputs with
5//! similarity hashing, optional fuzzy matching, and iteration limits.
6
7use serde::{Deserialize, Serialize};
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10
11/// Configuration for loop detection and iteration limits.
12#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
13pub struct LoopConfig {
14    /// Maximum number of recent outputs to track.
15    #[serde(default = "default_window_size")]
16    pub window_size: usize,
17    /// Number of identical hashes that trigger loop detection.
18    #[serde(default = "default_repeat_threshold")]
19    pub repeat_threshold: usize,
20    /// Minimum output length to consider for loop detection.
21    #[serde(default = "default_min_output_len")]
22    pub min_output_len: usize,
23    /// Maximum agent iterations before halting. `None` = unbounded.
24    #[serde(default)]
25    pub max_iterations: Option<u64>,
26    /// When true, halt once [`max_iterations`] is reached.
27    #[serde(default)]
28    pub halt_on_max: bool,
29    /// Whether failed iterations count against [`max_iterations`].
30    #[serde(default = "default_count_failures")]
31    pub count_failed_iterations: bool,
32    /// Enable fuzzy signature matching (whitespace variants, near-duplicates).
33    #[serde(default)]
34    pub fuzzy_match: bool,
35    /// Halt after this many consecutive iteration failures.
36    #[serde(default = "default_halt_on_consecutive_failures")]
37    pub halt_on_consecutive_failures: u32,
38}
39
40fn default_window_size() -> usize {
41    10
42}
43fn default_repeat_threshold() -> usize {
44    3
45}
46fn default_min_output_len() -> usize {
47    20
48}
49fn default_count_failures() -> bool {
50    true
51}
52fn default_halt_on_consecutive_failures() -> u32 {
53    3
54}
55
56impl Default for LoopConfig {
57    fn default() -> Self {
58        Self {
59            window_size: default_window_size(),
60            repeat_threshold: default_repeat_threshold(),
61            min_output_len: default_min_output_len(),
62            max_iterations: None,
63            halt_on_max: false,
64            count_failed_iterations: default_count_failures(),
65            fuzzy_match: false,
66            halt_on_consecutive_failures: default_halt_on_consecutive_failures(),
67        }
68    }
69}
70
71/// Backward-compatible alias for [`LoopConfig`].
72pub type LoopDetectorConfig = LoopConfig;
73
74/// Runtime state for loop detection and iteration tracking.
75#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct LoopState {
77    /// Sliding window of output similarity hashes (oldest first).
78    pub recent_hashes: Vec<u64>,
79    /// Sliding window of normalized signatures for fuzzy matching.
80    pub recent_signatures: Vec<String>,
81    /// Total outputs checked (including short/ignored).
82    pub total_outputs: u64,
83    /// Number of loops detected.
84    pub loops_detected: u64,
85    /// Total agent iterations run.
86    pub iterations_run: u64,
87    pub iterations_succeeded: u64,
88    pub iterations_failed: u64,
89    pub consecutive_failures: u32,
90}
91
92impl LoopState {
93    /// Record a successful iteration. Resets the consecutive-failure counter.
94    pub fn record_success(&mut self) {
95        self.iterations_run += 1;
96        self.iterations_succeeded += 1;
97        self.consecutive_failures = 0;
98    }
99
100    /// Record a failed iteration. Increments the consecutive-failure counter.
101    pub fn record_failure(&mut self) {
102        self.iterations_run += 1;
103        self.iterations_failed += 1;
104        self.consecutive_failures += 1;
105    }
106
107    fn push_output(&mut self, config: &LoopConfig, hash: u64, signature: String) {
108        if self.recent_hashes.len() >= config.window_size {
109            self.recent_hashes.remove(0);
110            self.recent_signatures.remove(0);
111        }
112        self.recent_hashes.push(hash);
113        self.recent_signatures.push(signature);
114    }
115}
116
117/// Result of checking an output for loops.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum LoopStatus {
120    /// No loop detected, proceed normally.
121    Ok,
122    /// Loop detected — agent is repeating itself.
123    LoopDetected {
124        /// Number of consecutive repeats in the window.
125        repeats: usize,
126        /// Suggested action.
127        action: LoopAction,
128        /// Whether the match was exact-hash or fuzzy-signature.
129        kind: LoopMatchKind,
130    },
131}
132
133/// How a repetitive output was matched.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub enum LoopMatchKind {
136    Exact,
137    Fuzzy,
138}
139
140/// Actions to take when a loop is detected.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub enum LoopAction {
143    /// Inject a "you are repeating yourself" prompt.
144    InjectWarning,
145    /// Force the agent to try a different approach.
146    ForceAlternative,
147    /// Stop the agent entirely.
148    HaltAgent,
149}
150
151/// Pure detection result from [`detect_loop`].
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct LoopDetection {
154    pub repeats: usize,
155    pub kind: LoopMatchKind,
156}
157
158/// Hash normalized output content for exact duplicate detection.
159pub fn similarity_hash(output: &str) -> u64 {
160    let normalized = loop_signature(output);
161    let mut hasher = DefaultHasher::new();
162    normalized.hash(&mut hasher);
163    hasher.finish()
164}
165
166/// Canonical normalized signature (whitespace-stripped, lowercased, capped).
167pub fn loop_signature(output: &str) -> String {
168    output
169        .chars()
170        .take(500)
171        .filter(|c| !c.is_whitespace())
172        .collect::<String>()
173        .to_lowercase()
174}
175
176/// Count how many entries in `hashes` equal `hash`.
177pub fn count_repetitions(hashes: &[u64], hash: u64) -> usize {
178    hashes.iter().filter(|&&h| h == hash).count()
179}
180
181/// Count signatures in `signatures` that fuzzy-match `current`.
182pub fn count_fuzzy_repetitions(signatures: &[String], current: &str) -> usize {
183    signatures
184        .iter()
185        .filter(|s| signatures_similar(s, current))
186        .count()
187}
188
189fn signatures_similar(a: &str, b: &str) -> bool {
190    if a == b {
191        return true;
192    }
193    let min_len = a.len().min(b.len());
194    if min_len < 10 {
195        return false;
196    }
197    let common = a
198        .chars()
199        .zip(b.chars())
200        .take_while(|(x, y)| x == y)
201        .count();
202    common * 100 / min_len >= 80
203}
204
205/// Detect a repetitive output against recent window state (does not mutate `state`).
206pub fn detect_loop(config: &LoopConfig, state: &LoopState, output: &str) -> Option<LoopDetection> {
207    if output.len() < config.min_output_len {
208        return None;
209    }
210
211    let hash = similarity_hash(output);
212    let signature = loop_signature(output);
213    let exact = count_repetitions(&state.recent_hashes, hash);
214    let fuzzy = if config.fuzzy_match {
215        count_fuzzy_repetitions(&state.recent_signatures, &signature)
216    } else {
217        0
218    };
219
220    let repeats = exact.max(fuzzy);
221    if repeats < config.repeat_threshold {
222        return None;
223    }
224
225    let kind = if exact >= config.repeat_threshold {
226        LoopMatchKind::Exact
227    } else {
228        LoopMatchKind::Fuzzy
229    };
230    Some(LoopDetection { repeats, kind })
231}
232
233/// Returns true when [`LoopConfig::halt_on_max`] is set and iteration limits are reached.
234pub fn should_halt_on_max_iterations(state: &LoopState, config: &LoopConfig) -> bool {
235    if !config.halt_on_max {
236        return false;
237    }
238    let Some(max) = config.max_iterations else {
239        return false;
240    };
241    let counted = if config.count_failed_iterations {
242        state.iterations_run
243    } else {
244        state.iterations_succeeded
245    };
246    counted >= max
247}
248
249/// Whether the agent should halt due to iteration limits or consecutive failures.
250pub fn should_halt(state: &LoopState, config: &LoopConfig) -> bool {
251    should_halt_on_max_iterations(state, config)
252        || state.consecutive_failures >= config.halt_on_consecutive_failures
253}
254
255fn action_for_repeats(config: &LoopConfig, repeats: usize) -> LoopAction {
256    if repeats >= config.repeat_threshold * 2 {
257        LoopAction::HaltAgent
258    } else if repeats > config.repeat_threshold {
259        LoopAction::ForceAlternative
260    } else {
261        LoopAction::InjectWarning
262    }
263}
264
265/// Tracks agent outputs and detects repetitive loops.
266#[derive(Clone, Debug)]
267pub struct LoopDetector {
268    config: LoopConfig,
269    state: LoopState,
270}
271
272impl LoopDetector {
273    /// Create a new loop detector with default config.
274    pub fn new() -> Self {
275        Self::with_config(LoopConfig::default())
276    }
277
278    /// Create a new loop detector with custom config.
279    pub fn with_config(config: LoopConfig) -> Self {
280        Self {
281            state: LoopState::default(),
282            config,
283        }
284    }
285
286    /// Check if the given output indicates a loop.
287    pub fn check(&mut self, output: &str) -> LoopStatus {
288        self.state.total_outputs += 1;
289
290        if let Some(detection) = detect_loop(&self.config, &self.state, output) {
291            self.state.loops_detected += 1;
292            let action = action_for_repeats(&self.config, detection.repeats);
293            let status = LoopStatus::LoopDetected {
294                repeats: detection.repeats,
295                action,
296                kind: detection.kind,
297            };
298            // Still record the output in the window after detection.
299            let hash = similarity_hash(output);
300            let signature = loop_signature(output);
301            self.state.push_output(&self.config, hash, signature);
302            return status;
303        }
304
305        if output.len() >= self.config.min_output_len {
306            let hash = similarity_hash(output);
307            let signature = loop_signature(output);
308            self.state.push_output(&self.config, hash, signature);
309        }
310
311        LoopStatus::Ok
312    }
313
314    /// Record iteration success and return whether the agent should halt.
315    pub fn record_success(&mut self) -> bool {
316        self.state.record_success();
317        should_halt(&self.state, &self.config)
318    }
319
320    /// Record iteration failure and return whether the agent should halt.
321    pub fn record_failure(&mut self) -> bool {
322        self.state.record_failure();
323        should_halt(&self.state, &self.config)
324    }
325
326    /// Reset the detector (e.g., on new conversation).
327    pub fn reset(&mut self) {
328        self.state = LoopState::default();
329    }
330
331    /// Get statistics: (total outputs, loops detected).
332    pub fn stats(&self) -> (usize, usize) {
333        (
334            self.state.total_outputs as usize,
335            self.state.loops_detected as usize,
336        )
337    }
338
339    /// Access current configuration.
340    pub fn config(&self) -> &LoopConfig {
341        &self.config
342    }
343
344    /// Access current state.
345    pub fn state(&self) -> &LoopState {
346        &self.state
347    }
348}
349
350impl Default for LoopDetector {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn sample_config() -> LoopConfig {
361        LoopConfig {
362            window_size: 10,
363            repeat_threshold: 3,
364            min_output_len: 20,
365            max_iterations: None,
366            halt_on_max: false,
367            count_failed_iterations: true,
368            fuzzy_match: false,
369            halt_on_consecutive_failures: 3,
370        }
371    }
372
373    // --- Serde roundtrips ---
374
375    #[test]
376    fn loop_config_serde_roundtrip_json() {
377        let cfg = LoopConfig {
378            max_iterations: Some(50),
379            halt_on_max: true,
380            fuzzy_match: true,
381            ..sample_config()
382        };
383        let json = serde_json::to_string(&cfg).expect("serialize");
384        let back: LoopConfig = serde_json::from_str(&json).expect("deserialize");
385        assert_eq!(cfg, back);
386    }
387
388    #[test]
389    fn loop_state_serde_roundtrip_json() {
390        let state = LoopState {
391            recent_hashes: vec![1, 2, 3],
392            recent_signatures: vec!["abc".into(), "def".into()],
393            total_outputs: 5,
394            loops_detected: 1,
395            iterations_run: 10,
396            iterations_succeeded: 8,
397            iterations_failed: 2,
398            consecutive_failures: 1,
399        };
400        let json = serde_json::to_string(&state).expect("serialize");
401        let back: LoopState = serde_json::from_str(&json).expect("deserialize");
402        assert_eq!(state, back);
403    }
404
405    // --- Pure helpers ---
406
407    #[test]
408    fn similarity_hash_normalizes_whitespace_and_case() {
409        let a = similarity_hash("Hello   WORLD");
410        let b = similarity_hash("hello\nworld");
411        assert_eq!(a, b);
412    }
413
414    #[test]
415    fn loop_signature_is_stable_and_normalized() {
416        let sig = loop_signature("  Foo\tBAR  ");
417        assert_eq!(sig, "foobar");
418    }
419
420    #[test]
421    fn count_repetitions_counts_exact_matches() {
422        let hashes = vec![10, 20, 10, 30, 10];
423        assert_eq!(count_repetitions(&hashes, 10), 3);
424        assert_eq!(count_repetitions(&hashes, 99), 0);
425    }
426
427    #[test]
428    fn count_fuzzy_repetitions_detects_near_duplicates() {
429        let sigs = vec![
430            "searchingthecodebaseforauthhandlers".into(),
431            "searchingthecodebaseforauthhandlerz".into(),
432        ];
433        let current = "searchingthecodebaseforauthhandlernow";
434        assert_eq!(count_fuzzy_repetitions(&sigs, current), 2);
435    }
436
437    // --- detect_loop exact / fuzzy ---
438
439    #[test]
440    fn detect_loop_exact_repetition() {
441        let config = LoopConfig {
442            repeat_threshold: 2,
443            min_output_len: 10,
444            ..sample_config()
445        };
446        let repeated = "This is a repeated agent response for testing.";
447        let mut state = LoopState::default();
448        let hash = similarity_hash(repeated);
449        let sig = loop_signature(repeated);
450        state.push_output(&config, hash, sig.clone());
451        state.push_output(&config, hash, sig);
452
453        assert_eq!(
454            detect_loop(&config, &state, repeated),
455            Some(LoopDetection {
456                repeats: 2,
457                kind: LoopMatchKind::Exact,
458            })
459        );
460    }
461
462    #[test]
463    fn detect_loop_no_loop_for_varied_outputs() {
464        let config = sample_config();
465        let state = LoopState::default();
466        assert!(detect_loop(
467            &config,
468            &state,
469            "First unique output that is definitely long enough."
470        )
471        .is_none());
472    }
473
474    #[test]
475    fn detect_loop_skips_short_output() {
476        let config = sample_config();
477        let state = LoopState::default();
478        assert!(detect_loop(&config, &state, "short").is_none());
479    }
480
481    #[test]
482    fn detect_loop_fuzzy_match_when_enabled() {
483        let config = LoopConfig {
484            repeat_threshold: 2,
485            min_output_len: 10,
486            fuzzy_match: true,
487            ..sample_config()
488        };
489        let base = "I am searching the codebase for authentication handlers now";
490        let near = "I am searching the codebase for authentication handlers today";
491        let mut state = LoopState::default();
492        state.push_output(&config, similarity_hash(base), loop_signature(base));
493        state.push_output(
494            &config,
495            similarity_hash("different hash variant"),
496            loop_signature(base),
497        );
498
499        let detection = detect_loop(&config, &state, near).expect("fuzzy loop");
500        assert_eq!(detection.kind, LoopMatchKind::Fuzzy);
501        assert!(detection.repeats >= 2);
502    }
503
504    #[test]
505    fn detect_loop_cache_key_pattern_repetition() {
506        let config = LoopConfig {
507            repeat_threshold: 2,
508            min_output_len: 15,
509            ..sample_config()
510        };
511        let key_a = "cache:get:user-session:abc123-def456-789";
512        let key_b = "cache:get:user-session:abc123-def456-790";
513        let mut state = LoopState::default();
514        state.push_output(&config, similarity_hash(key_a), loop_signature(key_a));
515        state.push_output(&config, similarity_hash(key_b), loop_signature(key_b));
516
517        // Same prefix-heavy cache keys share a signature prefix → exact hash may differ
518        // but message-pattern style repetition is caught via similar signatures when fuzzy on.
519        let config_fuzzy = LoopConfig {
520            fuzzy_match: true,
521            ..config
522        };
523        let detection = detect_loop(&config_fuzzy, &state, key_b);
524        assert!(detection.is_some());
525    }
526
527    #[test]
528    fn detect_loop_message_pattern_repetition() {
529        let config = LoopConfig {
530            repeat_threshold: 2,
531            min_output_len: 20,
532            fuzzy_match: true,
533            ..sample_config()
534        };
535        let msg1 = "Let me try running the tests again to see what fails.";
536        let msg2 = "Let me try running the tests again to see what breaks.";
537        let mut state = LoopState::default();
538        state.push_output(&config, similarity_hash(msg1), loop_signature(msg1));
539        state.push_output(&config, similarity_hash(msg1), loop_signature(msg1));
540
541        let detection = detect_loop(&config, &state, msg2).expect("message pattern loop");
542        assert!(detection.repeats >= 2);
543    }
544
545    // --- max_iterations / halt_on_max ---
546
547    #[test]
548    fn should_halt_on_max_iterations_when_at_limit() {
549        let config = LoopConfig {
550            max_iterations: Some(3),
551            halt_on_max: true,
552            count_failed_iterations: true,
553            ..sample_config()
554        };
555        let mut state = LoopState::default();
556        state.record_success();
557        state.record_success();
558        assert!(!should_halt_on_max_iterations(&state, &config));
559        state.record_success();
560        assert!(should_halt_on_max_iterations(&state, &config));
561    }
562
563    #[test]
564    fn should_halt_on_max_iterations_boundary_below_limit() {
565        let config = LoopConfig {
566            max_iterations: Some(2),
567            halt_on_max: true,
568            ..sample_config()
569        };
570        let mut state = LoopState::default();
571        state.record_success();
572        assert!(!should_halt_on_max_iterations(&state, &config));
573    }
574
575    #[test]
576    fn should_halt_on_max_iterations_ignores_when_halt_on_max_disabled() {
577        let config = LoopConfig {
578            max_iterations: Some(1),
579            halt_on_max: false,
580            ..sample_config()
581        };
582        let mut state = LoopState::default();
583        state.record_success();
584        assert!(!should_halt_on_max_iterations(&state, &config));
585    }
586
587    #[test]
588    fn should_halt_on_max_iterations_counting_failures() {
589        let config = LoopConfig {
590            max_iterations: Some(2),
591            halt_on_max: true,
592            count_failed_iterations: true,
593            ..sample_config()
594        };
595        let mut state = LoopState::default();
596        state.record_failure();
597        assert!(!should_halt_on_max_iterations(&state, &config));
598        state.record_failure();
599        assert!(should_halt_on_max_iterations(&state, &config));
600    }
601
602    #[test]
603    fn should_halt_on_max_iterations_ignoring_failures() {
604        let config = LoopConfig {
605            max_iterations: Some(2),
606            halt_on_max: true,
607            count_failed_iterations: false,
608            ..sample_config()
609        };
610        let mut state = LoopState::default();
611        state.record_failure();
612        state.record_failure();
613        assert!(!should_halt_on_max_iterations(&state, &config));
614        state.record_success();
615        state.record_success();
616        assert!(should_halt_on_max_iterations(&state, &config));
617    }
618
619    #[test]
620    fn loop_detector_record_success_resets_consecutive_failures() {
621        let mut detector = LoopDetector::with_config(sample_config());
622        detector.state.consecutive_failures = 2;
623        detector.record_success();
624        assert_eq!(detector.state().consecutive_failures, 0);
625        assert_eq!(detector.state().iterations_succeeded, 1);
626    }
627
628    #[test]
629    fn loop_detector_record_failure_increments_consecutive_failures() {
630        let mut detector = LoopDetector::with_config(LoopConfig {
631            halt_on_consecutive_failures: 5,
632            ..sample_config()
633        });
634        detector.record_failure();
635        detector.record_failure();
636        assert_eq!(detector.state().consecutive_failures, 2);
637        assert_eq!(detector.state().iterations_failed, 2);
638    }
639
640    #[test]
641    fn loop_detector_halt_on_max_via_record_success() {
642        let mut detector = LoopDetector::with_config(LoopConfig {
643            max_iterations: Some(2),
644            halt_on_max: true,
645            count_failed_iterations: false,
646            ..sample_config()
647        });
648        assert!(!detector.record_success());
649        assert!(detector.record_success());
650    }
651
652    // --- LoopDetector integration (existing behavior) ---
653
654    #[test]
655    fn test_no_loop() {
656        let mut detector = LoopDetector::new();
657        assert_eq!(detector.check("Hello, how can I help?"), LoopStatus::Ok);
658        assert_eq!(detector.check("I can assist with that."), LoopStatus::Ok);
659        assert_eq!(detector.check("Here's what I found."), LoopStatus::Ok);
660    }
661
662    #[test]
663    fn test_loop_detected() {
664        let mut detector = LoopDetector::new();
665        let repeated = "I'm sorry, I cannot help with that request at this time.";
666        assert_eq!(detector.check(repeated), LoopStatus::Ok);
667        assert_eq!(detector.check(repeated), LoopStatus::Ok);
668        assert_eq!(detector.check(repeated), LoopStatus::Ok);
669        match detector.check(repeated) {
670            LoopStatus::LoopDetected {
671                repeats,
672                action,
673                kind,
674            } => {
675                assert!(repeats >= 3);
676                assert_eq!(action, LoopAction::InjectWarning);
677                assert_eq!(kind, LoopMatchKind::Exact);
678            }
679            _ => panic!("should detect loop"),
680        }
681    }
682
683    #[test]
684    fn test_short_output_ignored() {
685        let mut detector = LoopDetector::new();
686        assert_eq!(detector.check("ok"), LoopStatus::Ok);
687        assert_eq!(detector.check("ok"), LoopStatus::Ok);
688        assert_eq!(detector.check("ok"), LoopStatus::Ok);
689        assert_eq!(detector.check("ok"), LoopStatus::Ok);
690    }
691
692    #[test]
693    fn test_escalation() {
694        let mut detector = LoopDetector::with_config(LoopConfig {
695            window_size: 20,
696            repeat_threshold: 2,
697            min_output_len: 10,
698            ..LoopConfig::default()
699        });
700        let repeated = "This is a repeated response that keeps coming back.";
701        detector.check(repeated);
702        detector.check(repeated);
703        match detector.check(repeated) {
704            LoopStatus::LoopDetected { action, .. } => {
705                assert_eq!(action, LoopAction::InjectWarning)
706            }
707            _ => panic!("should warn"),
708        }
709        match detector.check(repeated) {
710            LoopStatus::LoopDetected { action, .. } => {
711                assert_eq!(action, LoopAction::ForceAlternative)
712            }
713            _ => panic!("should force alternative"),
714        }
715    }
716
717    #[test]
718    fn test_reset() {
719        let mut detector = LoopDetector::new();
720        let repeated = "A repeated output that should trigger detection.";
721        detector.check(repeated);
722        detector.check(repeated);
723        detector.check(repeated);
724        detector.reset();
725        assert_eq!(detector.check(repeated), LoopStatus::Ok);
726    }
727
728    #[test]
729    fn test_stats() {
730        let mut detector = LoopDetector::new();
731        detector.check("First unique output here and now.");
732        detector.check("Second unique output here and now.");
733        let (total, loops) = detector.stats();
734        assert_eq!(total, 2);
735        assert_eq!(loops, 0);
736    }
737
738    #[test]
739    fn test_whitespace_normalization() {
740        let mut detector = LoopDetector::with_config(LoopConfig {
741            repeat_threshold: 2,
742            ..LoopConfig::default()
743        });
744        detector.check("Hello   world,  how are you doing today?");
745        detector.check("Hello world, how are you doing today?");
746        match detector.check("Hello\n\tworld,\thow are you doing today?") {
747            LoopStatus::LoopDetected { kind, .. } => {
748                assert_eq!(kind, LoopMatchKind::Exact);
749            }
750            _ => panic!("whitespace-normalized duplicates should match"),
751        }
752    }
753
754    #[test]
755    fn test_config_default_values() {
756        let cfg = LoopConfig::default();
757        assert_eq!(cfg.window_size, 10);
758        assert_eq!(cfg.repeat_threshold, 3);
759        assert_eq!(cfg.min_output_len, 20);
760    }
761
762    #[test]
763    fn test_config_custom_values() {
764        let mut detector = LoopDetector::with_config(LoopConfig {
765            window_size: 5,
766            repeat_threshold: 2,
767            min_output_len: 30,
768            ..LoopConfig::default()
769        });
770        assert_eq!(detector.check("short"), LoopStatus::Ok);
771        assert_eq!(detector.check("short"), LoopStatus::Ok);
772        let long = "This output is long enough for custom config.";
773        assert_eq!(detector.check(&long), LoopStatus::Ok);
774        assert_eq!(detector.check(&long), LoopStatus::Ok);
775        match detector.check(&long) {
776            LoopStatus::LoopDetected { .. } => {}
777            other => panic!("custom threshold should detect loop, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn test_case_insensitive_duplicate_detection() {
783        let mut detector = LoopDetector::with_config(LoopConfig {
784            repeat_threshold: 2,
785            min_output_len: 10,
786            ..LoopConfig::default()
787        });
788        detector.check("HELLO WORLD, this is a long enough output.");
789        detector.check("hello world, this is a long enough output.");
790        match detector.check("Hello\nWorld, this is a long enough output.") {
791            LoopStatus::LoopDetected { .. } => {}
792            other => panic!("expected loop, got {other:?}"),
793        }
794    }
795
796    #[test]
797    fn test_window_eviction_prevents_stale_loop() {
798        let mut detector = LoopDetector::with_config(LoopConfig {
799            window_size: 2,
800            repeat_threshold: 2,
801            min_output_len: 10,
802            ..LoopConfig::default()
803        });
804        let repeated = "Repeated output long enough to count.";
805        detector.check(repeated);
806        detector.check(repeated);
807        detector.check("Completely different output that is long.");
808        detector.check("Another unique output that is still long.");
809        assert_eq!(detector.check(repeated), LoopStatus::Ok);
810    }
811
812    #[test]
813    fn test_halt_agent_on_severe_repetition() {
814        let mut detector = LoopDetector::with_config(LoopConfig {
815            window_size: 20,
816            repeat_threshold: 2,
817            min_output_len: 10,
818            ..LoopConfig::default()
819        });
820        let repeated = "Severe repetition output for halt testing.";
821        detector.check(repeated);
822        detector.check(repeated);
823        detector.check(repeated);
824        detector.check(repeated);
825        match detector.check(repeated) {
826            LoopStatus::LoopDetected { action, .. } => {
827                assert_eq!(action, LoopAction::HaltAgent);
828            }
829            other => panic!("expected halt, got {other:?}"),
830        }
831    }
832}