refactor(turn): extract retry-loop API error handler, request build, provider call and response check into agent/turn_api_*.py + agent/turn_response_check.py

This commit is contained in:
Teknium
2026-09-02 16:07:51 -07:00
parent 0dc36e0934
commit 9fda4e5bac
7 changed files with 1373 additions and 914 deletions
+121 -909
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
"""The provider call itself for the conversation turn's retry loop: streaming decision,
MoA prepared-request handshake, the LLM execution middleware wrapper, the redirect
``_model_request_active`` bracket and the response-vs-redirect crossing check. Extracted
from ``run_conversation``; nothing here imports ``agent.conversation_loop`` at module level.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, Optional
logger = logging.getLogger("agent.conversation_loop")
@dataclass
class ApiCallVerdict:
"""``action``: ``"fallthrough"`` (``response`` is ready for verification) or ``"break"``
(a redirect crossed the response — rebuild armed on ``_retry`` or ``interrupted``)."""
action: str
response: Any
thinking_spinner: Any
interrupted: Any
def perform_api_call(
agent: Any,
*,
api_kwargs: Any,
_original_api_kwargs: Any,
_llm_middleware_trace: Any,
_moa_prepared_request: Any,
_retry: Any,
thinking_spinner: Any,
retry_count: Any,
api_call_count: Any,
api_request_id: Any,
effective_task_id: Any,
turn_id: Any,
interrupted: Any,
) -> ApiCallVerdict:
"""Issue the request. Streaming is preferred even without consumers (stale-stream /
read-timeout health checks) and disabled per provider signal, ACP schemes, MoA without a
display consumer, or Mock clients in tests."""
response = None
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ApiCallVerdict:
return ApiCallVerdict(
action=action,
response=response,
thinking_spinner=thinking_spinner,
interrupted=interrupted,
)
# Always prefer streaming even without consumers: it gives stale-
# stream/read-timeout health checks that quiet callers otherwise lack.
# Falls back if unsupported.
def _stop_spinner():
nonlocal thinking_spinner
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
_use_streaming = True
# Provider signaled "stream not supported": stay non-streaming for the
# session.
if getattr(agent, "_disable_streaming", False):
_use_streaming = False
# ACP clients (`acp://` scheme, any vendor) return a plain
# SimpleNamespace, not a stream; mirrors the Responses API exclusion.
elif (
agent.provider in {"copilot-acp"}
or str(agent.base_url or "").lower().startswith("acp://")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
_use_streaming = False
# MoA streams only with a display/TTS consumer
# (MoAChatCompletions.create() honors stream=True); else complete-
# response path.
elif agent.provider == "moa" and not agent._has_stream_consumers():
_use_streaming = False
elif not agent._has_stream_consumers():
# No consumer: still stream for health checking, except Mock clients
# in tests (SimpleNamespace, not stream iterators).
from unittest.mock import Mock
if isinstance(getattr(agent, "client", None), Mock):
_use_streaming = False
def _perform_api_call(next_api_kwargs):
if agent.api_mode == "codex_responses":
next_api_kwargs = agent._get_transport().preflight_kwargs(
next_api_kwargs,
allow_stream=False,
is_github_responses=agent._is_copilot_url(),
sanitize_harmony_tokens=agent._is_codex_backend(),
)
if _use_streaming:
return agent._interruptible_streaming_api_call(
next_api_kwargs, on_first_delta=_stop_spinner
)
from agent import relay_llm
return relay_llm.execute(
next_api_kwargs,
agent._interruptible_api_call,
session_id=str(agent.session_id or ""),
name=str(agent.provider or "provider"),
model_name=str(agent.model or ""),
metadata={
"api_mode": agent.api_mode,
"api_request_id": api_request_id,
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
"retry_count": retry_count,
},
defer_logical_completion=True,
)
from hermes_cli.middleware import run_llm_execution_middleware
_model_request_active = getattr(agent, "_model_request_active", None)
_redirect_lock = getattr(agent, "_pending_redirect_lock", None)
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.set()
elif _model_request_active is not None:
_model_request_active.set()
_redirect_crossed_response = False
try:
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
finally:
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = bool(
agent._pending_redirect
)
else:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = agent._has_pending_redirect()
if _redirect_crossed_response:
# Response and redirect can cross threads: discard the now-stale
# response and rebuild from the correction rather than lose it.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
else:
interrupted = True
return _verdict("break")
return _verdict("fallthrough")
+476
View File
@@ -0,0 +1,476 @@
"""API-call exception handler for the conversation turn's retry loop: pre-/post-classification
recovery, interpreter-shutdown abandon, classified-error routing, overflow recovery, the
non-retryable client-error exit, max-retries exhaustion (primary transport recovery →
fallback → terminal result) and the interruptible backoff. Extracted from
``run_conversation``'s ``except Exception as api_error`` block; nothing here imports
``agent.conversation_loop`` at module level (cycle) — loop-internal helpers resolve lazily so
existing ``patch("agent.conversation_loop.X")`` sites keep intercepting.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, Optional
import json
import ssl
import time
from agent.error_classifier import FailoverReason, classify_api_error
from agent.message_sanitization import close_interrupted_tool_sequence
from agent.turn_overflow import recover_from_overflow
from agent.turn_recovery import (
compute_error_backoff,
interruptible_backoff_sleep,
log_api_error_attempt,
max_retries_exhausted_result,
nonretryable_client_error_result,
recover_after_classification,
recover_before_classification,
route_classified_error,
)
logger = logging.getLogger("agent.conversation_loop")
@dataclass
class ApiErrorVerdict:
"""``action``: ``"continue"`` (retry the API call), ``"break"`` (leave the retry loop:
fallback armed / redirect pending) or ``"return"`` (``result`` is the turn's result dict);
``"fallthrough"`` never happens — the handler always ends in an exit. The other fields
are the retry-loop locals the handler rebinds; ``_provider_overflow_recovery_pending`` is
merge-only (caller sets True when set)."""
action: str
thinking_spinner: Any
messages: Any
active_system_prompt: Any
conversation_history: Any
approx_tokens: Any
retry_count: Any
max_retries: Any
compression_attempts: Any
_provider_overflow_recovery_pending: Any
result: Optional[Dict[str, Any]] = None
def handle_api_error(
agent: Any,
*,
api_error: Any,
_retry: Any,
thinking_spinner: Any,
messages: Any,
api_messages: Any,
api_kwargs: Any,
system_message: Any,
active_system_prompt: Any,
conversation_history: Any,
approx_tokens: Any,
retry_count: Any,
max_retries: Any,
compression_attempts: Any,
max_compression_attempts: Any,
api_call_count: Any,
api_request_id: Any,
api_start_time: Any,
effective_task_id: Any,
turn_id: Any,
) -> ApiErrorVerdict:
"""Recover from ``api_error`` in the original order. Every fallback activation must leave
the retry loop with ``restart_with_rebuilt_messages`` armed (``"break"``) so the pre-API
preflight re-runs against the fallback's context window (#84733)."""
from agent.conversation_loop import (
_arm_fallback_restart,
_is_copilot_provider,
_is_stale_copilot_credential_error,
)
_provider_overflow_recovery_pending = False
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ApiErrorVerdict:
return ApiErrorVerdict(
action=action,
thinking_spinner=thinking_spinner,
messages=messages,
active_system_prompt=active_system_prompt,
conversation_history=conversation_history,
approx_tokens=approx_tokens,
retry_count=retry_count,
max_retries=max_retries,
compression_attempts=compression_attempts,
_provider_overflow_recovery_pending=_provider_overflow_recovery_pending,
result=result,
)
# Stop spinner silently — retry status is buffered and
# only flushed when every retry+fallback is exhausted.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
# Pre-classification recovery (encoding sanitization, image rejection,
# Bedrock SDK streaming fallback) — see agent/turn_recovery.py.
_recovered, active_system_prompt = recover_before_classification(
agent,
api_error,
messages=messages,
api_messages=api_messages,
api_kwargs=api_kwargs,
active_system_prompt=active_system_prompt,
)
if _recovered:
return _verdict("continue")
status_code = getattr(api_error, "status_code", None)
error_context = agent._extract_api_error_context(api_error)
# ── Interpreter finalization: abandon immediately ──
# Process is exiting mid-flight: retries/rotation/fallbacks are futile
# and the retry trace spams the shell. One log line; shared predicate.
from tools.interpreter_shutdown import interpreter_shutting_down
if interpreter_shutting_down(api_error):
logger.warning(
"%sInterpreter is shutting down — abandoning turn "
"during API call #%d (%s)",
agent.log_prefix, api_call_count, api_error,
)
_shutdown_summary = (
"Turn abandoned: the process was shutting down "
"before the model call could complete."
)
return _verdict("return", {
"final_response": _shutdown_summary,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _shutdown_summary,
"failure_reason": "interpreter_shutdown",
"failure_retryable": False,
})
# ── Classify the error for structured recovery decisions ──
_compressor = getattr(agent, "context_compressor", None)
_ctx_len = getattr(_compressor, "context_length", 200000) if _compressor else 200000
classified = classify_api_error(
api_error,
provider=getattr(agent, "provider", "") or "",
model=getattr(agent, "model", "") or "",
approx_tokens=approx_tokens,
context_length=_ctx_len,
num_messages=len(api_messages) if api_messages else 0,
)
logger.debug(
"Error classified: reason=%s status=%s retryable=%s compress=%s rotate=%s fallback=%s",
classified.reason.value, classified.status_code,
classified.retryable, classified.should_compress,
classified.should_rotate_credential, classified.should_fallback,
)
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=type(api_error).__name__,
error_message=str(api_error),
status_code=status_code,
retry_count=retry_count,
max_retries=max_retries,
retryable=classified.retryable,
reason=classified.reason.value,
)
# One-shot post-classification recovery chain (entitlement refresh, credential
# pool, image/multimodal strips, per-provider 401 refresh, format-recovery
# strips) — see agent/turn_recovery.py.
_recovered, recovered_with_pool = recover_after_classification(
agent,
api_error,
classified,
_retry,
status_code=status_code,
error_context=error_context,
messages=messages,
api_messages=api_messages,
)
if _recovered:
return _verdict("continue")
retry_count += 1
elapsed_time = time.time() - api_start_time
agent._touch_activity(
f"API error recovery (attempt {retry_count}/{max_retries})"
)
error_type, error_msg, _provider, _base, _model = log_api_error_attempt(
agent,
api_error,
retry_count=retry_count,
max_retries=max_retries,
status_code=status_code,
elapsed_time=elapsed_time,
api_messages=api_messages,
approx_tokens=approx_tokens,
)
# Check for interrupt before deciding to retry
if agent._interrupt_requested:
# Preserve a pending redirect: the user is steering, not stopping
# — rebuild the turn from the correction instead of aborting.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
return _verdict("break")
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return _verdict("return", {
"final_response": _interrupt_text,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"interrupted": True,
})
_ce = route_classified_error(
agent,
api_error,
classified,
_retry,
error_msg=error_msg,
error_context=error_context,
recovered_with_pool=recovered_with_pool,
base_url=_base,
model=_model,
messages=messages,
api_messages=api_messages,
system_message=system_message,
active_system_prompt=active_system_prompt,
conversation_history=conversation_history,
retry_count=retry_count,
max_retries=max_retries,
compression_attempts=compression_attempts,
max_compression_attempts=max_compression_attempts,
api_call_count=api_call_count,
effective_task_id=effective_task_id,
)
status_code = _ce.status_code
messages = _ce.messages
active_system_prompt = _ce.active_system_prompt
conversation_history = _ce.conversation_history
retry_count = _ce.retry_count
max_retries = _ce.max_retries
compression_attempts = _ce.compression_attempts
is_rate_limited = _ce.is_rate_limited
_wrapped_output_cap_budget = _ce.wrapped_output_cap_budget
_is_zai_coding_overload = _ce.is_zai_coding_overload
if _ce.provider_overflow_recovery_pending:
_provider_overflow_recovery_pending = True
if _ce.action == "return":
return _verdict("return", _ce.result)
if _ce.action == "break":
return _verdict("break")
if _ce.action == "continue":
return _verdict("continue")
_ov = recover_from_overflow(
agent,
api_error,
classified,
_retry,
status_code=status_code,
error_msg=error_msg,
wrapped_output_cap_budget=_wrapped_output_cap_budget,
messages=messages,
api_messages=api_messages,
system_message=system_message,
active_system_prompt=active_system_prompt,
conversation_history=conversation_history,
approx_tokens=approx_tokens,
compression_attempts=compression_attempts,
max_compression_attempts=max_compression_attempts,
api_call_count=api_call_count,
effective_task_id=effective_task_id,
)
messages = _ov.messages
active_system_prompt = _ov.active_system_prompt
conversation_history = _ov.conversation_history
approx_tokens = _ov.approx_tokens
compression_attempts = _ov.compression_attempts
is_context_length_error = _ov.is_context_length_error
if _ov.provider_overflow_recovery_pending:
_provider_overflow_recovery_pending = True
if _ov.action == "return":
return _verdict("return", _ov.result)
if _ov.action == "break":
return _verdict("break")
if _ov.action == "continue":
return _verdict("continue")
# Non-retryable: ValueError/TypeError are local bugs, except
# UnicodeEncodeError (surrogate path above) and json.JSONDecodeError, a
# transient provider/network failure that must be retried (#14782).
is_local_validation_error = (
isinstance(api_error, (ValueError, TypeError))
and not isinstance(
api_error, (UnicodeEncodeError, json.JSONDecodeError)
)
# ssl.SSLError inherits from OSError *and* ValueError, so the
# ValueError check would misclassify a TLS failure as a local bug;
# keep it retryable.
and not isinstance(api_error, ssl.SSLError)
# "NoneType is not iterable" TypeErrors are upstream shape
# mismatches (e.g. Codex response.completed.output=null), reachable
# via shims/mocks — retryable so the fallback path runs.
and not (
isinstance(api_error, TypeError)
and "nonetype" in str(api_error).lower()
and "not iterable" in str(api_error).lower()
)
)
# ``FailoverReason.billing`` (402) is deliberately NOT excluded: pool
# rotation and eager fallback already gave up, so retrying only burns
# paid requests on a depleted balance. Mirrors 401/403. (#31273)
is_client_error = (
is_local_validation_error
or (
not classified.retryable
and not classified.should_compress
and classified.reason not in {
FailoverReason.rate_limit,
FailoverReason.overloaded,
FailoverReason.context_overflow,
FailoverReason.payload_too_large,
FailoverReason.long_context_tier,
FailoverReason.thinking_signature,
}
)
) and not is_context_length_error
if is_client_error:
# Copilot self-heal BEFORE fallback: a stale credential yields a 400
# ``model_not_available_for_integrator`` / ``model_not_supported``,
# not a 401. Fresh token + client rebuild, one retry, SAME provider.
if (
_is_copilot_provider(agent)
and not _retry.copilot_stale_cred_retry_attempted
and _is_stale_copilot_credential_error(
status_code, str(getattr(api_error, "message", "") or api_error)
)
):
_retry.copilot_stale_cred_retry_attempted = True
if agent._try_recover_stale_copilot_credential():
agent._buffer_vprint(
"🔐 Copilot credential re-exchanged after "
"model_not_available 400. Retrying request..."
)
retry_count = 0
return _verdict("continue")
# Try fallback before aborting; announce it only when a fallback
# chain exists, else "trying fallback..." lies before a silent abort
# (#35314).
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
elif classified.reason == FailoverReason.ssl_cert_verification:
agent._buffer_status("⚠️ TLS certificate verification failed — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
active_system_prompt = _arm_fallback_restart(
agent, api_messages, active_system_prompt, _retry)
retry_count = 0
compression_attempts = 0
return _verdict("break")
return _verdict("return", nonretryable_client_error_result(
agent,
api_error,
classified,
status_code=status_code,
api_kwargs=api_kwargs,
api_messages=api_messages,
messages=messages,
conversation_history=conversation_history,
api_call_count=api_call_count,
approx_tokens=approx_tokens,
provider=_provider,
base_url=_base,
model=_model,
))
if retry_count >= max_retries:
# Before fallback, rebuild the primary client once for transient
# transport errors (stale pool, TCP reset). Once per API call block.
if not _retry.primary_recovery_attempted and agent._try_recover_primary_transport(
api_error, retry_count=retry_count, max_retries=max_retries,
):
_retry.primary_recovery_attempted = True
retry_count = 0
# Transport recovery starts a fresh attempt cycle: re-open
# fallback state so a follow-on 429 can still activate
# fallback_providers.
_retry.has_retried_429 = False
agent._fallback_index = 0
agent._fallback_activated = False
return _verdict("continue")
# Try fallback before giving up entirely
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._try_activate_fallback():
active_system_prompt = _arm_fallback_restart(
agent, api_messages, active_system_prompt, _retry)
retry_count = 0
compression_attempts = 0
return _verdict("break")
return _verdict("return", max_retries_exhausted_result(
agent,
api_error,
classified,
max_retries=max_retries,
is_rate_limited=is_rate_limited,
error_msg=error_msg,
api_kwargs=api_kwargs,
api_messages=api_messages,
messages=messages,
conversation_history=conversation_history,
api_call_count=api_call_count,
approx_tokens=approx_tokens,
provider=_provider,
base_url=_base,
model=_model,
))
wait_time = compute_error_backoff(
agent,
api_error,
retry_count=retry_count,
max_retries=max_retries,
is_rate_limited=is_rate_limited,
is_zai_coding_overload=_is_zai_coding_overload,
base_url=_base,
model=_model,
)
# Same preserve-redirect rule as the invalid-response wait: a steering
# correction must survive backoff, not die as "Operation interrupted".
_interrupted = interruptible_backoff_sleep(
agent, wait_time, _retry,
messages=messages,
conversation_history=conversation_history,
api_call_count=api_call_count,
abort_message="Interrupt detected during retry wait, aborting.",
interrupt_text=f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).",
activity_label=f"error retry backoff ({retry_count}/{max_retries})",
)
if _interrupted is not None:
return _verdict("return", _interrupted)
if _retry.restart_with_redirected_messages:
# Leave the retry loop — the check below rebuilds this iteration
# from the correction instead of re-firing the stale request.
return _verdict("break")
return _verdict("fallthrough")
+221
View File
@@ -0,0 +1,221 @@
"""Per-attempt request assembly for the conversation turn's retry loop: re-apply the
reasoning echo pad and prompt-cache decoration for the CURRENT provider (a fallback may
differ from the primary), build ``api_kwargs``, run the surrogate/ASCII chokepoints, Codex
preflight, OpenRouter cache bypass, Copilot ``x-initiator``, the LLM request middleware,
the ``pre_api_request`` hook and the debug dump. Extracted from ``run_conversation``;
nothing here imports ``agent.conversation_loop`` at module level.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, Optional
from agent.message_sanitization import (
_sanitize_structure_non_ascii,
_sanitize_structure_surrogates,
)
from utils import env_var_enabled
logger = logging.getLogger("agent.conversation_loop")
@dataclass
class ApiRequestBuild:
"""Always ``action == "fallthrough"``; the fields are the request-local values the caller
rebinds for the attempt."""
action: str
api_messages: Any
_moa_prepared_request: Any
tools_for_api: Any
api_kwargs: Any
_original_api_kwargs: Any
_llm_middleware_trace: Any
def build_api_request(
agent: Any,
*,
api_messages: Any,
_moa_prepared_request: Any,
tools_for_api: Any,
system_message: Any,
messages: Any,
original_user_message: Any,
approx_tokens: Any,
total_chars: Any,
retry_count: Any,
api_call_count: Any,
api_request_id: Any,
api_start_time: Any,
effective_task_id: Any,
turn_id: Any,
) -> ApiRequestBuild:
"""Assemble the attempt's request in the original order (every mutation happens BEFORE
middleware/hooks/debug dumps observe the payload)."""
from agent.conversation_loop import (
_moa_client_consumes_prepared_request,
_redecorate_prompt_cache_for_provider,
_system_prompt_for_hooks,
)
api_kwargs = None
_original_api_kwargs = None
_llm_middleware_trace = []
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ApiRequestBuild:
return ApiRequestBuild(
action=action,
api_messages=api_messages,
_moa_prepared_request=_moa_prepared_request,
tools_for_api=tools_for_api,
api_kwargs=api_kwargs,
_original_api_kwargs=_original_api_kwargs,
_llm_middleware_trace=_llm_middleware_trace,
)
agent._reset_stream_delivery_tracking()
# Per-attempt first-chunk timestamp so a stale value never leaks into
# post_api_request.
agent._last_api_first_chunk_at = None
# api_messages was built for the primary; a fallback (DeepSeek / Kimi /
# MiMo) may require reasoning_content. Re-apply the echo-back pad
# (idempotent).
agent._reapply_reasoning_echo_for_provider(api_messages)
# Same for prompt-cache decoration (#72626): strip the primary's
# breakpoints and re-render for the current provider.
api_messages, _moa_prepared_request, tools_for_api = (
_redecorate_prompt_cache_for_provider(
agent,
api_messages,
system_message=system_message,
moa_prepared=_moa_prepared_request,
tools_for_api=tools_for_api,
)
)
if tools_for_api == agent.tools:
api_kwargs = agent._build_api_kwargs(api_messages)
else:
api_kwargs = agent._build_api_kwargs(
api_messages,
tools_for_api=tools_for_api,
)
# Surrogate chokepoint (#50959): tool descriptions, extra_body and
# kwargs strings can carry invalid code points (HTTP 400). One walk
# makes the payload json.dumps()-safe.
_sanitize_structure_surrogates(api_kwargs)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
if agent.api_mode == "codex_responses":
api_kwargs = agent._get_transport().preflight_kwargs(
api_kwargs,
allow_stream=False,
is_github_responses=agent._is_copilot_url(),
sanitize_harmony_tokens=agent._is_codex_backend(),
)
# OpenRouter caching replays identical responses, even empty ones; an
# empty-response retry must bypass the cache.
if agent._empty_content_retries > 0 and agent._is_openrouter_url():
_xh = dict(api_kwargs.get("extra_headers") or {})
_xh["X-OpenRouter-Cache"] = "false"
api_kwargs["extra_headers"] = _xh
# Copilot x-initiator: first call of a user turn is "user" (billed
# premium); tool-loop follow-ups keep the default "agent" (#3040).
if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
_xh = dict(api_kwargs.get("extra_headers") or {})
_xh["x-initiator"] = "user"
api_kwargs["extra_headers"] = _xh
agent._is_user_initiated_turn = False
try:
from hermes_cli.middleware import apply_llm_request_middleware
_llm_request_mw = apply_llm_request_middleware(
api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
)
api_kwargs = _llm_request_mw.payload
_original_api_kwargs = _llm_request_mw.original_payload
_llm_middleware_trace = _llm_request_mw.trace
except Exception:
_original_api_kwargs = dict(api_kwargs)
_llm_middleware_trace = []
try:
from hermes_cli.lifecycle import (
has_hook,
invoke_hook as _invoke_hook,
)
if has_hook("pre_api_request"):
request_messages = api_kwargs.get("messages")
if not isinstance(request_messages, list):
request_messages = api_kwargs.get("input")
if not isinstance(request_messages, list):
request_messages = api_messages
# Shallow copy: plugins may retain the list; deepcopy is costly.
# ``request_messages``/``conversation_history`` are raw langfuse
# passthroughs.
_request_payload = agent._api_request_payload_for_hook(api_kwargs)
# Anthropic (``system``) and Responses/Codex (``instructions``)
# move the system prompt out of messages; pass it for
# observability.
system_prompt_for_hooks = _system_prompt_for_hooks(
api_kwargs, request_messages
)
_invoke_hook(
"pre_api_request",
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
user_message=original_user_message,
conversation_history=list(messages),
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
retry_count=retry_count,
request_messages=list(request_messages)
if isinstance(request_messages, list)
else [],
system_prompt=system_prompt_for_hooks,
message_count=len(api_messages),
tool_count=len(agent.tools or []),
approx_input_tokens=approx_tokens,
request_char_count=total_chars,
max_tokens=agent.max_tokens,
started_at=api_start_time,
middleware_trace=list(_llm_middleware_trace),
request=_request_payload,
)
except Exception:
pass
if env_var_enabled("HERMES_DUMP_REQUESTS"):
agent._dump_api_request_debug(api_kwargs, reason="preflight")
# Private to the in-process MoA facade; add after middleware/hooks/debug
# dumps so none serializes it into the provider payload.
if _moa_prepared_request is not None and agent.provider == "moa":
# Re-read the live client: rotation/fallback/cleanup rebuild
# agent.client between attempts; a native OpenAI client rejects this
# key (TypeError).
if _moa_client_consumes_prepared_request(agent.client):
api_kwargs["_moa_prepared_request"] = _moa_prepared_request
else:
logger.warning(
"MoA client replaced mid-turn (client=%s); sending the "
"prepared prompt without the MoA handshake",
type(agent.client).__name__,
)
return _verdict("fallthrough")
-1
View File
@@ -65,7 +65,6 @@ def handle_outer_loop_error(
_turn_exit_reason=_turn_exit_reason,
failed=failed,
final_response=final_response,
)
# Count every escaped exception before classification so permanent
+364
View File
@@ -0,0 +1,364 @@
"""Post-call response verification for the conversation turn's retry loop: stop the thinking
spinner, validate the response shape (retry / eager fallback / terminal invalid-response
result), derive ``finish_reason`` per api_mode, route content-policy refusals and
``length`` truncation, fold usage into the compressor, and mark the logical relay call
complete. Extracted from ``run_conversation``; nothing here imports
``agent.conversation_loop`` at module level (cycle) — loop-internal helpers resolve lazily so
existing ``patch("agent.conversation_loop.X")`` sites keep intercepting.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, Optional
import time
from agent.turn_truncation import handle_content_policy_refusal, recover_from_truncation
from agent.turn_usage import record_response_usage
logger = logging.getLogger("agent.conversation_loop")
@dataclass
class ResponseCheckVerdict:
"""``action``: ``"break"`` (leave the retry loop — success, or a fallback/refusal restart
armed on ``_retry``), ``"continue"`` (retry the API call) or ``"return"`` (``result`` is
the turn's result dict). The other fields are the retry-loop locals rebound."""
action: str
thinking_spinner: Any
messages: Any
active_system_prompt: Any
finish_reason: Any
retry_count: Any
compression_attempts: Any
length_continue_retries: Any
truncated_response_parts: Any
truncated_tool_call_retries: Any
_preflight_compression_blocked: Any
_last_preflight_pressure: Any
api_duration: Any
result: Optional[Dict[str, Any]] = None
def check_api_response(
agent: Any,
*,
response: Any,
_retry: Any,
thinking_spinner: Any,
messages: Any,
api_messages: Any,
api_kwargs: Any,
active_system_prompt: Any,
conversation_history: Any,
finish_reason: Any,
retry_count: Any,
max_retries: Any,
compression_attempts: Any,
max_compression_attempts: Any,
length_continue_retries: Any,
truncated_response_parts: Any,
truncated_tool_call_retries: Any,
current_turn_user_idx: Any,
api_call_count: Any,
api_request_id: Any,
api_start_time: Any,
effective_task_id: Any,
turn_id: Any,
_preflight_compression_blocked: Any,
_last_preflight_pressure: Any,
) -> ResponseCheckVerdict:
"""Verify ``response`` in the original order. The retry buffer is NOT cleared on success
(bytes back != usable content); ``_preflight_compression_blocked``/``_last_preflight_pressure``
reset only when the usage fold re-arms the compression budget."""
from agent.conversation_loop import (
_arm_fallback_restart,
describe_invalid_response,
jittered_backoff,
interruptible_backoff_sleep,
validate_response_shape,
)
api_duration = None
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ResponseCheckVerdict:
return ResponseCheckVerdict(
action=action,
thinking_spinner=thinking_spinner,
messages=messages,
active_system_prompt=active_system_prompt,
finish_reason=finish_reason,
retry_count=retry_count,
compression_attempts=compression_attempts,
length_continue_retries=length_continue_retries,
truncated_response_parts=truncated_response_parts,
truncated_tool_call_retries=truncated_tool_call_retries,
_preflight_compression_blocked=_preflight_compression_blocked,
_last_preflight_pressure=_last_preflight_pressure,
api_duration=api_duration,
result=result,
)
api_duration = time.time() - api_start_time
# Stop thinking spinner silently -- the response box or tool
# execution messages that follow are more informative.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if not agent.quiet_mode:
agent._vprint(f"{agent.log_prefix}⏱️ API call completed in {api_duration:.2f}s")
if agent.verbose_logging:
# Log response with provider info if available
resp_model = getattr(response, 'model', 'N/A') if response else 'N/A'
logging.debug(f"API Response received - Model: {resp_model}, Usage: {response.usage if hasattr(response, 'usage') else 'N/A'}")
# Validate response shape before proceeding
response_invalid, error_details = validate_response_shape(agent, response)
if response_invalid:
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="InvalidAPIResponse",
error_message=", ".join(error_details) or "Invalid API response",
status_code=getattr(getattr(response, "error", None), "code", None),
retry_count=retry_count,
max_retries=max_retries,
retryable=True,
reason="invalid_response",
)
# Stop spinner silently — retry status is now buffered
# and only surfaced if every retry+fallback exhausts.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
# Invalid response — could be rate limiting, provider timeout,
# upstream server error, or malformed response.
retry_count += 1
# Eager fallback: empty/malformed responses often mean rate limiting
# — switch now instead of extended backoff.
if agent._fallback_index < len(agent._fallback_chain):
agent._buffer_status("⚠️ Empty/malformed response — switching to fallback...")
if agent._try_activate_fallback():
active_system_prompt = _arm_fallback_restart(
agent, api_messages, active_system_prompt, _retry)
retry_count = 0
compression_attempts = 0
return _verdict("break")
error_msg, provider_name, _failure_hint = describe_invalid_response(
agent, response, api_duration
)
agent._buffer_vprint(f"⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}")
agent._buffer_vprint(f" 🏢 Provider: {provider_name}")
cleaned_provider_error = agent._clean_error_message(error_msg)
agent._buffer_vprint(f" 📝 Provider message: {cleaned_provider_error}")
agent._buffer_vprint(f" ⏱️ {_failure_hint}")
if retry_count >= max_retries:
# Try fallback before giving up
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._try_activate_fallback():
active_system_prompt = _arm_fallback_restart(
agent, api_messages, active_system_prompt, _retry)
retry_count = 0
compression_attempts = 0
return _verdict("break")
# Terminal — flush buffered retry trace so user sees what happened.
agent._flush_status_buffer()
agent._emit_status(f"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
logger.error("%sInvalid API response after %d retries.", agent.log_prefix, max_retries)
agent._persist_session(messages, conversation_history)
_final_response = f"Invalid API response after {max_retries} retries: {_failure_hint}"
return _verdict("return", {
"final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": _final_response,
"failed": True # Mark as failure for filtering
})
# Backoff before retry — jittered exponential: 5s base, 120s cap
wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)
agent._buffer_vprint(f"⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...")
logger.warning("Invalid API response (retry %d/%d): %s | Provider: %s", retry_count, max_retries, ', '.join(error_details), provider_name)
# A redirect cancels only the live request; the helper preserves the
# pending correction (restart_with_redirected_messages) instead of
# destroying it with clear_interrupt().
_interrupted = interruptible_backoff_sleep(
agent, wait_time, _retry,
messages=messages,
conversation_history=conversation_history,
api_call_count=api_call_count,
abort_message="Interrupt detected during retry wait, aborting.",
interrupt_text=f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
activity_label=f"retry backoff ({retry_count}/{max_retries})",
)
if _interrupted is not None:
return _verdict("return", _interrupted)
if _retry.restart_with_redirected_messages:
return _verdict("break") # rebuild this iteration from the correction
return _verdict("continue") # Retry the API call
agent._turn_received_provider_response = True
# Check finish_reason before proceeding
if agent.api_mode == "codex_responses":
status = getattr(response, "status", None)
if isinstance(status, str):
status = status.strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = None
if isinstance(incomplete_details, dict):
incomplete_reason = incomplete_details.get("reason")
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if incomplete_reason is not None:
incomplete_reason = str(incomplete_reason).strip().lower()
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
# Responses API max-output exhaustion is a normal Codex
# incomplete turn: use the Codex continuation path, not the
# length rollback.
finish_reason = "incomplete"
elif status == "incomplete" and incomplete_reason == "content_filter":
finish_reason = "content_filter"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
_tfr = agent._get_transport()
finish_reason = _tfr.map_finish_reason(response.stop_reason)
elif agent.api_mode == "bedrock_converse":
# Bedrock response already normalized at dispatch — use transport
_bt_fr = agent._get_transport()
_bedrock_result = _bt_fr.normalize_response(response)
finish_reason = _bedrock_result.finish_reason
else:
_cc_fr = agent._get_transport()
_finish_result = _cc_fr.normalize_response(response)
finish_reason = _finish_result.finish_reason
assistant_message = _finish_result
if agent._should_treat_stop_as_truncated(
finish_reason,
assistant_message,
messages,
):
agent._vprint(
f"{agent.log_prefix}⚠️ Treating suspicious Ollama/GLM stop response as truncated",
force=True,
)
finish_reason = "length"
# ── Content-policy refusal (HTTP 200) ──────────────────
# Refusal finish reasons (``content_filter``, ``guardrail_intervened``)
# are deterministic: one fallback try, else return the refusal.
if finish_reason == "content_filter":
_rv = handle_content_policy_refusal(
agent,
response,
_retry,
thinking_spinner=thinking_spinner,
messages=messages,
api_messages=api_messages,
api_kwargs=api_kwargs,
active_system_prompt=active_system_prompt,
conversation_history=conversation_history,
api_call_count=api_call_count,
effective_task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
api_start_time=api_start_time,
retry_count=retry_count,
max_retries=max_retries,
)
thinking_spinner = None
active_system_prompt = _rv.active_system_prompt
if _rv.action == "return":
return _verdict("return", _rv.result)
retry_count = 0
compression_attempts = 0
return _verdict("break")
if finish_reason == "length":
_tv = recover_from_truncation(
agent,
response,
finish_reason,
_retry,
messages=messages,
conversation_history=conversation_history,
api_kwargs=api_kwargs,
api_call_count=api_call_count,
effective_task_id=effective_task_id,
current_turn_user_idx=current_turn_user_idx,
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,
)
messages = _tv.messages
length_continue_retries = _tv.length_continue_retries
truncated_response_parts = _tv.truncated_response_parts
truncated_tool_call_retries = _tv.truncated_tool_call_retries
retry_count = _tv.retry_count
compression_attempts = _tv.compression_attempts
if _tv.action == "return":
return _verdict("return", _tv.result)
if _tv.action == "break":
return _verdict("break")
if _tv.action == "continue":
return _verdict("continue")
# Fold provider usage into compressor / anchors / session counters / state.db
# (agent/turn_usage.py). A rearmed budget also clears the preflight-block latch.
_usage_outcome = record_response_usage(
agent,
response,
messages=messages,
api_call_count=api_call_count,
api_duration=api_duration,
compression_attempts=compression_attempts,
max_compression_attempts=max_compression_attempts,
)
compression_attempts = _usage_outcome.compression_attempts
if _usage_outcome.rearmed:
_preflight_compression_blocked = False
_last_preflight_pressure = None
_retry.has_retried_429 = False # Reset on success
# Don't clear the retry buffer: bytes back != usable content; it is
# cleared once genuine content lands. Clearing Nous rate-limit state
# proves the limit reset so other sessions may resume.
if agent.provider == "nous":
try:
from agent.nous_rate_guard import clear_nous_rate_limit
clear_nous_rate_limit()
except Exception:
pass
from agent import relay_llm
relay_llm.complete_logical_call(
api_request_id,
outcome="success",
)
agent._touch_activity(f"API call #{api_call_count} completed")
return _verdict("break") # Success, exit retry loop
return _verdict("fallthrough")
+10 -4
View File
@@ -171,16 +171,22 @@ def test_api_kwargs_walk_makes_tool_descriptions_json_safe():
def test_conversation_loop_sanitizes_api_kwargs_after_build():
"""Wiring pin: the structure walk runs on the fully-built api_kwargs
(after _build_api_kwargs, before any transport/provider sees it)."""
(after _build_api_kwargs, before any transport/provider sees it).
The request build lives in ``agent.turn_api_request.build_api_request`` and the
provider call in ``agent.turn_api_call.perform_api_call``; the loop calls them in
that order, so the sanitize step provably precedes the call."""
import inspect
import agent.conversation_loop as cl
import agent.turn_api_request as rq
src = inspect.getsource(cl.run_conversation)
src = inspect.getsource(rq.build_api_request)
build_idx = src.index("api_kwargs = agent._build_api_kwargs(api_messages)")
sanitize_idx = src.index("_sanitize_structure_surrogates(api_kwargs)")
perform_idx = src.index("def _perform_api_call")
assert build_idx < sanitize_idx < perform_idx
assert build_idx < sanitize_idx
loop_src = inspect.getsource(cl.run_conversation)
assert loop_src.index("build_api_request(") < loop_src.index("perform_api_call(")
# ---------------------------------------------------------------------------