use crate::config::constants::{defaults, tools};
use crate::tools::tool_intent;
use hashbrown::{HashMap, HashSet};
use std::collections::VecDeque;
use std::time::Instant;
const MAX_READONLY_TOOL_CALLS: usize = 10; const MAX_WRITE_TOOL_CALLS: usize = 3; const MAX_COMMAND_TOOL_CALLS: usize = 8; const MAX_OTHER_TOOL_CALLS: usize = 3; const DETECTION_WINDOW: usize = 20; const HARD_LIMIT_MULTIPLIER: usize = 2; const MAX_SIMILAR_READ_TARGET_CALLS: usize = 4;
const MAX_SIMILAR_READ_TARGET_VARIANTS: usize = 3;
const SLIDING_WINDOW_MAX_REPEATS: usize = 3;
pub(crate) const MAX_TOTAL_READONLY_CALLS: usize = 30;
pub(crate) const SUBAGENT_MAX_TOTAL_READONLY_CALLS: usize = 20;
const NAVIGATION_WARNING_STREAK: usize = 4;
const NAVIGATION_HARD_STOP_STREAK: usize = 7;
const SUBAGENT_NAVIGATION_WARNING_STREAK: usize = 3;
const SUBAGENT_NAVIGATION_HARD_STOP_STREAK: usize = 5;
const LEGACY_GREP_FILE: &str = tools::GREP_FILE;
const LEGACY_LIST_FILES: &str = tools::LIST_FILES;
const LEGACY_SEARCH_TOOLS: &str = "search_tools";
#[inline]
fn base_tool_name(tool_name: &str) -> &str {
tool_name.split_once("::").map(|(base, _)| base).unwrap_or(tool_name)
}
#[inline]
fn is_command_tool_name(tool_name: &str) -> bool {
tool_intent::canonical_command_session_tool_name(tool_name).is_some()
}
fn canonicalize_command_for_detection(command: &str) -> Option<String> {
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
if (tokens[0] == "command" && tokens.len() >= 3 && tokens[1] == "-v") || (tokens[0] == "which" && tokens.len() >= 2)
{
let tool = if tokens[0] == "command" { tokens[2] } else { tokens[1] };
let basename = tool.rsplit('/').next().unwrap_or(tool);
return Some(format!("__verify__:{basename}"));
}
if tokens.len() >= 2 {
let last = tokens.last().expect("tokens.len() >= 2 checked above");
if matches!(*last, "--help" | "-h" | "--version" | "-V" | "version") {
let tool_token = if tokens[0] == "env" {
tokens.iter().skip(1).find(|t| !t.contains('=')).unwrap_or(&tokens[0])
} else {
&tokens[0]
};
let basename = tool_token.rsplit('/').next().unwrap_or(tool_token);
if !basename.is_empty() {
return Some(format!("__verify__:{basename}"));
}
}
}
if tokens.len() == 2 && matches!(tokens[0], "cat" | "head" | "tail") {
let path = tokens[1].trim_matches(|c| c == '\'' || c == '"');
return Some(format!("__read__:{path}"));
}
None
}
fn hash_normalized_args(tool_name: &str, args: &serde_json::Value) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let base_name = base_tool_name(tool_name);
let mut hasher = DefaultHasher::new();
if base_name == tools::CODE_SEARCH {
hash_normalized_code_search_args(args, &mut hasher);
return hasher.finish();
}
if let Some(obj) = args.as_object() {
let is_read_tool =
base_name == tools::READ_FILE || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
let is_write_tool = base_name == tools::WRITE_FILE
|| base_name == tools::CREATE_FILE
|| base_name == tools::EDIT_FILE
|| base_name == tools::APPLY_PATCH
|| (base_name == tools::UNIFIED_FILE && !tool_name.ends_with("::read"));
let is_list_tool = base_name == LEGACY_LIST_FILES;
let is_command_tool = is_command_tool_name(base_name);
let irrelevant_keys: &[&str] = &["page", "per_page", "encoding", "action"];
let read_aliases: &[(&str, &str)] = &[
("file_path", "path"),
("filepath", "path"),
("target_path", "path"),
("file", "path"),
("offset_lines", "offset"),
("line_start", "offset"),
("offset_bytes", "offset"),
("start_line", "offset"),
("max_lines", "limit"),
("chunk_lines", "limit"),
("limit_lines", "limit"),
("page_size_lines", "limit"),
];
fn is_root_marker(v: &serde_json::Value) -> bool {
if let Some(s) = v.as_str() {
let trimmed = s.trim();
trimmed.is_empty() || trimmed.trim_matches(|c: char| c == '.' || c == '/').is_empty()
} else {
false
}
}
let mut entries: Vec<(String, serde_json::Value)> = Vec::with_capacity(obj.len());
for (key, value) in obj {
if irrelevant_keys.contains(&key.as_str()) {
continue;
}
if is_write_tool && matches!(key.as_str(), "content" | "new_content" | "diff") {
entries.push((key.clone(), serde_json::Value::Null));
continue;
}
if is_read_tool {
if let Some((_, canonical)) = read_aliases.iter().find(|(alias, _)| *alias == key.as_str()) {
let canonical = canonical.to_string();
if !entries.iter().any(|(k, _)| *k == canonical) {
entries.push((canonical, value.clone()));
}
continue;
}
}
entries.push((key.clone(), value.clone()));
}
if is_list_tool {
if let Some(pos) = entries.iter().position(|(k, _)| k == "path") {
if is_root_marker(&entries[pos].1) {
entries[pos].1 = serde_json::Value::Null; }
} else {
entries.push(("path".to_string(), serde_json::Value::Null));
}
}
if is_read_tool {
let line_end_val = entries
.iter()
.position(|(k, _)| k == "line_end" || k == "end_line")
.and_then(|pos| {
let val = entries.remove(pos).1;
val.as_u64()
});
if let Some(end) = line_end_val {
if !entries.iter().any(|(k, _)| k == "limit") {
let start = entries
.iter()
.find(|(k, _)| k == "offset")
.and_then(|(_, v)| v.as_u64())
.unwrap_or(1);
let limit = end.saturating_sub(start).saturating_add(1);
entries.push(("limit".to_string(), serde_json::Value::Number(limit.into())));
}
}
if !entries.iter().any(|(k, _)| k == "offset") {
entries.push(("offset".to_string(), serde_json::Value::Number(1.into())));
}
if !entries.iter().any(|(k, _)| k == "limit") {
entries.push(("limit".to_string(), serde_json::Value::Null));
}
}
if is_command_tool {
if let Some(pos) = entries.iter().position(|(k, _)| k == "command") {
if let serde_json::Value::String(cmd) = &entries[pos].1 {
if let Some(canonical) = canonicalize_command_for_detection(cmd) {
entries[pos].1 = serde_json::Value::String(canonical);
}
}
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (key, value) in &entries {
key.hash(&mut hasher);
value.hash(&mut hasher);
}
} else {
args.hash(&mut hasher);
}
hasher.finish()
}
fn hash_normalized_code_search_args(args: &serde_json::Value, hasher: &mut impl std::hash::Hasher) {
use std::hash::Hash;
crate::tools::normalised_code_search_loop_identity(args)
.unwrap_or_else(|| args.to_string())
.hash(hasher);
}
#[cfg(test)]
fn normalize_args_for_detection(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
let base_name = base_tool_name(tool_name);
if let Some(obj) = args.as_object() {
let mut normalized = obj.clone();
normalized.remove("page");
normalized.remove("per_page");
if base_name == LEGACY_LIST_FILES {
if let Some(path) = normalized.get("path").and_then(|v| v.as_str()) {
let trimmed = path.trim();
let only_root_markers = trimmed.trim_matches(|c| c == '.' || c == '/').is_empty();
if trimmed.is_empty() || only_root_markers {
normalized.insert("path".into(), serde_json::json!("__ROOT__"));
}
} else {
normalized.insert("path".into(), serde_json::json!("__ROOT__"));
}
}
let is_read_tool =
base_name == tools::READ_FILE || (base_name == tools::UNIFIED_FILE && tool_name.ends_with("::read"));
if is_read_tool {
for alias in ["file_path", "filepath", "target_path", "file"] {
if let Some(val) = normalized.remove(alias)
&& !normalized.contains_key("path")
{
normalized.insert("path".into(), val);
}
}
for alias in ["offset_lines", "line_start", "offset_bytes", "start_line"] {
if let Some(val) = normalized.remove(alias)
&& !normalized.contains_key("offset")
{
normalized.insert("offset".into(), val);
}
}
if let Some(line_end) = normalized.remove("line_end").or_else(|| normalized.remove("end_line")) {
if !normalized.contains_key("limit") {
let start = normalized.get("offset").and_then(|v| v.as_u64()).unwrap_or(1);
let end = line_end.as_u64().unwrap_or(start);
let limit = end.saturating_sub(start).saturating_add(1);
normalized.insert("limit".into(), serde_json::json!(limit));
}
}
for alias in ["max_lines", "chunk_lines", "limit_lines", "page_size_lines"] {
if let Some(val) = normalized.remove(alias) {
normalized.entry(String::from("limit")).or_insert(val);
}
}
normalized.entry(String::from("offset")).or_insert(serde_json::json!(1));
normalized.remove("encoding");
normalized.remove("action");
}
if is_command_tool_name(base_name) {
if let Some(cmd) = normalized.get("command").and_then(|v| v.as_str()) {
if let Some(canonical) = canonicalize_command_for_detection(cmd) {
normalized.insert("command".into(), serde_json::Value::String(canonical));
}
}
}
serde_json::Value::Object(normalized)
} else {
args.clone()
}
}
#[derive(Debug, Clone)]
pub struct ToolCallRecord {
pub tool_name: String,
pub args_hash: u64,
pub read_target: Option<String>,
pub timestamp: Instant,
}
#[derive(Debug)]
pub struct LoopDetector {
recent_calls: VecDeque<ToolCallRecord>,
tool_counts: HashMap<String, usize>,
last_warning: Option<Instant>,
max_identical_call_limit: Option<usize>,
custom_limits: HashMap<String, usize>,
norm_cache: HashMap<u64, u64>,
norm_cache_order: VecDeque<u64>,
readonly_streak: usize,
total_readonly_calls: usize,
is_subagent: bool,
action_hash_window: VecDeque<u64>,
}
impl LoopDetector {
pub fn new() -> Self {
Self::with_max_repeated_calls(defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS)
}
pub fn with_max_repeated_calls(limit: usize) -> Self {
let normalized_limit = (limit > 1).then_some(limit);
Self {
recent_calls: VecDeque::with_capacity(DETECTION_WINDOW),
tool_counts: HashMap::new(),
last_warning: None,
max_identical_call_limit: normalized_limit,
custom_limits: HashMap::new(),
norm_cache: HashMap::with_capacity(128),
norm_cache_order: VecDeque::with_capacity(128),
readonly_streak: 0,
total_readonly_calls: 0,
is_subagent: false,
action_hash_window: VecDeque::with_capacity(DETECTION_WINDOW),
}
}
pub fn set_subagent_mode(&mut self, is_subagent: bool) {
self.is_subagent = is_subagent;
}
fn effective_readonly_budget(&self) -> usize {
if self.is_subagent {
SUBAGENT_MAX_TOTAL_READONLY_CALLS
} else {
MAX_TOTAL_READONLY_CALLS
}
}
fn effective_navigation_thresholds(&self) -> (usize, usize) {
if self.is_subagent {
(SUBAGENT_NAVIGATION_WARNING_STREAK, SUBAGENT_NAVIGATION_HARD_STOP_STREAK)
} else {
(NAVIGATION_WARNING_STREAK, NAVIGATION_HARD_STOP_STREAK)
}
}
pub fn set_tool_limit(&mut self, tool_name: &str, limit: usize) {
self.custom_limits.insert(tool_name.to_string(), limit);
}
pub fn record_call(&mut self, tool_name: &str, args: &serde_json::Value) -> Option<String> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut raw_hasher = DefaultHasher::new();
tool_name.hash(&mut raw_hasher);
if let Ok(bytes) = serde_json::to_vec(args) {
bytes.hash(&mut raw_hasher);
} else {
args.to_string().hash(&mut raw_hasher);
}
let raw_key = raw_hasher.finish();
let args_hash = if let Some(&cached) = self.norm_cache.get(&raw_key) {
cached
} else {
let hash = hash_normalized_args(tool_name, args);
const NORM_CACHE_CAPACITY: usize = 128;
if self.norm_cache.len() >= NORM_CACHE_CAPACITY {
if let Some(oldest_key) = self.norm_cache_order.pop_front() {
self.norm_cache.remove(&oldest_key);
}
}
self.norm_cache.insert(raw_key, hash);
self.norm_cache_order.push_back(raw_key);
hash
};
let enforce_identical = Self::should_enforce_identical_limit(tool_name)
|| (is_command_tool_name(base_tool_name(tool_name))
&& Self::is_verification_or_read_command(tool_name, args));
if let Some(limit) = self.max_identical_call_limit
&& enforce_identical
{
let required_history = limit.saturating_sub(1);
if required_history > 0 && self.recent_calls.len() >= required_history {
let identical = self
.recent_calls
.iter()
.rev()
.take(required_history)
.all(|record| record.tool_name == tool_name && record.args_hash == args_hash);
if identical {
let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
self.tool_counts.insert(tool_name.to_string(), hard_limit);
return Some(format!(
"HARD STOP: Identical tool call repeated {limit} times: {tool_name} with same arguments. This indicates a loop."
));
}
}
}
let record = ToolCallRecord {
tool_name: tool_name.to_string(),
args_hash,
read_target: read_target_for_tool_call(tool_name, args),
timestamp: Instant::now(),
};
if self.recent_calls.len() >= DETECTION_WINDOW
&& let Some(old) = self.recent_calls.pop_front()
&& let Some(count) = self.tool_counts.get_mut(&old.tool_name)
{
*count = count.saturating_sub(1);
}
self.recent_calls.push_back(record);
match self.tool_counts.get_mut(tool_name) {
Some(count) => *count += 1,
None => {
self.tool_counts.insert(tool_name.to_string(), 1);
}
}
if let Some(read_target_warning) = self.detect_repetitive_read_target(tool_name) {
return Some(read_target_warning);
}
let base_name = base_tool_name(tool_name);
let is_readonly =
matches!(base_name, tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::CODE_SEARCH)
|| (base_name == tools::UNIFIED_FILE
&& self.recent_calls.back().is_some_and(|r| r.read_target.is_some()));
let is_mutating = matches!(
base_name,
tools::WRITE_FILE | tools::CREATE_FILE | tools::EDIT_FILE | tools::UNIFIED_EXEC | tools::APPLY_PATCH
);
if is_readonly {
self.readonly_streak += 1;
self.total_readonly_calls += 1;
} else if is_mutating {
self.readonly_streak = 0;
}
let readonly_budget = self.effective_readonly_budget();
if self.total_readonly_calls >= readonly_budget {
let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
self.tool_counts.insert(tool_name.to_string(), hard_limit);
return Some(format!(
"HARD STOP: Global read-only budget exhausted ({} total read-only calls, limit: {}). \
You have collected far more information than needed. \
Synthesize a final answer NOW using the data already in your conversation history. \
Do NOT call any more read-only tools.",
self.total_readonly_calls, readonly_budget
));
}
let (warning_streak, hard_stop_streak) = self.effective_navigation_thresholds();
if self.readonly_streak >= warning_streak {
if self.readonly_streak >= hard_stop_streak {
let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
self.tool_counts.insert(tool_name.to_string(), hard_limit);
return Some(format!(
"HARD STOP: {} consecutive exploration calls without taking action. \
Execution halted. You have enough information from previous tool outputs. \
Synthesize a final answer now using the data already in your conversation history. \
Do NOT call any more tools.",
self.readonly_streak
));
}
let msg = format!(
"Navigation Loop Detected: {} consecutive exploration calls without action.\n\n\
**Synthesis Required**: You have collected sufficient information from previous tool outputs. \
Review your conversation history and produce a concrete answer or implementation. \
Do NOT re-read files or re-run searches you have already performed. \
If a tool output was truncated, use the exact continuation range, \
or use `exec_command.cmd` with `cat` for full content.",
self.readonly_streak
);
let now = Instant::now();
let should_warn = self
.last_warning
.map(|last| now.duration_since(last).as_secs() > 30)
.unwrap_or(true);
if should_warn {
self.last_warning = Some(now);
return Some(msg);
}
}
if let Some(pattern_warning) = self.detect_patterns() {
return Some(pattern_warning);
}
let enforce_identical = Self::should_enforce_identical_limit(tool_name)
|| (is_command_tool_name(base_tool_name(tool_name))
&& Self::is_verification_or_read_command(tool_name, args));
if enforce_identical {
use std::hash::{Hash, Hasher};
let mut composite_hasher = DefaultHasher::new();
tool_name.hash(&mut composite_hasher);
args_hash.hash(&mut composite_hasher);
let composite_hash = composite_hasher.finish();
if self.action_hash_window.len() >= DETECTION_WINDOW {
self.action_hash_window.pop_front();
}
let count_in_window = self
.action_hash_window
.iter()
.rev()
.skip_while(|&&h| h == composite_hash)
.filter(|&&h| h == composite_hash)
.count() as u32;
self.action_hash_window.push_back(composite_hash);
if count_in_window >= SLIDING_WINDOW_MAX_REPEATS as u32 {
return Some(format!(
"Warning: Same action ({tool_name}) detected {} times (non-consecutive) within \
the last {} operations. You appear to be repeating the same operation without \
making progress. Consider re-planning your approach.",
count_in_window + 1,
DETECTION_WINDOW,
));
}
}
self.check_for_loops(tool_name)
}
fn check_for_loops(&mut self, tool_name: &str) -> Option<String> {
let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);
let max_calls = self.get_limit_for_tool(tool_name);
let hard_limit = max_calls * HARD_LIMIT_MULTIPLIER;
if count >= hard_limit {
return Some(format!(
"CRITICAL: Tool '{tool_name}' called {count} times (hard limit: {hard_limit}). Execution halted to prevent infinite loop.\n\
Agent must reformulate task or request user guidance."
));
}
if count >= max_calls {
let now = Instant::now();
let should_warn = self
.last_warning
.map(|last| now.duration_since(last).as_secs() > 30)
.unwrap_or(true);
if should_warn {
self.last_warning = Some(now);
let alternatives = Self::suggest_alternative_for_tool(tool_name);
return Some(format!(
"Loop detected: '{tool_name}' called {count} times in last {DETECTION_WINDOW} operations.\n\n\
{alternatives}\n\n\
Hard limit at {hard_limit} calls."
));
}
}
None
}
fn detect_repetitive_read_target(&mut self, tool_name: &str) -> Option<String> {
let base_name = base_tool_name(tool_name);
let current_has_read_target = self.recent_calls.back().is_some_and(|r| r.read_target.is_some());
let is_read_tool = base_name == tools::READ_FILE
|| (base_name == tools::UNIFIED_FILE && current_has_read_target)
|| (is_command_tool_name(base_name) && current_has_read_target);
if !is_read_tool {
return None;
}
let current_target = self
.recent_calls
.iter()
.rev()
.find(|record| record.read_target.is_some())
.and_then(|record| record.read_target.as_deref())?;
let mut same_target_streak = 0usize;
let mut variants = HashSet::new();
for record in self.recent_calls.iter().rev() {
let rec_base = base_tool_name(&record.tool_name);
let rec_has_read_target = record.read_target.is_some();
let rec_is_read_tool = rec_base == tools::READ_FILE
|| (rec_base == tools::UNIFIED_FILE && rec_has_read_target)
|| (is_command_tool_name(rec_base) && rec_has_read_target);
let rec_is_mutating =
matches!(rec_base, tools::WRITE_FILE | tools::CREATE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH)
|| (is_command_tool_name(rec_base) && !rec_has_read_target);
if rec_is_mutating {
break;
}
if rec_is_read_tool && record.read_target.as_deref() == Some(current_target) {
same_target_streak += 1;
variants.insert(record.args_hash);
}
}
if same_target_streak >= MAX_SIMILAR_READ_TARGET_CALLS && variants.len() <= MAX_SIMILAR_READ_TARGET_VARIANTS {
let hard_limit = self.get_limit_for_tool(tool_name) * HARD_LIMIT_MULTIPLIER;
self.tool_counts.insert(tool_name.to_string(), hard_limit);
return Some(format!(
"HARD STOP: Repeated '{}' calls for '{}' with minimal argument variation ({}-call streak, {} variants). \
You are stuck in a read loop. Review the tool outputs already in your conversation history — \
you likely have the information needed. If a read was truncated, use `exec_command.cmd` with \
`cat {}` for full content, or use the exact continuation range. \
Do NOT re-read the same file with the same parameters.",
tool_name,
current_target,
same_target_streak,
variants.len(),
current_target
));
}
None
}
pub fn is_hard_limit_exceeded(&self, tool_name: &str) -> bool {
let count = self.tool_counts.get(tool_name).copied().unwrap_or(0);
let max_calls = self.get_limit_for_tool(tool_name);
count >= max_calls * HARD_LIMIT_MULTIPLIER
}
pub fn get_call_count(&self, tool_name: &str) -> usize {
self.tool_counts.get(tool_name).copied().unwrap_or(0)
}
pub fn total_readonly_calls(&self) -> usize {
self.total_readonly_calls
}
pub fn reset_tool(&mut self, tool_name: &str) {
self.tool_counts.remove(tool_name);
self.recent_calls.retain(|r| r.tool_name != tool_name);
}
#[cold]
pub fn suggest_alternative(&self, tool_name: &str) -> Option<String> {
match tool_name {
LEGACY_LIST_FILES => Some(
"Instead of listing files repeatedly:\n\
• Use code_search with a specific query and narrow file_types for code patterns\n\
• Use exec_command.cmd with rg for raw text, docs, or logs\n\
• Target specific subdirectories (e.g., 'src/', 'tests/')\n\
• Use exec_command.cmd with sed or cat if you know the exact file path"
.to_string(),
),
LEGACY_GREP_FILE => Some(
"Instead of grepping repeatedly:\n\
• If syntax matters, switch to code_search and request definition or usage results\n\
• Refine your text pattern or narrow the path when grep is the right tool\n\
• Use exec_command.cmd with sed or cat to examine specific files\n\
• Consider a small script through exec_command.cmd for complex filtering"
.to_string(),
),
tools::READ_FILE => Some(
"Instead of reading files repeatedly:\n\
• Use code_search with a specific query, path, and result_types for code lookups\n\
• Use exec_command.cmd with rg to find specific content first\n\
• Read specific line ranges with sed through exec_command.cmd\n\
• Consider if you already have the information needed"
.to_string(),
),
LEGACY_SEARCH_TOOLS => Some(
"Instead of searching tools repeatedly:\n\
• Review the tools you've already discovered\n\
• Use the dedicated MCP or deferred discovery affordance when available\n\
• Check if you need a different approach to the task"
.to_string(),
),
_ => Some(
"Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
• Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
• Breaking down the problem into smaller, verifiable sub-tasks\n\
• Checking if a recent change introduced a regression (run existing tests)\n\
• Asking for user guidance if strategic direction is ambiguous"
.to_string(),
),
}
}
pub fn get_tracked_tool_count(&self) -> usize {
self.tool_counts.len()
}
pub fn reset(&mut self) {
self.recent_calls.clear();
self.tool_counts.clear();
self.last_warning = None;
self.norm_cache.clear();
self.readonly_streak = 0;
self.total_readonly_calls = 0;
}
pub fn reset_readonly_streak(&mut self) {
self.readonly_streak = 0;
self.last_warning = None;
}
#[inline]
fn get_limit_for_tool(&self, tool_name: &str) -> usize {
if let Some(&limit) = self.custom_limits.get(tool_name) {
return limit;
}
let base_name = base_tool_name(tool_name);
if let Some(&limit) = self.custom_limits.get(base_name) {
return limit;
}
if base_name == tools::UNIFIED_FILE {
if let Some((_, action)) = tool_name.split_once("::") {
return if action.eq_ignore_ascii_case("read") {
MAX_READONLY_TOOL_CALLS
} else {
MAX_WRITE_TOOL_CALLS
};
}
return MAX_READONLY_TOOL_CALLS;
}
match base_name {
tools::READ_FILE | LEGACY_GREP_FILE | LEGACY_LIST_FILES | tools::CODE_SEARCH => MAX_READONLY_TOOL_CALLS,
tools::WRITE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => MAX_WRITE_TOOL_CALLS,
_ if is_command_tool_name(base_name) => MAX_COMMAND_TOOL_CALLS,
_ => MAX_OTHER_TOOL_CALLS,
}
}
#[inline]
fn should_enforce_identical_limit(tool_name: &str) -> bool {
let base_name = base_tool_name(tool_name);
!is_command_tool_name(base_name)
}
#[inline]
fn is_verification_or_read_command(tool_name: &str, args: &serde_json::Value) -> bool {
let base_name = base_tool_name(tool_name);
if !is_command_tool_name(base_name) {
return false;
}
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
return canonicalize_command_for_detection(cmd).is_some();
}
false
}
#[cold]
#[inline(never)]
fn suggest_alternative_for_tool(tool_name: &str) -> String {
match base_tool_name(tool_name) {
LEGACY_LIST_FILES => "Instead of listing repeatedly:\n\
• Use code_search with a specific query and narrow file_types for code patterns\n\
• Use exec_command.cmd with rg for raw text, docs, or logs\n\
• Target specific subdirectories (e.g., 'src/', 'tests/')\n\
• Use exec_command.cmd with sed or cat if you know the exact file path"
.to_string(),
LEGACY_GREP_FILE => "Instead of grepping repeatedly:\n\
• If syntax matters, switch to code_search and request definition or usage results\n\
• Refine your text pattern or narrow the path when grep is the right tool\n\
• Use exec_command.cmd with sed or cat to examine specific files\n\
• Consider a small script through exec_command.cmd for complex filtering"
.to_string(),
tools::READ_FILE => "Instead of reading files repeatedly:\n\
• Use code_search with a specific query, path, and result_types for code lookups\n\
• Use exec_command.cmd with rg to find specific content first\n\
• Read specific line ranges with sed through exec_command.cmd\n\
• Consider if you already have the information needed"
.to_string(),
LEGACY_SEARCH_TOOLS => "Instead of searching tools repeatedly:\n\
• Review the tools you've already discovered\n\
• Use the dedicated MCP or deferred discovery affordance when available\n\
• Check if you need a different approach to the task"
.to_string(),
_ => "Shift focus to ROOT CAUSE analysis rather than patching symptoms. Re-evaluate planning assumptions specifically regarding environmental constraints. Consider:\n\
• Verifying environment state (`env`, `ls -la`, `which <cmd>`) before more code edits\n\
• Breaking down the problem into smaller, verifiable sub-tasks\n\
• Checking if a recent change introduced a regression (run existing tests)\n\
• Asking for user guidance if strategic direction is ambiguous"
.to_string(),
}
}
fn detect_patterns(&self) -> Option<String> {
let history: Vec<(&str, u64)> = self.recent_calls.iter().map(|r| (r.tool_name.as_str(), r.args_hash)).collect();
let len = history.len();
if len < 4 {
return None;
}
for k in 2..=(len / 2) {
let suffix = &history[len - k..];
let prev = &history[len - 2 * k..len - k];
if suffix == prev {
let pattern_desc: Vec<&str> = suffix.iter().map(|(name, _)| *name).collect();
let pattern_str = pattern_desc.join(" -> ");
return Some(format!(
"Repetitive pattern detected: [{pattern_str}]\n\
The agent appears to be cycling through the same actions. \
Please pause and reassess the strategy."
));
}
let suffix_names: Vec<&str> = suffix.iter().map(|(n, _)| *n).collect();
let prev_names: Vec<&str> = prev.iter().map(|(n, _)| *n).collect();
if suffix_names == prev_names && k >= 2 {
return Some(format!(
"Oscillating tool pattern detected: [{}]\n\
The agent is repeating the same sequence of tools. \
Ensure you are making actual progress.",
suffix_names.join(" -> ")
));
}
}
None
}
}
impl Default for LoopDetector {
fn default() -> Self {
Self::new()
}
}
fn read_target_for_tool_call(tool_name: &str, args: &serde_json::Value) -> Option<String> {
let base_name = base_tool_name(tool_name);
if is_command_tool_name(base_name) {
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
if let Some(path) = cmd.strip_prefix("__read__:") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.len() == 2 && matches!(tokens[0], "cat" | "head" | "tail") {
let path = tokens[1].trim_matches(|c| c == '\'' || c == '"');
if !path.is_empty() {
return Some(path.to_string());
}
}
}
return None;
}
let read_tool = base_name == tools::READ_FILE
|| base_name == tools::CODE_SEARCH
|| (base_name == tools::UNIFIED_FILE && is_file_operation_read(tool_name, args));
if !read_tool {
return None;
}
let obj = args.as_object()?;
let keys: &[&str] = &["path", "file_path", "filepath", "target_path", "file"];
for key in keys {
if let Some(path) = obj.get(*key).and_then(|v| v.as_str()) {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn is_file_operation_read(tool_name: &str, args: &serde_json::Value) -> bool {
tool_name.ends_with("::read") || matches!(args.get("action").and_then(|v| v.as_str()), Some("read"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_immediate_repetition_detection() {
let mut detector = LoopDetector::with_max_repeated_calls(3);
let args = json!({"path": "src/"});
assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
assert!(detector.record_call(LEGACY_GREP_FILE, &args).is_none());
let warning = detector.record_call(LEGACY_GREP_FILE, &args);
assert!(warning.is_some());
assert!(warning.unwrap().contains("HARD STOP"));
}
#[test]
fn test_command_tools_skip_identical_hard_stop() {
let mut detector = LoopDetector::new();
let args = json!({"command": "cargo test"});
assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
assert!(detector.record_call(tools::RUN_PTY_CMD, &args).is_none());
}
#[test]
fn test_exec_command_alias_skips_identical_hard_stop() {
let mut detector = LoopDetector::new();
let args = json!({"cmd": "cargo test"});
assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
assert!(detector.record_call(tools::EXEC_COMMAND, &args).is_none());
}
#[test]
fn test_root_path_normalization() {
let mut detector = LoopDetector::with_max_repeated_calls(3);
let paths = [
json!({"path": "."}),
json!({"path": ""}),
json!({"path": "././"}),
json!({"path": "//"}),
json!({}),
];
for path in &paths[..2] {
assert!(detector.record_call(LEGACY_LIST_FILES, path).is_none());
}
let warning = detector.record_call(LEGACY_LIST_FILES, &paths[2]);
assert!(warning.is_some());
for path in &paths[3..] {
assert!(detector.record_call(LEGACY_LIST_FILES, path).is_some());
}
}
#[test]
fn test_detects_repeated_calls() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_name = "test_repeated_tool";
detector.set_tool_limit(tool_name, MAX_READONLY_TOOL_CALLS);
let args = json!({"path": "/src"});
let mut saw_warning = false;
for _ in 0..MAX_READONLY_TOOL_CALLS {
if detector.record_call(tool_name, &args).is_some() {
saw_warning = true;
}
}
assert!(saw_warning);
assert_eq!(detector.get_call_count(tool_name), MAX_READONLY_TOOL_CALLS);
}
#[test]
fn test_hard_limit_enforcement() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_name = "test_hard_limit_tool";
detector.set_tool_limit(tool_name, 2);
let args = json!({"pattern": "test"});
let hard_limit = 2 * HARD_LIMIT_MULTIPLIER;
let mut saw_warning = false;
for i in 0..hard_limit {
let result = detector.record_call(tool_name, &args);
if result.is_some() {
saw_warning = true;
}
if i >= hard_limit - 1 {
assert!(result.is_some());
}
}
assert!(saw_warning);
assert!(detector.is_hard_limit_exceeded(tool_name));
}
#[test]
fn test_different_tools_no_warning() {
let mut detector = LoopDetector::new();
detector.record_call(LEGACY_LIST_FILES, &json!({"path": "/src"}));
detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));
detector.record_call(tools::READ_FILE, &json!({"path": "main.rs"}));
assert_eq!(detector.tool_counts.len(), 3);
}
#[test]
fn test_non_root_paths_distinct() {
let mut detector = LoopDetector::new();
detector.record_call(LEGACY_LIST_FILES, &json!({"path": "src"}));
detector.record_call(LEGACY_LIST_FILES, &json!({"path": "docs"}));
detector.record_call(LEGACY_LIST_FILES, &json!({"path": "tests"}));
assert_eq!(detector.tool_counts.get(LEGACY_LIST_FILES).copied().unwrap_or(0), 3);
}
#[test]
fn test_identical_calls_trigger_hard_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(3);
let args = json!({"path": "."});
assert!(detector.record_call(tools::READ_FILE, &args).is_none());
assert!(detector.record_call(tools::READ_FILE, &args).is_none());
let warning = detector.record_call(tools::READ_FILE, &args);
assert!(warning.is_some());
assert!(detector.is_hard_limit_exceeded(tools::READ_FILE));
}
#[test]
fn test_normalize_args_removes_pagination() {
let args = json!({"path": "src", "page": 1, "per_page": 10});
let normalized = normalize_args_for_detection(LEGACY_LIST_FILES, &args);
assert!(normalized.get("page").is_none());
assert!(normalized.get("per_page").is_none());
assert_eq!(normalized.get("path").and_then(|v| v.as_str()), Some("src"));
}
#[test]
fn test_reset_tool_clears_specific_tool() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let args = json!({"path": "src"});
detector.record_call(LEGACY_LIST_FILES, &args);
detector.record_call(LEGACY_LIST_FILES, &args);
detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "test"}));
assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 2);
assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);
detector.reset_tool(LEGACY_LIST_FILES);
assert_eq!(detector.get_call_count(LEGACY_LIST_FILES), 0);
assert_eq!(detector.get_call_count(LEGACY_GREP_FILE), 1);
}
#[test]
fn test_suggest_alternative_for_list_files() {
let detector = LoopDetector::new();
let suggestion = detector.suggest_alternative(LEGACY_LIST_FILES);
assert!(suggestion.is_some());
let msg = suggestion.unwrap();
assert!(msg.contains("code_search"));
assert!(msg.contains("file_types"));
assert!(msg.contains("subdirectories"));
}
#[test]
fn test_suggest_alternative_for_grep_file() {
let detector = LoopDetector::new();
let suggestion = detector.suggest_alternative(LEGACY_GREP_FILE);
assert!(suggestion.is_some());
let msg = suggestion.unwrap();
assert!(msg.contains("exec_command.cmd"));
assert!(msg.contains("definition or usage"));
assert!(msg.contains("pattern"));
}
#[test]
fn code_search_loop_fingerprint_uses_only_normalised_search_identity() {
let first = json!({
"query": " Widget ",
"path": " src ",
"file_types": [".rs", "python", "rust"],
"result_types": ["path", "definition", "path"],
"max_results": 5
});
let second = json!({
"query": "Widget",
"path": "src",
"file_types": ["rust", ".py"],
"result_types": ["definition", "path"],
"max_results": 100
});
assert_eq!(hash_normalized_args(tools::CODE_SEARCH, &first), hash_normalized_args(tools::CODE_SEARCH, &second));
for changed in [
json!({"query": "Other", "path": "src", "file_types": ["rust", "python"], "result_types": ["definition", "path"]}),
json!({"query": "Widget", "path": "tests", "file_types": ["rust", "python"], "result_types": ["definition", "path"]}),
json!({"query": "Widget", "path": "src", "file_types": ["rust"], "result_types": ["definition", "path"]}),
json!({"query": "Widget", "path": "src", "file_types": ["rust", "python"], "result_types": ["usage", "path"]}),
] {
assert_ne!(
hash_normalized_args(tools::CODE_SEARCH, &first),
hash_normalized_args(tools::CODE_SEARCH, &changed)
);
}
}
#[test]
fn test_suggest_alternative_for_unknown_tool() {
let detector = LoopDetector::new();
let suggestion = detector.suggest_alternative("unknown_tool");
assert!(suggestion.is_some());
let msg = suggestion.unwrap();
assert!(msg.contains("ROOT CAUSE analysis"));
}
#[test]
fn test_faster_detection_with_lower_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
detector.set_tool_limit(LEGACY_LIST_FILES, 3);
let args = json!({"path": "src"});
assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
assert!(detector.record_call(LEGACY_LIST_FILES, &args).is_none());
let warning = detector.record_call(LEGACY_LIST_FILES, &args);
assert!(warning.is_some());
assert!(warning.unwrap().contains("Loop detected"));
}
#[test]
fn test_file_operation_action_suffix_uses_action_specific_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_key = format!("{}::read", tools::UNIFIED_FILE);
for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
let args = json!({"path": "src/main.rs", "offset_lines": idx + 1, "limit": 1});
let _ = detector.record_call(&tool_key, &args);
}
assert!(!detector.is_hard_limit_exceeded(&tool_key));
}
#[test]
fn test_file_operation_write_suffix_uses_write_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_key = format!("{}::write", tools::UNIFIED_FILE);
for idx in 0..(MAX_WRITE_TOOL_CALLS * HARD_LIMIT_MULTIPLIER) {
let args = json!({"path": format!("src/file_{idx}.rs"), "content": "x"});
let _ = detector.record_call(&tool_key, &args);
}
assert!(detector.is_hard_limit_exceeded(&tool_key));
}
#[test]
fn test_command_session_action_suffix_skips_identical_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(3);
let tool_key = format!("{}::run", tools::UNIFIED_EXEC);
let args = json!({"command": "cargo check"});
assert!(detector.record_call(&tool_key, &args).is_none());
assert!(detector.record_call(&tool_key, &args).is_none());
assert!(detector.record_call(&tool_key, &args).is_none());
}
#[test]
fn test_repetitive_read_target_with_small_variations_triggers_hard_stop() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_key = format!("{}::read", tools::UNIFIED_FILE);
let mut saw_hard_stop = false;
for offset in [1, 2, 1, 2, 1, 2, 1, 2] {
let args =
json!({"path": "crates/codegen/vtcode-core/src/a2a/server.rs", "offset_lines": offset, "limit": 20});
if let Some(warning) = detector.record_call(&tool_key, &args)
&& warning.contains("HARD STOP")
{
saw_hard_stop = true;
}
}
assert!(saw_hard_stop);
assert!(detector.is_hard_limit_exceeded(&tool_key));
}
#[test]
fn test_repetitive_read_target_with_many_ranges_is_not_hard_stopped() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool_key = format!("{}::read", tools::UNIFIED_FILE);
for offset in 1..=MAX_SIMILAR_READ_TARGET_CALLS {
let args = json!({"path": "crates/codegen/vtcode-core/src/a2a/server.rs", "offset_lines": offset * 40, "limit": 40});
if let Some(warning) = detector.record_call(&tool_key, &args) {
assert!(!warning.contains("HARD STOP"));
}
}
assert!(!detector.is_hard_limit_exceeded(&tool_key));
}
#[test]
fn test_repetitive_read_target_grep_calls_do_not_break_streak() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let read_tool = format!("{}::read", tools::UNIFIED_FILE);
for offset in 1..=(MAX_SIMILAR_READ_TARGET_CALLS - 1) {
let _ = detector.record_call(
&read_tool,
&json!({"path": "crates/codegen/vtcode-core/src/a2a/server.rs", "offset_lines": offset * 40, "limit": 20}),
);
let _ = detector.record_call(
LEGACY_GREP_FILE,
&json!({"pattern": "handle_loop_detection", "path": "crates/codegen/vtcode-core/src"}),
);
}
assert!(!detector.is_hard_limit_exceeded(&read_tool));
}
#[test]
fn test_repetitive_read_target_same_params_with_grep_between_triggers_hard_stop() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let read_tool = format!("{}::read", tools::UNIFIED_FILE);
for _ in 0..MAX_SIMILAR_READ_TARGET_CALLS + 2 {
let _ = detector.record_call(&read_tool, &json!({"path": "Cargo.lock", "offset_lines": 1, "limit": 2000}));
let _ = detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "aws-lc", "path": "Cargo.lock"}));
}
assert!(detector.is_hard_limit_exceeded(&read_tool));
}
#[test]
fn test_read_file_alias_cycling_triggers_identical_detection() {
let mut detector = LoopDetector::with_max_repeated_calls(3);
let call1 = json!({"path": "docs/README.md", "max_lines": 200});
let call2 = json!({"path": "docs/README.md", "offset_lines": 1, "limit": 200});
let call3 = json!({"path": "docs/README.md", "chunk_lines": 200});
let n1 = normalize_args_for_detection(tools::READ_FILE, &call1);
let n2 = normalize_args_for_detection(tools::READ_FILE, &call2);
let n3 = normalize_args_for_detection(tools::READ_FILE, &call3);
assert!(n1.get("max_lines").is_none(), "max_lines should be removed");
assert!(n2.get("offset_lines").is_none(), "offset_lines should be removed");
assert!(n3.get("chunk_lines").is_none(), "chunk_lines should be removed");
assert_eq!(n1.get("limit"), n2.get("limit"));
assert_eq!(n2.get("limit"), n3.get("limit"));
assert!(detector.record_call(tools::READ_FILE, &call1).is_none());
assert!(detector.record_call(tools::READ_FILE, &call2).is_none());
let warning = detector.record_call(tools::READ_FILE, &call3);
assert!(warning.is_some(), "Third aliased call should be detected");
assert!(warning.unwrap().contains("HARD STOP"));
}
#[test]
fn test_read_file_encoding_and_action_are_stripped() {
let with_encoding = json!({"path": "foo.rs", "encoding": "utf-8", "offset_lines": 1, "max_lines": 200});
let without_encoding = json!({"path": "foo.rs", "offset_lines": 1, "max_lines": 200});
let n1 = normalize_args_for_detection(tools::READ_FILE, &with_encoding);
let n2 = normalize_args_for_detection(tools::READ_FILE, &without_encoding);
assert!(n1.get("encoding").is_none());
assert_eq!(n1, n2);
}
#[test]
fn test_line_start_line_end_normalized_to_offset_limit() {
let args = json!({"path": "foo.rs", "line_start": 1, "line_end": 200});
let normalized = normalize_args_for_detection(tools::READ_FILE, &args);
assert!(normalized.get("line_start").is_none());
assert!(normalized.get("line_end").is_none());
assert_eq!(normalized.get("offset").and_then(|v| v.as_u64()), Some(1));
assert_eq!(normalized.get("limit").and_then(|v| v.as_u64()), Some(200));
}
#[test]
fn test_start_line_end_line_normalized_to_offset_limit() {
let args = json!({"path": "Cargo.lock", "start_line": 550, "end_line": 590});
let normalized = normalize_args_for_detection(tools::READ_FILE, &args);
assert!(normalized.get("start_line").is_none());
assert!(normalized.get("end_line").is_none());
assert_eq!(normalized.get("offset").and_then(|v| v.as_u64()), Some(550));
assert_eq!(normalized.get("limit").and_then(|v| v.as_u64()), Some(41));
}
#[test]
fn test_navigation_loop_detection() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let list_args = serde_json::json!({"path": "src"});
let grep_args = serde_json::json!({"pattern": "fn", "path": "src/main.rs"});
let read_args = serde_json::json!({"path": "src/main.rs"});
let sequence = [
(LEGACY_LIST_FILES, &list_args),
(LEGACY_GREP_FILE, &grep_args),
(tools::READ_FILE, &read_args),
];
for (i, (tool, args)) in sequence.iter().enumerate() {
let res = detector.record_call(tool, args);
assert!(res.is_none(), "Call {} ({}) should not have triggered a warning", i + 1, tool);
}
let warning = detector.record_call(LEGACY_GREP_FILE, &grep_args);
assert!(warning.is_some(), "4th call should have triggered a navigation loop warning");
assert!(warning.unwrap().contains("Navigation Loop Detected"));
let write_args = serde_json::json!({"path": "src/new.rs", "content": "test"});
assert!(detector.record_call(tools::WRITE_FILE, &write_args).is_none());
assert!(detector.record_call(LEGACY_LIST_FILES, &list_args).is_none());
}
#[test]
fn test_checkpoint_324_pattern_detected() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let read_tool = format!("{}::read", tools::UNIFIED_FILE);
let r = detector.record_call(&read_tool, &json!({"path": "Cargo.toml"}));
assert!(r.is_none());
let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
assert!(r.is_none());
let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock"}));
assert!(r.is_none());
let r = detector.record_call(LEGACY_GREP_FILE, &json!({"pattern": "aws-lc", "path": "Cargo.lock"}));
assert!(r.is_some(), "Navigation loop warning should fire at streak 4");
let msg = r.unwrap();
assert!(msg.contains("Navigation Loop Detected"));
let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock", "start_line": 550, "end_line": 590}));
assert!(r.is_none());
let r = detector.record_call(&read_tool, &json!({"path": "Cargo.lock", "start_line": 4400, "end_line": 4420}));
assert!(r.is_some(), "HARD STOP should fire at call 6");
let msg = r.unwrap();
assert!(msg.contains("HARD STOP"), "Expected HARD STOP, got: {msg}");
assert!(msg.contains("Cargo.lock"));
assert!(msg.contains("exact continuation range"));
assert!(detector.is_hard_limit_exceeded(&read_tool));
}
#[test]
fn read_target_extracted_for_file_operation_read_action() {
let args = json!({"action": "read", "path": "src/lib.rs"});
let target = read_target_for_tool_call(tools::UNIFIED_FILE, &args);
assert_eq!(target.as_deref(), Some("src/lib.rs"));
}
#[test]
fn read_target_none_for_file_operation_write_action() {
let args = json!({"action": "write", "path": "src/lib.rs", "content": "fn main() {}"});
let target = read_target_for_tool_call(tools::UNIFIED_FILE, &args);
assert!(target.is_none());
}
#[test]
fn read_target_extracted_for_file_operation_with_read_suffix() {
let args = json!({"path": "src/main.rs"});
let read_tool = format!("{}::read", tools::UNIFIED_FILE);
let target = read_target_for_tool_call(&read_tool, &args);
assert_eq!(target.as_deref(), Some("src/main.rs"));
}
#[test]
fn repetitive_read_target_fires_for_file_operation_read_action() {
let mut detector = LoopDetector::new();
let offsets = [0, 100, 0, 100];
for (i, offset) in offsets.iter().enumerate() {
let result = detector
.record_call(tools::UNIFIED_FILE, &json!({"action": "read", "path": "src/lib.rs", "offset": offset}));
if i < offsets.len() - 1 {
assert!(result.is_none(), "call {i} should not trigger");
} else {
assert!(result.is_some(), "call {i} should trigger HARD STOP");
let msg = result.unwrap();
assert!(msg.contains("HARD STOP"), "Expected HARD STOP: {msg}");
assert!(msg.contains("src/lib.rs"));
}
}
}
#[test]
fn global_readonly_budget_fires_at_limit() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let mut saw_hard_stop = false;
for i in 0..MAX_TOTAL_READONLY_CALLS {
let args = json!({"query": format!("pattern_{i}"), "path": "src/"});
if let Some(msg) = detector.record_call(tools::CODE_SEARCH, &args)
&& msg.contains("Global read-only budget")
{
saw_hard_stop = true;
break;
}
}
assert!(saw_hard_stop, "Global readonly budget should fire at {MAX_TOTAL_READONLY_CALLS}");
assert!(detector.is_hard_limit_exceeded(tools::CODE_SEARCH));
}
#[test]
fn global_readonly_budget_counts_across_different_tools() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let mut hard_stop_count = 0;
for i in 0..MAX_TOTAL_READONLY_CALLS + 5 {
if i % 2 == 0 {
let args = json!({"query": format!("p_{i}"), "path": "src/"});
if let Some(msg) = detector.record_call(tools::CODE_SEARCH, &args)
&& msg.contains("Global read-only budget")
{
hard_stop_count += 1;
}
} else {
let args = json!({"action": "read", "path": format!("src/file_{i}.rs")});
if let Some(msg) = detector.record_call(tools::UNIFIED_FILE, &args)
&& msg.contains("Global read-only budget")
{
hard_stop_count += 1;
}
}
}
assert!(hard_stop_count > 0, "Global budget should fire when alternating tools");
assert_eq!(detector.total_readonly_calls(), MAX_TOTAL_READONLY_CALLS + 5);
}
#[test]
fn global_readonly_budget_resets_on_reset() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
for i in 0..10 {
let args = json!({"query": format!("p_{i}"), "path": "src/"});
detector.record_call(tools::CODE_SEARCH, &args);
}
assert_eq!(detector.total_readonly_calls(), 10);
detector.reset();
assert_eq!(detector.total_readonly_calls(), 0);
}
#[test]
fn global_readonly_budget_not_incremented_by_mutating_tools() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
for i in 0..5 {
let args = json!({"path": format!("src/file_{i}.rs"), "content": "fn main() {}"});
detector.record_call(tools::WRITE_FILE, &args);
}
assert_eq!(detector.total_readonly_calls(), 0);
}
#[test]
fn lowered_navigation_loop_thresholds() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
for i in 0..3 {
let args = json!({"query": format!("p_{i}"), "path": "src/"});
assert!(detector.record_call(tools::CODE_SEARCH, &args).is_none(), "Call {i} should not trigger warning");
}
let args = json!({"query": "p_4", "path": "src/"});
let warning = detector.record_call(tools::CODE_SEARCH, &args);
assert!(warning.is_some(), "Navigation loop warning should fire at streak 4");
assert!(warning.unwrap().contains("Navigation Loop Detected"));
}
#[test]
fn navigation_hard_stop_at_lowered_threshold() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
for i in 0..6 {
let args = json!({"query": format!("p_{i}"), "path": "src/"});
detector.record_call(tools::CODE_SEARCH, &args);
}
let args = json!({"query": "p_7", "path": "src/"});
let warning = detector.record_call(tools::CODE_SEARCH, &args);
assert!(warning.is_some(), "Navigation hard stop should fire at streak 7");
let msg = warning.unwrap();
assert!(msg.contains("HARD STOP"), "Expected HARD STOP: {msg}");
assert!(detector.is_hard_limit_exceeded(tools::CODE_SEARCH));
}
#[test]
fn subagent_navigation_hard_stop_at_5() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
detector.set_subagent_mode(true);
for i in 0..4 {
let args = json!({"query": format!("p_{i}"), "path": "src/"});
detector.record_call(tools::CODE_SEARCH, &args);
}
let args = json!({"query": "p_5", "path": "src/"});
let warning = detector.record_call(tools::CODE_SEARCH, &args);
assert!(warning.is_some(), "Subagent navigation hard stop should fire at streak 5");
let msg = warning.unwrap();
assert!(msg.contains("HARD STOP"), "Expected HARD STOP: {msg}");
}
#[test]
fn subagent_readonly_budget_at_20() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
detector.set_subagent_mode(true);
for i in 0..20 {
let args = json!({"query": format!("unique_{i}"), "path": "src/"});
detector.record_call(tools::CODE_SEARCH, &args);
}
let args = json!({"query": "final", "path": "src/"});
let warning = detector.record_call(tools::CODE_SEARCH, &args);
assert!(warning.is_some(), "Subagent global read-only budget should fire at 20");
let msg = warning.unwrap();
assert!(msg.contains("Global read-only budget exhausted"), "Expected budget exhaustion: {msg}");
assert!(msg.contains("limit: 20"), "Expected limit 20 in message: {msg}");
}
#[test]
fn main_agent_readonly_budget_still_30() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
for i in 0..30 {
let args = json!({"query": format!("unique_{i}"), "path": "src/"});
detector.record_call(tools::CODE_SEARCH, &args);
}
let args = json!({"query": "final", "path": "src/"});
let warning = detector.record_call(tools::CODE_SEARCH, &args);
assert!(warning.is_some(), "Main agent global read-only budget should fire at 30");
let msg = warning.unwrap();
assert!(msg.contains("limit: 30"), "Expected limit 30 in message: {msg}");
}
#[test]
fn canonicalize_command_v_to_verify() {
assert_eq!(canonicalize_command_for_detection("command -v ast-grep"), Some("__verify__:ast-grep".to_string()));
}
#[test]
fn canonicalize_which_to_verify() {
assert_eq!(canonicalize_command_for_detection("which ast-grep"), Some("__verify__:ast-grep".to_string()));
}
#[test]
fn canonicalize_help_to_verify() {
assert_eq!(canonicalize_command_for_detection("ast-grep --help"), Some("__verify__:ast-grep".to_string()));
}
#[test]
fn canonicalize_version_to_verify() {
assert_eq!(canonicalize_command_for_detection("ast-grep --version"), Some("__verify__:ast-grep".to_string()));
}
#[test]
fn canonicalize_short_help_to_verify() {
assert_eq!(canonicalize_command_for_detection("ast-grep -h"), Some("__verify__:ast-grep".to_string()));
}
#[test]
fn canonicalize_path_prefix_stripped() {
assert_eq!(
canonicalize_command_for_detection("command -v /usr/bin/ast-grep"),
Some("__verify__:ast-grep".to_string())
);
}
#[test]
fn canonicalize_cat_to_read() {
assert_eq!(canonicalize_command_for_detection("cat src/main.rs"), Some("__read__:src/main.rs".to_string()));
}
#[test]
fn canonicalize_head_to_read() {
assert_eq!(canonicalize_command_for_detection("head src/main.rs"), Some("__read__:src/main.rs".to_string()));
}
#[test]
fn canonicalize_arbitrary_command_returns_none() {
assert_eq!(canonicalize_command_for_detection("cargo check -p vtcode-core"), None);
}
#[test]
fn canonicalize_empty_command_returns_none() {
assert_eq!(canonicalize_command_for_detection(""), None);
}
#[test]
fn canonicalize_env_prefix_skips_to_real_tool() {
assert_eq!(
canonicalize_command_for_detection("env VAR=val ast-grep --help"),
Some("__verify__:ast-grep".to_string())
);
}
#[test]
fn canonicalize_env_prefix_with_path() {
assert_eq!(
canonicalize_command_for_detection("env PATH=/usr/bin ast-grep --version"),
Some("__verify__:ast-grep".to_string())
);
}
#[test]
fn verification_commands_normalize_to_same_hash() {
let mut detector = LoopDetector::with_max_repeated_calls(2);
let tool = tools::UNIFIED_EXEC;
let args1 = json!({"command": "command -v ast-grep"});
let args2 = json!({"command": "which ast-grep"});
let _args3 = json!({"command": "ast-grep --help"});
assert!(detector.record_call(tool, &args1).is_none());
let warning = detector.record_call(tool, &args2);
assert!(warning.is_some(), "which ast-grep should be detected as duplicate of command -v ast-grep");
}
#[test]
fn cat_commands_normalize_to_read_target() {
let mut detector = LoopDetector::with_max_repeated_calls(100);
let tool = tools::UNIFIED_EXEC;
let args = json!({"command": "cat src/main.rs"});
detector.record_call(tool, &args);
let record = detector.recent_calls.back().unwrap();
assert_eq!(record.read_target.as_deref(), Some("src/main.rs"), "cat command should produce read_target");
}
#[test]
fn verification_spiral_detected_across_tools() {
let mut detector = LoopDetector::with_max_repeated_calls(2);
let tool = tools::UNIFIED_EXEC;
let args1 = json!({"command": "command -v ast-grep"});
let _args2 = json!({"command": "ast-grep --version 2>&1 | head -5"});
assert!(detector.record_call(tool, &args1).is_none());
}
}