pub struct LoopDetector {
run: Option<Run>,
}Expand description
Stateful guard that detects when the same tool-call batch repeats back to back and gets the same answer back.
Create once per agent run. Call LoopDetector::check before executing a
batch and LoopDetector::record_results once its answers are known.
§What counts as a strike
Counting is run-length, not session-wide: only the current unbroken run of one signature is held, and a batch with a different signature discards it. A session-wide tally made any long conversation terminal — a client replays the whole history every turn, so a batch that recurred often enough anywhere in the session was rejected on every subsequent request for the rest of it.
Within a run, an occurrence is a strike only when its answers matched the previous occurrence’s. The same call with a different answer is progress that happens to look alike — an agent polling a build for output issues an identical batch every time, and run-length counting alone refused it exactly as a session-wide tally did. The verdict could not see what came back; now it can.
§The ceiling on that
A changed answer restarts the run, so on its own it would exempt any tool
whose output carries a clock, an elapsed time, a progress counter or a
random id — cargo test’s finished in 0.31s is enough. Read-only batches
are exempt anyway, because that tier exists on the ground that repeating a
call which changes nothing is free — except where it changes nothing only
here, which is_costly_batch withholds the waiver from. Everything else
keeps a ceiling: a batch that changes something may be carried by changing
answers only while the run stays inside the read-only allowance,
max_observation_steps. Reusing that number rather than inventing one is
deliberate; there is no measurement behind a new one.
With max_observation_steps: None the tier is off: no batch is exempt, the
ceiling collapses to max_strikes, and since total >= count that subsumes
the strike count and leaves behaviour exactly as it was before results were
read at all — for read-only batches too, which are still classified by
observation_tools and would otherwise have been left unbounded.
The ceiling is never tighter than max_strikes, so lowering the read-only
allowance cannot make the guard refuse a mutating batch earlier than its own
threshold says.
§What it still does not catch
A cycle of tool batches, at any period of two or more — A → B → A → B, and equally A → A → B repeating. The run breaks on signature, before answers are ever consulted, so reading them changes nothing about this. Separating a cycle from scattered repeats needs a window or a decay rate and there is no measurement behind either number.
A quiet poll, either. Sixteen identical answers in a row to a read-only batch is still a loop by this detector’s definition, so an agent watching a compile that prints nothing for two minutes is still refused at the observation ceiling. Result-awareness helps only once the output moves.
super::StagnationDetector does not backstop this. It reads only prose
turns — a turn that called a tool is not recorded at all — so an oscillating
session is caught by neither guard, at any cycle period of two or more.
What observes the rest is the proxy’s ledger, which is a reading for a
person and not a verdict. See ADR 0011.
Fields§
§run: Option<Run>The current unbroken run, or None until the first batch arrives.
Implementations§
Source§impl LoopDetector
impl LoopDetector
Sourcepub fn check(
&mut self,
calls: &[ToolCall],
max_strikes: usize,
observation_tools: &[String],
max_observation_steps: Option<usize>,
) -> Result<BatchRecord, AgentError>
pub fn check( &mut self, calls: &[ToolCall], max_strikes: usize, observation_tools: &[String], max_observation_steps: Option<usize>, ) -> Result<BatchRecord, AgentError>
Count this batch and error if it has now repeated too often.
Selects the effective threshold by batch classification: if every call
matches an observation pattern (via is_observation_batch),
max_observation_steps is used, falling back to max_strikes when
None. Otherwise max_strikes.
The count is incremented before the comparison, so max_strikes = 2
allows two identical batches and errors on the third, and
max_strikes = 0 rejects the very first occurrence.
A batch with a different signature resets the run to one. Both call
sites skip this method when the batch is empty, so a prose answer and a
role: "tool" result are transparent — load bearing, since every call
is answered before the next one and a run those could break would never
reach two. A user turn ends a run explicitly: see Self::break_run.
§Errors
AgentError::LoopDetected when the run has passed its threshold, or
when a batch that is not read-only — or is read-only but not free to
repeat — has been carried past the read-only allowance by changing
answers. The cases are not distinguished in the error: the remedy is
identical, and the variant is mirrored into the proxy’s 400 body.
Sourcepub fn break_run(&mut self)
pub fn break_run(&mut self)
Forget the current run, as a user turn does.
Structural on the agent path — run is invoked once per user message
and builds a fresh Guards — so only the proxy, which walks one
detector across a whole replayed conversation, has to be told. Resets
the read-only allowance with the strike count, as a fresh Guards does.
Sourcepub fn record_results(
&mut self,
record: BatchRecord,
answers: Option<u64>,
) -> RepeatOutcome
pub fn record_results( &mut self, record: BatchRecord, answers: Option<u64>, ) -> RepeatOutcome
Record what the batch named by record got back.
Called once the answers exist, which on the agent path is after the batch executes — the reason the verdict and the recording are separate calls at all. The proxy calls both together, since it reads a completed transcript.
answers is None when the batch was unanswered or only partly
answered. Unknown answers never rescue a run: an answer nobody can read
is not evidence of progress, and treating it as such would let any
client that omits id on replayed calls switch the guard off. It is
also what makes a detector that is never told anything behave exactly as
it did before it could be.