Skip to content

Commit f59b0d8

Browse files
tarekziadeclaude
andcommitted
salvage empty/None-finish final answers instead of failing unparseable
The budget-exhausted path forced one tool-free final turn and handed its content straight to the parser. When that turn came back empty with finish_reason=None (provider truncating a huge-context stream), it surfaced as "LLM returned unparseable output" — the exact failure hitting every transformers task. Both final-answer paths (in-loop and the post-budget forced turn) now re-ask once for the JSON only when the answer is empty or hit the output-token limit, bounded by _MAX_TRUNCATION_RETRIES. The previously length-only salvage now also covers blank content (finish_reason=None). The forced final turn is also logged as a chat event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6993abb commit f59b0d8

2 files changed

Lines changed: 94 additions & 15 deletions

File tree

reviewbot/reviewer.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,41 @@ def _merge_chunk_event(events: list[str], comments_count: int) -> str:
656656
"analysis, no explanation, no <think> block, no markdown fences. Keep it "
657657
"minimal so the whole answer fits within the output limit."
658658
)
659+
# An empty final answer (blank content, usually finish_reason=None) means the
660+
# model returned nothing parseable — often the provider truncated the stream on
661+
# a very large context. Re-ask once, tool-free, for just the JSON — same
662+
# recovery path as the length-truncation case, bounded by the same retry cap.
663+
_EMPTY_ANSWER_RECOVERY_MESSAGE = (
664+
"Your previous reply was empty — no JSON object came through. Reply now "
665+
"with ONLY the final JSON object the task requires: no analysis, no "
666+
"explanation, no <think> block, no markdown fences."
667+
)
668+
669+
670+
def _needs_final_salvage(chat: ChatResult) -> bool:
671+
"""True when a tool-free final answer should be re-asked rather than
672+
parsed: it either hit the output-token limit (``finish_reason="length"``)
673+
or came back with blank content (commonly ``finish_reason=None`` when the
674+
provider truncates the stream on a very large context)."""
675+
return chat.finish_reason == "length" or not (chat.content or "").strip()
676+
677+
678+
def _final_recovery_message(chat: ChatResult) -> str:
679+
blank = not (chat.content or "").strip()
680+
return _EMPTY_ANSWER_RECOVERY_MESSAGE if blank else _TRUNCATION_RECOVERY_MESSAGE
681+
682+
683+
def _emit_final_salvage(
684+
emit: Optional[Callable[[str, str], None]], chat: ChatResult, attempt: int
685+
) -> None:
686+
if emit is None:
687+
return
688+
what = "empty" if not (chat.content or "").strip() else "truncated"
689+
emit(
690+
"log",
691+
f"Final answer was {what} (finish_reason={chat.finish_reason}); re-asking "
692+
f"for the JSON only (recovery {attempt}/{_MAX_TRUNCATION_RETRIES})",
693+
)
659694

660695

661696
def _run_agentic_loop(
@@ -819,26 +854,21 @@ def _run_agentic_loop(
819854
)
820855

821856
if not chat.tool_calls:
822-
# Salvage a truncated final answer before anything else: the model
823-
# ran out of output budget mid-JSON (reasoning ate it). Re-ask for
824-
# the JSON only, tool-less and low-reasoning, instead of returning
825-
# unparseable content that fails the whole task.
857+
# Salvage a truncated OR empty final answer before anything else:
858+
# the model either ran out of output budget mid-JSON
859+
# (finish_reason="length", reasoning ate it) or returned nothing
860+
# parseable at all (blank content — commonly finish_reason=None when
861+
# the provider truncates a huge-context stream). Re-ask for the JSON
862+
# only, tool-less and low-reasoning, instead of returning content
863+
# that just fails the parse.
826864
if (
827-
chat.finish_reason == "length"
865+
_needs_final_salvage(chat)
828866
and truncation_retries < _MAX_TRUNCATION_RETRIES
829867
):
830868
truncation_retries += 1
831-
if emit is not None:
832-
emit(
833-
"log",
834-
"Final answer hit the output-token limit "
835-
f"(recovery {truncation_retries}/{_MAX_TRUNCATION_RETRIES}); "
836-
"re-asking for the JSON only",
837-
)
869+
_emit_final_salvage(emit, chat, truncation_retries)
838870
messages.append({"role": "assistant", "content": chat.content or None})
839-
messages.append(
840-
{"role": "user", "content": _TRUNCATION_RECOVERY_MESSAGE}
841-
)
871+
messages.append({"role": "user", "content": _final_recovery_message(chat)})
842872
force_json_only = True
843873
continue
844874
if validate is None:
@@ -954,6 +984,25 @@ def _run_agentic_loop(
954984
if chat.completion_tokens is not None:
955985
metrics.completion_tokens += chat.completion_tokens
956986
_emit_metrics(emit, metrics)
987+
_emit_chat_message(
988+
emit,
989+
"assistant",
990+
content=chat.content,
991+
reasoning_chars=chat.reasoning_chars,
992+
finish_reason=chat.finish_reason,
993+
)
994+
995+
# Salvage an empty/truncated forced-final answer before validating or
996+
# returning it. This is the exact failure the budget-exhausted path used
997+
# to die on: an empty completion (finish_reason=None) went straight to
998+
# the parser and surfaced as "LLM returned unparseable output". Re-ask
999+
# for the JSON only instead (bounded by _MAX_TRUNCATION_RETRIES).
1000+
if _needs_final_salvage(chat) and truncation_retries < _MAX_TRUNCATION_RETRIES:
1001+
truncation_retries += 1
1002+
_emit_final_salvage(emit, chat, truncation_retries)
1003+
messages.append({"role": "assistant", "content": chat.content or None})
1004+
messages.append({"role": "user", "content": _final_recovery_message(chat)})
1005+
continue
9571006

9581007
# The verification gate must run on the forced final answer too —
9591008
# exhausting the tool budget must not silently bypass validation (for

tests/test_reviewer.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
_build_annotated_diff_chunks,
1313
_content_preview,
1414
_emit_chat_message,
15+
_final_recovery_message,
16+
_needs_final_salvage,
1517
_extract_json,
1618
_merge_chunk_event,
1719
_merge_chunk_summaries,
@@ -70,6 +72,34 @@ def test_none_emit_is_a_noop(self) -> None:
7072
_emit_chat_message(None, "assistant", content="hi") # must not raise
7173

7274

75+
class FinalSalvageTests(unittest.TestCase):
76+
def test_empty_content_needs_salvage(self) -> None:
77+
# The production failure: empty completion, finish_reason=None.
78+
self.assertTrue(_needs_final_salvage(ChatResult(content="", finish_reason=None)))
79+
self.assertTrue(
80+
_needs_final_salvage(ChatResult(content=" \n", finish_reason=None))
81+
)
82+
83+
def test_length_truncation_needs_salvage(self) -> None:
84+
self.assertTrue(
85+
_needs_final_salvage(ChatResult(content='{"partial', finish_reason="length"))
86+
)
87+
88+
def test_good_answer_does_not_need_salvage(self) -> None:
89+
self.assertFalse(
90+
_needs_final_salvage(ChatResult(content='{"ok": true}', finish_reason="stop"))
91+
)
92+
93+
def test_recovery_message_varies_by_cause(self) -> None:
94+
empty = _final_recovery_message(ChatResult(content="", finish_reason=None))
95+
truncated = _final_recovery_message(
96+
ChatResult(content='{"partial', finish_reason="length")
97+
)
98+
self.assertIn("empty", empty)
99+
self.assertIn("cut off", truncated)
100+
self.assertNotEqual(empty, truncated)
101+
102+
73103
class AssistantToolCallDictTests(unittest.TestCase):
74104
def test_omits_extra_content_without_signature(self) -> None:
75105
tc = ToolCall(id="t0", name="read_file", arguments='{"path":"a.py"}')

0 commit comments

Comments
 (0)