@@ -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
661696def _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
0 commit comments