Skip to content

Commit d01c021

Browse files
gadievronclaude
andauthored
fix(repo-explorer): recover from a blank survey turn instead of aborting (#222)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3718696 commit d01c021

2 files changed

Lines changed: 90 additions & 7 deletions

File tree

libs/openant-core/context/repo_explorer.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838

3939
from utilities.file_io import UnsafeRepoFile, read_repo_file
4040
from utilities.llm.adapter import (
41+
LLMRefusalError,
42+
LLMResponseError,
4143
Message,
4244
TextBlock,
4345
ToolDef,
@@ -48,6 +50,12 @@
4850
# Bounds. Chosen so a survey of a mid-sized repository completes in a few dollars
4951
# rather than tens, and so a pathological tree cannot run away.
5052
MAX_TURNS = 24
53+
# A blank/malformed turn (an adapter raises LLMResponseError -- e.g. an empty
54+
# completion, which every adapter guards) is treated as a transient: the survey
55+
# retries rather than aborting work already done. This many CONSECUTIVE blanks
56+
# are tolerated; the NEXT one re-raises, so a persistently-empty model fails
57+
# loudly after MAX_CONSECUTIVE_EMPTY_TURNS + 1 calls -- well short of MAX_TURNS.
58+
MAX_CONSECUTIVE_EMPTY_TURNS = 2
5159
MAX_FILE_BYTES = 40_000
5260
MAX_TOTAL_BYTES = 400_000
5361
MAX_LIST_ENTRIES = 300
@@ -267,15 +275,35 @@ def explore_repository(
267275
tools = [*EXPLORATION_TOOLS, finish_tool]
268276
messages = [Message(role="user", content=(TextBlock(text=task_prompt),))]
269277

278+
consecutive_empty = 0
270279
while budget.turns < MAX_TURNS:
271280
budget.turns += 1
272-
response = binding.adapter.complete(
273-
model=binding.model,
274-
system=system_prompt,
275-
messages=messages,
276-
max_tokens=MAX_TOKENS_PER_TURN,
277-
tools=tools,
278-
)
281+
try:
282+
response = binding.adapter.complete(
283+
model=binding.model,
284+
system=system_prompt,
285+
messages=messages,
286+
max_tokens=MAX_TOKENS_PER_TURN,
287+
tools=tools,
288+
)
289+
except LLMRefusalError:
290+
# A deliberate refusal / content-filter is NOT transient: propagate it
291+
# so a safety signal is never silently churned past. (Caught before the
292+
# broader LLMResponseError below since it is a subclass.)
293+
raise
294+
except LLMResponseError:
295+
# A structurally-bad turn -- most often an empty completion, also a
296+
# missing usage block or malformed tool_use -- must not abort a survey
297+
# that may already have read useful context. Retry the SAME messages
298+
# (appending nothing keeps the user/assistant roles alternating). This
299+
# helps a transient blank; a deterministic malformation just re-raises
300+
# once the consecutive count exceeds the cap (bounded, well short of
301+
# MAX_TURNS). A refusal is caught above and never reaches here.
302+
consecutive_empty += 1
303+
if consecutive_empty > MAX_CONSECUTIVE_EMPTY_TURNS:
304+
raise
305+
continue
306+
consecutive_empty = 0
279307
assistant_content = tuple(response.content)
280308
results: list[ToolResultBlock] = []
281309

libs/openant-core/tests/test_repo_explorer_loop.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,58 @@ def test_never_finishing_raises_after_max_turns(tmp_path):
141141
adapter = _FakeAdapter([_Resp((TextBlock(text="thinking"),)) for _ in range(MAX_TURNS)])
142142
with pytest.raises(RuntimeError):
143143
explore_repository(_repo(tmp_path), _FakeBinding(adapter), "sys", "task", _FINISH)
144+
145+
146+
class _ScriptedRaisingAdapter(_FakeAdapter):
147+
"""Scripted entries may be exceptions: a completion that RAISES (an empty/
148+
malformed turn -- every adapter raises LLMResponseError on empty content).
149+
"""
150+
def complete(self, *, model, system, messages, max_tokens, tools):
151+
self.seen_messages.append(list(messages))
152+
item = self._scripted.pop(0)
153+
if isinstance(item, BaseException):
154+
raise item
155+
return item
156+
157+
158+
def test_empty_turn_is_recovered_not_fatal(tmp_path):
159+
# An empty/malformed turn (adapter raises LLMResponseError) mid-survey must NOT
160+
# abort a survey that may already have read useful context. The loop consumes
161+
# the turn and retries; a later valid finish still succeeds.
162+
# Raise the exact class repo_explorer's `except` is bound to (its own module
163+
# binding) so the test is stable even if another test purged utilities.* from
164+
# sys.modules and re-minted a second LLMResponseError identity.
165+
from context.repo_explorer import LLMResponseError
166+
adapter = _ScriptedRaisingAdapter([
167+
LLMResponseError("OpenAI returned an empty completion"),
168+
_Resp((ToolUseBlock(id="tu-fin", name="finish", input={"ok": 1}),)),
169+
])
170+
payload, budget = explore_repository(_repo(tmp_path), _FakeBinding(adapter),
171+
"sys", "task", _FINISH)
172+
assert payload == {"ok": 1}
173+
assert budget.turns == 2 # the empty turn was consumed, then finish
174+
175+
176+
def test_persistent_empty_turns_fail_loud_and_bounded(tmp_path):
177+
# A model that returns nothing on EVERY turn must still fail loudly (never a
178+
# silent partial) and bounded (not burn the entire MAX_TURNS budget).
179+
from context.repo_explorer import LLMResponseError
180+
adapter = _ScriptedRaisingAdapter(
181+
[LLMResponseError("empty") for _ in range(MAX_TURNS + 2)])
182+
with pytest.raises(LLMResponseError):
183+
explore_repository(_repo(tmp_path), _FakeBinding(adapter), "sys", "task", _FINISH)
184+
# Bailed early on consecutive empties -- did NOT consume the whole budget.
185+
assert len(adapter.seen_messages) < MAX_TURNS
186+
187+
188+
def test_refusal_is_not_retried_propagates(tmp_path):
189+
# A refusal/content-filter (LLMRefusalError, subclass of LLMResponseError) is
190+
# NOT a transient blank: it must propagate immediately, never be churned past.
191+
from context.repo_explorer import LLMRefusalError
192+
adapter = _ScriptedRaisingAdapter([
193+
LLMRefusalError("model refused"),
194+
_Resp((ToolUseBlock(id="tu-fin", name="finish", input={"ok": 1}),)),
195+
])
196+
with pytest.raises(LLMRefusalError):
197+
explore_repository(_repo(tmp_path), _FakeBinding(adapter), "sys", "task", _FINISH)
198+
assert len(adapter.seen_messages) == 1 # bailed on the refusal, did not retry

0 commit comments

Comments
 (0)