Skip to main content

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;