Skip to main content

oxicode_ai/utils/
thinking_loop.rs

1//! Thinking-loop detector — ported from omp
2//! `packages/ai/src/utils/thinking-loop.ts`.
3//!
4//! MIT — attribution: adapted from
5//! [omp](https://github.com/can1357/oh-my-pi) (Can Berk Güder, earendil-works).
6//!
7//! ## Purpose
8//!
9//! Reasoning models (Gemini, DeepSeek-R1, …) sometimes enter a degenerate
10//! loop in their thinking stream: the same paragraph reshuffled over and
11//! over, a short token repeated back-to-back, or filler that recycles the
12//! recent vocabulary without ever naming a new concrete reference. The
13//! model bills tokens indefinitely; the user never sees an answer.
14//!
15//! This module provides [`ThinkingLoopDetector`] — a stateful detector
16//! fed streamed thinking deltas. It recognises three loop shapes:
17//!
18//! 1. **Verbatim tail repetition** — a short unit repeated back-to-back
19//!    at the tail of the rolling window (e.g. `🌊 🌊 🌊 …`).
20//! 2. **Near-duplicate segment cluster** — paragraphs whose word-trigram
21//!    fingerprints overlap above a Jaccard threshold (cosmetic rewording
22//!    of the same paragraph).
23//! 3. **Progress-lexicon stall** — paragraphs that recycle the recent
24//!    vocabulary (low novelty) and introduce no new concrete reference
25//!    (no new code span / path / identifier).
26
27use std::collections::HashSet;
28
29/// Rolling tail (chars) inspected for verbatim back-to-back repetition.
30const VERBATIM_TAIL_WINDOW: usize = 250;
31/// Minimum total repeated chars before a verbatim run counts as a loop.
32const VERBATIM_MIN_REPEATED_CHARS: usize = 180;
33/// Longest unit length probed for a verbatim repeat.
34const VERBATIM_MAX_UNIT: usize = 60;
35/// Minimum consecutive repeats for a verbatim loop.
36const VERBATIM_MIN_COUNT: usize = 4;
37
38/// Char cap for an unterminated segment; forces a flush so a wall-of-text
39/// loop (no blank lines / headings) still segments.
40const SEGMENT_CHAR_CAP: usize = 700;
41/// Normalized-length floor below which a segment is ignored.
42const SEGMENT_MIN_NORM_CHARS: usize = 60;
43/// How many recent substantial segments are kept for similarity
44/// comparison.
45const SEGMENT_WINDOW: usize = 16;
46/// Word-trigram Jaccard at/above which two segments count as
47/// near-duplicates.
48const SEGMENT_SIMILARITY: f64 = 0.8;
49/// Substantial segments required before detection may fire (warm-up).
50const SEGMENT_MIN_COUNT: usize = 8;
51/// Near-duplicate cluster size (current + matches) that trips the loop.
52const SEGMENT_MIN_CLUSTER: usize = 4;
53
54/// Recent segments whose pooled unigram vocabulary is the novelty
55/// baseline for progress-lexicon stall detection.
56const LEX_NOVELTY_WINDOW: usize = 8;
57/// Novelty (fraction of a segment's content words unseen across the
58/// recent window) at/below which a segment counts as recycling earlier
59/// wording.
60const LEX_STALL_NOVELTY_FLOOR: f64 = 0.2;
61/// Consecutive low-information segments that trip a progress-lexicon
62/// stall.
63const LEX_STALL_MIN_RUN: usize = 8;
64
65/// Stable lead phrase of the detector's reason strings. Used by upstream
66/// retry classifiers to recognise the failure as a transient stream
67/// stall.
68pub const THINKING_LOOP_MARKER: &str = "thinking loop detected";
69
70/// Stateful detector fed streamed thinking deltas.
71#[derive(Debug, Default)]
72pub struct ThinkingLoopDetector {
73    tail: String,
74    pending: String,
75    window: Vec<HashSet<String>>,
76    count: usize,
77    word_window: Vec<HashSet<String>>,
78    lex_stall_run: usize,
79    anchor_window: Vec<HashSet<String>>,
80    fired: bool,
81}
82
83impl ThinkingLoopDetector {
84    /// Construct a fresh detector with empty state.
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Feed one streamed thinking delta.
90    ///
91    /// Returns `Some(reason)` the first time a loop is recognised. The
92    /// caller should stop the stream and surface the reason upstream.
93    /// Empty deltas are no-ops.
94    pub fn push(&mut self, delta: &str) -> Option<String> {
95        if self.fired || delta.is_empty() {
96            return None;
97        }
98
99        // 1. Verbatim back-to-back repetition over the rolling tail.
100        self.tail.push_str(delta);
101        let tail_chars = self.tail.chars().count();
102        if tail_chars > VERBATIM_TAIL_WINDOW {
103            let skip = tail_chars - VERBATIM_TAIL_WINDOW;
104            self.tail = self.tail.chars().skip(skip).collect();
105        }
106        if let Some((unit, times)) = detect_verbatim_repetition(&self.tail) {
107            self.fired = true;
108            let trimmed = unit.trim();
109            return Some(format!(
110                "{THINKING_LOOP_MARKER}: repeated \"{trimmed}\" {times}× back-to-back"
111            ));
112        }
113
114        // 2. Near-duplicate paragraph loop.
115        self.pending.push_str(delta);
116        loop {
117            let boundary = find_blank_line(&self.pending);
118            let raw: String;
119            match boundary {
120                Some(range) => {
121                    // Drain the buffer through the end of the blank-line
122                    // run, but keep only the segment text (chars before
123                    // the run).
124                    let consumed: String = self.pending.drain(..range.end_byte).collect();
125                    raw = consumed
126                        .char_indices()
127                        .take(range.start_char)
128                        .map(|(_, c)| c)
129                        .collect();
130                }
131                None => {
132                    let pending_chars = self.pending.chars().count();
133                    if pending_chars > SEGMENT_CHAR_CAP {
134                        let split_at = self
135                            .pending
136                            .char_indices()
137                            .nth(SEGMENT_CHAR_CAP)
138                            .map(|(b, _)| b)
139                            .unwrap_or(self.pending.len());
140                        raw = self.pending.drain(..split_at).collect();
141                    } else {
142                        return None;
143                    }
144                }
145            }
146            let mut rest = raw;
147            while !rest.is_empty() {
148                let chunk_len = rest.chars().count().min(SEGMENT_CHAR_CAP);
149                let split_at = rest
150                    .char_indices()
151                    .nth(chunk_len)
152                    .map(|(b, _)| b)
153                    .unwrap_or(rest.len());
154                let chunk: String = rest.drain(..split_at).collect();
155                if let Some(hit) = self.consume_segment(&chunk) {
156                    self.fired = true;
157                    return Some(hit);
158                }
159            }
160        }
161    }
162
163    /// Process the buffered trailing paragraph. Called when the thinking
164    /// block ends so the final segment is not dropped.
165    pub fn flush(&mut self) -> Option<String> {
166        if self.fired || self.pending.is_empty() {
167            return None;
168        }
169        let mut rest = std::mem::take(&mut self.pending);
170        while !rest.is_empty() {
171            let chunk_len = rest.chars().count().min(SEGMENT_CHAR_CAP);
172            let split_at = rest
173                .char_indices()
174                .nth(chunk_len)
175                .map(|(b, _)| b)
176                .unwrap_or(rest.len());
177            let chunk: String = rest.drain(..split_at).collect();
178            if let Some(hit) = self.consume_segment(&chunk) {
179                self.fired = true;
180                return Some(hit);
181            }
182        }
183        None
184    }
185
186    /// Reset to initial state.
187    pub fn reset(&mut self) {
188        *self = Self::default();
189    }
190
191    /// True once a loop has been recognised (sticky until `reset`).
192    pub fn fired(&self) -> bool {
193        self.fired
194    }
195
196    fn consume_segment(&mut self, raw: &str) -> Option<String> {
197        let stripped = strip_summary_headers(raw);
198        let normalized = normalize_segment(&stripped);
199        if normalized.chars().count() < SEGMENT_MIN_NORM_CHARS {
200            return None;
201        }
202
203        // (a) Near-duplicate trigram cluster.
204        let fingerprint = trigram_shingles(&normalized);
205        let mut cluster = 1usize;
206        for prev in &self.window {
207            if jaccard(&fingerprint, prev) >= SEGMENT_SIMILARITY {
208                cluster += 1;
209            }
210        }
211
212        // (b) Progress-lexicon stall.
213        let words: HashSet<String> = normalized
214            .split_whitespace()
215            .filter(|w| !w.is_empty())
216            .map(|w| w.to_string())
217            .collect();
218        let mut prior_vocab: HashSet<String> = HashSet::new();
219        for set in &self.word_window {
220            for w in set {
221                prior_vocab.insert(w.clone());
222            }
223        }
224        let unseen = words.iter().filter(|w| !prior_vocab.contains(*w)).count();
225        let novelty = if prior_vocab.is_empty() {
226            1.0
227        } else {
228            unseen as f64 / words.len().max(1) as f64
229        };
230
231        let anchors = extract_concrete_anchors(&stripped);
232        let mut new_anchor = false;
233        for anchor in &anchors {
234            let is_new = self.anchor_window.iter().all(|seen| !seen.contains(anchor));
235            if is_new {
236                new_anchor = true;
237                break;
238            }
239        }
240
241        if novelty <= LEX_STALL_NOVELTY_FLOOR && !new_anchor {
242            self.lex_stall_run += 1;
243        } else {
244            self.lex_stall_run = 0;
245        }
246
247        self.window.push(fingerprint);
248        if self.window.len() > SEGMENT_WINDOW {
249            self.window.remove(0);
250        }
251        self.word_window.push(words);
252        if self.word_window.len() > LEX_NOVELTY_WINDOW {
253            self.word_window.remove(0);
254        }
255        self.anchor_window.push(anchors);
256        if self.anchor_window.len() > LEX_NOVELTY_WINDOW {
257            self.anchor_window.remove(0);
258        }
259        self.count += 1;
260
261        if self.count >= SEGMENT_MIN_COUNT {
262            if cluster >= SEGMENT_MIN_CLUSTER {
263                return Some(format!(
264                    "{THINKING_LOOP_MARKER}: {cluster} near-identical segments within the last {SEGMENT_WINDOW}"
265                ));
266            }
267            if self.lex_stall_run >= LEX_STALL_MIN_RUN {
268                return Some(format!(
269                    "{THINKING_LOOP_MARKER}: {} low-information segments recycling recent wording",
270                    self.lex_stall_run
271                ));
272            }
273        }
274        None
275    }
276}
277
278/// A found blank-line boundary — character index where the boundary
279/// starts and the byte offset just past the end of the consumed run.
280struct CharRange {
281    start_char: usize,
282    end_byte: usize,
283}
284
285/// Find the first `\n\s*\n` boundary in `pending`. Returns the
286/// character index of the boundary and the byte offset just past the
287/// consumed run.
288fn find_blank_line(pending: &str) -> Option<CharRange> {
289    let chars: Vec<char> = pending.chars().collect();
290    let mut i = 0;
291    while i + 1 < chars.len() {
292        if chars[i] == '\n' {
293            let mut j = i + 1;
294            while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t' || chars[j] == '\n') {
295                j += 1;
296            }
297            if j > i + 1 {
298                let mut bytes_consumed = 0usize;
299                for (k, c) in chars.iter().enumerate() {
300                    if k >= j {
301                        break;
302                    }
303                    bytes_consumed += c.len_utf8();
304                }
305                return Some(CharRange {
306                    start_char: i,
307                    end_byte: bytes_consumed,
308                });
309            }
310        }
311        i += 1;
312    }
313    None
314}
315
316/// Detect a short unit repeated back-to-back at the tail (verbatim
317/// loop). Only a unit carrying a letter or pictographic emoji counts.
318fn detect_verbatim_repetition(text: &str) -> Option<(String, usize)> {
319    let chars: Vec<char> = text.chars().collect();
320    if chars.len() < VERBATIM_MIN_REPEATED_CHARS {
321        return None;
322    }
323    let window_size = chars.len().min(VERBATIM_TAIL_WINDOW);
324    let search_space = &chars[chars.len() - window_size..];
325
326    for len in 2..=VERBATIM_MAX_UNIT {
327        if search_space.len() < len * 4 {
328            continue;
329        }
330        let unit: String = search_space[search_space.len() - len..].iter().collect();
331        if !unit
332            .chars()
333            .any(|c| c.is_alphabetic() || is_pictographic(c))
334        {
335            continue;
336        }
337
338        let mut count = 0usize;
339        let mut pos = search_space.len();
340        while pos >= len {
341            let slice = &search_space[pos - len..pos];
342            let candidate: String = slice.iter().collect();
343            if candidate == unit {
344                count += 1;
345                pos -= len;
346            } else {
347                break;
348            }
349        }
350        if count >= VERBATIM_MIN_COUNT && len * count >= VERBATIM_MIN_REPEATED_CHARS {
351            return Some((unit, count));
352        }
353    }
354    None
355}
356
357/// Conservative pictographic check (covers common emoji ranges without
358/// pulling in `unicode-properties` or regex).
359fn is_pictographic(c: char) -> bool {
360    let cp = c as u32;
361    matches!(
362        cp,
363        0x1F300..=0x1F5FF
364            | 0x1F600..=0x1F64F
365            | 0x1F680..=0x1F6FF
366            | 0x1F700..=0x1F77F
367            | 0x1F780..=0x1F7FF
368            | 0x1F800..=0x1F8FF
369            | 0x1F900..=0x1F9FF
370            | 0x1FA00..=0x1FA6F
371            | 0x1FA70..=0x1FAFF
372            | 0x2600..=0x26FF
373            | 0x2700..=0x27BF
374    )
375}
376
377/// Strip reasoning-summarizer titles ("## Heading", "**bold title**").
378fn strip_summary_headers(s: &str) -> String {
379    use std::fmt::Write;
380    let mut out = String::with_capacity(s.len());
381    for line in s.lines() {
382        let trimmed = line.trim_start();
383        if trimmed.starts_with('#') {
384            let mut hash_count = 0;
385            for c in trimmed.chars() {
386                if c == '#' {
387                    hash_count += 1;
388                } else {
389                    break;
390                }
391            }
392            if (1..=6).contains(&hash_count) {
393                let after = &trimmed[hash_count..];
394                if after.starts_with(' ') || after.starts_with('\t') {
395                    continue;
396                }
397            }
398        }
399        if trimmed.starts_with("**")
400            && trimmed.len() >= 4
401            && trimmed[2..].trim_end().ends_with("**")
402        {
403            continue;
404        }
405        if trimmed.starts_with("***")
406            && trimmed.len() >= 6
407            && trimmed[3..].trim_end().ends_with("***")
408        {
409            continue;
410        }
411        let _ = writeln!(out, "{line}");
412    }
413    out
414}
415
416/// Lowercase and tokenize prose plus code/path payloads, dropping pure
417/// numbers.
418fn normalize_segment(segment: &str) -> String {
419    use std::fmt::Write;
420    let lower: String = segment.to_lowercase();
421    let unbackticked = lower.replace('`', " ");
422    let mut out = String::with_capacity(unbackticked.len());
423    let mut prev_space = true;
424    for c in unbackticked.chars() {
425        if c.is_ascii_alphanumeric() {
426            out.push(c);
427            prev_space = false;
428        } else if !prev_space {
429            out.push(' ');
430            prev_space = true;
431        }
432    }
433    let mut filtered = String::with_capacity(out.len());
434    for token in out.split_whitespace() {
435        if token.chars().any(|c| c.is_ascii_lowercase()) {
436            let _ = write!(filtered, "{token} ");
437        }
438    }
439    filtered.trim_end().to_string()
440}
441
442/// Word-trigram shingle set of a normalized segment.
443fn trigram_shingles(normalized: &str) -> HashSet<String> {
444    let words: Vec<&str> = normalized.split_whitespace().collect();
445    let mut shingles = HashSet::new();
446    if words.len() < 3 {
447        if !words.is_empty() {
448            shingles.insert(words.join(" "));
449        }
450        return shingles;
451    }
452    for i in 0..=words.len() - 3 {
453        shingles.insert(format!("{} {} {}", words[i], words[i + 1], words[i + 2]));
454    }
455    shingles
456}
457
458fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
459    if a.is_empty() || b.is_empty() {
460        return 0.0;
461    }
462    let (small, large) = if a.len() < b.len() { (a, b) } else { (b, a) };
463    let mut intersection = 0usize;
464    for x in small {
465        if large.contains(x) {
466            intersection += 1;
467        }
468    }
469    let union = a.len() + b.len() - intersection;
470    if union == 0 {
471        0.0
472    } else {
473        intersection as f64 / union as f64
474    }
475}
476
477/// Extract concrete references the model is reasoning about: code spans
478/// (backticks), multi-segment paths, snake/camel/Pascal identifiers.
479fn extract_concrete_anchors(segment: &str) -> HashSet<String> {
480    let mut out = HashSet::new();
481
482    // Backtick code spans.
483    let mut cur = String::new();
484    let mut in_backtick = false;
485    for c in segment.chars() {
486        if c == '`' {
487            if in_backtick {
488                let trimmed = cur.trim();
489                if !trimmed.is_empty() {
490                    out.insert(trimmed.to_lowercase());
491                }
492                cur.clear();
493                in_backtick = false;
494            } else {
495                in_backtick = true;
496            }
497            continue;
498        }
499        if in_backtick {
500            cur.push(c);
501        }
502    }
503    if in_backtick {
504        let trimmed = cur.trim();
505        if !trimmed.is_empty() {
506            out.insert(trimmed.to_lowercase());
507        }
508    }
509
510    // Multi-segment paths.
511    for token in segment.split_whitespace() {
512        if token.contains('/') && token.chars().any(|c| c.is_alphabetic()) {
513            out.insert(token.to_lowercase());
514        }
515    }
516
517    // snake_case and CamelCase / PascalCase identifiers.
518    for raw_token in segment.split_whitespace() {
519        let token: String = raw_token
520            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
521            .to_string();
522        if token.is_empty() {
523            continue;
524        }
525        let only_word = token.chars().all(|c| c.is_alphanumeric() || c == '_');
526        if !only_word {
527            continue;
528        }
529        let has_snake = token.contains('_');
530        let has_camel = token
531            .chars()
532            .enumerate()
533            .any(|(i, c)| i > 0 && c.is_ascii_uppercase());
534        if has_snake || has_camel {
535            out.insert(token.to_lowercase());
536        }
537    }
538    out
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn verbatim_repetition_short_unit_loop() {
547        let unit = "🌊".repeat(200);
548        let hit = detect_verbatim_repetition(&unit);
549        assert!(hit.is_some(), "expected verbatim detection");
550        let (u, n) = hit.unwrap();
551        assert!(u.contains('🌊'));
552        assert!(n >= 4);
553    }
554
555    #[test]
556    fn verbatim_repetition_too_short_no_loop() {
557        assert!(detect_verbatim_repetition("ababab").is_none());
558    }
559
560    #[test]
561    fn verbatim_repetition_punctuation_only_no_loop() {
562        let s = "----".repeat(60);
563        assert!(detect_verbatim_repetition(&s).is_none());
564    }
565
566    #[test]
567    fn detector_push_fires_on_verbatim_loop() {
568        let mut det = ThinkingLoopDetector::new();
569        let unit = "ab ".repeat(120);
570        let hit = det.push(&unit);
571        assert!(hit.is_some(), "verbatim loop should fire");
572        let reason = hit.unwrap();
573        assert!(reason.contains(THINKING_LOOP_MARKER));
574        assert!(reason.contains("back-to-back"));
575    }
576
577    #[test]
578    fn detector_fires_once_then_sticky() {
579        let mut det = ThinkingLoopDetector::new();
580        let unit = "ab ".repeat(120);
581        let first = det.push(&unit);
582        let second = det.push(&unit);
583        assert!(first.is_some());
584        assert!(second.is_none(), "after firing, pushes are no-ops");
585        assert!(det.fired());
586    }
587
588    #[test]
589    fn detector_reset_clears_state() {
590        let mut det = ThinkingLoopDetector::new();
591        let _ = det.push(&"ab ".repeat(120));
592        assert!(det.fired());
593        det.reset();
594        assert!(!det.fired());
595    }
596
597    #[test]
598    fn detector_empty_push_is_noop() {
599        let mut det = ThinkingLoopDetector::new();
600        assert!(det.push("").is_none());
601    }
602
603    #[test]
604    fn normalize_segment_lowercases_and_drops_numbers() {
605        let n = normalize_segment("The QUICK brown 123 fox jumps over `code`");
606        assert!(n.contains("the"));
607        assert!(n.contains("quick"));
608        assert!(n.contains("brown"));
609        assert!(!n.contains("123"));
610    }
611
612    #[test]
613    fn trigram_shingles_three_words() {
614        let s = trigram_shingles("the quick brown");
615        assert_eq!(s.len(), 1);
616        assert!(s.contains("the quick brown"));
617    }
618
619    #[test]
620    fn trigram_shingles_short_input_passthrough() {
621        let s = trigram_shingles("hello");
622        assert_eq!(s.len(), 1);
623        assert!(s.contains("hello"));
624    }
625
626    #[test]
627    fn jaccard_identical_sets() {
628        let a: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
629        let b = a.clone();
630        assert!((jaccard(&a, &b) - 1.0).abs() < 1e-6);
631    }
632
633    #[test]
634    fn jaccard_disjoint_sets() {
635        let a: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
636        let b: HashSet<String> = ["b"].iter().map(|s| s.to_string()).collect();
637        assert!((jaccard(&a, &b) - 0.0).abs() < 1e-6);
638    }
639
640    #[test]
641    fn jaccard_empty_set_zero() {
642        let a: HashSet<String> = HashSet::new();
643        let b: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
644        assert!((jaccard(&a, &b) - 0.0).abs() < 1e-6);
645    }
646
647    #[test]
648    fn strip_summary_headers_drops_atx_and_bold() {
649        let input = "## Heading\n**bold title**\nactual content";
650        let stripped = strip_summary_headers(input);
651        assert!(!stripped.contains("Heading"));
652        assert!(!stripped.contains("bold title"));
653        assert!(stripped.contains("actual content"));
654    }
655
656    #[test]
657    fn extract_concrete_anchors_catches_code_spans() {
658        let s = "Look at `oxicode_ai::Message` and `lib.rs` for details";
659        let anchors = extract_concrete_anchors(s);
660        assert!(anchors.contains("oxicode_ai::message"));
661        assert!(anchors.contains("lib.rs"));
662    }
663
664    #[test]
665    fn extract_concrete_anchors_catches_paths() {
666        let s = "see src/main.rs and crates/foo/Cargo.toml";
667        let anchors = extract_concrete_anchors(s);
668        assert!(anchors.iter().any(|a| a.contains("src/main.rs")));
669        assert!(anchors.iter().any(|a| a.contains("crates/foo/cargo.toml")));
670    }
671
672    #[test]
673    fn extract_concrete_anchors_catches_snake_case() {
674        let s = "Consider the variable my_var_name.";
675        let anchors = extract_concrete_anchors(s);
676        assert!(anchors.contains("my_var_name"));
677    }
678
679    #[test]
680    fn extract_concrete_anchors_catches_camel_case() {
681        let s = "Use the MyCoolClass implementation.";
682        let anchors = extract_concrete_anchors(s);
683        assert!(anchors.contains("mycoolclass"));
684    }
685}