autumn_plugin_agent/loop_guard.rs
1//! Loop detection: stop an agent that repeats the same tool call.
2//!
3//! A model sometimes polls a tool that never changes, or bounces between two
4//! calls. Each repeat costs a full model call. The [`LoopGuard`] remembers a
5//! fingerprint of the recent calls — tool name, arguments, *and* result — and
6//! acts on repeats:
7//!
8//! * at `warn_after` identical fingerprints it appends a note to the tool
9//! result, telling the model to change approach;
10//! * at `stop_after` it ends the run with
11//! [`AgentOutcome::LoopDetected`](crate::agent::AgentOutcome::LoopDetected).
12//!
13//! The result is part of the fingerprint, so polling a tool whose answer
14//! changes is never a loop. Ping-pong between two calls is caught too: both
15//! fingerprints repeat inside the window.
16
17use std::collections::VecDeque;
18use std::hash::{Hash, Hasher};
19
20/// Loop-detection thresholds.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct LoopGuard {
23 /// Identical calls (in the window) before the loop adds a warning note.
24 pub warn_after: u32,
25 /// Identical calls (in the window) before the run stops.
26 pub stop_after: u32,
27 /// How many recent calls to remember.
28 pub window: usize,
29}
30
31impl Default for LoopGuard {
32 fn default() -> Self {
33 Self {
34 warn_after: 3,
35 stop_after: 5,
36 window: 30,
37 }
38 }
39}
40
41impl LoopGuard {
42 /// A guard that never fires.
43 #[must_use]
44 pub const fn disabled() -> Self {
45 Self {
46 warn_after: u32::MAX,
47 stop_after: u32::MAX,
48 window: 0,
49 }
50 }
51}
52
53/// What the guard says about one recorded call.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum LoopVerdict {
56 /// Nothing unusual.
57 Ok,
58 /// The call repeated `repeats` times: warn the model.
59 Warn {
60 /// Identical calls in the window, this one included.
61 repeats: u32,
62 },
63 /// The call repeated `repeats` times: stop the run.
64 Stop {
65 /// Identical calls in the window, this one included.
66 repeats: u32,
67 },
68}
69
70/// Rolling fingerprint history for one run.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub(crate) struct LoopTracker {
73 recent: VecDeque<u64>,
74}
75
76impl LoopTracker {
77 /// Rebuild a tracker from fingerprints saved in a paused run.
78 pub(crate) fn from_saved(saved: Vec<u64>) -> Self {
79 Self {
80 recent: saved.into(),
81 }
82 }
83
84 /// The fingerprints, oldest first, for saving with a paused run.
85 pub(crate) fn saved(&self) -> Vec<u64> {
86 self.recent.iter().copied().collect()
87 }
88
89 /// Record one executed call and judge it.
90 pub(crate) fn record(
91 &mut self,
92 guard: &LoopGuard,
93 name: &str,
94 arguments: &serde_json::Value,
95 result: &str,
96 ) -> LoopVerdict {
97 if guard.window == 0 {
98 return LoopVerdict::Ok;
99 }
100 let print = fingerprint(name, arguments, result);
101 self.recent.push_back(print);
102 while self.recent.len() > guard.window {
103 self.recent.pop_front();
104 }
105 let repeats = u32::try_from(self.recent.iter().filter(|seen| **seen == print).count())
106 .unwrap_or(u32::MAX);
107 if repeats >= guard.stop_after {
108 LoopVerdict::Stop { repeats }
109 } else if repeats >= guard.warn_after {
110 LoopVerdict::Warn { repeats }
111 } else {
112 LoopVerdict::Ok
113 }
114 }
115}
116
117/// Hash a call's name, arguments, and result.
118///
119/// `serde_json` keeps object keys sorted, so equal arguments always print
120/// the same. `DefaultHasher::new()` uses fixed keys, so fingerprints stay
121/// stable inside one build — enough to resume a paused run on the same
122/// deployment.
123fn fingerprint(name: &str, arguments: &serde_json::Value, result: &str) -> u64 {
124 let mut hasher = std::collections::hash_map::DefaultHasher::new();
125 name.hash(&mut hasher);
126 arguments.to_string().hash(&mut hasher);
127 result.hash(&mut hasher);
128 hasher.finish()
129}
130
131/// The note appended to a repeated call's result.
132pub(crate) fn warning_note(name: &str, repeats: u32) -> String {
133 format!(
134 "\n[harness] You called {name} {repeats} times with the same arguments and got the same result. Do not call it again with these arguments: change approach or answer with what you have."
135 )
136}
137
138#[cfg(test)]
139mod tests;