Skip to main content

lean_ctx/core/
loop_detection.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use super::config::LoopDetectionConfig;
5
6const SEARCH_TOOLS: &[&str] = &["ctx_search", "ctx_semantic_search"];
7
8const SEARCH_SHELL_PREFIXES: &[&str] = &["grep ", "rg ", "find ", "fd ", "ag ", "ack "];
9
10const CORRECTION_WINDOW: Duration = Duration::from_mins(2);
11const MODE_BOUNCE_WINDOW: Duration = Duration::from_secs(30);
12const SHELL_RERUN_WINDOW: Duration = Duration::from_mins(1);
13const COLD_START_CALLS: u32 = 3;
14
15/// Classification of why an agent re-requested data it already had.
16#[derive(Debug, Clone, PartialEq)]
17pub enum CorrectionKind {
18    FreshReRead,
19    ShellReRun,
20    ModeBounce,
21}
22
23/// Tracks repeated tool calls within a time window to detect and throttle agent loops.
24#[derive(Debug, Clone)]
25pub struct LoopDetector {
26    call_history: HashMap<String, Vec<Instant>>,
27    duplicate_counts: HashMap<String, u32>,
28    tool_total_counts: HashMap<String, u32>,
29    tool_total_limits: HashMap<String, u32>,
30    search_group_history: Vec<Instant>,
31    recent_search_patterns: Vec<String>,
32    normal_threshold: u32,
33    reduced_threshold: u32,
34    blocked_threshold: u32,
35    window: Duration,
36    search_group_limit: u32,
37    // Correction-loop tracking (Fix A)
38    correction_signals: Vec<(Instant, CorrectionKind)>,
39    recent_reads: HashMap<String, (Instant, String)>,
40    recent_commands: HashMap<String, Instant>,
41    total_calls: u32,
42    // CCR-learning (#941): timestamps of verbatim/original re-fetches
43    // (`ctx_expand`/`ctx_retrieve`). A high rate means the inline compressed form
44    // was too lossy, so compression is dialed back for the session.
45    retrieve_signals: Vec<Instant>,
46}
47
48/// Severity of throttling applied to a repeated call: normal, reduced, or blocked.
49#[derive(Debug, Clone, PartialEq)]
50pub enum ThrottleLevel {
51    Normal,
52    Reduced,
53    Blocked,
54}
55
56/// Outcome of a loop detection check: throttle level, count, and optional warning.
57#[derive(Debug, Clone)]
58pub struct ThrottleResult {
59    pub level: ThrottleLevel,
60    pub call_count: u32,
61    pub message: Option<String>,
62}
63
64impl Default for ThrottleResult {
65    fn default() -> Self {
66        Self {
67            level: ThrottleLevel::Normal,
68            call_count: 0,
69            message: None,
70        }
71    }
72}
73
74impl Default for LoopDetector {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl LoopDetector {
81    /// Creates a loop detector with default thresholds.
82    pub fn new() -> Self {
83        Self::with_config(&LoopDetectionConfig::default())
84    }
85
86    /// Creates a loop detector with custom thresholds from config.
87    /// Set blocked_threshold to 0 to disable blocking entirely (LeanCTX philosophy).
88    pub fn with_config(cfg: &LoopDetectionConfig) -> Self {
89        Self {
90            call_history: HashMap::new(),
91            duplicate_counts: HashMap::new(),
92            tool_total_counts: HashMap::new(),
93            tool_total_limits: cfg.tool_total_limits.clone(),
94            search_group_history: Vec::new(),
95            recent_search_patterns: Vec::new(),
96            normal_threshold: cfg.normal_threshold.max(1),
97            reduced_threshold: cfg.reduced_threshold.max(2),
98            blocked_threshold: cfg.blocked_threshold,
99            window: Duration::from_secs(cfg.window_secs),
100            search_group_limit: if cfg.blocked_threshold == 0 {
101                u32::MAX
102            } else {
103                cfg.search_group_limit.max(3)
104            },
105            correction_signals: Vec::new(),
106            recent_reads: HashMap::new(),
107            recent_commands: HashMap::new(),
108            total_calls: 0,
109            retrieve_signals: Vec::new(),
110        }
111    }
112
113    /// Records a tool call and returns the throttle result based on repetition count.
114    pub fn record_call(&mut self, tool: &str, args_fingerprint: &str) -> ThrottleResult {
115        let now = Instant::now();
116        self.prune_window(now);
117
118        // Per-tool total count (regardless of args)
119        let total = self.tool_total_counts.entry(tool.to_string()).or_insert(0);
120        *total += 1;
121        let total_count = *total;
122
123        if let Some(&limit) = self.tool_total_limits.get(tool)
124            && total_count > limit
125        {
126            let msg = if crate::core::protocol::meta_visible() {
127                Some(format!(
128                    "Warning: {tool} called {total_count}x total (limit: {limit}). \
129                         Consider ctx_compress or narrowing scope."
130                ))
131            } else {
132                None
133            };
134            return ThrottleResult {
135                level: ThrottleLevel::Reduced,
136                call_count: total_count,
137                message: msg,
138            };
139        }
140
141        let key = format!("{tool}:{args_fingerprint}");
142        let entries = self.call_history.entry(key.clone()).or_default();
143        entries.push(now);
144        let count = entries.len() as u32;
145        *self.duplicate_counts.entry(key).or_default() = count;
146
147        if self.blocked_threshold > 0 && count > self.blocked_threshold {
148            return ThrottleResult {
149                level: ThrottleLevel::Blocked,
150                call_count: count,
151                message: Some(self.block_message(tool, count)),
152            };
153        }
154        if count > self.reduced_threshold {
155            if !crate::core::protocol::meta_visible() {
156                return ThrottleResult {
157                    level: ThrottleLevel::Reduced,
158                    call_count: count,
159                    message: None,
160                };
161            }
162            return ThrottleResult {
163                level: ThrottleLevel::Reduced,
164                call_count: count,
165                message: Some(format!(
166                    "Warning: {tool} called {count}x with same args. \
167                     Results reduced. Try a different approach or narrow your scope."
168                )),
169            };
170        }
171        if count > self.normal_threshold {
172            if !crate::core::protocol::meta_visible() {
173                return ThrottleResult {
174                    level: ThrottleLevel::Reduced,
175                    call_count: count,
176                    message: None,
177                };
178            }
179            return ThrottleResult {
180                level: ThrottleLevel::Reduced,
181                call_count: count,
182                message: Some(format!(
183                    "Note: {tool} called {count}x with similar args. Consider narrowing scope."
184                )),
185            };
186        }
187        ThrottleResult {
188            level: ThrottleLevel::Normal,
189            call_count: count,
190            message: None,
191        }
192    }
193
194    /// Undo the pre-dispatch count for a call that resulted in an error.
195    /// Prevents failed retries from triggering throttling prematurely.
196    pub fn record_error_outcome(&mut self, tool: &str, args_fingerprint: &str) {
197        let key = format!("{tool}:{args_fingerprint}");
198        if let Some(entries) = self.call_history.get_mut(&key) {
199            entries.pop();
200            let count = entries.len() as u32;
201            self.duplicate_counts.insert(key, count);
202        }
203    }
204
205    /// Record a search-category call and check the cross-tool search group limit.
206    /// `search_pattern` is the extracted query/regex the agent is looking for (if available).
207    pub fn record_search(
208        &mut self,
209        tool: &str,
210        args_fingerprint: &str,
211        search_pattern: Option<&str>,
212    ) -> ThrottleResult {
213        let now = Instant::now();
214
215        self.search_group_history.push(now);
216        let search_count = self.search_group_history.len() as u32;
217
218        let similar_count = if let Some(pat) = search_pattern {
219            let sc = self.count_similar_patterns(pat);
220            if !pat.is_empty() {
221                self.recent_search_patterns.push(pat.to_string());
222                if self.recent_search_patterns.len() > 15 {
223                    self.recent_search_patterns.remove(0);
224                }
225            }
226            sc
227        } else {
228            0
229        };
230
231        // blocked_threshold == 0 means blocking is disabled (LeanCTX default)
232        if self.blocked_threshold > 0 && similar_count >= self.blocked_threshold {
233            return ThrottleResult {
234                level: ThrottleLevel::Blocked,
235                call_count: similar_count,
236                message: Some(self.search_block_message(similar_count)),
237            };
238        }
239
240        // search_group_limit == u32::MAX when blocking is disabled
241        if self.blocked_threshold > 0 && search_count > self.search_group_limit {
242            return ThrottleResult {
243                level: ThrottleLevel::Blocked,
244                call_count: search_count,
245                message: Some(self.search_group_block_message(search_count)),
246            };
247        }
248
249        if similar_count >= self.reduced_threshold {
250            if !crate::core::protocol::meta_visible() {
251                return ThrottleResult {
252                    level: ThrottleLevel::Reduced,
253                    call_count: similar_count,
254                    message: None,
255                };
256            }
257            return ThrottleResult {
258                level: ThrottleLevel::Reduced,
259                call_count: similar_count,
260                message: Some(format!(
261                    "Warning: You've searched for similar patterns {similar_count}x. \
262                     Narrow your search with the 'path' parameter or try ctx_tree first."
263                )),
264            };
265        }
266
267        if search_count > self.search_group_limit.saturating_sub(3) {
268            let per_fp = self.record_call(tool, args_fingerprint);
269            if per_fp.level != ThrottleLevel::Normal {
270                return per_fp;
271            }
272            if !crate::core::protocol::meta_visible() {
273                return ThrottleResult {
274                    level: ThrottleLevel::Reduced,
275                    call_count: search_count,
276                    message: None,
277                };
278            }
279            return ThrottleResult {
280                level: ThrottleLevel::Reduced,
281                call_count: search_count,
282                message: Some(format!(
283                    "Note: {search_count} search calls in the last {}s. \
284                     Use ctx_tree to orient first, then scope searches with 'path'.",
285                    self.window.as_secs()
286                )),
287            };
288        }
289
290        self.record_call(tool, args_fingerprint)
291    }
292
293    /// Returns `true` if the tool name is a known search tool (ctx_search, etc.).
294    pub fn is_search_tool(tool: &str) -> bool {
295        SEARCH_TOOLS.contains(&tool)
296    }
297
298    /// Returns `true` if the shell command starts with a search tool (grep, rg, find, etc.).
299    pub fn is_search_shell_command(command: &str) -> bool {
300        let cmd = command.trim_start();
301        SEARCH_SHELL_PREFIXES.iter().any(|p| cmd.starts_with(p))
302    }
303
304    /// Computes a deterministic hash fingerprint of JSON tool arguments.
305    pub fn fingerprint(args: &serde_json::Value) -> String {
306        use std::collections::hash_map::DefaultHasher;
307        use std::hash::{Hash, Hasher};
308
309        let canonical = canonical_json(args);
310        let mut hasher = DefaultHasher::new();
311        canonical.hash(&mut hasher);
312        format!("{:016x}", hasher.finish())
313    }
314
315    /// Returns duplicate call entries sorted by count (descending), filtered to count > 1.
316    pub fn stats(&self) -> Vec<(String, u32)> {
317        let mut entries: Vec<(String, u32)> = self
318            .duplicate_counts
319            .iter()
320            .filter(|&(_, &count)| count > 1)
321            .map(|(k, &v)| (k.clone(), v))
322            .collect();
323        entries.sort_by_key(|x| std::cmp::Reverse(x.1));
324        entries
325    }
326
327    /// Records a ctx_read call and detects correction signals:
328    /// - `fresh=true` re-read of a previously cached file
329    /// - Mode bounce: map/signatures followed by full within 30s
330    pub fn record_read_for_correction(&mut self, path: &str, mode: &str, fresh: bool) {
331        self.total_calls += 1;
332        let now = Instant::now();
333
334        if self.total_calls <= COLD_START_CALLS {
335            self.recent_reads
336                .insert(path.to_string(), (now, mode.to_string()));
337            return;
338        }
339
340        if fresh
341            && let Some((prev_time, _)) = self.recent_reads.get(path)
342            && now.duration_since(*prev_time) < CORRECTION_WINDOW
343        {
344            self.correction_signals
345                .push((now, CorrectionKind::FreshReRead));
346        }
347
348        if mode == "full"
349            && let Some((prev_time, prev_mode)) = self.recent_reads.get(path)
350        {
351            let is_bounce = (prev_mode == "map" || prev_mode == "signatures")
352                && now.duration_since(*prev_time) < MODE_BOUNCE_WINDOW;
353            if is_bounce {
354                self.correction_signals
355                    .push((now, CorrectionKind::ModeBounce));
356            }
357        }
358
359        self.recent_reads
360            .insert(path.to_string(), (now, mode.to_string()));
361    }
362
363    /// Records a ctx_shell command and detects re-runs of the same command within 60s.
364    pub fn record_shell_for_correction(&mut self, command: &str) {
365        self.total_calls += 1;
366        let now = Instant::now();
367
368        if self.total_calls <= COLD_START_CALLS {
369            self.recent_commands.insert(command.to_string(), now);
370            return;
371        }
372
373        let key = normalize_shell_command(command);
374        if let Some(prev_time) = self.recent_commands.get(&key)
375            && now.duration_since(*prev_time) < SHELL_RERUN_WINDOW
376        {
377            self.correction_signals
378                .push((now, CorrectionKind::ShellReRun));
379        }
380        self.recent_commands.insert(key, now);
381    }
382
383    /// Returns the number of correction signals in the sliding window.
384    pub fn correction_count(&self) -> u32 {
385        let now = Instant::now();
386        self.correction_signals
387            .iter()
388            .filter(|(t, _)| now.duration_since(*t) < CORRECTION_WINDOW)
389            .count() as u32
390    }
391
392    /// Records one verbatim/original re-fetch (`ctx_expand`/`ctx_retrieve`) — the
393    /// CCR-learning signal (#941): the agent had to pull back content the inline
394    /// compressed form dropped.
395    pub fn record_retrieve(&mut self) {
396        self.retrieve_signals.push(Instant::now());
397    }
398
399    /// Returns the number of verbatim/original re-fetches in the sliding window.
400    pub fn retrieve_count(&self) -> u32 {
401        let now = Instant::now();
402        self.retrieve_signals
403            .iter()
404            .filter(|t| now.duration_since(**t) < CORRECTION_WINDOW)
405            .count() as u32
406    }
407
408    /// Returns the correction rate: signals per minute within the window.
409    pub fn correction_rate(&self) -> f64 {
410        let count = self.correction_count();
411        if count == 0 {
412            return 0.0;
413        }
414        let window_mins = CORRECTION_WINDOW.as_secs_f64() / 60.0;
415        f64::from(count) / window_mins
416    }
417
418    /// Prunes expired correction signals and stale read/command entries.
419    pub fn prune_corrections(&mut self) {
420        let now = Instant::now();
421        self.correction_signals
422            .retain(|(t, _)| now.duration_since(*t) < CORRECTION_WINDOW);
423        self.recent_reads
424            .retain(|_, (t, _)| now.duration_since(*t) < CORRECTION_WINDOW);
425        self.recent_commands
426            .retain(|_, t| now.duration_since(*t) < CORRECTION_WINDOW);
427        self.retrieve_signals
428            .retain(|t| now.duration_since(*t) < CORRECTION_WINDOW);
429    }
430
431    /// Clears all tracking state (call history, search patterns, counters).
432    pub fn reset(&mut self) {
433        self.call_history.clear();
434        self.duplicate_counts.clear();
435        self.search_group_history.clear();
436        self.recent_search_patterns.clear();
437        self.correction_signals.clear();
438        self.recent_reads.clear();
439        self.recent_commands.clear();
440        self.total_calls = 0;
441        self.retrieve_signals.clear();
442    }
443
444    fn prune_window(&mut self, now: Instant) {
445        for entries in self.call_history.values_mut() {
446            entries.retain(|t| now.duration_since(*t) < self.window);
447        }
448        // Drop keys whose window emptied, plus their orphaned duplicate counts, so the
449        // per-fingerprint maps don't grow unbounded over a long session. Behavior-neutral:
450        // empty Vecs contribute 0 to record_call's count, and duplicate_counts is only
451        // read by stats() (already filtered to count > 1). tool_total_counts is left
452        // intact — it is a cumulative per-tool-name guard (bounded by the tool set).
453        self.call_history.retain(|_, v| !v.is_empty());
454        let live = &self.call_history;
455        self.duplicate_counts.retain(|k, _| live.contains_key(k));
456        self.search_group_history
457            .retain(|t| now.duration_since(*t) < self.window);
458    }
459
460    fn count_similar_patterns(&self, new_pattern: &str) -> u32 {
461        let new_lower = new_pattern.to_lowercase();
462        let new_root = extract_alpha_root(&new_lower);
463
464        let mut count = 0u32;
465        for existing in &self.recent_search_patterns {
466            let existing_lower = existing.to_lowercase();
467            if patterns_are_similar(&new_lower, &existing_lower) {
468                count += 1;
469            } else if new_root.len() >= 4 {
470                let existing_root = extract_alpha_root(&existing_lower);
471                if existing_root.len() >= 4
472                    && (new_root.starts_with(&existing_root)
473                        || existing_root.starts_with(&new_root))
474                {
475                    count += 1;
476                }
477            }
478        }
479        count
480    }
481
482    fn block_message(&self, tool: &str, count: u32) -> String {
483        if Self::is_search_tool(tool) {
484            self.search_block_message(count)
485        } else {
486            format!(
487                "LOOP DETECTED: {tool} called {count}x with same/similar args. \
488                 Call blocked. Change your approach — the current strategy is not working."
489            )
490        }
491    }
492
493    #[allow(clippy::unused_self)]
494    fn search_block_message(&self, count: u32) -> String {
495        format!(
496            "LOOP DETECTED: You've searched {count}x with similar patterns. STOP searching and change strategy. \
497             1) Use ctx_tree to understand the project structure first. \
498             2) Narrow your search with the 'path' parameter to a specific directory. \
499             3) Use ctx_read with mode='map' to understand a file before searching more."
500        )
501    }
502
503    fn search_group_block_message(&self, count: u32) -> String {
504        format!(
505            "LOOP DETECTED: {count} search calls in {}s — too many. STOP and rethink. \
506             1) Use ctx_tree to map the project structure. \
507             2) Pick ONE specific directory and search there with the 'path' parameter. \
508             3) Read files with ctx_read mode='map' instead of searching blindly.",
509            self.window.as_secs()
510        )
511    }
512}
513
514fn normalize_shell_command(cmd: &str) -> String {
515    cmd.split_whitespace()
516        .take(5)
517        .collect::<Vec<_>>()
518        .join(" ")
519        .to_lowercase()
520}
521
522fn extract_alpha_root(pattern: &str) -> String {
523    pattern
524        .chars()
525        .take_while(|c| c.is_alphanumeric())
526        .collect()
527}
528
529fn patterns_are_similar(a: &str, b: &str) -> bool {
530    if a == b {
531        return true;
532    }
533    if a.contains(b) || b.contains(a) {
534        return true;
535    }
536    let a_alpha: String = a.chars().filter(|c| c.is_alphanumeric()).collect();
537    let b_alpha: String = b.chars().filter(|c| c.is_alphanumeric()).collect();
538    if a_alpha.len() >= 3
539        && b_alpha.len() >= 3
540        && (a_alpha.contains(&b_alpha) || b_alpha.contains(&a_alpha))
541    {
542        return true;
543    }
544    false
545}
546
547fn canonical_json(value: &serde_json::Value) -> String {
548    match value {
549        serde_json::Value::Object(map) => {
550            let mut keys: Vec<&String> = map.keys().collect();
551            keys.sort();
552            let entries: Vec<String> = keys
553                .iter()
554                .map(|k| format!("{}:{}", k, canonical_json(&map[*k])))
555                .collect();
556            format!("{{{}}}", entries.join(","))
557        }
558        serde_json::Value::Array(arr) => {
559            let entries: Vec<String> = arr.iter().map(canonical_json).collect();
560            format!("[{}]", entries.join(","))
561        }
562        _ => value.to_string(),
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn test_config(normal: u32, reduced: u32, blocked: u32) -> LoopDetectionConfig {
571        LoopDetectionConfig {
572            normal_threshold: normal,
573            reduced_threshold: reduced,
574            blocked_threshold: blocked,
575            window_secs: 300,
576            search_group_limit: 10,
577            tool_total_limits: std::collections::HashMap::new(),
578        }
579    }
580
581    #[test]
582    fn normal_calls_pass_through() {
583        let mut detector = LoopDetector::new();
584        let r1 = detector.record_call("ctx_read", "abc123");
585        assert_eq!(r1.level, ThrottleLevel::Normal);
586        assert_eq!(r1.call_count, 1);
587        assert!(r1.message.is_none());
588    }
589
590    #[test]
591    fn repeated_calls_trigger_reduced() {
592        let _lock = crate::core::data_dir::test_env_lock();
593        crate::test_env::set_var("LEAN_CTX_META", "1");
594        let cfg = LoopDetectionConfig::default();
595        let mut detector = LoopDetector::with_config(&cfg);
596        for _ in 0..cfg.normal_threshold {
597            detector.record_call("ctx_read", "same_fp");
598        }
599        let result = detector.record_call("ctx_read", "same_fp");
600        assert_eq!(result.level, ThrottleLevel::Reduced);
601        assert!(result.message.is_some());
602        crate::test_env::remove_var("LEAN_CTX_META");
603    }
604
605    #[test]
606    fn excessive_calls_get_blocked_when_enabled() {
607        // Blocking must be explicitly enabled (blocked_threshold > 0)
608        let cfg = LoopDetectionConfig {
609            blocked_threshold: 6,
610            ..Default::default()
611        };
612        let mut detector = LoopDetector::with_config(&cfg);
613        for _ in 0..cfg.blocked_threshold {
614            detector.record_call("ctx_shell", "same_fp");
615        }
616        let result = detector.record_call("ctx_shell", "same_fp");
617        assert_eq!(result.level, ThrottleLevel::Blocked);
618        assert!(result.message.unwrap().contains("LOOP DETECTED"));
619    }
620
621    #[test]
622    fn blocking_disabled_by_default() {
623        // Default config has blocked_threshold = 0, so blocking never happens
624        let cfg = LoopDetectionConfig::default();
625        assert_eq!(cfg.blocked_threshold, 0);
626        let mut detector = LoopDetector::with_config(&cfg);
627        // Even 100 calls should not block when blocking is disabled
628        for _ in 0..100 {
629            detector.record_call("ctx_shell", "same_fp");
630        }
631        let result = detector.record_call("ctx_shell", "same_fp");
632        // Should be Reduced (warning) but never Blocked
633        assert_ne!(result.level, ThrottleLevel::Blocked);
634    }
635
636    #[test]
637    fn different_args_tracked_separately() {
638        let mut detector = LoopDetector::new();
639        for _ in 0..10 {
640            detector.record_call("ctx_read", "fp_a");
641        }
642        let result = detector.record_call("ctx_read", "fp_b");
643        assert_eq!(result.level, ThrottleLevel::Normal);
644        assert_eq!(result.call_count, 1);
645    }
646
647    #[test]
648    fn fingerprint_deterministic() {
649        let args = serde_json::json!({"path": "test.rs", "mode": "full"});
650        let fp1 = LoopDetector::fingerprint(&args);
651        let fp2 = LoopDetector::fingerprint(&args);
652        assert_eq!(fp1, fp2);
653    }
654
655    #[test]
656    fn fingerprint_order_independent() {
657        let a = serde_json::json!({"mode": "full", "path": "test.rs"});
658        let b = serde_json::json!({"path": "test.rs", "mode": "full"});
659        assert_eq!(LoopDetector::fingerprint(&a), LoopDetector::fingerprint(&b));
660    }
661
662    #[test]
663    fn stats_shows_duplicates() {
664        let mut detector = LoopDetector::new();
665        for _ in 0..5 {
666            detector.record_call("ctx_read", "fp_a");
667        }
668        detector.record_call("ctx_shell", "fp_b");
669        let stats = detector.stats();
670        assert_eq!(stats.len(), 1);
671        assert_eq!(stats[0].1, 5);
672    }
673
674    #[test]
675    fn reset_clears_state() {
676        let mut detector = LoopDetector::new();
677        for _ in 0..5 {
678            detector.record_call("ctx_read", "fp_a");
679        }
680        detector.reset();
681        let result = detector.record_call("ctx_read", "fp_a");
682        assert_eq!(result.call_count, 1);
683    }
684
685    #[test]
686    fn custom_thresholds_from_config() {
687        let cfg = test_config(1, 2, 3);
688        let mut detector = LoopDetector::with_config(&cfg);
689        detector.record_call("ctx_read", "fp");
690        let r = detector.record_call("ctx_read", "fp");
691        assert_eq!(r.level, ThrottleLevel::Reduced);
692        detector.record_call("ctx_read", "fp");
693        let r = detector.record_call("ctx_read", "fp");
694        assert_eq!(r.level, ThrottleLevel::Blocked);
695    }
696
697    #[test]
698    fn similar_patterns_detected() {
699        assert!(patterns_are_similar("compress", "compress"));
700        assert!(patterns_are_similar("compress", "compression"));
701        assert!(patterns_are_similar("compress.*data", "compress"));
702        assert!(!patterns_are_similar("foo", "bar"));
703        assert!(!patterns_are_similar("ab", "cd"));
704    }
705
706    #[test]
707    fn search_group_tracking_when_blocking_enabled() {
708        // Blocking must be explicitly enabled for search group limits to block
709        let cfg = LoopDetectionConfig {
710            search_group_limit: 5,
711            blocked_threshold: 6, // Enable blocking
712            ..Default::default()
713        };
714        let mut detector = LoopDetector::with_config(&cfg);
715        for i in 0..5 {
716            let fp = format!("fp_{i}");
717            let r = detector.record_search("ctx_search", &fp, Some(&format!("pattern_{i}")));
718            assert_ne!(r.level, ThrottleLevel::Blocked, "call {i} should not block");
719        }
720        let r = detector.record_search("ctx_search", "fp_5", Some("pattern_5"));
721        assert_eq!(r.level, ThrottleLevel::Blocked);
722        assert!(r.message.unwrap().contains("search calls"));
723    }
724
725    #[test]
726    fn similar_search_patterns_trigger_block_when_enabled() {
727        // Blocking must be explicitly enabled
728        let cfg = LoopDetectionConfig {
729            blocked_threshold: 6,
730            ..Default::default()
731        };
732        let mut detector = LoopDetector::with_config(&cfg);
733        let variants = [
734            "compress",
735            "compression",
736            "compress.*data",
737            "compress_output",
738            "compressor",
739            "compress_result",
740            "compress_file",
741        ];
742        for (i, pat) in variants
743            .iter()
744            .enumerate()
745            .take(cfg.blocked_threshold as usize)
746        {
747            detector.record_search("ctx_search", &format!("fp_{i}"), Some(pat));
748        }
749        let r = detector.record_search("ctx_search", "fp_new", Some("compress_all"));
750        assert_eq!(r.level, ThrottleLevel::Blocked);
751    }
752
753    #[test]
754    fn is_search_tool_detection() {
755        assert!(LoopDetector::is_search_tool("ctx_search"));
756        assert!(LoopDetector::is_search_tool("ctx_semantic_search"));
757        assert!(!LoopDetector::is_search_tool("ctx_read"));
758        assert!(!LoopDetector::is_search_tool("ctx_shell"));
759    }
760
761    #[test]
762    fn is_search_shell_command_detection() {
763        assert!(LoopDetector::is_search_shell_command("grep -r foo ."));
764        assert!(LoopDetector::is_search_shell_command("rg pattern src/"));
765        assert!(LoopDetector::is_search_shell_command("find . -name '*.rs'"));
766        assert!(!LoopDetector::is_search_shell_command("cargo build"));
767        assert!(!LoopDetector::is_search_shell_command("git status"));
768    }
769
770    #[test]
771    fn correction_fresh_reread_detected() {
772        let mut detector = LoopDetector::new();
773        // First read (cold start period, skipped)
774        detector.record_read_for_correction("src/main.rs", "full", false);
775        detector.record_read_for_correction("src/lib.rs", "full", false);
776        detector.record_read_for_correction("src/util.rs", "full", false);
777        // 4th call: past cold start
778        detector.record_read_for_correction("src/main.rs", "full", false);
779        assert_eq!(detector.correction_count(), 0);
780        // fresh=true re-read of previously read file = correction signal
781        detector.record_read_for_correction("src/main.rs", "full", true);
782        assert_eq!(detector.correction_count(), 1);
783    }
784
785    #[test]
786    fn correction_mode_bounce_detected() {
787        let mut detector = LoopDetector::new();
788        // Cold start
789        for i in 0..COLD_START_CALLS {
790            detector.record_read_for_correction(&format!("f{i}.rs"), "full", false);
791        }
792        // Read with map mode
793        detector.record_read_for_correction("src/cache.rs", "map", false);
794        assert_eq!(detector.correction_count(), 0);
795        // Immediately bounce to full mode = correction
796        detector.record_read_for_correction("src/cache.rs", "full", false);
797        assert_eq!(detector.correction_count(), 1);
798    }
799
800    #[test]
801    fn retrieve_count_tracks_signals_and_resets() {
802        // #941: each ctx_expand/ctx_retrieve is a CCR-learning signal; the count is
803        // a sliding window that prune/reset clear.
804        let mut detector = LoopDetector::new();
805        assert_eq!(detector.retrieve_count(), 0);
806        detector.record_retrieve();
807        detector.record_retrieve();
808        detector.record_retrieve();
809        assert_eq!(detector.retrieve_count(), 3, "three re-fetches counted");
810        // Independent of the correction counter (separate signal).
811        assert_eq!(detector.correction_count(), 0);
812        detector.reset();
813        assert_eq!(
814            detector.retrieve_count(),
815            0,
816            "reset clears retrieve signals"
817        );
818    }
819
820    #[test]
821    fn correction_shell_rerun_detected() {
822        let mut detector = LoopDetector::new();
823        // Cold start
824        for i in 0..COLD_START_CALLS {
825            detector.record_shell_for_correction(&format!("echo {i}"));
826        }
827        // First run
828        detector.record_shell_for_correction("cargo test --lib");
829        assert_eq!(detector.correction_count(), 0);
830        // Same command again within 60s = correction
831        detector.record_shell_for_correction("cargo test --lib");
832        assert_eq!(detector.correction_count(), 1);
833    }
834
835    #[test]
836    fn correction_rate_calculation() {
837        let mut detector = LoopDetector::new();
838        for i in 0..COLD_START_CALLS {
839            detector.record_shell_for_correction(&format!("init{i}"));
840        }
841        detector.record_shell_for_correction("cargo check");
842        detector.record_shell_for_correction("cargo check");
843        detector.record_shell_for_correction("cargo check");
844        // 2 corrections (first run doesn't count)
845        assert_eq!(detector.correction_count(), 2);
846        assert!(detector.correction_rate() > 0.0);
847    }
848
849    #[test]
850    fn correction_cold_start_ignored() {
851        let mut detector = LoopDetector::new();
852        // During cold start, same-command re-runs are not counted
853        detector.record_shell_for_correction("cargo check");
854        detector.record_shell_for_correction("cargo check");
855        detector.record_shell_for_correction("cargo check");
856        assert_eq!(detector.correction_count(), 0);
857    }
858
859    #[test]
860    fn search_block_message_has_guidance_when_blocking_enabled() {
861        // Blocking must be explicitly enabled to get block messages
862        let cfg = LoopDetectionConfig {
863            blocked_threshold: 6,
864            search_group_limit: 8,
865            ..Default::default()
866        };
867        let mut detector = LoopDetector::with_config(&cfg);
868        for i in 0..10 {
869            detector.record_search("ctx_search", &format!("fp_{i}"), Some("compress"));
870        }
871        let r = detector.record_search("ctx_search", "fp_new", Some("compress"));
872        assert_eq!(r.level, ThrottleLevel::Blocked);
873        let msg = r.message.unwrap();
874        assert!(msg.contains("ctx_tree"));
875        assert!(msg.contains("path"));
876        assert!(msg.contains("ctx_read"));
877    }
878
879    #[test]
880    fn error_outcome_undoes_pre_dispatch_count() {
881        let cfg = test_config(2, 4, 0);
882        let mut detector = LoopDetector::with_config(&cfg);
883
884        detector.record_call("ctx_read", "fp1");
885        detector.record_call("ctx_read", "fp1");
886        detector.record_error_outcome("ctx_read", "fp1");
887
888        let r = detector.record_call("ctx_read", "fp1");
889        assert_eq!(r.call_count, 2, "error should have undone one count");
890        assert_eq!(r.level, ThrottleLevel::Normal);
891    }
892
893    #[test]
894    fn repeated_errors_dont_trigger_reduced() {
895        let cfg = test_config(2, 4, 0);
896        let mut detector = LoopDetector::with_config(&cfg);
897
898        for _ in 0..5 {
899            detector.record_call("ctx_read", "fp1");
900            detector.record_error_outcome("ctx_read", "fp1");
901        }
902
903        let r = detector.record_call("ctx_read", "fp1");
904        assert_eq!(
905            r.level,
906            ThrottleLevel::Normal,
907            "5 failed retries should not throttle"
908        );
909    }
910
911    #[test]
912    fn mixed_success_and_error_correct_count() {
913        let cfg = test_config(2, 4, 0);
914        let mut detector = LoopDetector::with_config(&cfg);
915
916        detector.record_call("ctx_read", "fp1");
917        detector.record_error_outcome("ctx_read", "fp1");
918        detector.record_call("ctx_read", "fp1");
919        detector.record_error_outcome("ctx_read", "fp1");
920        detector.record_call("ctx_read", "fp1");
921        // 3 pre-dispatch, 2 error undos -> effective count = 1
922        assert_eq!(detector.record_call("ctx_read", "fp1").call_count, 2);
923    }
924
925    #[test]
926    fn error_outcome_on_nonexistent_key_is_noop() {
927        let mut detector = LoopDetector::new();
928        detector.record_error_outcome("ctx_read", "never_called");
929        let r = detector.record_call("ctx_read", "never_called");
930        assert_eq!(r.call_count, 1);
931    }
932
933    #[test]
934    fn error_outcome_doesnt_go_negative() {
935        let mut detector = LoopDetector::new();
936        detector.record_call("ctx_read", "fp1");
937        detector.record_error_outcome("ctx_read", "fp1");
938        detector.record_error_outcome("ctx_read", "fp1");
939        let r = detector.record_call("ctx_read", "fp1");
940        assert_eq!(r.call_count, 1, "count should never go below 0");
941    }
942
943    #[test]
944    fn error_in_tool_a_doesnt_affect_tool_b() {
945        let cfg = test_config(2, 4, 0);
946        let mut detector = LoopDetector::with_config(&cfg);
947
948        for _ in 0..5 {
949            detector.record_call("ctx_read", "fp1");
950            detector.record_error_outcome("ctx_read", "fp1");
951        }
952
953        let r = detector.record_call("ctx_shell", "fp_shell");
954        assert_eq!(r.call_count, 1);
955        assert_eq!(r.level, ThrottleLevel::Normal);
956    }
957
958    #[test]
959    fn different_fingerprints_independent_after_errors() {
960        let cfg = test_config(2, 4, 0);
961        let mut detector = LoopDetector::with_config(&cfg);
962
963        detector.record_call("ctx_read", "fp_a");
964        detector.record_error_outcome("ctx_read", "fp_a");
965
966        detector.record_call("ctx_read", "fp_b");
967        let r = detector.record_call("ctx_read", "fp_b");
968        assert_eq!(r.call_count, 2);
969
970        let r_a = detector.record_call("ctx_read", "fp_a");
971        assert_eq!(r_a.call_count, 1, "fp_a count should be reset to 0 then +1");
972    }
973
974    #[test]
975    fn correction_degrade_recovery_after_prune() {
976        let mut detector = LoopDetector::new();
977        for i in 0..4u32 {
978            detector.record_read_for_correction(&format!("warmup{i}.rs"), "full", false);
979        }
980        detector.record_read_for_correction("target.rs", "full", false);
981        detector.record_read_for_correction("target.rs", "full", true);
982        assert!(detector.correction_count() > 0);
983        detector.prune_corrections();
984        // After prune, count is still > 0 because signal is within window
985        // but the mechanism works: once window expires, count drops
986        assert!(detector.correction_count() >= 1);
987    }
988
989    #[test]
990    fn success_after_errors_resets_to_normal() {
991        let cfg = test_config(2, 4, 0);
992        let mut detector = LoopDetector::with_config(&cfg);
993
994        for _ in 0..3 {
995            detector.record_call("ctx_read", "fp1");
996            detector.record_error_outcome("ctx_read", "fp1");
997        }
998
999        let r = detector.record_call("ctx_read", "fp1");
1000        assert_eq!(r.level, ThrottleLevel::Normal);
1001        assert_eq!(r.call_count, 1);
1002    }
1003
1004    #[test]
1005    fn throttle_result_default_is_normal() {
1006        let r = ThrottleResult::default();
1007        assert_eq!(r.level, ThrottleLevel::Normal);
1008        assert_eq!(r.call_count, 0);
1009        assert!(r.message.is_none());
1010    }
1011}