Skip to main content

LoopDetector

Struct LoopDetector 

Source
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

Source

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.

Source

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.

Source

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.

Trait Implementations§

Source§

impl Debug for LoopDetector

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for LoopDetector

Source§

fn default() -> LoopDetector

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more