diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 6b01c80e7e..b54dc2ec01 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -42,6 +42,7 @@ from agent.turn_context import ( from agent.turn_retry_state import TurnRetryState from agent.turn_usage import record_response_usage from agent.turn_overflow import recover_from_overflow +from agent.turn_empty_response import recover_empty_response from agent.turn_truncation import continue_codex_incomplete, recover_from_truncation from agent.turn_recovery import ( describe_invalid_response, @@ -95,7 +96,6 @@ from agent.trajectory import has_incomplete_scratchpad # Bind before the turn starts so a source-tree swap cannot load a skewed # finalizer at turn end. from agent.turn_finalizer import finalize_turn -from agent import empty_response_guard as _empty_guard from hermes_logging import set_session_context from tools.skill_provenance import set_current_write_origin from utils import base_url_host_matches, env_var_enabled @@ -4594,301 +4594,29 @@ def run_conversation( # Check if response only has think block with no actual content after it if not agent._has_content_after_think_block(final_response): - # Partial stream recovery: content streamed before the connection - # died becomes the final response instead of fallback or retries. - _partial_streamed = ( - getattr(agent, "_current_streamed_assistant_text", "") or "" + _ev = recover_empty_response( + agent, + assistant_message, + response, + finish_reason, + final_response=final_response, + messages=messages, + api_messages=api_messages, + conversation_history=conversation_history, + active_system_prompt=active_system_prompt, + api_call_count=api_call_count, + turn_exit_reason=_turn_exit_reason, + preflight_compression_blocked=_preflight_compression_blocked, ) - if agent._has_content_after_think_block(_partial_streamed): - _turn_exit_reason = "partial_stream_recovery" - _recovered = agent._strip_think_blocks(_partial_streamed).strip() - logger.info( - "Partial stream content delivered (%d chars) " - "— using as final response", - len(_recovered), - ) - agent._emit_status( - "↻ Stream interrupted — using delivered content " - "as final response" - ) - final_response = _recovered - # A streamed fragment isn't a confirmed preview: keep - # response_previewed false so gateway fallback delivery can - # send the text plus the abnormal-turn explanation. - agent._response_was_previewed = False + final_response = _ev.final_response + _turn_exit_reason = _ev.turn_exit_reason + active_system_prompt = _ev.active_system_prompt + _preflight_compression_blocked = _ev.preflight_compression_blocked + if _ev.action == "return": + return _ev.result + if _ev.action == "break": break - - # Prior turn had real content + ONLY housekeeping tools: model is - # done, reuse it. With substantive tools it was mid-task narration - # and the empty reply is a choke; let the post-tool nudge handle it. - fallback = getattr(agent, '_last_content_with_tools', None) - if fallback and getattr(agent, '_last_content_tools_all_housekeeping', False): - _turn_exit_reason = "fallback_prior_turn_content" - logger.info("Empty follow-up after tool calls — using prior turn content as final response") - agent._emit_status("↻ Empty response after tool calls — using earlier content as final answer") - agent._last_content_with_tools = None - agent._last_content_tools_all_housekeeping = False - agent._empty_content_retries = 0 - # Do NOT modify the assistant message content (injected text - # poisoned history); use the fallback as the response and break. - final_response = agent._strip_think_blocks(fallback).strip() - agent._response_was_previewed = True - break - - # ── Post-tool-call empty response nudge ─────────── - # Empty after tool results (no prior content, or only mid-task - # narration): nudge once via a user-level hint. (#9400) - _prior_was_tool = any( - m.get("role") == "tool" - for m in messages[-5:] # check recent messages - ) - # Ollama puts in content, not reasoning_content, so - # _has_structured misses it; detect here to route to prefill. - _has_inline_thinking = bool( - re.search( - r'||', - final_response or "", - re.IGNORECASE, - ) - ) - if ( - _prior_was_tool - and not getattr(agent, "_post_tool_empty_retried", False) - and not _has_inline_thinking # thinking model still working — let prefill handle - ): - agent._post_tool_empty_retried = True - # Clear stale narration so it doesn't resurface - # on a later empty response after the nudge. - agent._last_content_with_tools = None - agent._last_content_tools_all_housekeeping = False - logger.info( - "Empty response after tool calls — nudging model " - "to continue processing" - ) - agent._buffer_status( - "⚠️ Model returned empty after tool calls — " - "nudging to continue" - ) - # Append the empty assistant first so the sequence stays valid: - # tool → assistant("(empty)") → user (APIs reject tool→user). - _nudge_msg = agent._build_assistant_message(assistant_message, finish_reason) - _nudge_msg["content"] = "(empty)" - _nudge_msg["_empty_recovery_synthetic"] = True - append_message(messages, _nudge_msg) - append_message(messages, { - "role": "user", - "content": _EMPTY_TOOL_RESPONSE_NUDGE, - "_empty_recovery_synthetic": True, - }) - continue - - # ── Thinking-only prefill continuation ────────── - # Reasoning but no text: append as-is and continue so the model sees - # its own reasoning and writes text. Covers _has_inline_thinking. - _has_structured = bool( - getattr(assistant_message, "reasoning", None) - or getattr(assistant_message, "reasoning_content", None) - or getattr(assistant_message, "reasoning_details", None) - or _has_inline_thinking - ) - if _has_structured and agent._thinking_prefill_retries < 2: - agent._thinking_prefill_retries += 1 - logger.info( - "Thinking-only response (no visible content) — " - "prefilling to continue (%d/2)", - agent._thinking_prefill_retries, - ) - agent._buffer_status( - f"↻ Thinking-only response — prefilling to continue " - f"({agent._thinking_prefill_retries}/2)" - ) - interim_msg = agent._build_assistant_message( - assistant_message, "incomplete" - ) - interim_msg["_thinking_prefill"] = True - append_message(messages, interim_msg) - agent._session_messages = messages - continue - - # ── Empty response retry ────────────────────── - # Retry up to 3 times before fallback; covers truly empty replies - # AND reasoning-only replies after prefill exhaustion. - _truly_empty = not agent._strip_think_blocks( - final_response - ).strip() - _prefill_exhausted = ( - _has_structured - and agent._thinking_prefill_retries >= 2 - ) - _empty_candidate = _truly_empty and ( - not _has_structured or _prefill_exhausted - ) - if _empty_candidate: - # Each empty attempt re-bills the full input; record its - # signature so deterministic empties stop burning paid retries. - # Fails open: missing usage or any output keeps the budget. - _empty_guard.record_empty_attempt( - agent, - finish_reason=finish_reason, - response=response, - ) - _empty_retry_budget = ( - _empty_guard.empty_retry_budget(agent, response) - if _empty_candidate - else _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET - ) - _deterministic_empty = _empty_candidate and ( - _empty_guard.deterministic_empty(agent) - ) - if ( - _empty_candidate - and agent._empty_content_retries < _empty_retry_budget - and not _deterministic_empty - ): - agent._empty_content_retries += 1 - wait_time = jittered_backoff( - agent._empty_content_retries, - base_delay=5.0, - max_delay=60.0, - ) - logger.warning( - "Empty response (no content or reasoning) — " - "retry %d/%d in %.1fs (model=%s)", - agent._empty_content_retries, - _empty_retry_budget, wait_time, agent.model, - ) - _budget_note = ( - " — high-cost request, reduced retry budget" - if _empty_retry_budget < _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET - else "" - ) - agent._buffer_status( - f"⚠️ Empty response from model — retrying " - f"({agent._empty_content_retries}/{_empty_retry_budget}) " - f"in {wait_time:.0f}s{_budget_note}" - ) - _interrupted = interruptible_backoff_sleep( - agent, wait_time, None, - messages=messages, - conversation_history=conversation_history, - api_call_count=api_call_count, - abort_message="Interrupt detected during empty-response retry wait, aborting.", - interrupt_text=( - f"Operation interrupted: retrying empty response from model " - f"(retry {agent._empty_content_retries}/{_empty_retry_budget})." - ), - activity_label=f"empty response retry backoff ({agent._empty_content_retries}/{_empty_retry_budget})", - ) - if _interrupted is not None: - return _interrupted - continue - - if _truly_empty and _deterministic_empty: - logger.warning( - "Deterministic empty response detected " - "(consecutive zero-output completions, " - "model=%s provider=%s finish_reason=%s) — " - "skipping remaining retries", - agent.model, agent.provider, finish_reason, - ) - agent._buffer_status( - "⚠️ Model is deterministically returning empty " - "(zero output tokens) — skipping further retries " - "to avoid repeat charges" - ) - - # ── Exhausted retries — try fallback provider ── - # Before "(empty)", switch to the next provider in the chain. - if _truly_empty and agent._fallback_chain: - logger.warning( - "Empty response after %d retries — " - "attempting fallback (model=%s, provider=%s)", - agent._empty_content_retries, agent.model, - agent.provider, - ) - agent._buffer_status( - "⚠️ Model returning empty responses — " - "switching to fallback provider..." - ) - if agent._try_activate_fallback(): - active_system_prompt = _sync_failover_system_message( - agent, api_messages, active_system_prompt) - agent._empty_content_retries = 0 - agent._buffer_status( - f"↻ Switched to fallback: {agent.model} " - f"({agent.provider})" - ) - logger.info( - "Fallback activated after empty responses: " - "now using %s on %s", - agent.model, agent.provider, - ) - # OUTER loop: `continue` re-runs preflight against the - # fallback's window; `break` would end the turn without - # calling the fallback. Clear the preflight block. (#84733) - _preflight_compression_blocked = False - continue - - # Retries and fallback exhausted — fall through to "(empty)". - # Surface the buffered retry trace and, if known, what the empty - # streak cost (each attempt re-billed the full input). - _streak_cost = _empty_guard.streak_cost_usd(agent) - if _streak_cost is not None: - agent._buffer_status( - f"ℹ️ Estimated cost of these empty attempts: " - f"~${_streak_cost:.2f} (input tokens are billed " - f"per attempt even when no answer is produced)" - ) - agent._flush_status_buffer() - _turn_exit_reason = "empty_response_exhausted" - reasoning_text = agent._extract_reasoning(assistant_message) - agent._drop_trailing_empty_response_scaffolding(messages) - assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) - assistant_msg["content"] = "(empty)" - # Gateway failure sentinel, not content: persisting it lets later - # "continue" turns replay assistant("(empty)") and loop on empties. - assistant_msg["_empty_terminal_sentinel"] = True - append_message(messages, assistant_msg) - - if reasoning_text: - reasoning_preview = reasoning_text[:500] + "..." if len(reasoning_text) > 500 else reasoning_text - logger.warning( - "Reasoning-only response (no visible content) " - "after exhausting retries and fallback. " - "Reasoning: %s", reasoning_preview, - ) - agent._emit_status( - "⚠️ Model produced reasoning but no visible " - "response after all retries. Returning empty." - ) - else: - logger.warning( - "Empty response (no content or reasoning) " - "after %d retries. No fallback available. " - "model=%s provider=%s", - agent._empty_content_retries, agent.model, - agent.provider, - ) - agent._emit_status( - "❌ Model returned no content after all retries" - + (" and fallback attempts." if agent._fallback_chain else - ". No fallback providers configured.") - ) - - # Delivery-only: show labeled reasoning instead of bare "(empty)" - # when the model thought but wrote no text. The persisted row keeps - # the sentinel; reasoning is never promoted earlier in the ladder. - if reasoning_text: - final_response = ( - "⚠️ The model produced only internal reasoning and " - "no final answer, despite retries" - + (" and fallback" if agent._fallback_chain else "") - + ". Its last reasoning, which may contain the " - "answer:\n\n" + reasoning_preview - ) - else: - final_response = "(empty)" - break + continue # Reset retry counter/signature on successful content agent._empty_content_retries = 0 diff --git a/agent/turn_empty_response.py b/agent/turn_empty_response.py new file mode 100644 index 0000000000..a83a1b557f --- /dev/null +++ b/agent/turn_empty_response.py @@ -0,0 +1,377 @@ +"""Empty / thinking-only final-response recovery ladder for the conversation turn loop. + +Extracted from ``run_conversation``. Runs when the model returned no visible text after +```` blocks. Ladder order is load-bearing: partial-stream recovery → reuse prior +turn content (housekeeping tools only) → one post-tool-call nudge (#9400) → thinking-only +prefill continuation (×2) → empty-response retries (budgeted, deterministic-empty +short-circuit) → fallback provider → terminal ``(empty)`` sentinel. Nothing here imports +``agent.conversation_loop`` at module level (cycle); loop-internal helpers resolve lazily. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from agent import empty_response_guard as _empty_guard +from agent.message_metadata import append_message +from agent.turn_recovery import interruptible_backoff_sleep + +logger = logging.getLogger("agent.conversation_loop") + + +@dataclass +class EmptyResponseVerdict: + """Outcome of ``recover_empty_response``. + + ``action``: ``"break"`` (turn is done — ``final_response`` is set), ``"continue"`` + (re-enter the OUTER turn loop: a nudge/prefill row was appended, a retry wait + elapsed, or a fallback was activated and preflight must re-run), ``"return"`` + (interrupted during a retry wait — return ``result``) or ``"fallthrough"`` + (unreachable: every path exits; kept for the contract).""" + + action: str + result: Optional[Dict[str, Any]] + final_response: Any + turn_exit_reason: Any + active_system_prompt: Any + preflight_compression_blocked: bool + + +def recover_empty_response( + agent: Any, + assistant_message: Any, + response: Any, + finish_reason: str, + *, + final_response: Any, + messages: List[Dict[str, Any]], + api_messages: Any, + conversation_history: Any, + active_system_prompt: Any, + api_call_count: int, + turn_exit_reason: Any, + preflight_compression_blocked: bool, +) -> EmptyResponseVerdict: + """Recover from a final response with no visible content (see module docstring for + the ladder). Role alternation is preserved: the post-tool nudge appends the empty + assistant row BEFORE the user-level hint (APIs reject tool→user). Reasoning is + surfaced only at the terminal step, for delivery — the persisted row keeps the + ``(empty)`` sentinel.""" + from agent.conversation_loop import ( + _EMPTY_TOOL_RESPONSE_NUDGE, + _sync_failover_system_message, + jittered_backoff, + ) + + _turn_exit_reason = turn_exit_reason + _preflight_compression_blocked = preflight_compression_blocked + + def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> EmptyResponseVerdict: + return EmptyResponseVerdict( + action=action, + result=result, + final_response=final_response, + turn_exit_reason=_turn_exit_reason, + active_system_prompt=active_system_prompt, + preflight_compression_blocked=_preflight_compression_blocked, + ) + + # Partial stream recovery: content streamed before the connection + # died becomes the final response instead of fallback or retries. + _partial_streamed = ( + getattr(agent, "_current_streamed_assistant_text", "") or "" + ) + if agent._has_content_after_think_block(_partial_streamed): + _turn_exit_reason = "partial_stream_recovery" + _recovered = agent._strip_think_blocks(_partial_streamed).strip() + logger.info( + "Partial stream content delivered (%d chars) " + "— using as final response", + len(_recovered), + ) + agent._emit_status( + "↻ Stream interrupted — using delivered content " + "as final response" + ) + final_response = _recovered + # A streamed fragment isn't a confirmed preview: keep + # response_previewed false so gateway fallback delivery can + # send the text plus the abnormal-turn explanation. + agent._response_was_previewed = False + return _verdict("break") + + # Prior turn had real content + ONLY housekeeping tools: model is + # done, reuse it. With substantive tools it was mid-task narration + # and the empty reply is a choke; let the post-tool nudge handle it. + fallback = getattr(agent, '_last_content_with_tools', None) + if fallback and getattr(agent, '_last_content_tools_all_housekeeping', False): + _turn_exit_reason = "fallback_prior_turn_content" + logger.info("Empty follow-up after tool calls — using prior turn content as final response") + agent._emit_status("↻ Empty response after tool calls — using earlier content as final answer") + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + agent._empty_content_retries = 0 + # Do NOT modify the assistant message content (injected text + # poisoned history); use the fallback as the response and break. + final_response = agent._strip_think_blocks(fallback).strip() + agent._response_was_previewed = True + return _verdict("break") + + # ── Post-tool-call empty response nudge ─────────── + # Empty after tool results (no prior content, or only mid-task + # narration): nudge once via a user-level hint. (#9400) + _prior_was_tool = any( + m.get("role") == "tool" + for m in messages[-5:] # check recent messages + ) + # Ollama puts in content, not reasoning_content, so + # _has_structured misses it; detect here to route to prefill. + _has_inline_thinking = bool( + re.search( + r'||', + final_response or "", + re.IGNORECASE, + ) + ) + if ( + _prior_was_tool + and not getattr(agent, "_post_tool_empty_retried", False) + and not _has_inline_thinking # thinking model still working — let prefill handle + ): + agent._post_tool_empty_retried = True + # Clear stale narration so it doesn't resurface + # on a later empty response after the nudge. + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + logger.info( + "Empty response after tool calls — nudging model " + "to continue processing" + ) + agent._buffer_status( + "⚠️ Model returned empty after tool calls — " + "nudging to continue" + ) + # Append the empty assistant first so the sequence stays valid: + # tool → assistant("(empty)") → user (APIs reject tool→user). + _nudge_msg = agent._build_assistant_message(assistant_message, finish_reason) + _nudge_msg["content"] = "(empty)" + _nudge_msg["_empty_recovery_synthetic"] = True + append_message(messages, _nudge_msg) + append_message(messages, { + "role": "user", + "content": _EMPTY_TOOL_RESPONSE_NUDGE, + "_empty_recovery_synthetic": True, + }) + return _verdict("continue") + + # ── Thinking-only prefill continuation ────────── + # Reasoning but no text: append as-is and continue so the model sees + # its own reasoning and writes text. Covers _has_inline_thinking. + _has_structured = bool( + getattr(assistant_message, "reasoning", None) + or getattr(assistant_message, "reasoning_content", None) + or getattr(assistant_message, "reasoning_details", None) + or _has_inline_thinking + ) + if _has_structured and agent._thinking_prefill_retries < 2: + agent._thinking_prefill_retries += 1 + logger.info( + "Thinking-only response (no visible content) — " + "prefilling to continue (%d/2)", + agent._thinking_prefill_retries, + ) + agent._buffer_status( + f"↻ Thinking-only response — prefilling to continue " + f"({agent._thinking_prefill_retries}/2)" + ) + interim_msg = agent._build_assistant_message( + assistant_message, "incomplete" + ) + interim_msg["_thinking_prefill"] = True + append_message(messages, interim_msg) + agent._session_messages = messages + return _verdict("continue") + + # ── Empty response retry ────────────────────── + # Retry up to 3 times before fallback; covers truly empty replies + # AND reasoning-only replies after prefill exhaustion. + _truly_empty = not agent._strip_think_blocks( + final_response + ).strip() + _prefill_exhausted = ( + _has_structured + and agent._thinking_prefill_retries >= 2 + ) + _empty_candidate = _truly_empty and ( + not _has_structured or _prefill_exhausted + ) + if _empty_candidate: + # Each empty attempt re-bills the full input; record its + # signature so deterministic empties stop burning paid retries. + # Fails open: missing usage or any output keeps the budget. + _empty_guard.record_empty_attempt( + agent, + finish_reason=finish_reason, + response=response, + ) + _empty_retry_budget = ( + _empty_guard.empty_retry_budget(agent, response) + if _empty_candidate + else _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET + ) + _deterministic_empty = _empty_candidate and ( + _empty_guard.deterministic_empty(agent) + ) + if ( + _empty_candidate + and agent._empty_content_retries < _empty_retry_budget + and not _deterministic_empty + ): + agent._empty_content_retries += 1 + wait_time = jittered_backoff( + agent._empty_content_retries, + base_delay=5.0, + max_delay=60.0, + ) + logger.warning( + "Empty response (no content or reasoning) — " + "retry %d/%d in %.1fs (model=%s)", + agent._empty_content_retries, + _empty_retry_budget, wait_time, agent.model, + ) + _budget_note = ( + " — high-cost request, reduced retry budget" + if _empty_retry_budget < _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET + else "" + ) + agent._buffer_status( + f"⚠️ Empty response from model — retrying " + f"({agent._empty_content_retries}/{_empty_retry_budget}) " + f"in {wait_time:.0f}s{_budget_note}" + ) + _interrupted = interruptible_backoff_sleep( + agent, wait_time, None, + messages=messages, + conversation_history=conversation_history, + api_call_count=api_call_count, + abort_message="Interrupt detected during empty-response retry wait, aborting.", + interrupt_text=( + f"Operation interrupted: retrying empty response from model " + f"(retry {agent._empty_content_retries}/{_empty_retry_budget})." + ), + activity_label=f"empty response retry backoff ({agent._empty_content_retries}/{_empty_retry_budget})", + ) + if _interrupted is not None: + return _verdict("return", _interrupted) + return _verdict("continue") + + if _truly_empty and _deterministic_empty: + logger.warning( + "Deterministic empty response detected " + "(consecutive zero-output completions, " + "model=%s provider=%s finish_reason=%s) — " + "skipping remaining retries", + agent.model, agent.provider, finish_reason, + ) + agent._buffer_status( + "⚠️ Model is deterministically returning empty " + "(zero output tokens) — skipping further retries " + "to avoid repeat charges" + ) + + # ── Exhausted retries — try fallback provider ── + # Before "(empty)", switch to the next provider in the chain. + if _truly_empty and agent._fallback_chain: + logger.warning( + "Empty response after %d retries — " + "attempting fallback (model=%s, provider=%s)", + agent._empty_content_retries, agent.model, + agent.provider, + ) + agent._buffer_status( + "⚠️ Model returning empty responses — " + "switching to fallback provider..." + ) + if agent._try_activate_fallback(): + active_system_prompt = _sync_failover_system_message( + agent, api_messages, active_system_prompt) + agent._empty_content_retries = 0 + agent._buffer_status( + f"↻ Switched to fallback: {agent.model} " + f"({agent.provider})" + ) + logger.info( + "Fallback activated after empty responses: " + "now using %s on %s", + agent.model, agent.provider, + ) + # OUTER loop: `continue` re-runs preflight against the + # fallback's window; `break` would end the turn without + # calling the fallback. Clear the preflight block. (#84733) + _preflight_compression_blocked = False + return _verdict("continue") + + # Retries and fallback exhausted — fall through to "(empty)". + # Surface the buffered retry trace and, if known, what the empty + # streak cost (each attempt re-billed the full input). + _streak_cost = _empty_guard.streak_cost_usd(agent) + if _streak_cost is not None: + agent._buffer_status( + f"ℹ️ Estimated cost of these empty attempts: " + f"~${_streak_cost:.2f} (input tokens are billed " + f"per attempt even when no answer is produced)" + ) + agent._flush_status_buffer() + _turn_exit_reason = "empty_response_exhausted" + reasoning_text = agent._extract_reasoning(assistant_message) + agent._drop_trailing_empty_response_scaffolding(messages) + assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) + assistant_msg["content"] = "(empty)" + # Gateway failure sentinel, not content: persisting it lets later + # "continue" turns replay assistant("(empty)") and loop on empties. + assistant_msg["_empty_terminal_sentinel"] = True + append_message(messages, assistant_msg) + + if reasoning_text: + reasoning_preview = reasoning_text[:500] + "..." if len(reasoning_text) > 500 else reasoning_text + logger.warning( + "Reasoning-only response (no visible content) " + "after exhausting retries and fallback. " + "Reasoning: %s", reasoning_preview, + ) + agent._emit_status( + "⚠️ Model produced reasoning but no visible " + "response after all retries. Returning empty." + ) + else: + logger.warning( + "Empty response (no content or reasoning) " + "after %d retries. No fallback available. " + "model=%s provider=%s", + agent._empty_content_retries, agent.model, + agent.provider, + ) + agent._emit_status( + "❌ Model returned no content after all retries" + + (" and fallback attempts." if agent._fallback_chain else + ". No fallback providers configured.") + ) + + # Delivery-only: show labeled reasoning instead of bare "(empty)" + # when the model thought but wrote no text. The persisted row keeps + # the sentinel; reasoning is never promoted earlier in the ladder. + if reasoning_text: + final_response = ( + "⚠️ The model produced only internal reasoning and " + "no final answer, despite retries" + + (" and fallback" if agent._fallback_chain else "") + + ". Its last reasoning, which may contain the " + "answer:\n\n" + reasoning_preview + ) + else: + final_response = "(empty)" + return _verdict("break") + return _verdict("fallthrough")