Skip to content

Commit ee23711

Browse files
author
Yogthos
committed
feat(agent): phase 1 — storm breaker / repeat-loop detection
Faithful port of DeepSeek-Reasonix src/repair/storm.ts (66 LOC). StormBreaker tracks (tool_name, args) tuples in a sliding window (default window=6, threshold=3). When the same call appears threshold times, the call is suppressed. - Mutating calls (write, edit, bash) clear prior read-only entries so a post-edit verify-read isn't flagged as a repeat. - Storm-exempt tools (cheap inspectors) never trip the guard. - First-time all-suppressed: self-correction — the model gets one shot to self-correct with guard messages injected as tool results. - Second-time all-suppressed: inner loop exits (model is stuck). Tests: 12 tests ported from Reasonix tests/repair/storm.test.ts. All 14 existing agent_loop::run loop tests still pass. Build + fmt clean.
1 parent 714a29a commit ee23711

3 files changed

Lines changed: 472 additions & 8 deletions

File tree

src/agent/agent_loop/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub mod rig_stream_factory;
3939
pub mod rig_tool;
4040
pub mod run;
4141
pub mod steering;
42+
pub mod storm;
4243
pub mod stream;
4344
pub mod tool;
4445
pub mod tool_input_repair;

src/agent/agent_loop/run.rs

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use tokio::sync::mpsc;
4747
use super::message::{
4848
AssistantMessage, ContentBlock, LoopEvent, LoopMessage, StopReason, ToolResultMessage,
4949
};
50+
use super::storm::{StormBreaker, StormReport};
5051
use super::stream::{StreamFn, stream_assistant_response};
5152
use super::tool::AbortSignal;
5253
use super::tools::execute_tool_calls;
@@ -187,13 +188,22 @@ pub async fn run_loop(
187188
) -> Vec<LoopMessage> {
188189
let mut first_turn = true;
189190

191+
// Storm breaker: tracks (tool_name, args) repeats to detect
192+
// stuck-in-a-loop behavior. Reset each new user turn.
193+
// Port of Reasonix `repair/index.ts:38-46` + `loop.ts:621`.
194+
let mut storm = StormBreaker::default();
195+
190196
// Pi line 167: initial steering poll.
191197
let mut pending_messages: Vec<LoopMessage> = match &config.get_steering_messages {
192198
Some(get) => get().await,
193199
None => Vec::new(),
194200
};
195201

196202
'outer: loop {
203+
// Storm: fresh intent on each new user turn.
204+
// Port of Reasonix loop.ts:621 `this.repair.resetStorm()`.
205+
storm.reset();
206+
let mut turn_self_corrected = false;
197207
let mut has_more_tool_calls = true;
198208

199209
// Pi line 174: INNER LOOP.
@@ -261,14 +271,63 @@ pub async fn run_loop(
261271
let mut tool_results: Vec<ToolResultMessage> = Vec::new();
262272
has_more_tool_calls = false;
263273
if !tool_calls.is_empty() {
264-
let batch =
265-
execute_tool_calls(&current_context, &assistant_msg, &config, &signal, emit)
266-
.await;
267-
tool_results = batch.messages;
268-
has_more_tool_calls = !batch.terminate;
269-
for result in &tool_results {
270-
current_context.messages.push(tool_result_to_value(result));
271-
new_messages.push(LoopMessage::ToolResult(result.clone()));
274+
let original_count = tool_calls.len();
275+
let (surviving_calls, storm_report) = storm.filter_calls(&tool_calls);
276+
let all_suppressed = storm_report.all_suppressed(original_count);
277+
278+
// Port of Reasonix loop.ts:935-956 — first-time
279+
// all-suppressed: self-correction. Stub tool
280+
// results with a guard message and give the model
281+
// one shot to self-correct before the loud-warning
282+
// path.
283+
if all_suppressed && !turn_self_corrected {
284+
turn_self_corrected = true;
285+
let guard_text = "[repeat-loop guard] this call was suppressed because it was identical to a previous call in this turn. Earlier results for it are above — try a meaningfully different approach, or stop and answer if you have enough.";
286+
let guard_blocks = vec![ContentBlock::Text {
287+
text: guard_text.to_string(),
288+
}];
289+
for call in &tool_calls {
290+
let tr = ToolResultMessage {
291+
tool_call_id: call.id.clone(),
292+
tool_name: call.name.clone(),
293+
content: guard_blocks.clone(),
294+
details: Value::Null,
295+
is_error: false,
296+
};
297+
current_context.messages.push(tool_result_to_value(&tr));
298+
new_messages.push(LoopMessage::ToolResult(tr.clone()));
299+
tool_results.push(tr);
300+
}
301+
// Surface the self-correction as a tool result
302+
// with a guard text — the model sees it as
303+
// output for its suppressed tool calls.
304+
has_more_tool_calls = true;
305+
} else if storm_report.storms_broken > 0 && surviving_calls.is_empty() {
306+
// Port of Reasonix loop.ts:975-982:
307+
// no calls left, all suppressed and already
308+
// self-corrected. Model is stuck — no more
309+
// tool calls to dispatch, exit the inner
310+
// loop.
311+
has_more_tool_calls = false;
312+
}
313+
314+
// Dispatch surviving calls.
315+
if !surviving_calls.is_empty() {
316+
let batch = execute_tool_calls_with(
317+
&current_context,
318+
&assistant_msg,
319+
&surviving_calls,
320+
&config,
321+
&signal,
322+
emit,
323+
)
324+
.await;
325+
tool_results.extend(batch.messages.clone());
326+
has_more_tool_calls = !batch.terminate;
327+
for result in &batch.messages {
328+
current_context.messages.push(tool_result_to_value(result));
329+
new_messages.push(LoopMessage::ToolResult(result.clone()));
330+
}
272331
}
273332
}
274333

@@ -380,6 +439,50 @@ fn extract_tool_calls_from(msg: &AssistantMessage) -> Vec<super::tools::ToolCall
380439
super::tools::extract_tool_calls(msg)
381440
}
382441

442+
/// Dispatch pre-filtered tool calls (after storm breaker has
443+
/// removed suppressed calls). Mirrors the dispatch logic in
444+
/// `execute_tool_calls` but takes the calls directly instead of
445+
/// extracting from the assistant message.
446+
async fn execute_tool_calls_with(
447+
context: &Context,
448+
assistant_message: &AssistantMessage,
449+
tool_calls: &[super::tools::ToolCall],
450+
config: &LoopConfig,
451+
signal: &AbortSignal,
452+
emit: &mpsc::Sender<LoopEvent>,
453+
) -> super::tools::ExecutedToolCallBatch {
454+
use super::ExecutedToolCallBatch;
455+
let has_sequential = tool_calls.iter().any(|tc| {
456+
context
457+
.tools
458+
.iter()
459+
.find(|t| t.name() == tc.name)
460+
.and_then(|t| t.execution_mode())
461+
== Some(super::ToolExecutionMode::Sequential)
462+
});
463+
if config.tool_execution == super::ToolExecutionMode::Sequential || has_sequential {
464+
super::tools::execute_tool_calls_sequential(
465+
context,
466+
assistant_message,
467+
tool_calls,
468+
config,
469+
signal,
470+
emit,
471+
)
472+
.await
473+
} else {
474+
super::tools::execute_tool_calls_parallel(
475+
context,
476+
assistant_message,
477+
tool_calls,
478+
config,
479+
signal,
480+
emit,
481+
)
482+
.await
483+
}
484+
}
485+
383486
/// Convert a `LoopMessage` to the placeholder `Value` shape used
384487
/// in `Context.messages`. Mirrors `serialize_assistant` from
385488
/// stream.rs but covers every variant.

0 commit comments

Comments
 (0)