1use serde::{Deserialize, Serialize};
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10
11#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
13pub struct LoopConfig {
14 #[serde(default = "default_window_size")]
16 pub window_size: usize,
17 #[serde(default = "default_repeat_threshold")]
19 pub repeat_threshold: usize,
20 #[serde(default = "default_min_output_len")]
22 pub min_output_len: usize,
23 #[serde(default)]
25 pub max_iterations: Option<u64>,
26 #[serde(default)]
28 pub halt_on_max: bool,
29 #[serde(default = "default_count_failures")]
31 pub count_failed_iterations: bool,
32 #[serde(default)]
34 pub fuzzy_match: bool,
35 #[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
71pub type LoopDetectorConfig = LoopConfig;
73
74#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct LoopState {
77 pub recent_hashes: Vec<u64>,
79 pub recent_signatures: Vec<String>,
81 pub total_outputs: u64,
83 pub loops_detected: u64,
85 pub iterations_run: u64,
87 pub iterations_succeeded: u64,
88 pub iterations_failed: u64,
89 pub consecutive_failures: u32,
90}
91
92impl LoopState {
93 pub fn record_success(&mut self) {
95 self.iterations_run += 1;
96 self.iterations_succeeded += 1;
97 self.consecutive_failures = 0;
98 }
99
100 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#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum LoopStatus {
120 Ok,
122 LoopDetected {
124 repeats: usize,
126 action: LoopAction,
128 kind: LoopMatchKind,
130 },
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
135pub enum LoopMatchKind {
136 Exact,
137 Fuzzy,
138}
139
140#[derive(Clone, Debug, PartialEq, Eq)]
142pub enum LoopAction {
143 InjectWarning,
145 ForceAlternative,
147 HaltAgent,
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct LoopDetection {
154 pub repeats: usize,
155 pub kind: LoopMatchKind,
156}
157
158pub 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
166pub 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
176pub fn count_repetitions(hashes: &[u64], hash: u64) -> usize {
178 hashes.iter().filter(|&&h| h == hash).count()
179}
180
181pub 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
205pub 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
233pub 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
249pub 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#[derive(Clone, Debug)]
267pub struct LoopDetector {
268 config: LoopConfig,
269 state: LoopState,
270}
271
272impl LoopDetector {
273 pub fn new() -> Self {
275 Self::with_config(LoopConfig::default())
276 }
277
278 pub fn with_config(config: LoopConfig) -> Self {
280 Self {
281 state: LoopState::default(),
282 config,
283 }
284 }
285
286 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 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 pub fn record_success(&mut self) -> bool {
316 self.state.record_success();
317 should_halt(&self.state, &self.config)
318 }
319
320 pub fn record_failure(&mut self) -> bool {
322 self.state.record_failure();
323 should_halt(&self.state, &self.config)
324 }
325
326 pub fn reset(&mut self) {
328 self.state = LoopState::default();
329 }
330
331 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 pub fn config(&self) -> &LoopConfig {
341 &self.config
342 }
343
344 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 #[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 #[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 #[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 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 #[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 #[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}