1620 lines
78 KiB
Python
1620 lines
78 KiB
Python
"""Recovery-branch handlers for the conversation turn's inner retry loop.
|
||
|
||
``run_conversation`` wraps every model API call in ``while retry_count < max_retries``.
|
||
When the call raises, a long chain of one-shot recovery branches runs before the generic
|
||
retry/backoff path: payload sanitization (surrogates / ASCII codec), image rejection,
|
||
per-provider 401 credential refresh, format-recovery strips (thinking signatures,
|
||
encrypted reasoning, native compaction, llama.cpp grammar), etc. Each handler here owns
|
||
one contiguous chain and returns a verdict the loop acts on:
|
||
|
||
* ``True`` → the request was repaired in place; the loop ``continue``s (re-issues the
|
||
call with the same ``retry_count``, exactly as the inline ``continue`` did).
|
||
* ``False`` → nothing applied; the loop falls through to the generic retry path.
|
||
|
||
One-shot guards live on ``TurnRetryState`` (``agent/turn_retry_state.py``); handlers set
|
||
them exactly where the inline code did. Handlers mutate ``agent`` / ``messages`` /
|
||
``api_messages`` in place — side effects are the point; only locals moved.
|
||
|
||
Logger name stays ``agent.conversation_loop`` so caplog pins and log routing are
|
||
unchanged. Nothing here imports ``agent.conversation_loop`` at module level (cycle);
|
||
the few loop-internal helpers are passed in or imported lazily.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from agent.conversation_compression import COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE
|
||
from agent.model_metadata import is_output_cap_error, parse_available_output_tokens_from_error
|
||
from agent.retry_utils import is_zai_coding_overload_error, zai_coding_overload_retry_ceiling
|
||
from agent.error_classifier import FailoverReason
|
||
from agent.message_sanitization import (
|
||
_looks_like_image_content_rejection, _sanitize_messages_non_ascii,
|
||
_sanitize_messages_surrogates, _sanitize_structure_non_ascii, _sanitize_structure_surrogates,
|
||
_sanitize_tools_non_ascii, _strip_images_from_messages, _strip_non_ascii,
|
||
close_interrupted_tool_sequence,
|
||
)
|
||
from agent.turn_retry_state import TurnRetryState
|
||
from utils import base_url_host_matches
|
||
|
||
logger = logging.getLogger("agent.conversation_loop")
|
||
|
||
|
||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||
parts = []
|
||
for value in (
|
||
error, getattr(error, "message", None), getattr(error, "body", None)
|
||
):
|
||
if value:
|
||
try:
|
||
parts.append(str(value))
|
||
except Exception:
|
||
pass
|
||
text = " ".join(parts).lower()
|
||
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
|
||
return None
|
||
|
||
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
|
||
if not match:
|
||
return None
|
||
try:
|
||
max_dimension = int(match.group(1))
|
||
except ValueError:
|
||
return None
|
||
if 512 <= max_dimension <= 8000:
|
||
return max_dimension
|
||
return None
|
||
|
||
|
||
def _try_refresh_nous_paid_entitlement_credentials(agent) -> bool:
|
||
"""Refresh Nous runtime credentials after a fresh paid-entitlement check."""
|
||
try:
|
||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||
|
||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||
if account_info.paid_service_access is not True:
|
||
return False
|
||
return agent._try_refresh_nous_client_credentials(
|
||
force=True,
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def recover_before_classification(
|
||
agent: Any, api_error: Exception, *, messages: List[Dict[str, Any]], api_messages: Any,
|
||
api_kwargs: Any, active_system_prompt: Any,
|
||
) -> Tuple[bool, Any]:
|
||
"""Recovery branches that run BEFORE ``classify_api_error``: UnicodeEncodeError
|
||
sanitization (surrogates, then ASCII codec), provider image-content rejection
|
||
(switch session to text-only), and the Bedrock AnthropicBedrock SDK streaming
|
||
fallback. Returns ``(retry_now, active_system_prompt)``; the prompt may be
|
||
ASCII-sanitized in place."""
|
||
# UnicodeEncodeError recovery: lone surrogates (clipboard paste) or an
|
||
# ASCII codec under a non-UTF-8 locale. Sanitize in-place; at most two
|
||
# retries (surrogate strip, then ASCII-only).
|
||
if isinstance(api_error, UnicodeEncodeError) and getattr(agent, '_unicode_sanitization_passes', 0) < 2:
|
||
_err_str = str(api_error).lower()
|
||
_is_ascii_codec = "'ascii'" in _err_str or "ascii" in _err_str
|
||
# Surrogate errors: utf-8 refusing U+D800..U+DFFF
|
||
# ("surrogates not allowed").
|
||
_is_surrogate_error = (
|
||
"surrogate" in _err_str or ("'utf-8'" in _err_str and not _is_ascii_codec)
|
||
)
|
||
# Sanitize `messages` AND `api_messages` (which may carry
|
||
# `reasoning_content`/`reasoning_details`), plus `api_kwargs` and
|
||
# `prefill_messages` if present. Mirrors the ASCII recovery below.
|
||
_surrogates_found = _sanitize_messages_surrogates(messages)
|
||
if isinstance(api_messages, list):
|
||
if _sanitize_messages_surrogates(api_messages):
|
||
_surrogates_found = True
|
||
if isinstance(api_kwargs, dict):
|
||
if _sanitize_structure_surrogates(api_kwargs):
|
||
_surrogates_found = True
|
||
if isinstance(getattr(agent, "prefill_messages", None), list):
|
||
if _sanitize_messages_surrogates(agent.prefill_messages):
|
||
_surrogates_found = True
|
||
# Gate the retry on the error type, not on whether anything was
|
||
# found — a new transformed field could slip through. Bounded by
|
||
# _unicode_sanitization_passes < 2 (outer guard).
|
||
if _surrogates_found or _is_surrogate_error:
|
||
agent._unicode_sanitization_passes += 1
|
||
if _surrogates_found:
|
||
agent._buffer_vprint(
|
||
"⚠️ Stripped invalid surrogate characters from messages. Retrying..."
|
||
)
|
||
else:
|
||
agent._buffer_vprint(
|
||
"⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
|
||
)
|
||
return True, active_system_prompt
|
||
if _is_ascii_codec:
|
||
agent._force_ascii_payload = True
|
||
# ASCII codec: strip all non-ASCII from messages/tool schemas
|
||
# and retry — both `messages` and `api_messages` (which may
|
||
# carry extra fields like reasoning_content).
|
||
_messages_sanitized = _sanitize_messages_non_ascii(messages)
|
||
if isinstance(api_messages, list):
|
||
_sanitize_messages_non_ascii(api_messages)
|
||
# Also sanitize the last api_kwargs so a non-ASCII transformed
|
||
# field doesn't survive via _build_api_kwargs cache paths.
|
||
if isinstance(api_kwargs, dict):
|
||
_sanitize_structure_non_ascii(api_kwargs)
|
||
_prefill_sanitized = False
|
||
if isinstance(getattr(agent, "prefill_messages", None), list):
|
||
_prefill_sanitized = _sanitize_messages_non_ascii(agent.prefill_messages)
|
||
|
||
_tools_sanitized = False
|
||
if isinstance(getattr(agent, "tools", None), list):
|
||
_tools_sanitized = _sanitize_tools_non_ascii(agent.tools)
|
||
|
||
_system_sanitized = False
|
||
if isinstance(active_system_prompt, str):
|
||
_sanitized_system = _strip_non_ascii(active_system_prompt)
|
||
if _sanitized_system != active_system_prompt:
|
||
active_system_prompt = _sanitized_system
|
||
agent._cached_system_prompt = _sanitized_system
|
||
_system_sanitized = True
|
||
if isinstance(getattr(agent, "ephemeral_system_prompt", None), str):
|
||
_sanitized_ephemeral = _strip_non_ascii(agent.ephemeral_system_prompt)
|
||
if _sanitized_ephemeral != agent.ephemeral_system_prompt:
|
||
agent.ephemeral_system_prompt = _sanitized_ephemeral
|
||
_system_sanitized = True
|
||
|
||
_headers_sanitized = False
|
||
_default_headers = (
|
||
agent._client_kwargs.get("default_headers")
|
||
if isinstance(getattr(agent, "_client_kwargs", None), dict)
|
||
else None
|
||
)
|
||
if isinstance(_default_headers, dict):
|
||
_headers_sanitized = _sanitize_structure_non_ascii(_default_headers)
|
||
|
||
# Sanitize the API key: non-ASCII in credentials makes httpx
|
||
# fail encoding the Authorization header — the usual persistent
|
||
# cause after message/tool sanitization (#6843).
|
||
_credential_sanitized = False
|
||
_raw_key = getattr(agent, "api_key", None) or ""
|
||
# Entra ID bearer providers are callables minting ASCII JWTs;
|
||
# skip (``_strip_non_ascii`` would crash on a callable).
|
||
if _raw_key and isinstance(_raw_key, str):
|
||
_clean_key = _strip_non_ascii(_raw_key)
|
||
if _clean_key != _raw_key:
|
||
agent.api_key = _clean_key
|
||
if isinstance(getattr(agent, "_client_kwargs", None), dict):
|
||
agent._client_kwargs["api_key"] = _clean_key
|
||
# Also update the live client — auth_headers reads its
|
||
# own api_key copy on every request.
|
||
if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"):
|
||
agent.client.api_key = _clean_key
|
||
_credential_sanitized = True
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ API key contained non-ASCII characters "
|
||
f"(bad copy-paste?) — stripped them. If auth fails, "
|
||
f"re-copy the key from your provider's dashboard.",
|
||
force=True,
|
||
)
|
||
|
||
# Always retry on ASCII codec detection: _force_ascii_payload
|
||
# sanitizes the full api_kwargs next iteration even when
|
||
# checks above find nothing. Bounded by passes < 2.
|
||
agent._unicode_sanitization_passes += 1
|
||
_any_sanitized = (
|
||
_messages_sanitized
|
||
or _prefill_sanitized
|
||
or _tools_sanitized
|
||
or _system_sanitized
|
||
or _headers_sanitized
|
||
or _credential_sanitized
|
||
)
|
||
if _any_sanitized:
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ System encoding is ASCII — stripped non-ASCII characters from request payload. Retrying...",
|
||
force=True,
|
||
)
|
||
else:
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ System encoding is ASCII — enabling full-payload sanitization for retry...",
|
||
force=True,
|
||
)
|
||
return True, active_system_prompt
|
||
|
||
# ── Image-rejection recovery ──────────────────────────────
|
||
# Some providers 4xx on image_url content: strip images, mark session
|
||
# vision-unsupported, retry text-only. English phrase match; extend it.
|
||
_err_body = ""
|
||
try:
|
||
_err_body = str(getattr(api_error, "body", None) or
|
||
getattr(api_error, "message", None) or
|
||
str(api_error))
|
||
except Exception:
|
||
pass
|
||
_err_status = getattr(api_error, "status_code", None)
|
||
_looks_like_image_rejection = _looks_like_image_content_rejection(_err_body)
|
||
# 4xx-only gate: 5xx/timeouts are transient and take the retry path.
|
||
_status_ok = _err_status is None or (400 <= int(_err_status) < 500)
|
||
if (
|
||
getattr(agent, "_vision_supported", True) and _looks_like_image_rejection and _status_ok
|
||
):
|
||
agent._vision_supported = False
|
||
_imgs_removed = _strip_images_from_messages(messages)
|
||
if isinstance(api_messages, list):
|
||
_strip_images_from_messages(api_messages)
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Server rejected image content — "
|
||
f"switching to text-only mode for this session"
|
||
+ (". Stripped images from history and retrying." if _imgs_removed else "."),
|
||
force=True,
|
||
)
|
||
return True, active_system_prompt
|
||
|
||
# ── Bedrock AnthropicBedrock SDK streaming failure ──
|
||
# SDK raises "Unexpected event order" when Bedrock errors before
|
||
# message_start; fall back to native Converse for this session (#28156).
|
||
if (
|
||
isinstance(api_error, RuntimeError)
|
||
and "unexpected event order" in str(api_error).lower()
|
||
and getattr(agent, "provider", "") == "bedrock"
|
||
and agent.api_mode == "anthropic_messages"
|
||
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
|
||
):
|
||
agent._bedrock_converse_fallback_attempted = True
|
||
agent.api_mode = "bedrock_converse"
|
||
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
|
||
agent.client = None # Drop the AnthropicBedrock client
|
||
agent._client_kwargs = {}
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
|
||
f"falling back to native Converse API for this session.",
|
||
force=True,
|
||
)
|
||
return True, active_system_prompt
|
||
return False, active_system_prompt
|
||
|
||
|
||
def recover_after_classification(
|
||
agent: Any, api_error: Exception, classified: Any, _retry: TurnRetryState, *,
|
||
status_code: Optional[int], error_context: Any, messages: List[Dict[str, Any]],
|
||
api_messages: Any,
|
||
) -> Tuple[bool, bool]:
|
||
"""One-shot recovery chain that runs AFTER ``classify_api_error`` and before the
|
||
generic retry path. Order is load-bearing (each branch may ``return`` early):
|
||
Nous paid-entitlement refresh → credential-pool rotation → image shrink →
|
||
multimodal-tool-content strip → corrupt-image strip → Anthropic OAuth 1M-beta
|
||
disable → per-provider 401 credential refresh (codex/xai, vertex, nous, copilot,
|
||
anthropic, with user-facing diagnostics when refresh fails) → thinking-signature
|
||
strip → invalid-encrypted-content replay disable → native-compaction reject →
|
||
llama.cpp grammar strip. Returns ``(retry_now, recovered_with_pool)``;
|
||
``recovered_with_pool`` is read later by the Nous rate-limit guard."""
|
||
# Shared with the billing/entitlement helpers that stay in the loop module;
|
||
# lazy so this module never imports agent.conversation_loop at load time.
|
||
from agent.conversation_loop import (
|
||
_is_copilot_provider, _is_nous_inference_route, _print_nous_entitlement_guidance
|
||
)
|
||
|
||
if (
|
||
classified.reason == FailoverReason.billing
|
||
and _is_nous_inference_route(
|
||
getattr(agent, "provider", "") or "", getattr(agent, "base_url", "") or ""
|
||
)
|
||
and not _retry.nous_paid_entitlement_refresh_attempted
|
||
):
|
||
_retry.nous_paid_entitlement_refresh_attempted = True
|
||
if _try_refresh_nous_paid_entitlement_credentials(agent):
|
||
agent._vprint(
|
||
f"{agent.log_prefix}🔐 Nous paid access verified — "
|
||
"refreshed runtime credentials and retrying request...",
|
||
force=True,
|
||
)
|
||
return True, False
|
||
|
||
recovered_with_pool, _retry.has_retried_429 = agent._recover_with_credential_pool(
|
||
status_code=status_code, has_retried_429=_retry.has_retried_429,
|
||
classified_reason=classified.reason, error_context=error_context,
|
||
billing_unverified=classified.billing_unverified,
|
||
)
|
||
if recovered_with_pool:
|
||
return True, recovered_with_pool
|
||
|
||
# Image-too-large recovery: shrink oversized native image parts
|
||
# in-place and retry once; otherwise fall through to normal handling.
|
||
if (
|
||
classified.reason == FailoverReason.image_too_large
|
||
and not _retry.image_shrink_retry_attempted
|
||
):
|
||
_retry.image_shrink_retry_attempted = True
|
||
image_max_dimension = _image_error_max_dimension(api_error) or 8000
|
||
if agent._try_shrink_image_parts_in_messages(
|
||
api_messages, max_dimension=image_max_dimension
|
||
):
|
||
agent._vprint(
|
||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||
f"shrank and retrying...",
|
||
force=True,
|
||
)
|
||
return True, recovered_with_pool
|
||
else:
|
||
logger.info(
|
||
"image-shrink recovery: no data-URL image parts found "
|
||
"or shrink didn't reduce size; surfacing original error."
|
||
)
|
||
|
||
# Multimodal-tool-content recovery: strict OpenAI-spec providers 400
|
||
# on list-type tool content. Strip images, mark (provider, model)
|
||
# no-list-tool-content for the session, retry once (#27344).
|
||
if (
|
||
classified.reason == FailoverReason.multimodal_tool_content_unsupported
|
||
and not _retry.multimodal_tool_content_retry_attempted
|
||
):
|
||
_retry.multimodal_tool_content_retry_attempted = True
|
||
if agent._try_strip_image_parts_from_tool_messages(api_messages):
|
||
agent._vprint(
|
||
f"{agent.log_prefix}📐 Provider rejected list-type tool content — "
|
||
f"downgraded screenshots to text and retrying...",
|
||
force=True,
|
||
)
|
||
return True, recovered_with_pool
|
||
else:
|
||
logger.info(
|
||
"multimodal-tool-content recovery: no list-type tool "
|
||
"messages with image parts found; surfacing original error."
|
||
)
|
||
|
||
# Image-corrupt recovery: provider rejected the image bytes; shrinking
|
||
# can't help, so strip image parts and retry once (#69078).
|
||
if classified.reason == FailoverReason.image_corrupt:
|
||
# Strip ONLY the per-call copy: replacing msg["content"] on the
|
||
# shallow api_messages rows keeps canonical history's images
|
||
# (copy-on-write; transient rejection must not erase history).
|
||
_imgs_removed = False
|
||
if isinstance(api_messages, list):
|
||
_imgs_removed = _strip_images_from_messages(api_messages)
|
||
if _imgs_removed:
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Provider rejected a corrupted image — "
|
||
f"stripped images from the retry payload and retrying...",
|
||
force=True,
|
||
)
|
||
return True, recovered_with_pool
|
||
else:
|
||
logger.info(
|
||
"image-corrupt recovery: no image parts found to "
|
||
"strip; surfacing original error."
|
||
)
|
||
|
||
# Anthropic OAuth subscription rejected the 1M-context beta: disable it
|
||
# for this session, rebuild the client, retry once. Reactive so capable
|
||
# subscriptions keep full 1M context (#17680).
|
||
if (
|
||
classified.reason == FailoverReason.oauth_long_context_beta_forbidden
|
||
and agent.api_mode == "anthropic_messages"
|
||
and agent._is_anthropic_oauth
|
||
and not _retry.oauth_1m_beta_retry_attempted
|
||
):
|
||
_retry.oauth_1m_beta_retry_attempted = True
|
||
if not getattr(agent, "_oauth_1m_beta_disabled", False):
|
||
agent._oauth_1m_beta_disabled = True
|
||
try:
|
||
agent._anthropic_client.close()
|
||
except Exception:
|
||
pass
|
||
agent._rebuild_anthropic_client()
|
||
agent._vprint(
|
||
f"{agent.log_prefix}🔕 OAuth subscription doesn't support "
|
||
f"the 1M-context beta — disabled for this session and retrying...",
|
||
force=True,
|
||
)
|
||
return True, recovered_with_pool
|
||
|
||
if (
|
||
agent.api_mode == "codex_responses"
|
||
and agent.provider in {"openai-codex", "xai-oauth"}
|
||
and status_code == 401
|
||
and not _retry.codex_auth_retry_attempted
|
||
):
|
||
_retry.codex_auth_retry_attempted = True
|
||
if agent._try_refresh_codex_client_credentials(force=True):
|
||
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
|
||
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
|
||
return True, recovered_with_pool
|
||
if (
|
||
agent.api_mode == "chat_completions"
|
||
and agent.provider == "vertex"
|
||
and status_code == 401
|
||
and not _retry.vertex_auth_retry_attempted
|
||
):
|
||
_retry.vertex_auth_retry_attempted = True
|
||
if agent._try_refresh_vertex_client_credentials():
|
||
agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...")
|
||
return True, recovered_with_pool
|
||
if (
|
||
agent.api_mode in ("chat_completions", "anthropic_messages")
|
||
and agent.provider == "nous"
|
||
and status_code == 401
|
||
and not _retry.nous_auth_retry_attempted
|
||
):
|
||
_retry.nous_auth_retry_attempted = True
|
||
if agent._try_refresh_nous_client_credentials(force=True):
|
||
agent._buffer_vprint("🔐 Nous agent key refreshed after 401. Retrying request...")
|
||
return True, recovered_with_pool
|
||
# Refresh didn't help: likely Portal OAuth expired/revoked,
|
||
# no credits, or agent key blocked.
|
||
from hermes_constants import display_hermes_home as _dhh_fn
|
||
_dhh = _dhh_fn()
|
||
_body_text = ""
|
||
try:
|
||
_body = getattr(api_error, "body", None) or getattr(api_error, "response", None)
|
||
if _body is not None:
|
||
_body_text = str(_body)[:200]
|
||
except Exception:
|
||
pass
|
||
print(f"{agent.log_prefix}🔐 Nous 401 — Portal authentication failed.")
|
||
if _body_text:
|
||
print(f"{agent.log_prefix} Response: {_body_text}")
|
||
if not _print_nous_entitlement_guidance(agent, "Nous model access"):
|
||
print(f"{agent.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
|
||
print(f"{agent.log_prefix} Troubleshooting:")
|
||
print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous")
|
||
print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
|
||
print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json")
|
||
print(f"{agent.log_prefix} • Switch providers temporarily: /model <model> --provider openrouter")
|
||
if (
|
||
_is_copilot_provider(agent)
|
||
and status_code == 401
|
||
and not _retry.copilot_auth_retry_attempted
|
||
):
|
||
_retry.copilot_auth_retry_attempted = True
|
||
if agent._try_refresh_copilot_client_credentials():
|
||
agent._buffer_vprint("🔐 Copilot credentials refreshed after 401. Retrying request...")
|
||
return True, recovered_with_pool
|
||
if (
|
||
agent.api_mode == "anthropic_messages"
|
||
and status_code == 401
|
||
and hasattr(agent, '_anthropic_api_key')
|
||
and not _retry.anthropic_auth_retry_attempted
|
||
):
|
||
_retry.anthropic_auth_retry_attempted = True
|
||
from agent.anthropic_adapter import _is_oauth_token
|
||
from agent.azure_identity_adapter import is_token_provider
|
||
if agent._try_refresh_anthropic_client_credentials():
|
||
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||
return True, recovered_with_pool
|
||
# Credential refresh didn't help — show diagnostic info
|
||
key = agent._anthropic_api_key
|
||
print(f"{agent.log_prefix}🔐 Anthropic 401 — authentication failed.")
|
||
if is_token_provider(key):
|
||
# Azure Foundry Entra ID: JWT minted per-request by an httpx
|
||
# hook; 401 = Azure rejected it (RBAC, az login, IMDS).
|
||
print(f"{agent.log_prefix} Auth method: Microsoft Entra ID (httpx event hook)")
|
||
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
|
||
print(f"{agent.log_prefix} `az login` if your developer session expired.")
|
||
else:
|
||
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
|
||
print(f"{agent.log_prefix} Auth method: {auth_method}")
|
||
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
|
||
print(f"{agent.log_prefix} Troubleshooting:")
|
||
from hermes_constants import display_hermes_home as _dhh_fn
|
||
_dhh = _dhh_fn()
|
||
print(f"{agent.log_prefix} • Check ANTHROPIC_TOKEN in {_dhh}/.env for Hermes-managed OAuth/setup tokens")
|
||
print(f"{agent.log_prefix} • Check ANTHROPIC_API_KEY in {_dhh}/.env for API keys or legacy token values")
|
||
print(f"{agent.log_prefix} • For API keys: verify at https://platform.claude.com/settings/keys")
|
||
print(f"{agent.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry")
|
||
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
|
||
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
|
||
|
||
# Thinking block signature recovery: upstream mutation invalidates
|
||
# Anthropic's signature (400). Strip ``reasoning_details`` from
|
||
# ``api_messages`` only, never ``messages`` (state.db). One-shot.
|
||
if (
|
||
classified.reason == FailoverReason.thinking_signature
|
||
and not _retry.thinking_sig_retry_attempted
|
||
):
|
||
_retry.thinking_sig_retry_attempted = True
|
||
_api_stripped = 0
|
||
for _m in api_messages:
|
||
if isinstance(_m, dict) and "reasoning_details" in _m:
|
||
_m.pop("reasoning_details", None)
|
||
_api_stripped += 1
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
|
||
f"stripped reasoning_details from api_messages for retry...",
|
||
force=True,
|
||
)
|
||
logger.warning(
|
||
"%sThinking block signature recovery: stripped "
|
||
"reasoning_details from %d api_messages "
|
||
"(canonical messages unchanged)",
|
||
agent.log_prefix, _api_stripped,
|
||
)
|
||
return True, recovered_with_pool
|
||
|
||
# ── Invalid encrypted reasoning replay recovery ───────
|
||
# 400 ``invalid_encrypted_content`` on a stale ``codex_reasoning_items``
|
||
# blob: disable replay for the session, strip cached items, retry once.
|
||
if (
|
||
classified.reason == FailoverReason.invalid_encrypted_content
|
||
and not _retry.invalid_encrypted_content_retry_attempted
|
||
and agent.api_mode == "codex_responses"
|
||
and bool(getattr(agent, "_codex_reasoning_replay_enabled", True))
|
||
and any(
|
||
isinstance(_m, dict)
|
||
and _m.get("role") == "assistant"
|
||
and isinstance(_m.get("codex_reasoning_items"), list)
|
||
and _m.get("codex_reasoning_items")
|
||
for _m in messages
|
||
)
|
||
):
|
||
_retry.invalid_encrypted_content_retry_attempted = True
|
||
replay_stats = agent._disable_codex_reasoning_replay(messages)
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — "
|
||
f"disabled replay and stripped {replay_stats['items']} item(s) from "
|
||
f"{replay_stats['messages']} message(s), retrying...",
|
||
force=True,
|
||
)
|
||
logger.warning(
|
||
"%sInvalid encrypted reasoning recovery: disabled replay and stripped %d items from %d messages",
|
||
agent.log_prefix,
|
||
replay_stats["items"],
|
||
replay_stats["messages"],
|
||
)
|
||
return True, recovered_with_pool
|
||
|
||
# ── Native compaction rejection recovery ──────────────
|
||
# Structured 400 naming ``context_management``: disable native
|
||
# compaction for the session, retry once; local compression takes over.
|
||
if (
|
||
agent.api_mode == "codex_responses"
|
||
and not _retry.native_compaction_reject_retry_attempted
|
||
and bool(getattr(agent, "codex_responses_native_compaction", False))
|
||
):
|
||
from agent.native_compaction import is_native_compaction_rejection
|
||
if is_native_compaction_rejection(
|
||
api_error, getattr(api_error, "status_code", None)
|
||
):
|
||
_retry.native_compaction_reject_retry_attempted = True
|
||
agent.codex_responses_native_compaction = False
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Provider rejected native compaction "
|
||
f"(context_management) — disabled for this session, "
|
||
f"local compression stays active. Retrying...",
|
||
force=True,
|
||
)
|
||
logger.warning(
|
||
"%sNative compaction rejection recovery: disabled "
|
||
"codex_responses_native for this session and retrying",
|
||
agent.log_prefix,
|
||
)
|
||
return True, recovered_with_pool
|
||
|
||
# ── llama.cpp grammar-parse recovery ──────────────────
|
||
# ``json-schema-to-grammar`` rejects regex escapes and most ``format``
|
||
# values: strip ``pattern``/``format`` from ``agent.tools``, retry once.
|
||
if (
|
||
classified.reason == FailoverReason.llama_cpp_grammar_pattern
|
||
and not _retry.llama_cpp_grammar_retry_attempted
|
||
):
|
||
_retry.llama_cpp_grammar_retry_attempted = True
|
||
try:
|
||
from tools.schema_sanitizer import strip_pattern_and_format
|
||
_, _stripped = strip_pattern_and_format(agent.tools)
|
||
except Exception as _strip_exc: # pragma: no cover — defensive
|
||
logger.warning(
|
||
"%sllama.cpp grammar recovery: strip helper failed: %s",
|
||
agent.log_prefix, _strip_exc,
|
||
)
|
||
_stripped = 0
|
||
if _stripped:
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ llama.cpp rejected tool schema grammar — "
|
||
f"stripped {_stripped} pattern/format keyword(s), retrying...",
|
||
force=True,
|
||
)
|
||
logger.warning(
|
||
"%sllama.cpp grammar recovery: stripped %d "
|
||
"pattern/format keyword(s) from tool schemas",
|
||
agent.log_prefix, _stripped,
|
||
)
|
||
return True, recovered_with_pool
|
||
# No keywords found to strip — fall through to normal
|
||
# retry path rather than loop forever on the same error.
|
||
logger.warning(
|
||
"%sllama.cpp grammar error but no pattern/format "
|
||
"keywords to strip — falling through to normal retry",
|
||
agent.log_prefix,
|
||
)
|
||
return False, recovered_with_pool
|
||
|
||
|
||
def nonretryable_client_error_result(
|
||
agent: Any, api_error: Exception, classified: Any, *, status_code: Optional[int],
|
||
api_kwargs: Any, api_messages: Any, messages: List[Dict[str, Any]], conversation_history: Any,
|
||
api_call_count: int, approx_tokens: int, provider: Any, base_url: Any, model: Any,
|
||
) -> Dict[str, Any]:
|
||
"""Terminal path for a non-retryable 4xx once fallback is exhausted: dump the
|
||
request for debugging, flush the buffered retry trace, print actionable auth /
|
||
billing / content-policy / TLS guidance, persist the session (skipped for likely
|
||
context-overflow 400s so the failure does not grow the session, #1630) and build
|
||
the failed-turn result dict."""
|
||
# Result/guidance helpers stay in the loop module (tests import + patch them
|
||
# there); lazy import avoids a load-time cycle.
|
||
from agent.conversation_loop import (
|
||
_CONTENT_POLICY_RECOVERY_HINT, _billing_failure_result, _content_policy_blocked_result,
|
||
_print_billing_or_entitlement_guidance, _print_nous_entitlement_guidance,
|
||
)
|
||
|
||
if api_kwargs is not None:
|
||
agent._dump_api_request_debug(
|
||
api_kwargs, reason="non_retryable_client_error", error=api_error,
|
||
)
|
||
# Terminal — flush buffered context so the user sees
|
||
# what was tried before the abort.
|
||
agent._flush_status_buffer()
|
||
# Summarize once: Cloudflare/proxy HTML pages and raw provider
|
||
# bodies must be collapsed here or they leak verbatim via the
|
||
# ``error`` field.
|
||
_nonretryable_summary = agent._summarize_api_error(api_error)
|
||
if classified.reason == FailoverReason.content_policy_blocked:
|
||
agent._emit_status(
|
||
f"❌ Provider safety filter blocked this request: " f"{_nonretryable_summary}"
|
||
)
|
||
elif classified.reason == FailoverReason.ssl_cert_verification:
|
||
agent._emit_status(
|
||
f"❌ TLS certificate verification failed: " f"{_nonretryable_summary}"
|
||
)
|
||
else:
|
||
agent._emit_status(
|
||
f"❌ Non-retryable error (HTTP {status_code}): " f"{_nonretryable_summary}"
|
||
)
|
||
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {provider} Model: {model}", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 🌐 Endpoint: {base_url}", force=True)
|
||
# Actionable guidance for common auth errors
|
||
if classified.is_auth or classified.reason == FailoverReason.billing:
|
||
if classified.reason == FailoverReason.billing and _print_billing_or_entitlement_guidance(
|
||
agent, capability="model access", provider=provider, base_url=str(base_url),
|
||
model=model, unverified=classified.billing_unverified,
|
||
):
|
||
pass
|
||
elif provider == "nous" and _print_nous_entitlement_guidance(
|
||
agent, "Nous model access"
|
||
):
|
||
pass
|
||
elif provider in {"openai-codex", "xai-oauth", "nous"} and status_code == 401:
|
||
if provider == "openai-codex":
|
||
agent._vprint(f"{agent.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True)
|
||
agent._vprint(f"{agent.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True)
|
||
elif provider == "xai-oauth":
|
||
agent._vprint(f"{agent.log_prefix} 💡 xAI OAuth token was rejected (HTTP 401). To fix:", force=True)
|
||
agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok / Premium+) from `hermes model`.", force=True)
|
||
else: # nous
|
||
agent._vprint(f"{agent.log_prefix} 💡 Nous Portal OAuth token was rejected (HTTP 401). Your token may be", force=True)
|
||
agent._vprint(f"{agent.log_prefix} expired, revoked, or your account may be out of credits. To fix:", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes portal", force=True)
|
||
agent._vprint(f"{agent.log_prefix} 2. Check your portal account: https://portal.nousresearch.com", force=True)
|
||
# ``:free`` is OpenRouter slug syntax; Nous Portal will reject
|
||
# the model name even after a successful re-auth.
|
||
if isinstance(model, str) and model.endswith(":free"):
|
||
agent._vprint(f"{agent.log_prefix} ⚠️ Note: `{model}` looks like an OpenRouter slug (`:free` suffix).", force=True)
|
||
agent._vprint(f"{agent.log_prefix} Nous Portal won't recognize that model name. Either switch to a", force=True)
|
||
agent._vprint(f"{agent.log_prefix} Nous catalog model, or run `/model openrouter:{model}` to use OpenRouter.", force=True)
|
||
else:
|
||
agent._vprint(f"{agent.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True)
|
||
agent._vprint(f"{agent.log_prefix} • Is the key valid? Run: hermes setup", force=True)
|
||
agent._vprint(f"{agent.log_prefix} • Does your account have access to {model}?", force=True)
|
||
if base_url_host_matches(str(base_url), "openrouter.ai"):
|
||
agent._vprint(f"{agent.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True)
|
||
else:
|
||
agent._vprint(f"{agent.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True)
|
||
# Content-policy blocks get their own guidance: the provider refused
|
||
# this prompt, so recovery is a rephrase or another model, not
|
||
# key/retry advice.
|
||
if classified.reason == FailoverReason.content_policy_blocked:
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 💡 The provider's safety filter rejected this specific prompt.",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} • Try rephrasing the request, narrowing the context, or splitting into smaller steps.",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} • Configure a fallback provider so future blocks route automatically:",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
|
||
force=True,
|
||
)
|
||
# TLS certificate failures are environment problems — name the knobs
|
||
# that fix each common cause.
|
||
if classified.reason == FailoverReason.ssl_cert_verification:
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 💡 The TLS certificate chain could not be verified. This fails the same",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} way on every retry — fix the environment, then try again:",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} • Missing/stale system CA store? Install/refresh it:",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} for localhost, or add the server's cert to your trust store.",
|
||
force=True,
|
||
)
|
||
logger.error("%sNon-retryable client error: %s", agent.log_prefix, api_error)
|
||
# Skip persistence on likely context-overflow (400 + large session):
|
||
# persisting the failed message grows the session and repeats the
|
||
# failure. (#1630)
|
||
if status_code == 400 and (approx_tokens > 50000 or len(api_messages) > 80):
|
||
agent._vprint(
|
||
f"{agent.log_prefix}⚠️ Skipping session persistence "
|
||
f"for large failed session to prevent growth loop.",
|
||
force=True,
|
||
)
|
||
else:
|
||
agent._persist_session(messages, conversation_history)
|
||
if classified.reason == FailoverReason.content_policy_blocked:
|
||
_policy_response = (
|
||
"⚠️ The model provider's safety filter blocked this request "
|
||
"(not a Hermes/gateway failure).\n\n"
|
||
f"Provider message: {_nonretryable_summary}\n\n"
|
||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||
)
|
||
return _content_policy_blocked_result(
|
||
messages, api_call_count, final_response=_policy_response,
|
||
error_detail=_nonretryable_summary,
|
||
)
|
||
# Billing walls get the same structured recovery descriptor as the
|
||
# max-retries path so every surface renders one consistent signal.
|
||
if classified.reason == FailoverReason.billing:
|
||
return _billing_failure_result(
|
||
classified=classified, summary=_nonretryable_summary, messages=messages,
|
||
api_call_count=api_call_count, provider=provider, base_url=base_url, model=model,
|
||
)
|
||
return {
|
||
"final_response": _nonretryable_summary,
|
||
"messages": messages,
|
||
"api_calls": api_call_count,
|
||
"completed": False,
|
||
"failed": True,
|
||
"error": _nonretryable_summary,
|
||
}
|
||
|
||
|
||
def max_retries_exhausted_result(
|
||
agent: Any, api_error: Exception, classified: Any, *, max_retries: int, is_rate_limited: bool,
|
||
error_msg: str, api_kwargs: Any, api_messages: Any, messages: List[Dict[str, Any]],
|
||
conversation_history: Any, api_call_count: int, approx_tokens: int, provider: Any,
|
||
base_url: Any, model: Any,
|
||
) -> Dict[str, Any]:
|
||
"""Terminal path once ``retry_count >= max_retries`` and transport recovery +
|
||
fallback both failed: flush the buffered trace, emit the billing / rate-limit /
|
||
generic status line, print stream-drop or thinking-timeout guidance (the latter
|
||
wins, #52310), persist, and build the failed-turn result dict carrying the
|
||
classified ``failure_reason`` / ``failure_retryable`` / ``billing_block``."""
|
||
# Result/guidance helpers stay in the loop module (tests import + patch them
|
||
# there); lazy import avoids a load-time cycle.
|
||
from agent.conversation_loop import (
|
||
_billing_block_dict, _billing_or_entitlement_message, _billing_terminal_label,
|
||
_print_billing_or_entitlement_guidance,
|
||
)
|
||
|
||
# Terminal — flush buffered retry/fallback trace.
|
||
agent._flush_status_buffer()
|
||
_final_summary = agent._summarize_api_error(api_error)
|
||
_billing_guidance = ""
|
||
if classified.reason == FailoverReason.billing:
|
||
if classified.billing_unverified:
|
||
# Ambiguous body (#82154) — hedge the terminal line.
|
||
agent._emit_status(
|
||
"❌ Provider reported usage/credit exhaustion "
|
||
f"(unverified — may be a content-filter rejection) — {_final_summary}"
|
||
)
|
||
else:
|
||
agent._emit_status(f"❌ Billing or credits exhausted — {_final_summary}")
|
||
_billing_guidance = _billing_or_entitlement_message(
|
||
capability="model access", provider=provider, base_url=str(base_url), model=model,
|
||
unverified=classified.billing_unverified,
|
||
)
|
||
_print_billing_or_entitlement_guidance(
|
||
agent, capability="model access", provider=provider, base_url=str(base_url),
|
||
model=model, unverified=classified.billing_unverified,
|
||
)
|
||
elif is_rate_limited:
|
||
agent._emit_status(f"❌ Rate limited after {max_retries} retries — {_final_summary}")
|
||
else:
|
||
agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}")
|
||
agent._vprint(f"{agent.log_prefix} 💀 Final error: {_final_summary}", force=True)
|
||
|
||
# SSE stream-drop (e.g. "Network connection lost"): usually a
|
||
# proxy/CDN cutting a very large tool call mid-response; give
|
||
# actionable guidance.
|
||
_is_stream_drop = (
|
||
not getattr(api_error, "status_code", None)
|
||
and any(p in error_msg for p in (
|
||
"connection lost", "connection reset",
|
||
"connection closed", "network connection",
|
||
"network error", "terminated",
|
||
))
|
||
)
|
||
if _is_stream_drop:
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 💡 The provider's stream "
|
||
f"connection keeps dropping. This often happens "
|
||
f"when the model tries to write a very large "
|
||
f"file in a single tool call.",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} Try asking the model "
|
||
f"to use execute_code with Python's open() for "
|
||
f"large files, or to write the file in smaller "
|
||
f"sections.",
|
||
force=True,
|
||
)
|
||
|
||
# Thinking-timeout: a known reasoning model hit a transport error
|
||
# before the first content token. Distinct from _is_stream_drop;
|
||
# detection lives in agent.thinking_timeout_guidance. (#52310)
|
||
from agent.thinking_timeout_guidance import (
|
||
is_thinking_timeout,
|
||
)
|
||
_is_thinking_timeout = is_thinking_timeout(
|
||
classified, model, error_msg
|
||
)
|
||
if _is_thinking_timeout:
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 💡 The model's thinking "
|
||
f"phase exceeded the upstream proxy's idle "
|
||
f"timeout before the first content token "
|
||
f"arrived. This is a known issue with "
|
||
f"reasoning models behind cloud gateways "
|
||
f"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} Workarounds in priority order:", force=True
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 1. Set "
|
||
f"`providers.{provider}.models.{model}.stale_timeout_seconds: 900` "
|
||
f"in `~/.hermes/config.yaml` to extend the per-call "
|
||
f"timeout. (Hermes's built-in floor is 600s for "
|
||
f"known reasoning models — if you still see this "
|
||
f"after raising, the upstream cap is even shorter.)",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 2. Lower `reasoning_budget` or set "
|
||
f"`reasoning_effort: medium` on this model if the provider supports it.",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 3. Use a smaller / faster reasoning "
|
||
f"model if the task doesn't require deep thinking.",
|
||
force=True,
|
||
)
|
||
|
||
logger.error(
|
||
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
|
||
agent.log_prefix, max_retries, _final_summary,
|
||
provider, model, len(api_messages), f"{approx_tokens:,}",
|
||
)
|
||
if api_kwargs is not None:
|
||
agent._dump_api_request_debug(
|
||
api_kwargs, reason="max_retries_exhausted", error=api_error,
|
||
)
|
||
agent._persist_session(messages, conversation_history)
|
||
_billing_block = None
|
||
_billing_unverified = False
|
||
if classified.reason == FailoverReason.billing:
|
||
_billing_unverified = classified.billing_unverified
|
||
_final_response = _billing_terminal_label(
|
||
_final_summary, _billing_unverified
|
||
)
|
||
if _billing_guidance:
|
||
_final_response += f"\n\n{_billing_guidance}"
|
||
# Structured recovery descriptor so every surface renders
|
||
# the same link + label from one signal (see helper).
|
||
_billing_block = _billing_block_dict(
|
||
provider, base_url, model, _billing_guidance, unverified=_billing_unverified
|
||
)
|
||
else:
|
||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||
if _is_thinking_timeout:
|
||
# Thinking-timeout guidance overrides stream-drop guidance,
|
||
# which would wrongly suggest splitting large file writes.
|
||
from agent.thinking_timeout_guidance import (
|
||
build_thinking_timeout_guidance,
|
||
)
|
||
_final_response += build_thinking_timeout_guidance(
|
||
provider=provider, model=model
|
||
)
|
||
elif _is_stream_drop:
|
||
_final_response += (
|
||
"\n\nThe provider's stream connection keeps "
|
||
"dropping — this often happens when generating "
|
||
"very large tool call responses (e.g. write_file "
|
||
"with long content). Try asking me to use "
|
||
"execute_code with Python's open() for large "
|
||
"files, or to write in smaller sections."
|
||
)
|
||
return {
|
||
"final_response": _final_response,
|
||
"messages": messages,
|
||
"api_calls": api_call_count,
|
||
"completed": False,
|
||
"failed": True,
|
||
"error": _final_summary,
|
||
# Expose the classified reason so callers (kanban worker in
|
||
# cli.py) can tell a quota wall (``rate_limit`` / ``billing``)
|
||
# from a task failure.
|
||
"failure_reason": classified.reason.value,
|
||
# The classifier's own retry verdict — UI surfaces use
|
||
# this instead of re-deriving from the reason string.
|
||
"failure_retryable": bool(classified.retryable),
|
||
# True when the billing verdict rests on an ambiguous
|
||
# body (#82154) — may be a content-filter rejection.
|
||
"billing_unverified": _billing_unverified,
|
||
# Present only for billing walls: structured recovery
|
||
# descriptor (provider, billing_url, is_nous, message).
|
||
"billing_block": _billing_block,
|
||
}
|
||
|
||
|
||
def log_api_error_attempt(
|
||
agent: Any, api_error: Exception, *, retry_count: int, max_retries: int,
|
||
status_code: Optional[int], elapsed_time: float, api_messages: Any, approx_tokens: int,
|
||
) -> Tuple[str, str, Any, Any, Any]:
|
||
"""Log one failed API attempt: the ``API call failed`` warning plus the buffered
|
||
retry trace (provider/endpoint/error/4xx body/elapsed), the OpenRouter
|
||
"no tool endpoints" hint and the bare-404 missing-vendor-prefix hint (#78796).
|
||
The buffer only surfaces if every retry+fallback exhausts.
|
||
|
||
Returns ``(error_type, error_msg, provider, base_url, model)`` — the loop's
|
||
``error_type`` / ``error_msg`` / ``_provider`` / ``_base`` / ``_model`` locals."""
|
||
error_type = type(api_error).__name__
|
||
error_msg = str(api_error).lower()
|
||
_error_summary = agent._summarize_api_error(api_error)
|
||
logger.warning(
|
||
"API call failed (attempt %s/%s) error_type=%s %s summary=%s",
|
||
retry_count,
|
||
max_retries,
|
||
error_type,
|
||
agent._client_log_context(),
|
||
_error_summary,
|
||
)
|
||
|
||
_provider = getattr(agent, "provider", "unknown")
|
||
_base = getattr(agent, "base_url", "unknown")
|
||
_model = getattr(agent, "model", "unknown")
|
||
_status_code_str = f" [HTTP {status_code}]" if status_code else ""
|
||
agent._buffer_vprint(f"⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}")
|
||
agent._buffer_vprint(f" 🔌 Provider: {_provider} Model: {_model}")
|
||
agent._buffer_vprint(f" 🌐 Endpoint: {_base}")
|
||
agent._buffer_vprint(f" 📝 Error: {_error_summary}")
|
||
if status_code and status_code < 500:
|
||
_err_body = getattr(api_error, "body", None)
|
||
_err_body_str = str(_err_body)[:300] if _err_body else None
|
||
if _err_body_str:
|
||
agent._buffer_vprint(f" 📋 Details: {_err_body_str}")
|
||
agent._buffer_vprint(f" ⏱️ Elapsed: {elapsed_time:.2f}s Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens")
|
||
|
||
# OpenRouter "no tool endpoints" hint, buffered with the retry trace
|
||
# so it only surfaces if every retry+fallback exhausts.
|
||
if (
|
||
agent._is_openrouter_url() and "support tool use" in error_msg
|
||
):
|
||
agent._buffer_vprint(
|
||
f" 💡 No OpenRouter providers for {_model} support tool calling with your current settings."
|
||
)
|
||
if agent.providers_allowed:
|
||
agent._buffer_vprint(
|
||
" Your provider_routing.only restriction is filtering out tool-capable providers."
|
||
)
|
||
agent._buffer_vprint(
|
||
" Try removing the restriction or adding providers that support tools for this model."
|
||
)
|
||
agent._buffer_vprint(
|
||
f" Check which providers support tools: https://openrouter.ai/models/{_model}"
|
||
)
|
||
|
||
# Bare 404 on a ``vendor/model`` catalogue usually means the id lost its
|
||
# prefix; the provider never names the model, so we do (#78796).
|
||
if getattr(api_error, "status_code", None) == 404:
|
||
try:
|
||
from hermes_cli.model_normalize import suggest_prefixed_model_id
|
||
|
||
_suggestion = suggest_prefixed_model_id(_provider, _model)
|
||
except Exception:
|
||
_suggestion = None
|
||
if _suggestion:
|
||
agent._buffer_vprint(
|
||
f" 💡 Model '{_model}' is not a valid id for provider {_provider} — "
|
||
f"it is missing its vendor prefix."
|
||
)
|
||
agent._buffer_vprint(
|
||
f" Did you mean '{_suggestion}'? Re-pick it with `hermes model`."
|
||
)
|
||
return error_type, error_msg, _provider, _base, _model
|
||
|
||
|
||
def interruptible_backoff_sleep(
|
||
agent: Any, wait_time: float, _retry: Optional[TurnRetryState], *,
|
||
messages: List[Dict[str, Any]], conversation_history: Any, api_call_count: int,
|
||
abort_message: str, interrupt_text: str, activity_label: str,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Sleep ``wait_time`` in 200 ms slices so interrupts are honoured promptly, touching
|
||
activity every ~30 s so the gateway's inactivity monitor knows we are alive.
|
||
|
||
On interrupt: when ``_retry`` is given and a redirect is pending, preserve it
|
||
(``clear_interrupt(preserve_redirect=True)``), set
|
||
``_retry.restart_with_redirected_messages`` and return ``None`` — the caller
|
||
rebuilds the turn from the correction. Otherwise close any open tool sequence,
|
||
persist, clear the interrupt and return the ``interrupted`` result dict.
|
||
Returns ``None`` when the wait completed."""
|
||
sleep_end = time.time() + wait_time
|
||
_touch_counter = 0
|
||
while time.time() < sleep_end:
|
||
if agent._interrupt_requested:
|
||
if _retry is not None and agent.clear_interrupt(preserve_redirect=True):
|
||
_retry.restart_with_redirected_messages = True
|
||
return None
|
||
agent._vprint(f"{agent.log_prefix}⚡ {abort_message}", force=True)
|
||
close_interrupted_tool_sequence(messages, interrupt_text)
|
||
agent._persist_session(messages, conversation_history)
|
||
agent.clear_interrupt()
|
||
return {
|
||
"final_response": interrupt_text,
|
||
"messages": messages,
|
||
"api_calls": api_call_count,
|
||
"completed": False,
|
||
"interrupted": True,
|
||
}
|
||
time.sleep(0.2)
|
||
_touch_counter += 1
|
||
if _touch_counter % 150 == 0: # 150 × 0.2s = 30s
|
||
agent._touch_activity(
|
||
f"{activity_label}, {int(sleep_end - time.time())}s remaining"
|
||
)
|
||
return None
|
||
|
||
|
||
def compute_error_backoff(
|
||
agent: Any, api_error: Exception, *, retry_count: int, max_retries: int, is_rate_limited: bool,
|
||
is_zai_coding_overload: bool, base_url: Any, model: Any,
|
||
) -> float:
|
||
"""Pick the wait before the next API retry and announce it.
|
||
|
||
Retry-After header wins for rate limits (capped at 600s: Anthropic Tier 1 buckets
|
||
reset in ~171s, so a 120s cap retried early and re-tripped the limit, #26293);
|
||
otherwise jittered exponential backoff, replaced by the adaptive rate-limit policy
|
||
for 429s / Z.AI overloads. Normal retries are buffered to avoid chatter; long Z.AI
|
||
Coding waits can last minutes, so those surface immediately."""
|
||
# Resolved through the loop module so tests that patch
|
||
# ``agent.conversation_loop.jittered_backoff`` / ``adaptive_rate_limit_backoff``
|
||
# (incl. the run_agent conftest fast-backoff fixture) keep intercepting.
|
||
from agent.conversation_loop import adaptive_rate_limit_backoff, jittered_backoff
|
||
|
||
_retry_after = None
|
||
if is_rate_limited:
|
||
_resp_headers = getattr(getattr(api_error, "response", None), "headers", None)
|
||
if _resp_headers and hasattr(_resp_headers, "get"):
|
||
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
|
||
if _ra_raw:
|
||
try:
|
||
_retry_after = min(float(_ra_raw), 600)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||
_backoff_policy = None
|
||
if (is_rate_limited or is_zai_coding_overload) and not _retry_after:
|
||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||
retry_count, base_url=str(base_url), model=model, error=api_error,
|
||
default_wait=wait_time,
|
||
)
|
||
if is_rate_limited or is_zai_coding_overload:
|
||
_policy_note = ""
|
||
if _backoff_policy == "zai_coding_overload_long":
|
||
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
|
||
elif _backoff_policy == "zai_coding_overload_short":
|
||
_policy_note = " (Z.AI Coding overload short retry)"
|
||
_wait_reason = "Provider overloaded" if is_zai_coding_overload and not is_rate_limited else "Rate limited"
|
||
_rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||
if _backoff_policy == "zai_coding_overload_long":
|
||
agent._emit_status(_rate_limit_status)
|
||
else:
|
||
agent._buffer_status(_rate_limit_status)
|
||
else:
|
||
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||
logger.warning(
|
||
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
|
||
wait_time,
|
||
retry_count,
|
||
max_retries,
|
||
agent._client_log_context(),
|
||
_backoff_policy or "default",
|
||
api_error,
|
||
)
|
||
return wait_time
|
||
|
||
|
||
def validate_response_shape(agent: Any, response: Any) -> Tuple[bool, List[str]]:
|
||
"""Validate the raw provider response per api_mode via the transport's
|
||
``validate_response``. Returns ``(response_invalid, error_details)``; a Codex
|
||
``failed``/``cancelled`` status (terminal provider failure, e.g. quota exhaustion)
|
||
is treated as invalid so the fallback chain triggers, while an empty Codex
|
||
``output`` with a non-empty ``output_text`` is deferred to normalization."""
|
||
response_invalid = False
|
||
error_details = []
|
||
if agent.api_mode == "codex_responses":
|
||
_ct_v = agent._get_transport()
|
||
if not _ct_v.validate_response(response):
|
||
if response is None:
|
||
response_invalid = True
|
||
error_details.append("response is None")
|
||
else:
|
||
# Terminal provider failure (e.g. quota exhaustion): treat
|
||
# as invalid so the fallback chain triggers.
|
||
_codex_resp_status = str(getattr(response, "status", "") or "").strip().lower()
|
||
if _codex_resp_status in {"failed", "cancelled"}:
|
||
_codex_error_obj = getattr(response, "error", None)
|
||
_codex_error_msg = (
|
||
_codex_error_obj.get("message") if isinstance(_codex_error_obj, dict)
|
||
else str(_codex_error_obj) if _codex_error_obj
|
||
else f"Responses API returned status '{_codex_resp_status}'"
|
||
)
|
||
logger.warning(
|
||
"Codex response status='%s' (error=%s). Routing to fallback. %s",
|
||
_codex_resp_status, _codex_error_msg,
|
||
agent._client_log_context(),
|
||
)
|
||
response_invalid = True
|
||
error_details.append(f"response.status={_codex_resp_status}: {_codex_error_msg}")
|
||
else:
|
||
# output_text fallback: stream backfill may have failed
|
||
# but normalize can still recover from output_text
|
||
_out_text = getattr(response, "output_text", None)
|
||
_out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else ""
|
||
if _out_text_stripped:
|
||
logger.debug(
|
||
"Codex response.output is empty but output_text is present "
|
||
"(%d chars); deferring to normalization.",
|
||
len(_out_text_stripped),
|
||
)
|
||
else:
|
||
_resp_status = getattr(response, "status", None)
|
||
_resp_incomplete = getattr(response, "incomplete_details", None)
|
||
logger.warning(
|
||
"Codex response.output is empty after stream backfill "
|
||
"(status=%s, incomplete_details=%s, model=%s). %s",
|
||
_resp_status, _resp_incomplete,
|
||
getattr(response, "model", None),
|
||
f"api_mode={agent.api_mode} provider={agent.provider}",
|
||
)
|
||
response_invalid = True
|
||
error_details.append("response.output is empty")
|
||
elif agent.api_mode == "anthropic_messages":
|
||
_tv = agent._get_transport()
|
||
if not _tv.validate_response(response):
|
||
response_invalid = True
|
||
if response is None:
|
||
error_details.append("response is None")
|
||
else:
|
||
error_details.append("response.content invalid (not a non-empty list)")
|
||
elif agent.api_mode == "bedrock_converse":
|
||
_btv = agent._get_transport()
|
||
if not _btv.validate_response(response):
|
||
response_invalid = True
|
||
if response is None:
|
||
error_details.append("response is None")
|
||
else:
|
||
error_details.append("Bedrock response invalid (no output or choices)")
|
||
else:
|
||
_ctv = agent._get_transport()
|
||
if not _ctv.validate_response(response):
|
||
response_invalid = True
|
||
if response is None:
|
||
error_details.append("response is None")
|
||
elif not hasattr(response, 'choices'):
|
||
error_details.append("response has no 'choices' attribute")
|
||
elif response.choices is None:
|
||
error_details.append("response.choices is None")
|
||
else:
|
||
error_details.append("response.choices is empty")
|
||
return response_invalid, error_details
|
||
|
||
|
||
def describe_invalid_response(agent: Any, response: Any, api_duration: float) -> Tuple[str, str, str]:
|
||
"""Diagnostics for an empty/malformed response: ``(error_msg, provider_name,
|
||
failure_hint)``. The hint is derived from the provider error code (524/504/429/
|
||
5xx) and the response time, instead of always assuming rate limiting."""
|
||
# Check for error field in response (some providers include this)
|
||
error_msg = "Unknown"
|
||
provider_name = "Unknown"
|
||
if response and hasattr(response, 'error') and response.error:
|
||
error_msg = str(response.error)
|
||
# Try to extract provider from error metadata
|
||
if hasattr(response.error, 'metadata') and response.error.metadata:
|
||
provider_name = response.error.metadata.get('provider_name', 'Unknown')
|
||
elif response and hasattr(response, 'message') and response.message:
|
||
error_msg = str(response.message)
|
||
|
||
# Try to get provider from model field (OpenRouter often returns actual model used)
|
||
if provider_name == "Unknown" and response and hasattr(response, 'model') and response.model:
|
||
provider_name = f"model={response.model}"
|
||
|
||
# Check for x-openrouter-provider or similar metadata
|
||
if provider_name == "Unknown" and response:
|
||
# Log all response attributes for debugging
|
||
resp_attrs = {k: str(v)[:100] for k, v in vars(response).items() if not k.startswith('_')}
|
||
if agent.verbose_logging:
|
||
logging.debug(f"Response attributes for invalid response: {resp_attrs}")
|
||
|
||
# Extract error code from response for contextual diagnostics
|
||
_resp_error_code = None
|
||
if response and hasattr(response, 'error') and response.error:
|
||
_code_raw = getattr(response.error, 'code', None)
|
||
if _code_raw is None and isinstance(response.error, dict):
|
||
_code_raw = response.error.get('code')
|
||
if _code_raw is not None:
|
||
try:
|
||
_resp_error_code = int(_code_raw)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
# Build a human-readable failure hint from the error code
|
||
# and response time, instead of always assuming rate limiting.
|
||
if _resp_error_code == 524:
|
||
_failure_hint = f"upstream provider timed out (Cloudflare 524, {api_duration:.0f}s)"
|
||
elif _resp_error_code == 504:
|
||
_failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)"
|
||
elif _resp_error_code == 429:
|
||
_failure_hint = "rate limited by upstream provider (429)"
|
||
elif _resp_error_code in {500, 502}:
|
||
_failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)"
|
||
elif _resp_error_code in {503, 529}:
|
||
_failure_hint = f"upstream provider overloaded ({_resp_error_code})"
|
||
elif _resp_error_code is not None:
|
||
_failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)"
|
||
elif api_duration < 10:
|
||
_failure_hint = f"fast response ({api_duration:.1f}s) — likely rate limited"
|
||
elif api_duration > 60:
|
||
_failure_hint = f"slow response ({api_duration:.0f}s) — likely upstream timeout"
|
||
else:
|
||
_failure_hint = f"response time {api_duration:.1f}s"
|
||
return error_msg, provider_name, _failure_hint
|
||
|
||
|
||
@dataclass
|
||
class ClassifiedErrorVerdict:
|
||
"""Outcome of ``route_classified_error``.
|
||
|
||
``action``: ``"return"`` (terminal result), ``"break"`` (restart armed on
|
||
``_retry``), ``"continue"`` (re-enter the retry loop; Nous guard re-check) or
|
||
``"fallthrough"`` (proceed to overflow / client-error / backoff handling). The
|
||
remaining fields are loop locals the router may have rebound or computed for the
|
||
later steps (``is_rate_limited``, ``wrapped_output_cap_budget``,
|
||
``is_zai_coding_overload`` feed the overflow entry, client-error gate and backoff)."""
|
||
|
||
action: str
|
||
result: Optional[Dict[str, Any]]
|
||
status_code: Optional[int]
|
||
messages: List[Dict[str, Any]]
|
||
active_system_prompt: Any
|
||
conversation_history: Any
|
||
retry_count: int
|
||
max_retries: int
|
||
compression_attempts: int
|
||
provider_overflow_recovery_pending: bool
|
||
is_rate_limited: bool
|
||
wrapped_output_cap_budget: Optional[int]
|
||
is_zai_coding_overload: bool
|
||
|
||
|
||
def route_classified_error(
|
||
agent: Any, api_error: Exception, classified: Any, _retry: TurnRetryState, *, error_msg: str,
|
||
error_context: Any, recovered_with_pool: bool, base_url: Any, model: Any,
|
||
messages: List[Dict[str, Any]], api_messages: Any, system_message: Any,
|
||
active_system_prompt: Any, conversation_history: Any, retry_count: int, max_retries: int,
|
||
compression_attempts: int, max_compression_attempts: int, api_call_count: int,
|
||
effective_task_id: Any,
|
||
) -> ClassifiedErrorVerdict:
|
||
"""Ordered recovery steps between classification logging and overflow handling:
|
||
compaction-disabled overflow → terminal error (output-cap errors exempt);
|
||
Anthropic long-context tier 429 → cap at 200k and compress; eager fallback for
|
||
rate-limit/billing (immediately) and transport failures (after 1 retry), unless
|
||
credential-pool rotation may still recover (#11314; upstream-aggregator 429s always
|
||
fall back); persistent 401/403 → fallback chain once; genuine Nous 429 → record to
|
||
the cross-session breaker and re-enter the loop exactly once so the top-of-loop guard
|
||
runs. Order is load-bearing."""
|
||
from agent.conversation_loop import (
|
||
_arm_fallback_restart, _ra, conversation_history_after_compression,
|
||
estimate_request_tokens_rough,
|
||
)
|
||
|
||
_base = base_url
|
||
_model = model
|
||
_provider_overflow_recovery_pending = False
|
||
is_rate_limited = False
|
||
_wrapped_output_cap_budget = None
|
||
_is_zai_coding_overload = False
|
||
|
||
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ClassifiedErrorVerdict:
|
||
return ClassifiedErrorVerdict(
|
||
action=action, result=result, status_code=status_code, messages=messages,
|
||
active_system_prompt=active_system_prompt, conversation_history=conversation_history,
|
||
retry_count=retry_count, max_retries=max_retries,
|
||
compression_attempts=compression_attempts,
|
||
provider_overflow_recovery_pending=_provider_overflow_recovery_pending,
|
||
is_rate_limited=is_rate_limited, wrapped_output_cap_budget=_wrapped_output_cap_budget,
|
||
is_zai_coding_overload=_is_zai_coding_overload,
|
||
)
|
||
|
||
# Check 413 BEFORE the generic 4xx handler: compress + retry, not abort.
|
||
status_code = getattr(api_error, "status_code", None)
|
||
|
||
# ── Respect disabled auto-compaction on overflow ──────
|
||
# ``compression.enabled: false`` forbids every automatic trigger, incl.
|
||
# these overflow recovery paths; error out. Output-cap errors exempt.
|
||
_overflow_reasons = {
|
||
FailoverReason.long_context_tier, FailoverReason.payload_too_large,
|
||
FailoverReason.context_overflow,
|
||
}
|
||
_is_output_cap_error = (
|
||
is_output_cap_error(error_msg)
|
||
or parse_available_output_tokens_from_error(error_msg) is not None
|
||
)
|
||
if (
|
||
classified.reason in _overflow_reasons
|
||
and not getattr(agent, "compression_enabled", True)
|
||
and not _is_output_cap_error
|
||
):
|
||
agent._flush_status_buffer()
|
||
agent._vprint(
|
||
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
|
||
f"(compression.enabled: false).",
|
||
force=True,
|
||
)
|
||
agent._vprint(
|
||
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
|
||
f"switch to a larger-context model, or reduce attachments.",
|
||
force=True,
|
||
)
|
||
logger.error(
|
||
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
|
||
f"auto-compaction disabled — not compressing."
|
||
)
|
||
agent._persist_session(messages, conversation_history)
|
||
_final_response = (
|
||
"Context overflow and auto-compaction is disabled "
|
||
"(compression.enabled: false). Run /compress to compact manually, "
|
||
"/new to start fresh, or switch to a larger-context model."
|
||
)
|
||
return _verdict("return", {
|
||
"final_response": _final_response,
|
||
"messages": messages,
|
||
"completed": False,
|
||
"api_calls": api_call_count,
|
||
"error": _final_response,
|
||
"partial": True,
|
||
"failed": True,
|
||
"compaction_disabled": True,
|
||
})
|
||
|
||
# ── Anthropic Sonnet long-context tier gate ───────────
|
||
# 429 "Extra usage is required for long context requests" is a
|
||
# subscription-tier limit, not transient: cap at 200k and compress.
|
||
if classified.reason == FailoverReason.long_context_tier:
|
||
_reduced_ctx = 200000
|
||
compressor = agent.context_compressor
|
||
old_ctx = compressor.context_length
|
||
if old_ctx > _reduced_ctx:
|
||
compressor.update_model(
|
||
model=agent.model, context_length=_reduced_ctx, base_url=agent.base_url,
|
||
api_key=getattr(agent, "api_key", ""), provider=agent.provider,
|
||
api_mode=agent.api_mode,
|
||
)
|
||
# Context probing flags — only set on built-in
|
||
# compressor (plugin engines manage their own).
|
||
if hasattr(compressor, "_context_probed"):
|
||
compressor._context_probed = True
|
||
# Don't persist — subscription-tier limit, not a model
|
||
# capability; 1M should return if extra usage is enabled.
|
||
compressor._context_probe_persistable = False
|
||
agent._buffer_vprint(
|
||
f"⚠️ Anthropic long-context tier "
|
||
f"requires extra usage — reducing context: "
|
||
f"{old_ctx:,} → {_reduced_ctx:,} tokens"
|
||
)
|
||
|
||
compression_attempts += 1
|
||
if compression_attempts <= max_compression_attempts:
|
||
original_len = len(messages)
|
||
# Option A (LCM issue 441): overhead-aware request size so recovery arms on
|
||
# the true request (msgs + tools + system), not the tool-blind message count.
|
||
messages, active_system_prompt = agent._compress_context(
|
||
messages, system_message,
|
||
approx_tokens=estimate_request_tokens_rough(api_messages, tools=agent.tools or None),
|
||
task_id=effective_task_id,
|
||
)
|
||
conversation_history = conversation_history_after_compression(
|
||
agent, messages, conversation_history
|
||
)
|
||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||
agent._buffer_status(
|
||
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
|
||
new_ctx=_reduced_ctx, old_ctx=old_ctx
|
||
)
|
||
)
|
||
time.sleep(2)
|
||
# Provider proved the request doesn't fit the reduced
|
||
# window; row count isn't proof the rebuilt one does.
|
||
# Recheck the complete request before the next call.
|
||
_provider_overflow_recovery_pending = True
|
||
_retry.restart_with_compressed_messages = True
|
||
return _verdict("break")
|
||
# Fall through to normal error handling if compression
|
||
# is exhausted or didn't help.
|
||
|
||
# Eager fallback: rate-limit/billing switch immediately (primary won't
|
||
# recover in the retry window); transport errors get 1 retry first.
|
||
is_rate_limited = classified.reason in {
|
||
FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit
|
||
}
|
||
# Some relays wrap upstream output-cap 400s as 429 (rate_limit). Only
|
||
# the max_tokens clamp fixes it (#72281). Parsed once; gates the
|
||
# eager-fallback exemption and the overflow entry below.
|
||
_wrapped_output_cap_budget = (
|
||
parse_available_output_tokens_from_error(error_msg)
|
||
if classified.reason == FailoverReason.rate_limit
|
||
else None
|
||
)
|
||
_is_transport_failure = classified.reason in {
|
||
FailoverReason.timeout, FailoverReason.overloaded
|
||
}
|
||
# Z.AI overload 429s classify `overloaded`, which `is_rate_limited`
|
||
# excludes. Detect directly so the long backoff runs, and raise the
|
||
# ceiling to reach it (see zai_coding_overload_retry_ceiling()).
|
||
_is_zai_coding_overload = is_zai_coding_overload_error(
|
||
base_url=str(_base), model=_model, error=api_error
|
||
)
|
||
if _is_zai_coding_overload:
|
||
max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
|
||
_should_fallback = (
|
||
(is_rate_limited and _wrapped_output_cap_budget is None)
|
||
or (_is_transport_failure and retry_count >= 2)
|
||
)
|
||
if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
|
||
# No eager fallback while credential pool rotation may recover
|
||
# (_pool_may_recover_from_rate_limit, #11314). Exception: an
|
||
# upstream-aggregator 429 — the pool can't help, always fall back.
|
||
_is_upstream = classified.reason == FailoverReason.upstream_rate_limit
|
||
pool_may_recover = (
|
||
False if _is_upstream
|
||
else _ra()._pool_may_recover_from_rate_limit(
|
||
agent._credential_pool,
|
||
)
|
||
)
|
||
if not pool_may_recover:
|
||
if _is_upstream:
|
||
_upstream_name = (classified.error_context or {}).get(
|
||
"upstream_provider", "aggregator"
|
||
)
|
||
agent._buffer_status(
|
||
f"⚠️ Upstream {_upstream_name} rate-limited — " "switching to fallback model..."
|
||
)
|
||
elif classified.reason == FailoverReason.billing:
|
||
if classified.billing_unverified:
|
||
# Ambiguous body (#82154) — don't assert billing.
|
||
agent._buffer_status(
|
||
"⚠️ Provider reported usage/credit exhaustion "
|
||
"(unverified — may be a content-filter rejection) "
|
||
"— switching to fallback provider..."
|
||
)
|
||
else:
|
||
agent._buffer_status(
|
||
"⚠️ Billing or credits exhausted — switching to fallback provider..."
|
||
)
|
||
elif _is_transport_failure:
|
||
agent._buffer_status(
|
||
"⚠️ Provider unreachable — switching to fallback provider..."
|
||
)
|
||
else:
|
||
agent._buffer_status("⚠️ Rate limited — switching to fallback provider...")
|
||
if agent._try_activate_fallback(reason=classified.reason):
|
||
active_system_prompt = _arm_fallback_restart(
|
||
agent, api_messages, active_system_prompt, _retry)
|
||
retry_count = 0
|
||
compression_attempts = 0
|
||
return _verdict("break")
|
||
|
||
# ── Auth-failure provider failover ───────────────────────
|
||
# A 401/403 surviving credential refresh means a broken credential or
|
||
# endpoint: escalate to the fallback chain; False -> terminal handling.
|
||
if (
|
||
classified.is_auth
|
||
and not _retry.auth_failover_attempted
|
||
and agent._fallback_index < len(agent._fallback_chain)
|
||
):
|
||
_retry.auth_failover_attempted = True
|
||
agent._buffer_status(
|
||
"🔐 Authentication failed and could not be refreshed — "
|
||
"switching to fallback provider..."
|
||
)
|
||
if agent._try_activate_fallback(reason=classified.reason):
|
||
active_system_prompt = _arm_fallback_restart(
|
||
agent, api_messages, active_system_prompt, _retry)
|
||
retry_count = 0
|
||
compression_attempts = 0
|
||
return _verdict("break")
|
||
|
||
# ── Nous Portal: record rate limit & skip retries ─────
|
||
# A genuine account-level 429 is recorded to a shared file so ALL
|
||
# sessions back off; is_genuine_nous_rate_limit excludes upstream 429s.
|
||
if (
|
||
is_rate_limited
|
||
and agent.provider == "nous"
|
||
and classified.reason == FailoverReason.rate_limit
|
||
and not recovered_with_pool
|
||
):
|
||
_genuine_nous_rate_limit = False
|
||
try:
|
||
from agent.nous_rate_guard import (
|
||
is_genuine_nous_rate_limit, record_nous_rate_limit
|
||
)
|
||
_err_resp = getattr(api_error, "response", None)
|
||
_err_hdrs = (
|
||
getattr(_err_resp, "headers", None) if _err_resp else None
|
||
)
|
||
_genuine_nous_rate_limit = is_genuine_nous_rate_limit(
|
||
headers=_err_hdrs, last_known_state=agent._rate_limit_state
|
||
)
|
||
if _genuine_nous_rate_limit:
|
||
record_nous_rate_limit(
|
||
headers=_err_hdrs, error_context=error_context
|
||
)
|
||
else:
|
||
logger.info(
|
||
"Nous 429 looks like upstream capacity "
|
||
"(no exhausted bucket in headers or "
|
||
"last-known state) -- not tripping "
|
||
"cross-session breaker."
|
||
)
|
||
except Exception:
|
||
pass
|
||
if _genuine_nous_rate_limit:
|
||
# Re-enter the loop exactly once so the top-of-loop Nous guard
|
||
# runs (retry_count = max_retries would skip it entirely).
|
||
retry_count = max(0, max_retries - 1)
|
||
return _verdict("continue")
|
||
# Upstream capacity 429: normal retry logic will typically succeed.
|
||
return _verdict("fallthrough")
|