Skip to main content

vtcode_core/core/
loop_detector.rs

1//! Loop detection for agent operations
2//!
3//! Detects when the agent is stuck in repetitive patterns and suggests intervention.
4//! The unified interactive VT Code runloop applies its own richer turn-local
5//! recovery policy; this detector remains the generic safeguard for legacy or
6//! non-unified autonomous execution paths.
7
8use crate::config::constants::{defaults, tools};
9use crate::tools::tool_intent;
10use hashbrown::{HashMap, HashSet};
11use std::collections::VecDeque;
12use std::time::Instant;
13
14// Separate limits for different operation types to reduce false positives
15const MAX_READONLY_TOOL_CALLS: usize = 10; // read_file, grep_file, list_files
16const MAX_WRITE_TOOL_CALLS: usize = 3; // write_file, edit_file, apply_patch
17const MAX_COMMAND_TOOL_CALLS: usize = 5; // shell, unified_exec
18const MAX_OTHER_TOOL_CALLS: usize = 3; // Other tools (default)
19const DETECTION_WINDOW: usize = 10;
20const HARD_LIMIT_MULTIPLIER: usize = 2; // Hard stop at 2x soft limit
21const MAX_SIMILAR_READ_TARGET_CALLS: usize = 4;
22const MAX_SIMILAR_READ_TARGET_VARIANTS: usize = 3;
23const LEGACY_GREP_FILE: &str = tools::GREP_FILE;
24const LEGACY_LIST_FILES: &str = tools::LIST_FILES;
25const LEGACY_SEARCH_TOOLS: &str = "search_tools";
26
27#[inline]
28fn base_tool_name(tool_name: &str) -> &str {
29    tool_name
30        .split_once("::")
31        .map(|(base, _)| base)
32        .unwrap_or(tool_name)
33}
34
35#[inline]
36fn is_command_tool_name(tool_name: &str) -> bool {
37    tool_intent::canonical_unified_exec_tool_name(tool_name).is_some()
38}
39
40/// Normalize tool arguments for consistent loop detection.
41/// This ensures path variations like ".", "", "./" are treated as the same root path,
42/// and read-file parameter aliases (offset_lines, max_lines, chunk_lines, line_start/line_end, etc.)
43/// are collapsed to canonical keys so the model can't evade detection by cycling parameter names.
44fn normalize_args_for_detection(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
45    let base_name = base_tool_name(tool_name);
46    if let Some(obj) = args.as_object() {
47        let mut normalized = obj.clone();
48
49        // Remove pagination params that shouldn't affect loop detection
50        normalized.remove("page");
51        normalized.remove("per_page");
52
53        // For list_files: normalize root path variations
54        if base_name == LEGACY_LIST_FILES {
55            if let Some(path) = normalized.get("path").and_then(|v| v.as_str()) {
56                let trimmed = path.trim();
57                let only_root_markers = trimmed.trim_matches(|c| c == '.' || c == '/').is_empty();
58                if trimmed.is_empty() || only_root_markers {
59                    normalized.insert("path".into(), serde_json::json!("__ROOT__"));
60                }
61            } else {
62                normalized.insert("path".into(), serde_json::json!("__ROOT__"));
63            }
64        }
65
66        // For read-file tools: normalize parameter aliases so cycling through
67        // offset_lines/line_start, max_lines/chunk_lines/limit_lines/limit, encoding, action
68        // all hash to the same canonical form.
69        let is_read_tool = base_name == tools::READ_FILE
70            || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
71        if is_read_tool {
72            // Normalize path aliases to "path"
73            for alias in ["file_path", "filepath", "target_path", "file"] {
74                if let Some(val) = normalized.remove(alias)
75                    && !normalized.contains_key("path")
76                {
77                    normalized.insert("path".into(), val);
78                }
79            }
80
81            // Normalize offset aliases to "offset"
82            // line_start=N → offset=N, offset_lines=N → offset=N, start_line=N → offset=N
83            for alias in ["offset_lines", "line_start", "offset_bytes", "start_line"] {
84                if let Some(val) = normalized.remove(alias)
85                    && !normalized.contains_key("offset")
86                {
87                    normalized.insert("offset".into(), val);
88                }
89            }
90
91            // Normalize limit aliases to "limit"
92            // max_lines, chunk_lines, limit_lines, page_size_lines, line_end, end_line → limit
93            // For line_end/end_line: compute limit from offset + end_line
94            if let Some(line_end) = normalized
95                .remove("line_end")
96                .or_else(|| normalized.remove("end_line"))
97            {
98                // start_line/end_line or line_start/line_end → offset + limit
99                if !normalized.contains_key("limit") {
100                    let start = normalized
101                        .get("offset")
102                        .and_then(|v| v.as_u64())
103                        .unwrap_or(1);
104                    let end = line_end.as_u64().unwrap_or(start);
105                    let limit = end.saturating_sub(start).saturating_add(1);
106                    normalized.insert("limit".into(), serde_json::json!(limit));
107                }
108            }
109            for alias in ["max_lines", "chunk_lines", "limit_lines", "page_size_lines"] {
110                if let Some(val) = normalized.remove(alias) {
111                    normalized.entry(String::from("limit")).or_insert(val);
112                }
113            }
114
115            // Canonicalize omitted offsets to the first line.
116            normalized
117                .entry(String::from("offset"))
118                .or_insert(serde_json::json!(1));
119
120            // Remove noise params that don't change semantic intent
121            normalized.remove("encoding");
122            normalized.remove("action");
123        }
124
125        serde_json::Value::Object(normalized)
126    } else {
127        args.clone()
128    }
129}
130
131#[derive(Debug, Clone)]
132pub struct ToolCallRecord {
133    pub tool_name: String,
134    pub args_hash: u64,
135    pub read_target: Option<String>,
136    pub timestamp: Instant,
137}
138
139#[derive(Debug)]
140pub struct LoopDetector {
141    recent_calls: VecDeque<ToolCallRecord>,
142    tool_counts: HashMap<String, usize>,
143    last_warning: Option<Instant>,
144    max_identical_call_limit: Option<usize>,
145    custom_limits: HashMap<String, usize>,
146    /// Cache mapping (tool_name, raw_args) composite hash → normalized_args_hash.
147    /// Avoids re-running normalization + re-serialization on repeated identical calls.
148    norm_cache: HashMap<u64, u64>,
149    /// Tracks consecutive read-only calls without any write/execution progress.
150    /// Resets on any mutating tool call.
151    readonly_streak: usize,
152}
153
154impl LoopDetector {
155    pub fn new() -> Self {
156        Self::with_max_repeated_calls(defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS)
157    }
158
159    pub fn with_max_repeated_calls(limit: usize) -> Self {
160        let normalized_limit = (limit > 1).then_some(limit);
161        Self {
162            recent_calls: VecDeque::with_capacity(DETECTION_WINDOW),
163            tool_counts: HashMap::new(),
164            last_warning: None,
165            max_identical_call_limit: normalized_limit,
166            custom_limits: HashMap::new(),
167            norm_cache: HashMap::with_capacity(16),
168            readonly_streak: 0,
169        }
170    }
171
172    /// Set a custom limit for a specific tool.
173    /// This overrides the default category-based limits.
174    pub fn set_tool_limit(&mut self, tool_name: &str, limit: usize) {
175        self.custom_limits.insert(tool_name.to_string(), limit);
176    }
177
178    pub fn record_call(&mut self, tool_name: &str, args: &serde_json::Value) -> Option<String> {
179        use std::collections::hash_map::DefaultHasher;
180        use std::hash::{Hash, Hasher};
181
182        let mut raw_hasher = DefaultHasher::new();
183        tool_name.hash(&mut raw_hasher);
184        if let Ok(bytes) = serde_json::to_vec(args) {
185            bytes.hash(&mut raw_hasher);
186        } else {
187            args.to_string().hash(&mut raw_hasher);
188        }
189        let raw_key = raw_hasher.finish();
190
191        let args_hash = if let Some(&cached) = self.norm_cache.get(&raw_key) {
192            cached
193        } else {
194            let normalized_args = normalize_args_for_detection(tool_name, args);
195            let mut hasher = DefaultHasher::new();
196            if let Ok(bytes) = serde_json::to_vec(&normalized_args) {
197                bytes.hash(&mut hasher);
198            } else {
199                normalized_args.to_string().hash(&mut hasher);
200            }
201            let hash = hasher.finish();
202            if self.norm_cache.len() >= 16 {
203                self.norm_cache.clear();
204            }
205            self.norm_cache.insert(raw_key, hash);
206            hash
207        };
208
209        if let Some(limit) = self.max_identical_call_limit
210            && Self::should_enforce_identical_limit(tool_name)
211        {
212            let required_history = limit.saturating_sub(1);
213            if required_history > 0 && self.recent_calls.len() >= required_history {
214                let identical = self
215                    .recent_calls
216                    .iter()
217                    .rev()
218                    .take(required_history)
219                    .all(|record| record.tool_name == tool_name && record.args_hash == args_hash);
220
221                if identical {
222                    // Escalate to hard limit so callers halt immediately.
223                    let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
224                    self.tool_counts.insert(tool_name.to_string(), hard_limit);
225
226                    return Some(format!(
227                        "HARD STOP: Identical tool call repeated {} times: {} with same arguments. This indicates a loop.",
228                        limit, tool_name
229                    ));
230                }
231            }
232        }
233
234        let record = ToolCallRecord {
235            tool_name: tool_name.to_string(),
236            args_hash,
237            read_target: read_target_for_tool_call(tool_name, args),
238            timestamp: Instant::now(),
239        };
240
241        if self.recent_calls.len() >= DETECTION_WINDOW
242            && let Some(old) = self.recent_calls.pop_front()
243            && let Some(count) = self.tool_counts.get_mut(&old.tool_name)
244        {
245            *count = count.saturating_sub(1);
246        }
247
248        self.recent_calls.push_back(record);
249        // Use get_mut + insert to avoid String allocation on every call.
250        // entry() would allocate tool_name.to_string() even for existing keys.
251        match self.tool_counts.get_mut(tool_name) {
252            Some(count) => *count += 1,
253            None => {
254                self.tool_counts.insert(tool_name.to_string(), 1);
255            }
256        }
257
258        if let Some(read_target_warning) = self.detect_repetitive_read_target(tool_name) {
259            return Some(read_target_warning);
260        }
261
262        // --- Navigation Loop Detection (NL2Repo-Bench integration) ---
263        let base_name = base_tool_name(tool_name);
264        let is_readonly = matches!(
265            base_name,
266            tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::UNIFIED_SEARCH
267        ) || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
268
269        let is_mutating = matches!(
270            base_name,
271            tools::WRITE_FILE
272                | tools::CREATE_FILE
273                | tools::EDIT_FILE
274                | tools::UNIFIED_EXEC
275                | tools::APPLY_PATCH
276        );
277
278        if is_readonly {
279            self.readonly_streak += 1;
280        } else if is_mutating {
281            self.readonly_streak = 0;
282        }
283
284        const MAX_NAVIGATION_ONLY_STREAK: usize = 6;
285        const NAVIGATION_HARD_STOP_STREAK: usize = 10;
286        if self.readonly_streak >= MAX_NAVIGATION_ONLY_STREAK {
287            if self.readonly_streak >= NAVIGATION_HARD_STOP_STREAK {
288                let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
289                self.tool_counts.insert(tool_name.to_string(), hard_limit);
290                return Some(format!(
291                    "HARD STOP: {} consecutive exploration calls without taking action. \
292                     Execution halted. You have enough information from previous tool outputs. \
293                     Synthesize a final answer now using the data already in your conversation history. \
294                     Do NOT call any more tools.",
295                    self.readonly_streak
296                ));
297            }
298
299            let msg = format!(
300                "Navigation Loop Detected: {} consecutive exploration calls without action.\n\n\
301                 **Synthesis Required**: You have collected sufficient information from previous tool outputs. \
302                 Review your conversation history and produce a concrete answer or implementation. \
303                 Do NOT re-read files or re-run searches you have already performed. \
304                 If a tool output was truncated, use offset/limit to read the specific omitted range, \
305                 or use `cat` via unified_exec for full content.",
306                self.readonly_streak
307            );
308            let now = Instant::now();
309            let should_warn = self
310                .last_warning
311                .map(|last| now.duration_since(last).as_secs() > 30)
312                .unwrap_or(true);
313
314            if should_warn {
315                self.last_warning = Some(now);
316                return Some(msg);
317            }
318        }
319
320        if let Some(pattern_warning) = self.detect_patterns() {
321            return Some(pattern_warning);
322        }
323
324        self.check_for_loops(tool_name)
325    }
326
327    fn check_for_loops(&mut self, tool_name: &str) -> Option<String> {
328        let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);
329
330        // Determine tool-specific limits
331        let max_calls = self.get_limit_for_tool(tool_name);
332
333        // Hard limit check - immediate halt
334        let hard_limit = max_calls * HARD_LIMIT_MULTIPLIER;
335        if count >= hard_limit {
336            return Some(format!(
337                "CRITICAL: Tool '{}' called {} times (hard limit: {}). Execution halted to prevent infinite loop.\n\
338                 Agent must reformulate task or request user guidance.",
339                tool_name, count, hard_limit
340            ));
341        }
342
343        // Soft limit - warning with cooldown and alternative suggestions
344        if count >= max_calls {
345            let now = Instant::now();
346            let should_warn = self
347                .last_warning
348                .map(|last| now.duration_since(last).as_secs() > 30)
349                .unwrap_or(true);
350
351            if should_warn {
352                self.last_warning = Some(now);
353                let alternatives = Self::suggest_alternative_for_tool(tool_name);
354
355                return Some(format!(
356                    "Loop detected: '{}' called {} times in last {} operations.\n\n\
357                     {}\n\n\
358                     Hard limit at {} calls.",
359                    tool_name, count, DETECTION_WINDOW, alternatives, hard_limit
360                ));
361            }
362        }
363
364        None
365    }
366
367    fn detect_repetitive_read_target(&mut self, tool_name: &str) -> Option<String> {
368        let base_name = base_tool_name(tool_name);
369        let is_read_tool = base_name == tools::READ_FILE
370            || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
371        if !is_read_tool {
372            return None;
373        }
374
375        // Find the current read target from the most recent read_tool call,
376        // not just the last call (which might be a grep with no read_target).
377        let current_target = self
378            .recent_calls
379            .iter()
380            .rev()
381            .find(|record| record.read_target.is_some())
382            .and_then(|record| record.read_target.as_deref())?;
383
384        // Count read_file calls on the same target in recent history, skipping over
385        // other read-only tools (grep, list, search) that don't reset the streak.
386        // Only mutating tools (write, exec, edit, patch) break the streak.
387        let mut same_target_streak = 0usize;
388        let mut variants = HashSet::new();
389        for record in self.recent_calls.iter().rev() {
390            let rec_base = base_tool_name(&record.tool_name);
391            let rec_is_read_tool = rec_base == tools::READ_FILE
392                || (rec_base == tools::UNIFIED_FILE && record.tool_name.ends_with("::read"));
393            let rec_is_mutating = matches!(
394                rec_base,
395                tools::WRITE_FILE
396                    | tools::CREATE_FILE
397                    | tools::EDIT_FILE
398                    | tools::UNIFIED_EXEC
399                    | tools::APPLY_PATCH
400            );
401
402            if rec_is_mutating {
403                break;
404            }
405
406            if rec_is_read_tool && record.read_target.as_deref() == Some(current_target) {
407                same_target_streak += 1;
408                variants.insert(record.args_hash);
409            }
410        }
411
412        if same_target_streak >= MAX_SIMILAR_READ_TARGET_CALLS
413            && variants.len() <= MAX_SIMILAR_READ_TARGET_VARIANTS
414        {
415            let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
416            self.tool_counts.insert(tool_name.to_string(), hard_limit);
417            return Some(format!(
418                "HARD STOP: Repeated '{}' calls for '{}' with minimal argument variation ({}-call streak, {} variants). \
419                 You are stuck in a read loop. Review the tool outputs already in your conversation history — \
420                 you likely have the information needed. If a read was truncated, use `unified_exec` with \
421                 `cat {}` for full content, or use offset/limit to read the exact omitted range. \
422                 Do NOT re-read the same file with the same parameters.",
423                tool_name,
424                current_target,
425                same_target_streak,
426                variants.len(),
427                current_target
428            ));
429        }
430
431        None
432    }
433
434    /// Check if hard limit is exceeded (should halt execution)
435    pub fn is_hard_limit_exceeded(&self, tool_name: &str) -> bool {
436        let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);
437        let max_calls = self.get_limit_for_tool(tool_name);
438        count >= max_calls * HARD_LIMIT_MULTIPLIER
439    }
440
441    /// Get current call count for a tool
442    pub fn get_call_count(&self, tool_name: &str) -> usize {
443        self.tool_counts.get(tool_name).copied().unwrap_or(0)
444    }
445
446    /// Reset tracking for specific tool (use after successful progress)
447    pub fn reset_tool(&mut self, tool_name: &str) {
448        self.tool_counts.remove(tool_name);
449        self.recent_calls.retain(|r| r.tool_name != tool_name);
450    }
451
452    /// Suggest alternative approaches for common loop patterns
453    /// Only called after loop detection, so `#[cold]`.
454    #[cold]
455    pub fn suggest_alternative(&self, tool_name: &str) -> Option<String> {
456        match tool_name {
457            LEGACY_LIST_FILES => Some(
458                "Instead of listing files repeatedly:\n\
459                 • Use unified_search with action='structural' plus lang for code patterns\n\
460                 • Use unified_search with action='grep' for raw text, docs, or logs\n\
461                 • Target specific subdirectories (e.g., 'src/', 'tests/')\n\
462                 • Use unified_file with action='read' if you know the exact file path"
463                    .to_string(),
464            ),
465            LEGACY_GREP_FILE => Some(
466                "Instead of grepping repeatedly:\n\
467                 • If syntax matters, switch to unified_search with action='structural' and set lang\n\
468                 • Refine your text pattern or narrow the path when grep is the right tool\n\
469                 • Use unified_file with action='read' to examine specific files\n\
470                 • Consider using unified_exec with action='code' for complex filtering"
471                    .to_string(),
472            ),
473            tools::READ_FILE => Some(
474                "Instead of reading files repeatedly:\n\
475                 • Use unified_search with action='structural' plus lang for code lookups\n\
476                 • Use unified_search with action='grep' to find specific content first\n\
477                 • Read specific line ranges with unified_file offset/limit parameters\n\
478                 • Consider if you already have the information needed"
479                    .to_string(),
480            ),
481            LEGACY_SEARCH_TOOLS => Some(
482                "Instead of searching tools repeatedly:\n\
483                 • Review the tools you've already discovered\n\
484                 • Use unified_search with action='tools' to inspect available tools\n\
485                 • Check if you need a different approach to the task"
486                    .to_string(),
487            ),
488            _ => Some(
489                "Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
490                 • Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
491                 • Breaking down the problem into smaller, verifiable sub-tasks\n\
492                 • Checking if a recent change introduced a regression (run existing tests)\n\
493                 • Asking for user guidance if strategic direction is ambiguous"
494                    .to_string(),
495            ),
496        }
497    }
498
499    /// Get the number of tools currently being tracked
500    pub fn get_tracked_tool_count(&self) -> usize {
501        self.tool_counts.len()
502    }
503
504    pub fn reset(&mut self) {
505        self.recent_calls.clear();
506        self.tool_counts.clear();
507        self.last_warning = None;
508        self.norm_cache.clear();
509        self.readonly_streak = 0;
510    }
511
512    /// Reset only the read-only streak counter without clearing tool call history.
513    /// Used during stall recovery to allow the agent to try a different strategy
514    /// while still detecting re-entry into the same looping pattern.
515    pub fn reset_readonly_streak(&mut self) {
516        self.readonly_streak = 0;
517        self.last_warning = None;
518    }
519
520    /// Get limit for a specific tool.
521    /// Checks custom limits first, then falls back to category defaults.
522    #[inline]
523    fn get_limit_for_tool(&self, tool_name: &str) -> usize {
524        if let Some(&limit) = self.custom_limits.get(tool_name) {
525            return limit;
526        }
527        let base_name = base_tool_name(tool_name);
528        if let Some(&limit) = self.custom_limits.get(base_name) {
529            return limit;
530        }
531
532        if base_name == tools::UNIFIED_FILE {
533            if let Some((_, action)) = tool_name.split_once("::") {
534                return if action.eq_ignore_ascii_case("read") {
535                    MAX_READONLY_TOOL_CALLS
536                } else {
537                    MAX_WRITE_TOOL_CALLS
538                };
539            }
540            return MAX_READONLY_TOOL_CALLS;
541        }
542
543        match base_name {
544            tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::UNIFIED_SEARCH => {
545                MAX_READONLY_TOOL_CALLS
546            }
547            tools::WRITE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => MAX_WRITE_TOOL_CALLS,
548            _ if is_command_tool_name(base_name) => MAX_COMMAND_TOOL_CALLS,
549            _ => MAX_OTHER_TOOL_CALLS,
550        }
551    }
552
553    #[inline]
554    fn should_enforce_identical_limit(tool_name: &str) -> bool {
555        let base_name = base_tool_name(tool_name);
556        !is_command_tool_name(base_name)
557    }
558
559    /// Suggest alternatives for stuck tools (extracted to static method for efficiency)
560    /// Called only on the cold path (loop already detected); marked `#[cold]`
561    /// and `#[inline(never)]` so LLVM does not inline it into the hot caller.
562    #[cold]
563    #[inline(never)]
564    fn suggest_alternative_for_tool(tool_name: &str) -> String {
565        match base_tool_name(tool_name) {
566            LEGACY_LIST_FILES => "Instead of listing repeatedly:\n\
567                 • Use unified_search with action='structural' plus lang for code patterns\n\
568                 • Use unified_search with action='grep' for raw text, docs, or logs\n\
569                 • Target specific subdirectories (e.g., 'src/', 'tests/')\n\
570                 • Use unified_file with action='read' if you know the exact file path"
571                .to_string(),
572            LEGACY_GREP_FILE => "Instead of grepping repeatedly:\n\
573                 • If syntax matters, switch to unified_search with action='structural' and set lang\n\
574                 • Refine your text pattern or narrow the path when grep is the right tool\n\
575                 • Use unified_file with action='read' to examine specific files\n\
576                 • Consider using unified_exec with action='code' for complex filtering"
577                .to_string(),
578            tools::READ_FILE => "Instead of reading files repeatedly:\n\
579                 • Use unified_search with action='structural' plus lang for code lookups\n\
580                 • Use unified_search with action='grep' to find specific content first\n\
581                 • Read specific line ranges with unified_file offset/limit parameters\n\
582                 • Consider if you already have the information needed"
583                .to_string(),
584            LEGACY_SEARCH_TOOLS => "Instead of searching tools repeatedly:\n\
585                 • Review the tools you've already discovered\n\
586                 • Use unified_search with action='tools' to inspect available tools\n\
587                 • Check if you need a different approach to the task"
588                .to_string(),
589            _ => "Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
590                 • Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
591                 • Breaking down the problem into smaller, verifiable sub-tasks\n\
592                 • Checking if a recent change introduced a regression (run existing tests)\n\
593                 • Asking for user guidance if strategic direction is ambiguous"
594                .to_string(),
595        }
596    }
597
598    /// Detect complex repetitive patterns (e.g. A -> B -> A -> B)
599    fn detect_patterns(&self) -> Option<String> {
600        let history: Vec<(&str, u64)> = self
601            .recent_calls
602            .iter()
603            .map(|r| (r.tool_name.as_str(), r.args_hash))
604            .collect();
605
606        let len = history.len();
607        if len < 4 {
608            return None;
609        }
610
611        // Check for patterns of length K where 2*K <= len
612        // We look for imminent repetition: [.. A, B, A, B]
613        for k in 2..=(len / 2) {
614            let suffix = &history[len - k..];
615            let prev = &history[len - 2 * k..len - k];
616
617            if suffix == prev {
618                let pattern_desc: Vec<&str> = suffix.iter().map(|(name, _)| *name).collect();
619                let pattern_str = pattern_desc.join(" -> ");
620
621                return Some(format!(
622                    "Repetitive pattern detected: [{}]\n\
623                     The agent appears to be cycling through the same actions. \
624                     Please pause and reassess the strategy.",
625                    pattern_str
626                ));
627            }
628
629            // Fuzzy detection: if tool names match but hashes differ, check semantic similarity?
630            // For now, simpler fuzzy check: ignore edit_file content arguments?
631            // Better: Detecting "oscillating" behavior A->B->A->B even if args slightly differ.
632            // If tool names match exactly for a sequence of length >= 3
633            let suffix_names: Vec<&str> = suffix.iter().map(|(n, _)| *n).collect();
634            let prev_names: Vec<&str> = prev.iter().map(|(n, _)| *n).collect();
635
636            if suffix_names == prev_names && k >= 2 {
637                return Some(format!(
638                    "Oscillating tool pattern detected: [{}]\n\
639                     The agent is repeating the same sequence of tools. \
640                     Ensure you are making actual progress.",
641                    suffix_names.join(" -> ")
642                ));
643            }
644        }
645
646        None
647    }
648}
649
650impl Default for LoopDetector {
651    fn default() -> Self {
652        Self::new()
653    }
654}
655
656fn read_target_for_tool_call(tool_name: &str, args: &serde_json::Value) -> Option<String> {
657    let base_name = base_tool_name(tool_name);
658    let read_tool = base_name == tools::READ_FILE
659        || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
660    if !read_tool {
661        return None;
662    }
663
664    let obj = args.as_object()?;
665    for key in ["path", "file_path", "filepath", "target_path", "file"] {
666        if let Some(path) = obj.get(key).and_then(|v| v.as_str()) {
667            let trimmed = path.trim();
668            if !trimmed.is_empty() {
669                return Some(trimmed.to_string());
670            }
671        }
672    }
673    None
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use serde_json::json;
680
681    #[test]
682    fn test_immediate_repetition_detection() {
683        let mut detector = LoopDetector::with_max_repeated_calls(3);
684        let args = json!({"path": "src/"});
685
686        // First two calls - no warning
687        assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
688        assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
689
690        // Third identical call - hard stop
691        let warning = detector.record_call(LEGACY_GREP_FILE, &args);
692        assert!(warning.is_some());
693        assert!(warning.unwrap().contains("HARD STOP"));
694    }
695
696    #[test]
697    fn test_command_tools_skip_identical_hard_stop() {
698        let mut detector = LoopDetector::new();
699        let args = json!({"command": "cargo test"});
700
701        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
702        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
703        assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
704    }
705
706    #[test]
707    fn test_exec_command_alias_skips_identical_hard_stop() {
708        let mut detector = LoopDetector::new();
709        let args = json!({"cmd": "cargo test"});
710
711        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
712        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
713        assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
714    }
715
716    #[test]
717    fn test_root_path_normalization() {
718        let mut detector = LoopDetector::with_max_repeated_calls(3);
719
720        // All these should be treated as identical
721        let paths = [
722            json!({"path": "."}),
723            json!({"path": ""}),
724            json!({"path": "././"}),
725            json!({"path": "//"}),
726            json!({}),
727        ];
728
729        for path in &paths[..2] {
730            assert!(detector.record_call(LEGACY_LIST_FILES, path).is_none());
731        }
732
733        // Third call with any root variation should trigger
734        let warning = detector.record_call(LEGACY_LIST_FILES, &paths[2]);
735        assert!(warning.is_some());
736
737        // Further root-only variations should continue to warn
738        for path in &paths[3..] {
739            assert!(detector.record_call(LEGACY_LIST_FILES, path).is_some());
740        }
741    }
742
743    #[test]
744    fn test_detects_repeated_calls() {
745        let mut detector = LoopDetector::with_max_repeated_calls(100);
746        let tool_name = "test_repeated_tool";
747        detector.set_tool_limit(tool_name, MAX_READONLY_TOOL_CALLS);
748        let args = json!({"path": "/src"});
749
750        // Repetition heuristics (pattern detection and soft limits) should warn eventually.
751        let mut saw_warning = false;
752        for _ in 0..MAX_READONLY_TOOL_CALLS {
753            if detector.record_call(tool_name, &args).is_some() {
754                saw_warning = true;
755            }
756        }
757        assert!(saw_warning);
758        assert_eq!(detector.get_call_count(tool_name), MAX_READONLY_TOOL_CALLS);
759    }
760
761    #[test]
762    fn test_hard_limit_enforcement() {
763        let mut detector = LoopDetector::with_max_repeated_calls(100);
764        let tool_name = "test_hard_limit_tool";
765        detector.set_tool_limit(tool_name, 2);
766        let args = json!({"pattern": "test"});
767
768        // Hard limit is 2x configured soft limit.
769        let hard_limit = 2 * HARD_LIMIT_MULTIPLIER;
770        let mut saw_warning = false;
771        for i in 0..hard_limit {
772            let result = detector.record_call(tool_name, &args);
773            if result.is_some() {
774                saw_warning = true;
775            }
776            if i >= hard_limit - 1 {
777                assert!(result.is_some());
778            }
779        }
780
781        assert!(saw_warning);
782        assert!(detector.is_hard_limit_exceeded(tool_name));
783    }
784
785    #[test]
786    fn test_different_tools_no_warning() {
787        let mut detector = LoopDetector::new();
788
789        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "/src"}));
790        detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));
791        detector.record_call(tools::READ_FILE, &json!({"path": "main.rs"}));
792
793        assert_eq!(detector.tool_counts.len(), 3);
794    }
795
796    #[test]
797    fn test_non_root_paths_distinct() {
798        let mut detector = LoopDetector::new();
799
800        // These should be treated as different calls
801        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "src"}));
802        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "docs"}));
803        detector.record_call(LEGACY_LIST_FILES, &json!({"path": "tests"}));
804
805        // Count for each should be 1
806        assert_eq!(
807            detector
808                .tool_counts
809                .get(LEGACY_LIST_FILES)
810                .copied()
811                .unwrap_or(0),
812            3
813        );
814    }
815
816    #[test]
817    fn test_identical_calls_trigger_hard_limit() {
818        let mut detector = LoopDetector::with_max_repeated_calls(3);
819        let args = json!({"path": "."});
820
821        assert!(detector.record_call(tools::READ_FILE, &args).is_none());
822        assert!(detector.record_call(tools::READ_FILE, &args).is_none());
823
824        let warning = detector.record_call(tools::READ_FILE, &args);
825        assert!(warning.is_some());
826        assert!(detector.is_hard_limit_exceeded(tools::READ_FILE));
827    }
828
829    #[test]
830    fn test_normalize_args_removes_pagination() {
831        let args = json!({"path": "src", "page": 1, "per_page": 10});
832        let normalized = normalize_args_for_detection(LEGACY_LIST_FILES, &args);
833
834        assert!(normalized.get("page").is_none());
835        assert!(normalized.get("per_page").is_none());
836        assert_eq!(normalized.get("path").and_then(|v| v.as_str()), Some("src"));
837    }
838
839    #[test]
840    fn test_reset_tool_clears_specific_tool() {
841        let mut detector = LoopDetector::with_max_repeated_calls(100);
842        let args = json!({"path": "src"});
843
844        // Record calls for multiple tools
845        detector.record_call(LEGACY_LIST_FILES, &args);
846        detector.record_call(LEGACY_LIST_FILES, &args);
847        detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));
848
849        assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 2);
850        assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);
851
852        // Reset only list_files
853        detector.reset_tool(LEGACY_LIST_FILES);
854
855        assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 0);
856        assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);
857    }
858
859    #[test]
860    fn test_suggest_alternative_for_list_files() {
861        let detector = LoopDetector::new();
862        let suggestion = detector.suggest_alternative(LEGACY_LIST_FILES);
863
864        assert!(suggestion.is_some());
865        let msg = suggestion.unwrap();
866        assert!(msg.contains("unified_search"));
867        assert!(msg.contains("action='structural'"));
868        assert!(msg.contains("subdirectories"));
869    }
870
871    #[test]
872    fn test_suggest_alternative_for_grep_file() {
873        let detector = LoopDetector::new();
874        let suggestion = detector.suggest_alternative(LEGACY_GREP_FILE);
875
876        assert!(suggestion.is_some());
877        let msg = suggestion.unwrap();
878        assert!(msg.contains("unified_file"));
879        assert!(msg.contains("set lang"));
880        assert!(msg.contains("pattern"));
881    }
882
883    #[test]
884    fn test_suggest_alternative_for_unknown_tool() {
885        let detector = LoopDetector::new();
886        let suggestion = detector.suggest_alternative("unknown_tool");
887
888        assert!(suggestion.is_some());
889        let msg = suggestion.unwrap();
890        assert!(msg.contains("ROOT CAUSE analysis"));
891    }
892
893    #[test]
894    fn test_faster_detection_with_lower_limit() {
895        let mut detector = LoopDetector::with_max_repeated_calls(100);
896        detector.set_tool_limit(LEGACY_LIST_FILES, 3);
897        let args = json!({"path": "src"});
898
899        // First call - no warning
900        assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
901
902        // Second call - no warning
903        assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
904
905        // Third call - should trigger warning (soft limit = 3)
906        let warning = detector.record_call(LEGACY_LIST_FILES, &args);
907        assert!(warning.is_some());
908        assert!(warning.unwrap().contains("Loop detected"));
909    }
910
911    #[test]
912    fn test_unified_file_action_suffix_uses_action_specific_limit() {
913        let mut detector = LoopDetector::with_max_repeated_calls(100);
914        let tool_key = format!("{}::read", tools::UNIFIED_FILE);
915
916        for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
917            let args = json!({"path": "src/main.rs", "offset_lines": idx + 1, "limit": 1});
918            let _ = detector.record_call(&tool_key, &args);
919        }
920
921        // Read action should not use write limits.
922        assert!(!detector.is_hard_limit_exceeded(&tool_key));
923    }
924
925    #[test]
926    fn test_unified_file_write_suffix_uses_write_limit() {
927        let mut detector = LoopDetector::with_max_repeated_calls(100);
928        let tool_key = format!("{}::write", tools::UNIFIED_FILE);
929
930        for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
931            let args = json!({"path": format!("src/file_{idx}.rs"), "content": "x"});
932            let _ = detector.record_call(&tool_key, &args);
933        }
934
935        assert!(detector.is_hard_limit_exceeded(&tool_key));
936    }
937
938    #[test]
939    fn test_unified_exec_action_suffix_skips_identical_limit() {
940        let mut detector = LoopDetector::with_max_repeated_calls(3);
941        let tool_key = format!("{}::run", tools::UNIFIED_EXEC);
942        let args = json!({"command": "cargo check"});
943
944        assert!(detector.record_call(&tool_key, &args).is_none());
945        assert!(detector.record_call(&tool_key, &args).is_none());
946        assert!(detector.record_call(&tool_key, &args).is_none());
947    }
948
949    #[test]
950    fn test_repetitive_read_target_with_small_variations_triggers_hard_stop() {
951        let mut detector = LoopDetector::with_max_repeated_calls(100);
952        let tool_key = format!("{}::read", tools::UNIFIED_FILE);
953        let mut saw_hard_stop = false;
954
955        for offset in [1, 2, 1, 2, 1, 2, 1, 2] {
956            let args = json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": offset, "limit": 20});
957            if let Some(warning) = detector.record_call(&tool_key, &args)
958                && warning.contains("HARD STOP")
959            {
960                saw_hard_stop = true;
961            }
962        }
963
964        assert!(saw_hard_stop);
965        assert!(detector.is_hard_limit_exceeded(&tool_key));
966    }
967
968    #[test]
969    fn test_repetitive_read_target_with_many_ranges_is_not_hard_stopped() {
970        let mut detector = LoopDetector::with_max_repeated_calls(100);
971        let tool_key = format!("{}::read", tools::UNIFIED_FILE);
972
973        for offset in 1..=MAX_SIMILAR_READ_TARGET_CALLS {
974            let args = json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": offset * 40, "limit": 40});
975            if let Some(warning) = detector.record_call(&tool_key, &args) {
976                assert!(!warning.contains("HARD STOP"));
977            }
978        }
979
980        assert!(!detector.is_hard_limit_exceeded(&tool_key));
981    }
982
983    #[test]
984    fn test_repetitive_read_target_grep_calls_do_not_break_streak() {
985        let mut detector = LoopDetector::with_max_repeated_calls(100);
986        let read_tool = format!("{}::read", tools::UNIFIED_FILE);
987
988        // Interleave grep calls between read_file calls on the same target.
989        // With the new logic, grep (read-only) does not break the streak.
990        // Use distinct offsets so variants stay above the threshold.
991        for offset in 1..=MAX_SIMILAR_READ_TARGET_CALLS + 1 {
992            let _ = detector.record_call(
993                &read_tool,
994                &json!({"path": "vtcode-core/src/a2a/server.rs", "offset_lines": offset * 40, "limit": 20}),
995            );
996            let _ = detector.record_call(
997                LEGACY_GREP_FILE,
998                &json!({"pattern": "handle_loop_detection", "path": "vtcode-core/src"}),
999            );
1000        }
1001
1002        // Streak reaches MAX_SIMILAR_READ_TARGET_CALLS but variants exceed the threshold,
1003        // so no hard stop.
1004        assert!(!detector.is_hard_limit_exceeded(&read_tool));
1005    }
1006
1007    #[test]
1008    fn test_repetitive_read_target_same_params_with_grep_between_triggers_hard_stop() {
1009        let mut detector = LoopDetector::with_max_repeated_calls(100);
1010        let read_tool = format!("{}::read", tools::UNIFIED_FILE);
1011
1012        // Same offset repeated, with grep calls between reads.
1013        // Grep doesn't break the streak, so the hard stop fires.
1014        for _ in 0..MAX_SIMILAR_READ_TARGET_CALLS + 2 {
1015            let _ = detector.record_call(
1016                &read_tool,
1017                &json!({"path": "Cargo.lock", "offset_lines": 1, "limit": 2000}),
1018            );
1019            let _ = detector.record_call(
1020                LEGACY_GREP_FILE,
1021                &json!({"pattern": "aws-lc", "path": "Cargo.lock"}),
1022            );
1023        }
1024
1025        assert!(detector.is_hard_limit_exceeded(&read_tool));
1026    }
1027
1028    #[test]
1029    fn test_read_file_alias_cycling_triggers_identical_detection() {
1030        // Simulates the exact failure from the transcript: LLM cycles through
1031        // offset_lines, max_lines, chunk_lines, line_start/line_end, encoding
1032        // for the same file. Normalization should collapse them to identical hashes.
1033        let mut detector = LoopDetector::with_max_repeated_calls(3);
1034
1035        let call1 = json!({"path": "docs/README.md", "max_lines": 200});
1036        let call2 = json!({"path": "docs/README.md", "offset_lines": 1, "limit": 200});
1037        let call3 = json!({"path": "docs/README.md", "chunk_lines": 200});
1038
1039        // After normalization, all three should have: {path: "docs/README.md", offset: 1, limit: 200}
1040        let n1 = normalize_args_for_detection(tools::READ_FILE, &call1);
1041        let n2 = normalize_args_for_detection(tools::READ_FILE, &call2);
1042        let n3 = normalize_args_for_detection(tools::READ_FILE, &call3);
1043
1044        // Verify aliases are normalized
1045        assert!(n1.get("max_lines").is_none(), "max_lines should be removed");
1046        assert!(
1047            n2.get("offset_lines").is_none(),
1048            "offset_lines should be removed"
1049        );
1050        assert!(
1051            n3.get("chunk_lines").is_none(),
1052            "chunk_lines should be removed"
1053        );
1054        assert_eq!(n1.get("limit"), n2.get("limit"));
1055        assert_eq!(n2.get("limit"), n3.get("limit"));
1056
1057        // All three should trigger identical-call detection by call 3
1058        assert!(detector.record_call(tools::READ_FILE, &call1).is_none());
1059        assert!(detector.record_call(tools::READ_FILE, &call2).is_none());
1060
1061        let warning = detector.record_call(tools::READ_FILE, &call3);
1062        assert!(warning.is_some(), "Third aliased call should be detected");
1063        assert!(warning.unwrap().contains("HARD STOP"));
1064    }
1065
1066    #[test]
1067    fn test_read_file_encoding_and_action_are_stripped() {
1068        let with_encoding =
1069            json!({"path": "foo.rs", "encoding": "utf-8", "offset_lines": 1, "max_lines": 200});
1070        let without_encoding = json!({"path": "foo.rs", "offset_lines": 1, "max_lines": 200});
1071
1072        let n1 = normalize_args_for_detection(tools::READ_FILE, &with_encoding);
1073        let n2 = normalize_args_for_detection(tools::READ_FILE, &without_encoding);
1074
1075        assert!(n1.get("encoding").is_none());
1076        assert_eq!(n1, n2);
1077    }
1078
1079    #[test]
1080    fn test_line_start_line_end_normalized_to_offset_limit() {
1081        let args = json!({"path": "foo.rs", "line_start": 1, "line_end": 200});
1082        let normalized = normalize_args_for_detection(tools::READ_FILE, &args);
1083
1084        assert!(normalized.get("line_start").is_none());
1085        assert!(normalized.get("line_end").is_none());
1086        assert_eq!(normalized.get("offset").and_then(|v| v.as_u64()), Some(1));
1087        assert_eq!(normalized.get("limit").and_then(|v| v.as_u64()), Some(200));
1088    }
1089
1090    #[test]
1091    fn test_start_line_end_line_normalized_to_offset_limit() {
1092        let args = json!({"path": "Cargo.lock", "start_line": 550, "end_line": 590});
1093        let normalized = normalize_args_for_detection(tools::READ_FILE, &args);
1094
1095        assert!(normalized.get("start_line").is_none());
1096        assert!(normalized.get("end_line").is_none());
1097        assert_eq!(normalized.get("offset").and_then(|v| v.as_u64()), Some(550));
1098        assert_eq!(normalized.get("limit").and_then(|v| v.as_u64()), Some(41));
1099    }
1100
1101    #[test]
1102    fn test_navigation_loop_detection() {
1103        let mut detector = LoopDetector::with_max_repeated_calls(100);
1104        let list_args = serde_json::json!({"path": "src"});
1105        let grep_args = serde_json::json!({"pattern": "fn", "path": "src/main.rs"});
1106        let read_args = serde_json::json!({"path": "src/main.rs"});
1107
1108        // Sequence: A, B, C, B, A (where A=LIST, B=GREP, C=READ)
1109        // This avoids identical patterns (k=2: [B, A] vs [B, C], k=3: [C, B, A] vs ???)
1110        let sequence = [
1111            (LEGACY_LIST_FILES, &list_args),
1112            (LEGACY_GREP_FILE, &grep_args),
1113            (tools::READ_FILE, &read_args),
1114            (LEGACY_GREP_FILE, &grep_args),
1115            (LEGACY_LIST_FILES, &list_args),
1116        ];
1117
1118        for (i, (tool, args)) in sequence.iter().enumerate() {
1119            let res = detector.record_call(tool, args);
1120            assert!(
1121                res.is_none(),
1122                "Call {} ({}) should not have triggered a warning",
1123                i + 1,
1124                tool
1125            );
1126        }
1127
1128        // 6th call (any read-only) should trigger navigation loop warning (streak hits 6)
1129        let warning = detector.record_call(tools::READ_FILE, &read_args);
1130        assert!(
1131            warning.is_some(),
1132            "6th call should have triggered a navigation loop warning"
1133        );
1134        assert!(warning.unwrap().contains("Navigation Loop Detected"));
1135
1136        // A mutating call should reset the streak
1137        let write_args = serde_json::json!({"path": "src/new.rs", "content": "test"});
1138        assert!(
1139            detector
1140                .record_call(tools::WRITE_FILE, &write_args)
1141                .is_none()
1142        );
1143
1144        // Subsequent read calls should start from 0; single call should be fine
1145        assert!(
1146            detector
1147                .record_call(LEGACY_LIST_FILES, &list_args)
1148                .is_none()
1149        );
1150    }
1151
1152    #[test]
1153    fn test_checkpoint_324_pattern_detected() {
1154        // Simulates the exact failure from turn_324 checkpoint:
1155        // Agent reads Cargo.lock with start_line/end_line (ignored), retries with
1156        // different values, interleaves grep calls. Should HARD STOP at read #5.
1157        let mut detector = LoopDetector::with_max_repeated_calls(100);
1158        let read_tool = format!("{}::read", tools::UNIFIED_FILE);
1159
1160        // Call 1: read Cargo.toml (different target)
1161        let r = detector.record_call(&read_tool, &json!({"path": "Cargo.toml"}));
1162        assert!(r.is_none());
1163
1164        // Call 2: read Cargo.lock
1165        let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
1166        assert!(r.is_none());
1167
1168        // Call 3: read Cargo.lock (streak=2)
1169        let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
1170        assert!(r.is_none());
1171
1172        // Call 4: grep Cargo.lock (read-only, does NOT break streak)
1173        let r = detector.record_call(
1174            LEGACY_GREP_FILE,
1175            &json!({"pattern": "aws-lc", "path": "Cargo.lock"}),
1176        );
1177        assert!(r.is_none());
1178
1179        // Call 5: read Cargo.lock with start_line (streak=3)
1180        let r = detector.record_call(
1181            &read_tool,
1182            &json!({"path": "Cargo.lock", "start_line": 550, "end_line": 590}),
1183        );
1184        assert!(r.is_none());
1185
1186        // Call 6: read Cargo.lock with different start_line (streak=4, variants=3)
1187        // HARD STOP fires: streak >= 4 && variants <= 3
1188        let r = detector.record_call(
1189            &read_tool,
1190            &json!({"path": "Cargo.lock", "start_line": 4400, "end_line": 4420}),
1191        );
1192        assert!(r.is_some(), "HARD STOP should fire at call 6");
1193        let msg = r.unwrap();
1194        assert!(
1195            msg.contains("HARD STOP"),
1196            "Expected HARD STOP, got: {}",
1197            msg
1198        );
1199        assert!(msg.contains("Cargo.lock"));
1200        assert!(msg.contains("offset/limit"));
1201        assert!(detector.is_hard_limit_exceeded(&read_tool));
1202        assert!(detector.is_hard_limit_exceeded(&read_tool));
1203    }
1204}