1use crate::config::constants::{defaults, tools};
9use crate::tools::tool_intent;
10use hashbrown::{HashMap, HashSet};
11use std::collections::VecDeque;
12use std::time::Instant;
13
14const MAX_READONLY_TOOL_CALLS: usize = 10; const MAX_WRITE_TOOL_CALLS: usize = 3; const MAX_COMMAND_TOOL_CALLS: usize = 5; const MAX_OTHER_TOOL_CALLS: usize = 3; const DETECTION_WINDOW: usize = 10;
20const HARD_LIMIT_MULTIPLIER: usize = 2; const 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
40fn 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 normalized.remove("page");
51 normalized.remove("per_page");
52
53 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 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 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 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 if let Some(line_end) = normalized
95 .remove("line_end")
96 .or_else(|| normalized.remove("end_line"))
97 {
98 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 normalized
117 .entry(String::from("offset"))
118 .or_insert(serde_json::json!(1));
119
120 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 norm_cache: HashMap<u64, u64>,
149 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 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 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 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 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 let max_calls = self.get_limit_for_tool(tool_name);
332
333 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 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 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 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 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 pub fn get_call_count(&self, tool_name: &str) -> usize {
443 self.tool_counts.get(tool_name).copied().unwrap_or(0)
444 }
445
446 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 #[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 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 pub fn reset_readonly_streak(&mut self) {
516 self.readonly_streak = 0;
517 self.last_warning = None;
518 }
519
520 #[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 #[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 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 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 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 assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
688 assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
689
690 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 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 let warning = detector.record_call(LEGACY_LIST_FILES, &paths[2]);
735 assert!(warning.is_some());
736
737 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 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 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 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 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 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 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 assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
901
902 assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
904
905 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut detector = LoopDetector::with_max_repeated_calls(100);
1158 let read_tool = format!("{}::read", tools::UNIFIED_FILE);
1159
1160 let r = detector.record_call(&read_tool, &json!({"path": "Cargo.toml"}));
1162 assert!(r.is_none());
1163
1164 let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
1166 assert!(r.is_none());
1167
1168 let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
1170 assert!(r.is_none());
1171
1172 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 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 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}