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}