use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use bevy_ecs::prelude::Component;
#[derive(Debug, Clone)]
pub struct RepetitionConfig {
pub max_repeat_calls: usize,
pub max_readonly_streak: usize,
pub enabled: bool,
}
impl Default for RepetitionConfig {
fn default() -> Self {
Self {
max_repeat_calls: 3,
max_readonly_streak: 10,
enabled: true,
}
}
}
#[derive(Debug, Component)]
pub struct RepetitionDetector {
config: RepetitionConfig,
call_counts: HashMap<(String, u64), usize>,
readonly_streak: usize,
}
const READONLY_TOOLS: &[&str] = &[
"read_file",
"list_dir",
"list_files",
"glob",
"grep",
"search",
"context_read",
"context_list",
];
const PRODUCTIVE_TOOLS: &[&str] = &[
"write_file",
"edit_file",
"bash",
"run_command",
"execute",
"shell",
"context_write",
"context_append",
"context_delete",
];
fn hash_arguments(args: &str) -> u64 {
let mut hasher = DefaultHasher::new();
args.hash(&mut hasher);
hasher.finish()
}
fn is_readonly(tool_name: &str) -> bool {
READONLY_TOOLS.contains(&tool_name)
}
fn is_productive(tool_name: &str) -> bool {
PRODUCTIVE_TOOLS.contains(&tool_name)
}
impl RepetitionDetector {
pub fn new(config: RepetitionConfig) -> Self {
Self {
config,
call_counts: HashMap::new(),
readonly_streak: 0,
}
}
pub fn with_defaults() -> Self {
Self::new(RepetitionConfig::default())
}
pub fn from_detection_config(cfg: &leviath_core::blueprint::RepetitionDetectionConfig) -> Self {
let d = RepetitionConfig::default();
Self::new(RepetitionConfig {
max_repeat_calls: cfg.max_repeat_calls.unwrap_or(d.max_repeat_calls),
max_readonly_streak: cfg.max_readonly_streak.unwrap_or(d.max_readonly_streak),
enabled: cfg.enabled.unwrap_or(d.enabled),
})
}
pub fn record_call(&mut self, tool_name: &str, tool_arguments: &str) -> Option<String> {
if !self.config.enabled {
return None;
}
if is_productive(tool_name) {
self.call_counts.clear();
self.readonly_streak = 0;
return None;
}
if is_readonly(tool_name) {
self.readonly_streak += 1;
}
let args_hash = hash_arguments(tool_arguments);
let key = (tool_name.to_string(), args_hash);
let count = self.call_counts.entry(key).or_insert(0);
*count += 1;
if *count >= self.config.max_repeat_calls {
let count_val = *count;
return Some(format!(
"You have called {tool_name} with the same arguments {count_val} times. \
This appears to be a loop. Take a different action - try writing code \
with write_file, editing with edit_file, running commands with bash, \
or storing analysis with context_write."
));
}
if self.readonly_streak >= self.config.max_readonly_streak {
let streak = self.readonly_streak;
return Some(format!(
"You have made {streak} consecutive read-only tool calls \
(read_file/list_dir) without any writes or command execution. \
Break this pattern - write a file, edit code, run a command, \
or use context_write to store your analysis."
));
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_detection_config_maps_set_fields_and_defaults_unset() {
use leviath_core::blueprint::RepetitionDetectionConfig;
let all = RepetitionDetectionConfig {
max_repeat_calls: Some(1),
max_readonly_streak: Some(2),
enabled: Some(false),
};
let d = RepetitionDetector::from_detection_config(&all);
assert_eq!(d.config.max_repeat_calls, 1);
assert_eq!(d.config.max_readonly_streak, 2);
assert!(!d.config.enabled);
let unset = RepetitionDetectionConfig {
max_repeat_calls: None,
max_readonly_streak: None,
enabled: None,
};
let d2 = RepetitionDetector::from_detection_config(&unset);
assert_eq!(d2.config.max_repeat_calls, 3);
assert_eq!(d2.config.max_readonly_streak, 10);
assert!(d2.config.enabled);
}
#[test]
fn test_repeat_triggers_nudge_at_threshold() {
let config = RepetitionConfig {
max_repeat_calls: 3,
max_readonly_streak: 10,
enabled: true,
};
let mut detector = RepetitionDetector::new(config);
assert!(
detector
.record_call("read_file", r#"{"path":"src/main.rs"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"src/main.rs"}"#)
.is_none()
);
let nudge = detector.record_call("read_file", r#"{"path":"src/main.rs"}"#);
assert!(nudge.is_some());
let msg = nudge.unwrap();
assert!(msg.contains("read_file"));
assert!(msg.contains("3 times"));
}
#[test]
fn test_productive_call_resets_counter() {
let mut detector = RepetitionDetector::with_defaults();
detector.record_call("read_file", r#"{"path":"foo.py"}"#);
detector.record_call("read_file", r#"{"path":"foo.py"}"#);
assert!(
detector
.record_call("write_file", r#"{"path":"foo.py","content":"x"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"foo.py"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"foo.py"}"#)
.is_none()
);
let nudge = detector.record_call("read_file", r#"{"path":"foo.py"}"#);
assert!(nudge.is_some());
}
#[test]
fn test_readonly_streak_detection() {
let config = RepetitionConfig {
max_repeat_calls: 100, max_readonly_streak: 5,
enabled: true,
};
let mut detector = RepetitionDetector::new(config);
for i in 0..4 {
assert!(
detector
.record_call("read_file", &format!(r#"{{"path":"file{i}.rs"}}"#))
.is_none()
);
}
let nudge = detector.record_call("list_dir", r#"{"path":"src/"}"#);
assert!(nudge.is_some());
assert!(nudge.unwrap().contains("5 consecutive read-only"));
}
#[test]
fn test_different_arguments_dont_trigger_repeat() {
let mut detector = RepetitionDetector::with_defaults();
assert!(
detector
.record_call("read_file", r#"{"path":"a.rs"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"b.rs"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"c.rs"}"#)
.is_none()
);
}
#[test]
fn test_configurable_thresholds() {
let config = RepetitionConfig {
max_repeat_calls: 2,
max_readonly_streak: 100,
enabled: true,
};
let mut detector = RepetitionDetector::new(config);
assert!(
detector
.record_call("read_file", r#"{"path":"x.rs"}"#)
.is_none()
);
let nudge = detector.record_call("read_file", r#"{"path":"x.rs"}"#);
assert!(nudge.is_some());
}
#[test]
fn test_disabled_detector_never_nudges() {
let config = RepetitionConfig {
max_repeat_calls: 1,
max_readonly_streak: 1,
enabled: false,
};
let mut detector = RepetitionDetector::new(config);
assert!(
detector
.record_call("read_file", r#"{"path":"x.rs"}"#)
.is_none()
);
assert!(
detector
.record_call("read_file", r#"{"path":"x.rs"}"#)
.is_none()
);
}
#[test]
fn test_productive_call_resets_readonly_streak() {
let config = RepetitionConfig {
max_repeat_calls: 100,
max_readonly_streak: 3,
enabled: true,
};
let mut detector = RepetitionDetector::new(config);
detector.record_call("read_file", r#"{"path":"a.rs"}"#);
detector.record_call("read_file", r#"{"path":"b.rs"}"#);
detector.record_call("bash", r#"{"command":"cargo test"}"#);
detector.record_call("read_file", r#"{"path":"c.rs"}"#);
detector.record_call("read_file", r#"{"path":"d.rs"}"#);
assert!(
detector
.record_call("list_dir", r#"{"path":"src/"}"#)
.is_some()
); }
#[test]
fn test_non_readonly_non_productive_doesnt_affect_streak() {
let config = RepetitionConfig {
max_repeat_calls: 100,
max_readonly_streak: 3,
enabled: true,
};
let mut detector = RepetitionDetector::new(config);
detector.record_call("read_file", r#"{"path":"a.rs"}"#);
detector.record_call("read_file", r#"{"path":"b.rs"}"#);
detector.record_call("some_other_tool", r#"{}"#);
assert!(
detector
.record_call("read_file", r#"{"path":"c.rs"}"#)
.is_some()
); }
}