Skip to main content

gglib_core/domain/agent/loop_detection/
mod.rs

1#![doc = include_str!("README.md")]
2mod observation;
3pub(crate) mod results;
4pub(crate) mod signature;
5#[cfg(test)]
6mod tests;
7#[cfg(test)]
8#[path = "verdict_tests.rs"]
9mod verdict_tests;
10
11use super::tool_types::ToolCall;
12use crate::ports::AgentError;
13
14use observation::is_costly_batch;
15pub use observation::is_observation_batch;
16pub use signature::batch_signature;
17
18// =============================================================================
19// LoopDetector
20// =============================================================================
21
22/// Stateful guard that detects when the same tool-call batch repeats **back to
23/// back and gets the same answer back**.
24///
25/// Create once per agent run. Call [`LoopDetector::check`] before executing a
26/// batch and [`LoopDetector::record_results`] once its answers are known.
27///
28/// # What counts as a strike
29///
30/// Counting is run-length, not session-wide: only the current unbroken run of
31/// one signature is held, and a batch with a different signature discards it.
32/// A session-wide tally made any long conversation terminal — a client replays
33/// the whole history every turn, so a batch that recurred often enough
34/// anywhere in the session was rejected on every subsequent request for the
35/// rest of it.
36///
37/// Within a run, an occurrence is a strike only when its answers matched the
38/// previous occurrence's. The same call with a different answer is progress
39/// that happens to look alike — an agent polling a build for output issues an
40/// identical batch every time, and run-length counting alone refused it
41/// exactly as a session-wide tally did. The verdict could not see what came
42/// back; now it can.
43///
44/// # The ceiling on that
45///
46/// A changed answer restarts the run, so on its own it would exempt any tool
47/// whose output carries a clock, an elapsed time, a progress counter or a
48/// random id — `cargo test`'s `finished in 0.31s` is enough. Read-only batches
49/// are exempt anyway, because that tier exists on the ground that repeating a
50/// call which changes nothing is free — except where it changes nothing only
51/// *here*, which `is_costly_batch` withholds the waiver from. Everything else
52/// keeps a ceiling: a batch that changes something may be carried by changing
53/// answers only while the run stays inside the read-only allowance,
54/// `max_observation_steps`. Reusing that number rather than inventing one is
55/// deliberate; there is no measurement behind a new one.
56///
57/// With `max_observation_steps: None` the tier is off: no batch is exempt, the
58/// ceiling collapses to `max_strikes`, and since `total >= count` that subsumes
59/// the strike count and leaves behaviour exactly as it was before results were
60/// read at all — for read-only batches too, which are still *classified* by
61/// `observation_tools` and would otherwise have been left unbounded.
62///
63/// The ceiling is never tighter than `max_strikes`, so lowering the read-only
64/// allowance cannot make the guard refuse a mutating batch earlier than its own
65/// threshold says.
66///
67/// # What it still does not catch
68///
69/// A *cycle* of tool batches, at any period of two or more — A → B → A → B,
70/// and equally A → A → B repeating. The run breaks on **signature**, before
71/// answers are ever consulted, so reading them changes nothing about this.
72/// Separating a cycle from scattered repeats needs a window or a decay rate
73/// and there is no measurement behind either number.
74///
75/// A *quiet* poll, either. Sixteen identical answers in a row to a read-only
76/// batch is still a loop by this detector's definition, so an agent watching a
77/// compile that prints nothing for two minutes is still refused at the
78/// observation ceiling. Result-awareness helps only once the output moves.
79///
80/// [`super::StagnationDetector`] does not backstop this. It reads only prose
81/// turns — a turn that called a tool is not recorded at all — so an oscillating
82/// session is caught by neither guard, at any cycle period of two or more.
83/// What observes the rest is the proxy's ledger, which is a reading for a
84/// person and not a verdict. See ADR 0011.
85#[derive(Debug, Default)]
86pub struct LoopDetector {
87    /// The current unbroken run, or `None` until the first batch arrives.
88    run: Option<Run>,
89}
90
91/// One unbroken run of a single batch signature.
92#[derive(Debug)]
93struct Run {
94    /// The signature every occurrence in this run shares.
95    signature: String,
96    /// Occurrences since the answers last changed. This is what the threshold
97    /// is compared against.
98    count: usize,
99    /// Occurrences in this run, never reset by a changed answer. This is what
100    /// the read-only allowance is compared against, for a batch that is not
101    /// read-only.
102    total: usize,
103    /// The answers recorded for the most recent occurrence. `None` means they
104    /// could not be joined — or have not been recorded yet, which is the same
105    /// thing to a verdict that has nothing to compare.
106    last_answers: Option<u64>,
107}
108
109/// Names the batch [`LoopDetector::check`] just counted.
110///
111/// [`LoopDetector::record_results`] takes one so it cannot be handed the wrong
112/// batch. The answers it records belong to the batch that *just ran*, and the
113/// comparison at the next `check` is against those; passing the previous
114/// batch's instead would invert the measurement silently, which is a mistake
115/// this codebase has made once already with a global id map and caught only in
116/// review.
117/// Consumed by `record_results` and deliberately not `Clone`: recording one
118/// batch twice would let a second, different answer rescue a run that never
119/// changed its answer, and no call site has a reason to do it. The type is what
120/// stops that rather than a rule in prose.
121#[derive(Debug)]
122#[must_use = "the batch that was checked must have its answers recorded, or the verdict cannot read them"]
123pub struct BatchRecord {
124    signature: String,
125    rescued: bool,
126}
127
128impl BatchRecord {
129    /// Whether this occurrence survived only because an earlier answer moved.
130    ///
131    /// True when the run has repeated more times than the threshold allows and
132    /// [`LoopDetector::check`] passed anyway — which is exactly the turn that
133    /// signature-only counting would have refused. Known when the verdict is
134    /// reached, not when the next answers arrive: the reset that saved this
135    /// turn happened on a previous one.
136    #[must_use]
137    pub const fn rescued(&self) -> bool {
138        self.rescued
139    }
140}
141
142/// What one occurrence turned out to be, once its answers were known.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum RepeatOutcome {
145    /// First occurrence in this run, or answers that could not be compared on
146    /// one side or the other. Not evidence of progress, and not evidence
147    /// against it.
148    NotComparable,
149    /// The same batch got the same answer back. The run stands.
150    AnswerRepeated,
151    /// The same batch got a different answer. This occurrence was progress, so
152    /// the run starts again from it.
153    AnswerChanged,
154    /// The record does not name the current run — the run was already broken
155    /// by a different batch before its answers arrived. Nothing is recorded.
156    NotTheCurrentRun,
157}
158
159impl LoopDetector {
160    /// Count this batch and error if it has now repeated too often.
161    ///
162    /// Selects the effective threshold by batch classification: if every call
163    /// matches an observation pattern (via [`is_observation_batch`]),
164    /// `max_observation_steps` is used, falling back to `max_strikes` when
165    /// `None`. Otherwise `max_strikes`.
166    ///
167    /// The count is incremented **before** the comparison, so `max_strikes = 2`
168    /// allows two identical batches and errors on the third, and
169    /// `max_strikes = 0` rejects the very first occurrence.
170    ///
171    /// A batch with a different signature resets the run to one. Both call
172    /// sites skip this method when the batch is empty, so a prose answer and a
173    /// `role: "tool"` result are transparent — load bearing, since every call
174    /// is answered before the next one and a run those could break would never
175    /// reach two. A user turn ends a run explicitly: see [`Self::break_run`].
176    ///
177    /// # Errors
178    ///
179    /// [`AgentError::LoopDetected`] when the run has passed its threshold, or
180    /// when a batch that is not read-only — or is read-only but not free to
181    /// repeat — has been carried past the read-only allowance by changing
182    /// answers. The cases are not distinguished in the error: the remedy is
183    /// identical, and the variant is mirrored into the proxy's 400 body.
184    pub fn check(
185        &mut self,
186        calls: &[ToolCall],
187        max_strikes: usize,
188        observation_tools: &[String],
189        max_observation_steps: Option<usize>,
190    ) -> Result<BatchRecord, AgentError> {
191        let observation = is_observation_batch(calls, observation_tools);
192        let observation_max = max_observation_steps.unwrap_or(max_strikes);
193        let effective_max = if observation {
194            observation_max
195        } else {
196            max_strikes
197        };
198        // Exempt from the ceiling only when the tier is actually configured.
199        // `is_observation_batch` reads `observation_tools`, which is a separate
200        // field: with `max_observation_steps: None` a read-only batch is still
201        // *classified*, so waiving the ceiling on classification alone left it
202        // with no bound at all and a moving answer could carry it forever.
203        // Costly batches keep the ceiling for the same reason: a
204        // `fetch_webpage` answer moves every call, so the waiver would never end.
205        let exempt = observation && max_observation_steps.is_some() && !is_costly_batch(calls);
206        // Never tighter than the strike threshold. `total >= count` always, so
207        // an allowance below `max_strikes` would become the strike threshold
208        // for mutating batches and refuse them *earlier* than configured —
209        // lowering the read-only allowance must not tighten the guard for
210        // tools it does not classify.
211        let ceiling = observation_max.max(max_strikes);
212        let sig = batch_signature(calls);
213        let (count, total) = match &mut self.run {
214            Some(run) if run.signature == sig => {
215                run.count += 1;
216                run.total += 1;
217                (run.count, run.total)
218            }
219            slot => {
220                *slot = Some(Run {
221                    signature: sig.clone(),
222                    count: 1,
223                    total: 1,
224                    last_answers: None,
225                });
226                (1, 1)
227            }
228        };
229        if count > effective_max || (!exempt && total > ceiling) {
230            return Err(AgentError::LoopDetected { signature: sig });
231        }
232        // Reported against `total`, the count that no answer resets, because
233        // that is what the verdict used before it could read answers at all. A
234        // turn where `total` has passed the threshold and `count` has not is
235        // precisely a turn the old rule refused and this one does not.
236        //
237        // Not derived from `record_results` returning `AnswerChanged`: that
238        // fires on the *first* repeat with a new answer, which is one the guard
239        // would have allowed regardless, so it counted turns that were never at
240        // risk and inflated the ratio ADR 0010's kill criteria read.
241        Ok(BatchRecord {
242            signature: sig,
243            rescued: total > effective_max,
244        })
245    }
246
247    /// Forget the current run, as a user turn does.
248    ///
249    /// Structural on the agent path — `run` is invoked once per user message
250    /// and builds a fresh `Guards` — so only the proxy, which walks one
251    /// detector across a whole replayed conversation, has to be told. Resets
252    /// the read-only allowance with the strike count, as a fresh `Guards` does.
253    pub fn break_run(&mut self) {
254        self.run = None;
255    }
256
257    /// Record what the batch named by `record` got back.
258    ///
259    /// Called once the answers exist, which on the agent path is *after* the
260    /// batch executes — the reason the verdict and the recording are separate
261    /// calls at all. The proxy calls both together, since it reads a completed
262    /// transcript.
263    ///
264    /// `answers` is `None` when the batch was unanswered or only partly
265    /// answered. Unknown answers never rescue a run: an answer nobody can read
266    /// is not evidence of progress, and treating it as such would let any
267    /// client that omits `id` on replayed calls switch the guard off. It is
268    /// also what makes a detector that is never told anything behave exactly as
269    /// it did before it could be.
270    pub fn record_results(&mut self, record: BatchRecord, answers: Option<u64>) -> RepeatOutcome {
271        // Destructured rather than read through: taking the record by value is
272        // what stops one batch being recorded twice, and clippy's
273        // `needless_pass_by_value` fires on a by-value argument whose body only
274        // *reads* a field. Its suggested remedy — take `&BatchRecord` — would
275        // reinstate exactly the hole this signature closes, so the argument is
276        // consumed here instead of silenced.
277        let BatchRecord { signature, .. } = record;
278        let Some(run) = self.run.as_mut() else {
279            return RepeatOutcome::NotTheCurrentRun;
280        };
281        if run.signature != signature {
282            return RepeatOutcome::NotTheCurrentRun;
283        }
284        let outcome = match (run.last_answers, answers) {
285            (Some(previous), Some(now)) if previous != now => {
286                // This occurrence produced something new, so the run of
287                // identical answers starts here. `total` is untouched: it is
288                // the ceiling on how far this can be repeated.
289                run.count = 1;
290                RepeatOutcome::AnswerChanged
291            }
292            (Some(_), Some(_)) => RepeatOutcome::AnswerRepeated,
293            _ => RepeatOutcome::NotComparable,
294        };
295        run.last_answers = answers;
296        outcome
297    }
298}