use super::activity::Outcome;
use super::tools::ToolCall;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StormVerdict {
pub suppress: bool,
pub reason: Option<String>,
}
impl StormVerdict {
fn pass() -> Self {
Self {
suppress: false,
reason: None,
}
}
fn suppress(name: &str, count: usize) -> Self {
Self {
suppress: true,
reason: Some(format!(
"{name} called with identical args {count} times — repeat-loop guard tripped"
)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StormReport {
pub storms_broken: usize,
pub notes: Vec<String>,
}
impl StormReport {
pub fn all_suppressed(&self, original_count: usize) -> bool {
self.storms_broken > 0 && self.storms_broken == original_count && original_count > 0
}
}
struct RecentEntry {
name: String,
args: String,
read_only: bool,
}
#[allow(clippy::type_complexity)]
pub struct StormBreaker {
window_size: usize,
threshold: usize,
is_mutating: Option<Box<dyn Fn(&ToolCall) -> bool + Send + Sync>>,
is_storm_exempt: Option<Box<dyn Fn(&ToolCall) -> bool + Send + Sync>>,
recent: Vec<RecentEntry>,
expensive: HashSet<String>,
}
impl StormBreaker {
#[allow(clippy::type_complexity)]
pub fn new(
window_size: usize,
threshold: usize,
is_mutating: Option<Box<dyn Fn(&ToolCall) -> bool + Send + Sync>>,
is_storm_exempt: Option<Box<dyn Fn(&ToolCall) -> bool + Send + Sync>>,
) -> Self {
assert!(
threshold >= 2,
"storm breaker threshold must be >= 2 (got {threshold})"
);
assert!(
window_size >= threshold,
"storm breaker window_size ({window_size}) must be >= threshold ({threshold})"
);
Self {
window_size,
threshold,
is_mutating,
is_storm_exempt,
recent: Vec::with_capacity(window_size),
expensive: HashSet::new(),
}
}
fn signature(name: &str, args: &str) -> String {
format!("{name}\u{0}{args}")
}
pub fn note_outcome(&mut self, call: &ToolCall, outcome: Outcome) {
if outcome != Outcome::Timeout {
return;
}
let args = super::message::canonical_json(&call.arguments);
self.expensive.insert(Self::signature(&call.name, &args));
}
pub fn inspect(&mut self, call: &ToolCall) -> StormVerdict {
let name = &call.name;
if name.is_empty() {
return StormVerdict::pass();
}
if let Some(ref exempt) = self.is_storm_exempt
&& exempt(call)
{
return StormVerdict::pass();
}
let args = super::message::canonical_json(&call.arguments);
let mutating = self.is_mutating.as_ref().map(|f| f(call)).unwrap_or(false);
let read_only = !mutating;
if mutating {
let mut i = self.recent.len();
while i > 0 {
i -= 1;
if self.recent[i].read_only {
self.recent.remove(i);
}
}
}
let count = self
.recent
.iter()
.filter(|e| e.name == *name && e.args == args)
.count();
let effective = if self.expensive.contains(&Self::signature(name, &args)) {
self.threshold.saturating_sub(1).max(2)
} else {
self.threshold
};
if count >= effective.saturating_sub(1) {
return StormVerdict::suppress(name, count + 1);
}
self.recent.push(RecentEntry {
name: name.clone(),
args,
read_only,
});
while self.recent.len() > self.window_size {
self.recent.remove(0);
}
StormVerdict::pass()
}
pub fn reset(&mut self) {
self.recent.clear();
self.expensive.clear();
}
pub fn filter_calls(&mut self, calls: &[ToolCall]) -> (Vec<ToolCall>, StormReport) {
let mut surviving: Vec<ToolCall> = Vec::with_capacity(calls.len());
let mut report = StormReport::default();
for call in calls {
let verdict = self.inspect(call);
if verdict.suppress {
report.storms_broken += 1;
if let Some(reason) = verdict.reason {
tracing::warn!("storm breaker: {reason}");
report.notes.push(reason);
}
} else {
surviving.push(call.clone());
}
}
if report.storms_broken > 0 {
tracing::info!(
suppressed = report.storms_broken,
surviving = surviving.len(),
"storm breaker: {}/{} calls suppressed",
report.storms_broken,
calls.len()
);
}
(surviving, report)
}
}
pub fn failure_narrative(looped_tools: &[String]) -> String {
let mut seen = std::collections::HashSet::new();
let tools: Vec<&str> = looped_tools
.iter()
.filter(|t| seen.insert(t.as_str()))
.map(|t| t.as_str())
.collect();
let tool_phrase = match tools.as_slice() {
[] => "the same tool call".to_string(),
[one] => format!("the same `{one}` call"),
many => {
let quoted: Vec<String> = many.iter().map(|t| format!("`{t}`")).collect();
format!("the same {} calls", quoted.join(" and "))
}
};
format!(
"I've stopped here to avoid spinning in a loop. I kept making {tool_phrase} \
and getting the same result, and repeating it wasn't going to get me any further, \
so I'd rather pause than burn the session retrying a dead end.\n\n\
I wasn't able to finish what you asked. If you can confirm the goal, point me at \
the right file, or suggest a different angle, I'll pick it back up from there."
)
}
pub fn default_mutating(call: &ToolCall) -> bool {
use crate::permission::engine::tool_operation;
use crate::permission::engine::types::Operation;
matches!(
tool_operation(&call.name),
Operation::Edit | Operation::Execute
)
}
pub fn default_exempt(call: &ToolCall) -> bool {
use crate::permission::engine::tool_operation;
use crate::permission::engine::types::Operation;
matches!(tool_operation(&call.name), Operation::Read)
}
impl Default for StormBreaker {
fn default() -> Self {
Self::new(
6,
3,
Some(Box::new(default_mutating)),
Some(Box::new(default_exempt)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn call(name: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments: args,
}
}
fn call_json(name: &str, args_json: &str) -> ToolCall {
call(
name,
serde_json::from_str::<serde_json::Value>(args_json).unwrap_or(json!({})),
)
}
#[test]
fn passes_through_below_threshold() {
let mut sb = StormBreaker::new(6, 3, None, None);
assert!(!sb.inspect(&call_json("x", "{}")).suppress);
assert!(!sb.inspect(&call_json("x", "{}")).suppress);
}
#[test]
fn suppresses_on_threshold_reached() {
let mut sb = StormBreaker::new(6, 3, None, None);
sb.inspect(&call_json("x", "{}"));
sb.inspect(&call_json("x", "{}"));
let verdict = sb.inspect(&call_json("x", "{}"));
assert!(verdict.suppress);
assert!(verdict.reason.unwrap().contains("repeat-loop guard"));
}
#[test]
fn timed_out_call_is_suppressed_one_retry_sooner() {
let mut sb = StormBreaker::new(6, 3, None, None);
let c = call_json("bash", r#"{"command":"git clone x"}"#);
assert!(!sb.inspect(&c).suppress, "1st call runs");
sb.note_outcome(&c, Outcome::Timeout);
assert!(
sb.inspect(&c).suppress,
"2nd retry of a timed-out call should be suppressed"
);
}
#[test]
fn non_timeout_outcomes_do_not_lower_the_threshold() {
let mut sb = StormBreaker::new(6, 3, None, None);
let c = call_json("bash", r#"{"command":"false"}"#);
assert!(!sb.inspect(&c).suppress);
sb.note_outcome(&c, Outcome::Error); assert!(!sb.inspect(&c).suppress, "2nd call still allowed");
assert!(sb.inspect(&c).suppress, "3rd identical trips normally");
}
#[test]
fn reset_clears_expensive_signatures() {
let mut sb = StormBreaker::new(6, 3, None, None);
let c = call_json("bash", r#"{"command":"slow"}"#);
sb.inspect(&c);
sb.note_outcome(&c, Outcome::Timeout);
sb.reset();
assert!(!sb.inspect(&c).suppress);
assert!(!sb.inspect(&c).suppress);
assert!(sb.inspect(&c).suppress, "back to threshold 3 after reset");
}
#[test]
fn distinguishes_different_args_as_different_calls() {
let mut sb = StormBreaker::new(6, 3, None, None);
sb.inspect(&call_json("x", r#"{"a":1}"#));
sb.inspect(&call_json("x", r#"{"a":2}"#));
sb.inspect(&call_json("x", r#"{"a":3}"#));
let verdict = sb.inspect(&call_json("x", r#"{"a":4}"#));
assert!(!verdict.suppress);
}
#[test]
fn forgets_old_calls_beyond_window() {
let mut sb = StormBreaker::new(3, 3, None, None);
sb.inspect(&call_json("x", "{}"));
sb.inspect(&call_json("x", "{}"));
sb.inspect(&call_json("y", "{}"));
sb.inspect(&call_json("z", "{}"));
sb.inspect(&call_json("w", "{}"));
assert!(!sb.inspect(&call_json("x", "{}")).suppress);
}
#[test]
fn intervening_mutating_call_resets_window_for_rerereads() {
let mutators: Box<dyn Fn(&ToolCall) -> bool + Send + Sync> =
Box::new(|c| matches!(c.name.as_str(), "edit_file" | "write_file"));
let mut sb = StormBreaker::new(6, 3, Some(mutators), None);
let args = r#"{"path":"src/env.ts"}"#;
assert!(!sb.inspect(&call_json("read_file", args)).suppress);
assert!(
!sb.inspect(&call_json(
"edit_file",
r#"{"path":"src/env.ts","new_text":"x"}"#,
))
.suppress
);
assert!(!sb.inspect(&call_json("read_file", args)).suppress);
assert!(
!sb.inspect(&call_json(
"edit_file",
r#"{"path":"src/env.ts","new_text":"y"}"#,
))
.suppress
);
assert!(!sb.inspect(&call_json("read_file", args)).suppress);
}
#[test]
fn predicate_flagged_write_file_resets_the_window() {
let mutators: Box<dyn Fn(&ToolCall) -> bool + Send + Sync> =
Box::new(|c| c.name == "write_file");
let mut sb = StormBreaker::new(6, 3, Some(mutators), None);
assert!(!sb.inspect(&call_json("read_file", "{}")).suppress);
assert!(!sb.inspect(&call_json("read_file", "{}")).suppress);
assert!(!sb.inspect(&call_json("write_file", "{}")).suppress);
assert!(!sb.inspect(&call_json("read_file", "{}")).suppress);
assert!(!sb.inspect(&call_json("read_file", "{}")).suppress);
}
#[test]
fn default_classifiers_track_tool_operation() {
for t in ["write", "edit", "apply_patch", "bash"] {
assert!(
default_mutating(&call_json(t, "{}")),
"{t} must be mutating"
);
assert!(
!default_exempt(&call_json(t, "{}")),
"{t} must not be exempt"
);
}
for t in [
"read",
"grep",
"find_files",
"glob",
"list_dir",
"repo_overview",
"lsp",
"list_symbols",
] {
assert!(default_exempt(&call_json(t, "{}")), "{t} must be exempt");
assert!(!default_mutating(&call_json(t, "{}")), "{t} not mutating");
}
for t in ["webfetch", "task", "memory", "mcp_tool"] {
assert!(!default_mutating(&call_json(t, "{}")), "{t}");
assert!(!default_exempt(&call_json(t, "{}")), "{t}");
}
}
#[test]
fn with_no_predicate_every_tool_counts() {
let mut sb = StormBreaker::new(6, 3, None, None);
sb.inspect(&call_json("edit_file", "{}"));
sb.inspect(&call_json("edit_file", "{}"));
assert!(sb.inspect(&call_json("edit_file", "{}")).suppress);
}
mod storm_exempt {
use super::*;
#[test]
fn exempt_tools_never_trip_the_storm_guard() {
let exempt: Box<dyn Fn(&ToolCall) -> bool + Send + Sync> =
Box::new(|c| matches!(c.name.as_str(), "read_file" | "list_jobs"));
let mut sb = StormBreaker::new(6, 3, None, Some(exempt));
for _ in 0..10 {
assert!(
!sb.inspect(&call_json("read_file", r#"{"path":"/foo"}"#))
.suppress
);
}
}
#[test]
fn non_exempt_tools_still_trip_after_exempt_reads() {
let exempt: Box<dyn Fn(&ToolCall) -> bool + Send + Sync> =
Box::new(|c| c.name == "read_file");
let mut sb = StormBreaker::new(3, 3, None, Some(exempt));
sb.inspect(&call_json("edit_file", "{}"));
sb.inspect(&call_json("edit_file", "{}"));
sb.inspect(&call_json("read_file", "{}"));
sb.inspect(&call_json("read_file", "{}"));
sb.inspect(&call_json("read_file", "{}"));
assert!(sb.inspect(&call_json("edit_file", "{}")).suppress);
}
}
#[test]
fn filter_calls_passes_through_below_threshold() {
let mut sb = StormBreaker::new(6, 3, None, None);
let calls = vec![call_json("x", "{}"), call_json("x", "{}")];
let (surviving, report) = sb.filter_calls(&calls);
assert_eq!(surviving.len(), 2);
assert_eq!(report.storms_broken, 0);
}
#[test]
fn filter_calls_suppresses_at_threshold() {
let mut sb = StormBreaker::new(6, 3, None, None);
let calls = vec![
call_json("x", "{}"),
call_json("x", "{}"),
call_json("x", "{}"),
];
let (surviving, report) = sb.filter_calls(&calls);
assert_eq!(surviving.len(), 2);
assert_eq!(report.storms_broken, 1);
assert!(!report.all_suppressed(3));
}
#[test]
fn filter_calls_all_suppressed_on_second_batch() {
let mut sb = StormBreaker::new(6, 3, None, None);
let calls1: Vec<ToolCall> = (0..3).map(|_| call_json("x", "{}")).collect();
let (surviving1, _) = sb.filter_calls(&calls1);
assert_eq!(surviving1.len(), 2);
let calls2: Vec<ToolCall> = (0..3).map(|_| call_json("x", "{}")).collect();
let (surviving2, report2) = sb.filter_calls(&calls2);
assert_eq!(surviving2.len(), 0);
assert_eq!(report2.storms_broken, 3);
assert!(report2.all_suppressed(3));
}
#[test]
fn narrative_is_first_person_and_names_the_tool() {
let n = failure_narrative(&["bash".to_string()]);
assert!(n.starts_with("I've stopped"), "first-person: {n}");
assert!(n.contains("`bash`"), "names the tool: {n}");
assert!(!n.contains("Error"), "should not look like an error: {n}");
}
#[test]
fn narrative_dedups_and_lists_multiple_tools() {
let n = failure_narrative(&[
"edit".to_string(),
"bash".to_string(),
"edit".to_string(), ]);
assert!(n.contains("`edit` and `bash`"), "got: {n}");
assert_eq!(n.matches("`edit`").count(), 1, "edit listed once: {n}");
}
#[test]
fn narrative_handles_empty_tool_list() {
let n = failure_narrative(&[]);
assert!(n.contains("the same tool call"), "got: {n}");
}
}