ADR 050 — A validator's repeat-read turns are free but separately bounded¶
Status¶
Accepted.
Context¶
LLMValidator._evaluate_with_tools bounds a judge's tool-use loop with
max_tool_turns: one budget slot is charged per call to the model, and the
last slot forces a verdict-only turn (ADR 033, _VERDICT_ONLY_INSTRUCTION).
The loop already detects a repeat read — a call whose arguments are
identical to one already answered in an earlier turn — and intercepts it
with a pointer to that turn instead of re-reading (ReadMemoryTracker,
format_repeat_read_response, ADR 033). That response tells the judge the
call "wastes a turn," and until this decision it was telling the truth in a
way that worked against the engine: the turn was charged anyway, at exactly
the moment the judge was contributing nothing. A judge that circled on a
small set of reads could spend its entire budget before the cap forced a
halt, having examined nothing new for most of it. Raising the cap does not
fix this — a judge that circles will circle through any budget.
Decision¶
A turn whose tool calls are all repeat-read hits is not charged against
max_tool_turns. Progress is tracked separately (turns_used, compared
against the budget) from raw loop iterations: a fresh read, a refused
undeclared tool, an invalid submit_verdict, or narration all still charge
a turn as before — only the repeat-read interception is free.
Because repeats are free, a judge that only ever repeats itself cannot be
stopped by the budget alone — it could circle forever. A second, small,
fixed counter (_MAX_STALL_TURNS) counts consecutive turns whose only
tool calls were repeat-read hits. Once it is reached, the loop forces the
judge onto the same verdict-only turn already used at the true end of
budget and after a prose nudge: submit_verdict is the only tool offered,
required via tool_choice where the provider honors it, with
_VERDICT_ONLY_INSTRUCTION otherwise. That path already fails closed
(blocker, error=True) if the judge still will not decide. No new
severity, halt type, or task status is introduced — a judge that only
circles ends exactly where a judge that runs out of real budget already
ends, just sooner.
This is scoped to LLMValidator's turn budget only. The repeat-read
interception and its message are unchanged, and the coder's tool loop
(LiteLLMCoder) is unchanged: a coder is not judging under a turn cap in
the same sense, and nothing here alters what it does with the tracker.
Consequences¶
A judge that reads efficiently is unaffected: it never generates a repeat
hit, so turns_used tracks the raw turn count exactly as it did before this
decision. A judge that repeats a read a few times before moving on keeps
the budget it would previously have burned on those repeats, and can spend
it examining something new instead. A judge that only ever repeats itself
is stopped within a few turns of its first repeat rather than at the end of
a (possibly generous) turn cap, and still delivers a real verdict or a real
fail-closed error, never a silent, unattributed halt.
Alternatives¶
Raising the cap again was rejected: it was already raised once (12 to 40)
for exactly this failure mode and did not help, because the failure is
"progress per turn," not "total turns." Silently returning the file
contents again on a repeat was rejected — it is the behavior ADR 033
deliberately replaced and re-introduces the transcript growth and cache
pressure that decision exists to avoid. Adding a new halt type or severity
for "the judge stalled" was rejected: blocker/error=True from
"no verdict" already covers it, and the engine's halt vocabulary is closed.