Files
hermes-agent/agent/turn_truncation.py
kshitijk4poor a142d963e0 fix(agent): clean-EOF tool-call retry exhaustion no longer blames the network (#102766)
When the 4 truncated-tool-call retries are exhausted on a clean-EOF stub
(no transport error, no finish_reason), use a dedicated stream_closed_tool_call
copy and failure_reason=truncated instead of stream_dropped_tool_call
('check your network') stamped as timeout. Also compute the truncation
banner in a plain if/elif chain instead of a 4-arm conditional expression.
2026-09-26 06:10:53 +05:30

760 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Truncation recovery (``finish_reason == "length"``) for the conversation turn loop.
Handles thinking-budget exhaustion, repetition-dominated truncation, content-filter stream
stalls escalated to the fallback chain, text continuation nudges (up to 4, with the ceiling
exit that drops the fragment trail), truncated tool-call retries with max_tokens boosts, and
the final roll-back. Nothing here imports ``agent.conversation_loop`` at module level
(cycle); loop-internal helpers are imported lazily so tests patching them keep working.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from agent.error_classifier import FailoverReason
from agent.message_metadata import append_message
from agent.message_sanitization import close_interrupted_tool_sequence
from agent.repetition_guard import is_repetition_dominated
from agent.turn_api_call import stop_thinking_spinner
from agent.turn_failure_copy import content_policy_copy, provider_label_for, site_copy, stamp_failure
from agent.turn_retry_state import TurnRetryState
from agent.usage_pricing import normalize_usage
from hermes_constants import PARTIAL_STREAM_STUB_ID
logger = logging.getLogger("agent.conversation_loop")
# codex_responses only reaches ``finish_reason == "length"`` for a tool call cut off by
# max_output_tokens (turn_response_check.py::_derive_finish_reason); text truncation stays on
# the Codex incomplete continuation, so the text branch below never double-continues it.
_CONTINUABLE_MODES = {"chat_completions", "bedrock_converse", "anthropic_messages", "codex_responses"}
_THINK_TAG_RE = re.compile(r'<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)[^>]*>', re.IGNORECASE)
_TRUNCATED_FINAL = site_copy("truncated")
_FIRST_TRUNCATED_FINAL = _TRUNCATED_FINAL
# #106260: a stream that died on a context-overflow error after partial delivery must not seed a
# continuation — the transcript already cannot fit, and appending the partial stub grows every
# later request into the same overflow. End the turn via the recovery contract instead.
_CONTEXT_OVERFLOW_PARTIAL_FINAL = (
"The request no longer fits the model's context window, so the partial "
"response was not continued. Continue in a fresh session (/new; gateway "
"chats are reset automatically)."
)
def collapse_continuation_trail(
agent: Any, messages: List[Dict[str, Any]], current_turn_user_idx: Any, *,
finish_reason: str, parts: Optional[List[str]] = None,
) -> str:
"""Drop this turn's ``_length_continuation_fragment``/``_nudge`` rows and append one
assistant row holding the joined, think-stripped partial; returns that text ("" none).
``parts=None`` (retry exhaustion, #119001): the text comes from the fragment rows and
nothing happens without a valid turn index or a trail — an unanswered synthetic nudge
must never be persisted, and an earlier turn's rows must never be read. Explicit
``parts`` (the continuation ceiling) always appends, scanning from 0 without an index.
"""
idx = current_turn_user_idx
valid_idx = isinstance(idx, int) and idx >= 0
if parts is None and not (valid_idx and idx < len(messages)):
return ""
turn_start = idx + 1 if valid_idx else 0
fragment_parts: List[str] = []
retained: List[Any] = []
found_trail = False
for message in messages[turn_start:]:
if isinstance(message, dict) and (
message.get("_length_continuation_fragment") or message.get("_length_continuation_nudge")
):
found_trail = True
content = message.get("content")
if message.get("_length_continuation_fragment") and isinstance(content, str) and content:
fragment_parts.append(content)
continue
retained.append(message)
if parts is None and not found_trail:
return ""
messages[turn_start:] = retained
from agent.conversation_loop import _join_truncated_parts
raw = fragment_parts if parts is None else parts
join_parts = []
for item in raw:
if isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], str):
join_parts.append(item)
elif isinstance(item, str) and item:
join_parts.append((item, False))
partial = agent._strip_think_blocks(
_join_truncated_parts(join_parts)
).strip()
if partial:
append_message(messages, {"role": "assistant", "content": partial, "finish_reason": finish_reason})
agent._session_messages = messages
return partial
_THINKING_EXHAUSTED = (
"💭 Reasoning exhausted the output token budget — no visible response was produced.",
"⚠️ **Thinking Budget Exhausted**\n\nThe model used all its output tokens on reasoning "
"and had none left for the actual response.\n\nTo fix this:\n"
"→ Lower reasoning effort: `/reasoning low` or `/reasoning minimal`\n"
"→ Or switch to a larger/non-reasoning model with `/model`",
"Model used all output tokens on reasoning with none left "
"for the response. Try lowering reasoning effort or increasing max_tokens.",
)
def repetition_copy(stopping: str, outcome: str, refusal: str) -> Tuple[str, str, str]:
"""(log line, user copy, error) for a repetition-dominated abort; only the clauses naming
where the turn stopped differ between the length path and the stop path."""
return (
f"🔁 Response dominated by repeated text — stopping {stopping}.",
"⚠️ **Response Stopped — Repetition Detected**\n\nThe model fell into a repetition loop while "
f"writing this response, {outcome}\n\n→ Switch to a different model with `/model`\n"
"→ Or resend your message (your conversation history is preserved)",
f"Model output entered a repetition loop{refusal} degenerate response.",
)
_REPETITION_DOMINATED = repetition_copy(
"instead of continuing a degenerate response",
"so continuing would only produce more repeated text. The partial response was discarded.",
" and was truncated mid-loop; refusing to continue a",
)
_CEILING_NO_TEXT = (
"⚠️ **No visible answer was produced.** The model hit its output-token limit on every "
"continuation attempt — its reasoning consumed the entire budget each time.\n\nTo fix this:\n"
"→ Lower reasoning effort: `/reasoning low` or `/reasoning none`\n→ Or raise max_tokens for this model"
)
# Below this many free tokens the prompt itself filled the window: a continuation nudge +
# fragment costs ~100 tokens per attempt, so retrying only shrinks the room (#106120).
_MIN_CONTINUATION_HEADROOM = 512
_WINDOW_FILLED = (
"⚠️ **Context window full.** The prompt used {prompt:,} of this model's {ctx:,}-token "
"context window, leaving no room to answer in. This is a context-window limit, not an "
"output-length limit.\n\nTo fix this:\n→ Compress the conversation with `/compress` or start "
"a new session\n→ Or raise the model's context window (e.g. Ollama `num_ctx`)"
)
def _prompt_filled_window(agent: Any, response: Any) -> Optional[tuple[int, int]]:
"""``(prompt_tokens, context_length)`` when this response's usage shows the prompt left
less than ``_MIN_CONTINUATION_HEADROOM`` in the window compression resolves for the
model; ``None`` (keep continuing) when either number is unknown."""
ctx = int(getattr(getattr(agent, "context_compressor", None), "context_length", 0) or 0)
usage = getattr(response, "usage", None)
if not (ctx and usage):
return None
prompt = normalize_usage(usage, provider=agent.provider, api_mode=agent.api_mode).prompt_tokens
return (prompt, ctx) if prompt and ctx - prompt < _MIN_CONTINUATION_HEADROOM else None
def normalize_response_for_agent(agent: Any, response: Any) -> Any:
"""One OpenAI-style message from any transport; Anthropic strips the OAuth tool prefix."""
if agent.api_mode == "anthropic_messages":
return agent._get_transport().normalize_response(
response, strip_tool_prefix=agent._is_anthropic_oauth
)
return agent._get_transport().normalize_response(response)
def partial_result(
messages: List[Dict[str, Any]], api_call_count: int, final_response: str,
error: Optional[str] = None, *, failed: bool = False, compression_exhausted: bool = False,
) -> Dict[str, Any]:
"""Typed incomplete-turn result (``partial`` unless ``failed``); ``error`` defaults to
``final_response``. ``compression_exhausted`` carries the #98722 typed bit the gateway
consumes to reset/move future input to a clean session (see run_turn.py)."""
result = {
"final_response": final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
("failed" if failed else "partial"): True,
"error": final_response if error is None else error,
}
if compression_exhausted:
result["compression_exhausted"] = True
return result
@dataclass
class TruncationVerdict:
"""Outcome of ``recover_from_truncation``.
``action``: ``"return"`` (end the turn with ``result``), ``"break"`` (a
``_retry.restart_with_*`` flag is set — restart the API call), ``"continue"``
(re-issue the same call immediately) or ``"fallthrough"`` (unreachable in practice:
every path exits, kept for the contract). The remaining fields are the loop locals
the handler may have rebound."""
action: str
result: Optional[Dict[str, Any]]
messages: List[Dict[str, Any]]
length_continue_retries: int
truncated_response_parts: List[Tuple[str, bool]]
truncated_tool_call_retries: int
retry_count: int
compression_attempts: int
@dataclass(kw_only=True)
class _Trunc(TruncationVerdict):
"""Working state for the truncation phases — the verdict itself, plus the read-only
call context; phases mutate the loop-local fields and ``done()`` stamps the action."""
agent: Any
response: Any
finish_reason: str
conversation_history: Any
api_call_count: int
effective_task_id: Any
current_turn_user_idx: Any
action: str = "fallthrough"
result: Optional[Dict[str, Any]] = None
window_filled: Optional[tuple[int, int]] = None # (prompt_tokens, context_length)
def done(self, action: str, result: Optional[Dict[str, Any]] = None) -> TruncationVerdict:
self.action, self.result = action, result
return self
def end_turn(
self, final_response: str, error: Optional[str] = None, *,
result_messages: Optional[List[Dict[str, Any]]] = None, cleanup: bool = True,
failed: bool = False, compression_exhausted: bool = False,
failure: Tuple[str, bool] = ("truncated", True),
) -> TruncationVerdict:
"""Persist and end the turn as partial (or ``failed``).
``compression_exhausted`` forwards the #98722 typed bit so the gateway can
move future input off a bloated session (run_turn.py consumes it). ``failure`` is
the ``(failure_reason, retryable)`` verdict for the UI descriptor.
"""
agent = self.agent
if cleanup:
agent._cleanup_task_resources(self.effective_task_id)
agent._persist_session(self.messages, self.conversation_history)
return self.done("return", stamp_failure(partial_result(
self.messages if result_messages is None else result_messages, self.api_call_count,
final_response, error, failed=failed, compression_exhausted=compression_exhausted,
), *failure))
@property
def is_stub(self) -> bool:
return getattr(self.response, "id", "") == PARTIAL_STREAM_STUB_ID
def _abort_reason(agent: Any, content: Any, has_tool_calls: bool) -> Optional[tuple]:
"""``(vprint, user response, error)`` when continuation must NOT be attempted:
thinking exhausted the budget (reasoning blocks with no visible text after them —
``content=None`` from non-<think> models is normal truncation), or a repetition loop
burned the budget on one fragment (reasoning stripped first)."""
if has_tool_calls:
return None
if content and _THINK_TAG_RE.search(content) and not agent._has_content_after_think_block(content):
return _THINKING_EXHAUSTED
visible = agent._strip_think_blocks(content) if isinstance(content, str) else content
if visible and is_repetition_dominated(visible):
return _REPETITION_DOMINATED
return None
def _content_filter_fallback(st: _Trunc, _retry: TurnRetryState) -> Optional[TruncationVerdict]:
"""Content-filter stream stall → fallback. ``_content_filter_terminated`` is
content-deterministic, so escalate before retrying the primary; without a fallback
fall through to normal continuation (best-effort, may loop)."""
agent = st.agent
if not (
getattr(st.response, "_content_filter_terminated", False)
and agent._fallback_index < len(agent._fallback_chain)
):
return None
agent._vprint(
f"{agent.log_prefix}🛡️ Content filter terminated stream — activating fallback provider...",
force=True, diagnostic=True,
)
agent._emit_diagnostic_status("Content filter terminated stream; switching to fallback...")
if agent._try_activate_fallback():
# Roll partial content back to the last clean turn so the fallback gets a
# coherent continuation point; unmark survivors (their text left the partial).
if st.truncated_response_parts:
st.messages = agent._get_messages_up_to_last_assistant(st.messages)
for _frag in st.messages:
if isinstance(_frag, dict):
_frag.pop("_length_continuation_fragment", None)
_frag.pop("_length_continuation_nudge", None)
agent._session_messages = st.messages
st.length_continue_retries = 0
st.truncated_response_parts = []
st.retry_count = 0
st.compression_attempts = 0
_retry.primary_recovery_attempted = False
_retry.restart_with_rebuilt_messages = True
return st.done("break")
agent._vprint(
f"{agent.log_prefix}⚠️ No fallback provider configured — retrying with same provider "
f"(may re-hit filter)...",
force=True, diagnostic=True,
)
return None
def _continue_text(st: _Trunc, _retry: TurnRetryState, assistant_message: Any) -> TruncationVerdict:
"""Text truncation (no tool calls): append the fragment + a continuation nudge (up to
4), then the ceiling exit that drops the fragment trail and keeps the stitched partial.
Never appends an interim assistant row with NO visible content — strict providers
reject it with 400 — only the nudge."""
from agent.conversation_loop import _get_continuation_prompt, _join_truncated_parts
agent = st.agent
messages = st.messages
st.length_continue_retries += 1
n = st.length_continue_retries
_interim_content = getattr(assistant_message, "content", None)
if not _interim_content and not st.is_stub:
# Thinking-only truncation: continuing with thinking ON re-burns the budget.
agent._ephemeral_reasoning_off = True
if _interim_content:
interim_msg = agent._build_assistant_message(assistant_message, st.finish_reason)
interim_msg["_length_continuation_fragment"] = True # ceiling exit drops these
append_message(messages, interim_msg)
st.truncated_response_parts.append((_interim_content, st.is_stub))
filled = st.window_filled
if n < 4 and filled is None:
_dropped_tools = getattr(st.response, "_dropped_tool_names", None)
if st.is_stub and _dropped_tools:
agent._vprint(
f"{agent.log_prefix}↻ Stream interrupted mid "
f"tool-call ({', '.join(_dropped_tools[:3])}) — requesting chunked retry ({n}/4)...", diagnostic=True,
)
elif st.is_stub:
agent._vprint(f"{agent.log_prefix}↻ Stream interrupted — requesting continuation ({n}/4)...", diagnostic=True)
else:
agent._vprint(f"{agent.log_prefix}↻ Requesting continuation ({n}/4)...", diagnostic=True)
append_message(messages, {
"role": "user", "content": _get_continuation_prompt(st.is_stub, _dropped_tools),
"_length_continuation_nudge": True,
})
agent._session_messages = messages
_retry.restart_with_length_continuation = True
return st.done("break")
# Unanswered continue nudges made every later turn re-truncate: drop the trail.
partial_response = collapse_continuation_trail(
agent, messages, st.current_turn_user_idx, finish_reason="length",
parts=st.truncated_response_parts,
)
# The one-shot reasoning-off override must not leak into the next turn.
agent._ephemeral_reasoning_off = False
agent._vprint(
f"{agent.log_prefix}⚠️ Not continuing — each attempt would only grow the prompt."
if filled is not None else
f"{agent.log_prefix}⚠️ Response still truncated after {n} continuation attempts — "
+ ("keeping the partial response received so far." if partial_response
else "no visible text was produced."),
force=True, diagnostic=True,
)
if filled is not None:
notice = _WINDOW_FILLED.format(prompt=filled[0], ctx=filled[1])
return st.end_turn(
f"{partial_response}\n\n{notice}" if partial_response else notice,
f"Prompt used {filled[0]} of {filled[1]} context tokens; no room to answer",
)
return st.end_turn(
partial_response or _CEILING_NO_TEXT,
"Response remained truncated after 4 continuation attempts",
)
def _model_output_limit(agent: Any) -> Optional[int]:
"""The model's real max output tokens when Hermes knows it, else None."""
if getattr(agent, "api_mode", None) != "anthropic_messages":
return None
# Local: only Anthropic-Messages turns need the adapter module.
from agent.anthropic_adapter import _get_anthropic_max_output
return _get_anthropic_max_output(getattr(agent, "model", None) or "")
def boosted_output_cap(agent: Any, requested_cap: Optional[int], n: int, base: Optional[int] = None) -> int:
"""Output budget for truncation retry ``n`` (1-based): ``base·2ⁿ``, never below the
failed request's cap, at most ``max(32768, 2×cap)``, and never above the model's
known output limit. ``base`` defaults to max_tokens, else the cap actually sent.
A ceiling equal to the requested cap would re-send the same budget (#72770); a
ceiling past the model limit only buys a provider 400 (#79715).
"""
if base is None:
base = agent.max_tokens or requested_cap or 4096
anchor = requested_cap or base
limit = _model_output_limit(agent)
if limit and anchor >= limit:
return anchor # already at the model ceiling: doubling cannot help
boost = min(max(base * (2 ** n), requested_cap or 0), max(32768, anchor * 2))
return min(boost, limit) if limit else boost
def _retry_truncated_tool_call(st: _Trunc, api_kwargs: Any) -> TruncationVerdict:
"""Truncated tool call: re-run the same call (up to 4×) with a boosted max_tokens —
a real output-cap truncation needs it, harmless for a network stall — else refuse to
execute incomplete arguments."""
agent = st.agent
if st.truncated_tool_call_retries < 4:
st.truncated_tool_call_retries += 1
n = st.truncated_tool_call_retries
if st.is_stub:
agent._buffer_vprint(f"⚠️ Stream interrupted mid tool-call — retrying ({n}/4)...")
else:
agent._buffer_vprint(f"⚠️ Truncated tool call detected — retrying API call ({n}/4)...")
agent._ephemeral_max_output_tokens = boosted_output_cap(
agent, agent._requested_output_cap_from_api_kwargs(api_kwargs), n
)
return st.done("continue") # don't append the broken response
agent._flush_status_buffer()
_failure = FailoverReason.timeout.value if st.is_stub else "truncated"
if st.is_stub and getattr(st.response, "_clean_eof", False):
# #102766: no transport error — the server (or a proxy) closed the stream cleanly
# without a finish_reason, so "check your network" copy / a timeout stamp would mislead.
agent._vprint(
f"{agent.log_prefix}⚠️ Server kept closing the stream mid tool-call after 4 retries — the action was not executed.",
force=True, diagnostic=True,
)
_final_response = site_copy("stream_closed_tool_call", label=provider_label_for(agent.provider))
_failure = "truncated"
elif st.is_stub:
agent._vprint(
f"{agent.log_prefix}⚠️ Stream kept dropping mid tool-call after 4 retries — the action was not executed.",
force=True, diagnostic=True,
)
_final_response = site_copy("stream_dropped_tool_call", label=provider_label_for(agent.provider))
else:
agent._vprint(
f"{agent.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
force=True, diagnostic=True,
)
_final_response = _TRUNCATED_FINAL
agent._cleanup_task_resources(st.effective_task_id)
# Prior tool batches can leave a tool-result tail; this path never reaches finalize_turn.
close_interrupted_tool_sequence(st.messages, _final_response)
return st.end_turn(
_final_response, cleanup=False,
failure=(_failure, True),
)
def recover_from_truncation(
agent: Any, response: Any, finish_reason: str, _retry: TurnRetryState, *,
messages: List[Dict[str, Any]], conversation_history: Any, api_kwargs: Any, api_call_count: int,
effective_task_id: Any, current_turn_user_idx: Any, length_continue_retries: int,
truncated_response_parts: List[Tuple[str, bool]], truncated_tool_call_retries: int, retry_count: int,
compression_attempts: int,
) -> TruncationVerdict:
"""Recover from a truncated response. Order is load-bearing: thinking exhaustion and
repetition abort BEFORE any continuation; a content-filter stall escalates to the
fallback chain BEFORE the primary is retried; text continuation (no tool calls) then
truncated tool-call retry; finally roll back to the last complete assistant turn."""
st = _Trunc(
agent=agent, response=response, finish_reason=finish_reason,
conversation_history=conversation_history, api_call_count=api_call_count,
effective_task_id=effective_task_id, current_turn_user_idx=current_turn_user_idx,
messages=messages, length_continue_retries=length_continue_retries,
truncated_response_parts=truncated_response_parts,
truncated_tool_call_retries=truncated_tool_call_retries, retry_count=retry_count,
compression_attempts=compression_attempts,
)
st.window_filled = _prompt_filled_window(agent, response)
if st.is_stub and getattr(response, "_clean_eof", False):
_banner = ("Response truncated — server ended the stream without ever sending finish_reason "
"(no transport error — the server or a proxy closed the stream cleanly)")
elif st.is_stub:
_banner = "Response truncated — stream ended before completion"
elif st.window_filled:
_banner = (f"Response truncated (finish_reason='length') - the prompt filled the context window "
f"({st.window_filled[0]:,}/{st.window_filled[1]:,} tokens)")
else:
_banner = "Response truncated (finish_reason='length') - model hit max output tokens"
agent._vprint(f"{agent.log_prefix}⚠️ {_banner}", force=True, diagnostic=True)
# #106260: a context-overflow error after partial delivery must not seed a
# continuation. _partial_stream_stub marks such stubs _overflow_terminal and
# leaves content empty; continuing would only re-send a larger request into
# the same overflow. The stub path never raises, so this class never reached
# recover_from_overflow's compress-and-retry on main either — ending the turn
# replaces a growth loop, not a compression attempt.
if getattr(st.response, "_overflow_terminal", False):
agent._flush_status_buffer()
agent._vprint(
f"{agent.log_prefix}⚠️ Stream ended on a context-overflow error after "
"partial delivery — not continuing (the request no longer fits the model's "
"context window).",
force=True, diagnostic=True,
)
# Prior tool batches can leave a tool-result tail; this path never reaches
# finalize_turn (same as the truncated-tool-call terminal above).
close_interrupted_tool_sequence(st.messages, _CONTEXT_OVERFLOW_PARTIAL_FINAL)
# Carry the #98722 typed exhaustion bit so the gateway resets/moves future
# input to a clean session instead of leaving this bloated one authoritative
# for the next turn.
return st.end_turn(
_CONTEXT_OVERFLOW_PARTIAL_FINAL,
error=_CONTEXT_OVERFLOW_PARTIAL_FINAL,
failed=True,
compression_exhausted=True,
failure=("context_overflow", False),
)
_trunc_msg = normalize_response_for_agent(agent, response)
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
_trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False
abort = _abort_reason(agent, _trunc_content, _trunc_has_tool_calls)
if abort is not None:
line, user_response, error = abort
agent._vprint(f"{agent.log_prefix}{line}", force=True, diagnostic=True)
return st.end_turn(user_response, error)
if agent.api_mode in _CONTINUABLE_MODES:
cf = _content_filter_fallback(st, _retry)
if cf is not None:
return cf
if _trunc_msg is not None:
if not _trunc_has_tool_calls:
return _continue_text(st, _retry, _trunc_msg)
return _retry_truncated_tool_call(st, api_kwargs)
if len(messages) > 1:
agent._vprint(f"{agent.log_prefix} ⏪ Rolling back to last complete assistant turn", diagnostic=True)
return st.end_turn(
_TRUNCATED_FINAL, result_messages=agent._get_messages_up_to_last_assistant(messages)
)
# First message was truncated - mark as failed
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ First response truncated - cannot recover", force=True, diagnostic=True)
return st.end_turn(_FIRST_TRUNCATED_FINAL, cleanup=False, failed=True)
_CODEX_REPLAY_KEYS = (
"content", "reasoning", "reasoning_content", "reasoning_details",
"codex_reasoning_items", "codex_message_items",
)
# Third return value of ``continue_codex_incomplete``: the reasoning-only stall was handed to a
# fallback provider — the caller re-syncs the system prompt identity and continues the turn.
CODEX_FALLBACK_ACTIVATED = "codex_fallback_activated"
def continue_codex_incomplete(
agent: Any, assistant_message: Any, finish_reason: str, *, messages: List[Dict[str, Any]],
conversation_history: Any, api_call_count: int, response: Any = None,
) -> Optional[Any]:
"""Codex Responses ``status=incomplete`` continuation (max 3 per turn).
Appends the interim assistant message (deduped on visible content only — opaque
provider state drifts per continuation; ``codex_reasoning_items`` are merged, not
overwritten, because the earlier response holds the only native-compaction
checkpoint) and, when a bare retry would be byte-identical, a user-role nudge — only
after an assistant row, to preserve role alternation. Returns ``None`` to continue
the turn loop, ``CODEX_FALLBACK_ACTIVATED`` when a reasoning-only stall was handed to
the next fallback provider, or the terminal ``partial`` result once retries are exhausted.
Reasoning-only stall ladder (#67321): a response with neither visible text nor a tool
call advances ``_codex_reasoning_only_streak`` (a visible partial resets it; the aggregate
``_codex_incomplete_retries`` stays the cap for partials). Encrypted reasoning replays
byte-for-byte, so after replay (1) and nudge (2) the third consecutive reasoning-only
response goes to the configured fallback with the semantic ``incomplete_response`` reason
instead of ending on the sentinel; when that response consumed the last iteration the
fallback gets exactly one grace call (``_budget_grace_call`` is consumed by the next
iteration, and the streak restarts from 0, so a second grace call is unreachable).
When ``response`` hit ``max_output_tokens`` with no visible text (reasoning ate the
whole budget), the next attempt goes out with reasoning off and a doubled output
cap — the same one-shot overrides the chat-completions length path uses — because
re-sending the identical budget and effort re-burns the budget identically (#90393)."""
from agent.conversation_loop import _CODEX_INCOMPLETE_NUDGE
from agent.turn_response_check import _codex_finish_reason
agent._codex_incomplete_retries += 1
n = agent._codex_incomplete_retries
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
interim_has_content = bool((interim_msg.get("content") or "").strip())
_reasoning = interim_msg.get("reasoning")
interim_has_reasoning = isinstance(_reasoning, str) and bool(_reasoning.strip())
interim_has_codex_reasoning = bool(interim_msg.get("codex_reasoning_items"))
interim_has_codex_message_items = bool(interim_msg.get("codex_message_items"))
reasoning_only = not interim_has_content and not getattr(assistant_message, "tool_calls", None)
agent._codex_reasoning_only_streak = agent._codex_reasoning_only_streak + 1 if reasoning_only else 0
streak = agent._codex_reasoning_only_streak
if interim_has_content or interim_has_reasoning or interim_has_codex_reasoning or interim_has_codex_message_items:
last_msg = messages[-1] if messages else None
last_is_dict = isinstance(last_msg, dict)
last_interim_visible = agent._interim_assistant_visible_text(last_msg) if last_is_dict else ""
current_interim_visible = agent._interim_assistant_visible_text(interim_msg)
if last_interim_visible or current_interim_visible:
same_visible_output = last_interim_visible == current_interim_visible
else:
# Neither has text eligible for interim delivery: compare raw content+reasoning.
same_visible_output = last_is_dict and (
(last_msg.get("content") or "") == (interim_msg.get("content") or "")
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
)
if (
last_is_dict
and last_msg.get("role") == "assistant"
and last_msg.get("finish_reason") == "incomplete"
and same_visible_output
):
# Duplicate: refresh replay state in place, no re-emitted commentary.
for _key in _CODEX_REPLAY_KEYS:
if _key not in interim_msg:
continue
if _key == "codex_reasoning_items":
from agent.native_compaction import merge_interim_reasoning_items
last_msg[_key] = merge_interim_reasoning_items(last_msg.get(_key), interim_msg[_key])
else:
last_msg[_key] = interim_msg[_key]
else:
append_message(messages, interim_msg)
agent._emit_interim_assistant_message(interim_msg)
if reasoning_only and streak >= 3:
if agent._try_activate_fallback(reason=FailoverReason.incomplete_response):
# The trigger may have consumed the turn budget; without a grace call the loop
# exits before the fallback is ever asked.
if api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0:
agent._budget_grace_call = True
agent._codex_incomplete_retries = 0
agent._codex_reasoning_only_streak = 0
if not agent.quiet_mode:
agent._vprint(
f"{agent.log_prefix}↻ Codex reasoning-only stall after {streak} attempts — "
f"switching to fallback {agent.model} ({agent.provider})", diagnostic=True,
)
agent._emit_diagnostic_wait("↻ model stuck on internal reasoning — switching to fallback provider")
agent._session_messages = messages
return CODEX_FALLBACK_ACTIVATED
# No fallback left: fall through to the terminal sentinel.
elif n < 3 or reasoning_only:
# A reasoning-only streak below 3 continues even once partials used up the aggregate
# cap, so the mixed partial-then-stall variant reaches the ladder above.
# If the interim has nothing the Responses converter will replay, a bare retry is
# byte-identical; a replayable interim holding only a ``compaction`` checkpoint
# ALSO re-sends identically. One bare retry, then always nudge.
interim_replayable = interim_has_content or interim_has_codex_reasoning or interim_has_codex_message_items
if not interim_replayable or n >= 2:
_last_msg = messages[-1] if messages else None
if isinstance(_last_msg, dict):
_already_nudged = (
_last_msg.get("role") == "user" and _last_msg.get("content") == _CODEX_INCOMPLETE_NUDGE
)
# Alternation guard: the nudge may only follow an assistant row.
if not _already_nudged and _last_msg.get("role") == "assistant":
append_message(messages, {"role": "user", "content": _CODEX_INCOMPLETE_NUDGE})
if not interim_has_content and _codex_finish_reason(response) == "incomplete":
agent._ephemeral_reasoning_off = True
# No configured cap means the provider's own ceiling was hit: the observed
# output_tokens IS that ceiling, so seed the escalation from it (else 4096).
usage = getattr(response, "usage", None)
observed = getattr(usage, "output_tokens", None) if not isinstance(usage, dict) else usage.get("output_tokens")
agent._ephemeral_max_output_tokens = boosted_output_cap(
agent, None, n, base=agent.max_tokens or int(observed or 0) or 4096
)
if not agent.quiet_mode:
agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({n}/3)", diagnostic=True)
# Spinner/heartbeat notice: these retries can take minutes and otherwise look
# like infinite thinking.
# #70773: same FD-recycle corruption vector as #67142. The shared OpenAI client's connection pool
# must NOT be closed from this watchdog/poll thread — worker threads from previous stale-killed
# attempts may still be unwinding their SSL BIOs. The request-local client is already closed above
# via _close_request_client_once. The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next request.
# Surface the continuation on the live spinner/status line (CLI/TUI/Desktop) and gateway heartbeat:
# each of these retries can spend minutes waiting on the provider, and without a distinct notice the
# user only sees a generic thinking spinner ("infinite thinking", #64434).
agent._emit_diagnostic_wait(
f"↻ model returned reasoning with no final answer — asking it to continue ({n}/3)"
)
agent._session_messages = messages
return None
agent._codex_incomplete_retries = 0
agent._codex_reasoning_only_streak = 0
agent._persist_session(messages, conversation_history)
return partial_result(
messages, api_call_count, "Codex response remained incomplete after 3 continuation attempts"
)
@dataclass
class RefusalVerdict:
"""Outcome of ``handle_content_policy_refusal``: ``"break"`` (fallback activated —
restart armed on ``_retry``; caller resets retry/compression counters) or
``"return"`` (the typed content-policy result in ``result``). ``active_system_prompt``
is the possibly re-synced system prompt."""
action: str
result: Optional[Dict[str, Any]]
active_system_prompt: Any
def handle_content_policy_refusal(
agent: Any, response: Any, _retry: TurnRetryState, *, thinking_spinner: Any,
messages: List[Dict[str, Any]], api_messages: Any, api_kwargs: Any, active_system_prompt: Any,
conversation_history: Any, api_call_count: int, effective_task_id: Any, turn_id: Any,
api_request_id: Any, api_start_time: float, retry_count: int, max_retries: int,
) -> RefusalVerdict:
"""HTTP-200 refusal (``finish_reason`` ``content_filter`` / ``guardrail_intervened``).
Deterministic for the unchanged prompt — never retried: one configured-fallback try,
else surface the refusal (explanation may live only in the reasoning channel)."""
from agent.conversation_loop import _arm_fallback_restart, _content_policy_blocked_result
_refusal_result = normalize_response_for_agent(agent, response)
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
if not _refusal_text:
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
# Anthropic stop_reason=refusal carries its reason on stop_details (category + optional explanation),
# not in a content block — without it a classifier halt reads as "(no text)" (#113689).
_stop_details = (getattr(_refusal_result, "provider_data", None) or {}).get("stop_details")
if not _refusal_text and isinstance(_stop_details, dict):
_refusal_text = str(_stop_details.get("explanation") or "").strip() or (
f"provider refusal category: {_stop_details['category']}" if _stop_details.get("category") else ""
)
agent._invoke_api_request_error_hook(
task_id=effective_task_id, turn_id=turn_id, api_request_id=api_request_id,
api_call_count=api_call_count, api_start_time=api_start_time, api_kwargs=api_kwargs,
error_type="ContentPolicyBlocked",
error_message=_refusal_text or "model declined to respond (content_filter)",
status_code=None, retry_count=retry_count, max_retries=max_retries, retryable=False,
reason=FailoverReason.content_policy_blocked.value,
)
stop_thinking_spinner(agent, thinking_spinner)
if agent._has_pending_fallback():
agent._buffer_diagnostic_status("⚠️ Model declined to respond (safety refusal) — trying fallback...")
if agent._try_activate_fallback():
active_system_prompt = _arm_fallback_restart(agent, api_messages, active_system_prompt, _retry)
return RefusalVerdict("break", None, active_system_prompt)
agent._flush_status_buffer()
_refusal_log = _refusal_text[:500] + "..." if len(_refusal_text) > 500 else _refusal_text
# native_stop_reason tells an Anthropic classifier refusal (``refusal``) from a Bedrock guardrail
# block (``end_turn``); both arrive here as content_filter.
logger.warning(
"%sModel declined to respond (finish_reason=content_filter). model=%s provider=%s "
"native_stop_reason=%s stop_details=%s refusal=%s",
agent.log_prefix, agent.model, agent.provider,
getattr(response, "stop_reason", None) or "n/a", _stop_details or "n/a",
_refusal_log or "(no text)",
)
agent._emit_diagnostic_status("⚠️ The model declined to respond to this request (safety refusal).")
_refusal_response = "⚠️ " + content_policy_copy(
label=provider_label_for(agent.provider),
summary=_refusal_text or "the model returned no explanation",
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return RefusalVerdict("return", _content_policy_blocked_result(
messages, api_call_count, final_response=_refusal_response,
error_detail=_refusal_text or "model declined (content_filter)",
), active_system_prompt)