refactor(agent/turn): lift hook-firing, MoA context/prepare, empty-retry and terminal-empty phases into helpers

This commit is contained in:
Teknium
2026-09-02 18:40:01 -07:00
parent 4cf65d7d89
commit 863fc9afa8
4 changed files with 431 additions and 514 deletions
+153 -194
View File
@@ -1,11 +1,10 @@
"""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
``<think>`` 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.
Runs when the model returned no visible text after ``<think>`` blocks. Ladder order is
load-bearing: partial-stream recovery → reuse prior turn content (housekeeping tools only)
→ one post-tool-call nudge → 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.
"""
from __future__ import annotations
@@ -21,6 +20,8 @@ from agent.turn_recovery import interruptible_backoff_sleep
logger = logging.getLogger("agent.conversation_loop")
_INLINE_THINK_RE = re.compile(r'<think>|<thinking>|<reasoning>', re.IGNORECASE)
@dataclass
class EmptyResponseVerdict:
@@ -40,6 +41,112 @@ class EmptyResponseVerdict:
preflight_compression_blocked: bool
def _retry_empty(
agent: Any, response: Any, finish_reason: str, empty_candidate: bool, *, messages: Any,
conversation_history: Any, api_call_count: int,
) -> tuple:
"""Budgeted empty-response retry. Each empty attempt re-bills the full input, so the
signature is recorded and deterministic empties stop burning paid retries (fails
open: missing usage or any output keeps the budget). Returns
``(action_or_None, interrupt_result, deterministic_empty)``."""
from agent.conversation_loop import jittered_backoff
if empty_candidate:
_empty_guard.record_empty_attempt(agent, finish_reason=finish_reason, response=response)
budget = (
_empty_guard.empty_retry_budget(agent, response)
if empty_candidate else _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET
)
deterministic = empty_candidate and _empty_guard.deterministic_empty(agent)
if not (empty_candidate and agent._empty_content_retries < budget and not deterministic):
return None, None, deterministic
agent._empty_content_retries += 1
n = agent._empty_content_retries
wait_time = jittered_backoff(n, base_delay=5.0, max_delay=60.0)
logger.warning(
"Empty response (no content or reasoning) — "
"retry %d/%d in %.1fs (model=%s)",
n, budget, wait_time, agent.model,
)
_budget_note = (
" — high-cost request, reduced retry budget"
if budget < _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET else ""
)
agent._buffer_status(
f"⚠️ Empty response from model — retrying "
f"({n}/{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 {n}/{budget})."
),
activity_label=f"empty response retry backoff ({n}/{budget})",
)
if _interrupted is not None:
return "return", _interrupted, deterministic
return "continue", None, deterministic
def _terminal_empty(agent: Any, assistant_message: Any, finish_reason: str, messages: Any) -> str:
"""Retries and fallback exhausted: persist the ``(empty)`` sentinel row and return the
delivery text. Reasoning is surfaced ONLY here, for delivery — the persisted row keeps
the sentinel so later "continue" turns don't replay it and loop on empties."""
_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()
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)"
assistant_msg["_empty_terminal_sentinel"] = True
append_message(messages, assistant_msg)
if not reasoning_text:
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.")
)
return "(empty)"
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."
)
return (
"⚠️ 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
)
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,
@@ -48,12 +155,8 @@ def recover_empty_response(
) -> 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
)
assistant row BEFORE the user-level hint (APIs reject tool→user)."""
from agent.conversation_loop import _EMPTY_TOOL_RESPONSE_NUDGE, _sync_failover_system_message
_turn_exit_reason = turn_exit_reason
_preflight_compression_blocked = preflight_compression_blocked
@@ -65,11 +168,9 @@ def recover_empty_response(
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 ""
)
# 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()
@@ -78,19 +179,16 @@ def recover_empty_response(
"— using as final response",
len(_recovered),
)
agent._emit_status(
"↻ Stream interrupted — using delivered content " "as final response"
)
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.
# A streamed fragment isn't a confirmed preview: gateway fallback delivery
# sends 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.
# 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"
@@ -99,42 +197,28 @@ def recover_empty_response(
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.
# Do NOT modify the assistant message content (injected text poisoned history).
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 <think> in content, not reasoning_content, so
# _has_structured misses it; detect here to route to prefill.
_has_inline_thinking = bool(
re.search( r'<think>|<thinking>|<reasoning>', final_response or "", re.IGNORECASE )
)
# Post-tool-call empty (no prior content, or only mid-task narration): nudge once.
_prior_was_tool = any(m.get("role") == "tool" for m in messages[-5:])
# Ollama puts <think> in content, not reasoning_content, so _has_structured misses
# it; detect here to route to prefill.
_has_inline_thinking = bool(_INLINE_THINK_RE.search(final_response or ""))
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.
# Clear stale narration so it doesn't resurface on a later empty response.
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).
logger.info("Empty response after tool calls — nudging model " "to continue processing")
agent._buffer_status("⚠️ Model returned empty after tool calls — " "nudging to continue")
# tool → assistant("(empty)") → user keeps the sequence valid.
_nudge_msg = agent._build_assistant_message(assistant_message, finish_reason)
_nudge_msg["content"] = "(empty)"
_nudge_msg["_empty_recovery_synthetic"] = True
@@ -144,9 +228,8 @@ def recover_empty_response(
})
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.
# Thinking-only prefill: append the reasoning as-is and continue so the model sees
# its own reasoning and writes text.
_has_structured = bool(
getattr(assistant_message, "reasoning", None)
or getattr(assistant_message, "reasoning_content", None)
@@ -164,81 +247,22 @@ def recover_empty_response(
f"↻ Thinking-only response — prefilling to continue "
f"({agent._thinking_prefill_retries}/2)"
)
interim_msg = agent._build_assistant_message(
assistant_message, "incomplete"
)
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-response retries: truly empty replies AND reasoning-only replies after
# prefill exhaustion.
_truly_empty = not agent._strip_think_blocks(final_response).strip()
_empty_candidate = _truly_empty and (not _has_structured or agent._thinking_prefill_retries >= 2)
action, interrupt_result, _deterministic_empty = _retry_empty(
agent, response, finish_reason, _empty_candidate, messages=messages,
conversation_history=conversation_history, api_call_count=api_call_count,
)
_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 action is not None:
return _verdict(action, interrupt_result)
if _truly_empty and _deterministic_empty:
logger.warning(
@@ -254,8 +278,7 @@ def recover_empty_response(
"to avoid repeat charges"
)
# ── Exhausted retries — try fallback provider ──
# Before "(empty)", switch to the next provider in the chain.
# Exhausted retries — try the next provider in the chain before "(empty)".
if _truly_empty and agent._fallback_chain:
logger.warning(
"Empty response after %d retries — "
@@ -263,85 +286,21 @@ def recover_empty_response(
agent._empty_content_retries, agent.model,
agent.provider,
)
agent._buffer_status(
"⚠️ Model returning empty responses — " "switching to fallback 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)
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})"
)
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)
# OUTER loop: `continue` re-runs preflight against the fallback's window;
# `break` would end the turn without calling the fallback.
_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)"
final_response = _terminal_empty(agent, assistant_message, finish_reason, messages)
return _verdict("break")
return _verdict("fallthrough")