From ebd4b1002bce643ff0218ff40b25377215ccd1ed Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:14:19 -0700 Subject: [PATCH 01/16] refactor(agent/error_classifier): stage pipeline + status dispatch table, verdict dicts, 0 fuzz mismatches --- agent/error_classifier.py | 1741 ++++++++++++++++--------------------- 1 file changed, 733 insertions(+), 1008 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index fbb8a7fadc..0b0bfe17db 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -11,7 +11,7 @@ import enum import json import logging from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Iterator, Optional, Sequence logger = logging.getLogger(__name__) @@ -59,8 +59,6 @@ class FailoverReason(enum.Enum): unknown = "unknown" # Unclassifiable — retry with backoff -# ── Classification result ─────────────────────────────────────────────── - @dataclass class ClassifiedError: """Structured classification of an API error with recovery hints.""" @@ -88,32 +86,19 @@ class ClassifiedError: return bool(self.error_context.get("billing_unverified")) -# ── Provider-specific patterns ────────────────────────────────────────── +# ── Pattern tables (lowercased substrings) ────────────────────────────── # Billing exhaustion (not transient rate limit). -_BILLING_PATTERNS = [ - "insufficient credits", - "insufficient_quota", - "insufficient balance", - "credit balance", - "credits exhausted", - "credits have been exhausted", - "requires available credits", - "account balance is too low", - "no usable credits", - "top up your credits", - "payment required", - "billing hard limit", - "exceeded your current quota", - "account is deactivated", +_BILLING_PATTERNS = ( + "insufficient credits", "insufficient_quota", "insufficient balance", "credit balance", + "credits exhausted", "credits have been exhausted", "requires available credits", + "account balance is too low", "no usable credits", "top up your credits", "payment required", + "billing hard limit", "exceeded your current quota", "account is deactivated", "plan does not include", "out of extra usage", # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400) - "out of funds", - "run out of funds", - "balance_depleted", - "model_not_supported_on_free_tier", - "not available on the free tier", -] + "out of funds", "run out of funds", "balance_depleted", + "model_not_supported_on_free_tier", "not available on the free tier", +) # Billing matches that are NOT proof of exhaustion: Anthropic returns the same # "out of extra usage" body for a content-filter rejection (#82154). Verdict @@ -121,14 +106,6 @@ _BILLING_PATTERNS = [ # and the credential pool uses a short cooldown instead of the 1h bench. _UNVERIFIED_BILLING_PATTERNS = ("out of extra usage",) - -def _billing_ambiguity_context(error_msg: str) -> Dict[str, Any]: - """error_context marking a billing verdict as unverified (see above).""" - if any(p in error_msg for p in _UNVERIFIED_BILLING_PATTERNS): - return {"billing_unverified": True, "possible_content_filter": True} - return {} - - # xAI's explicit Grok credit-exhaustion code, returned as HTTP 403 rather than # 402. The 403 special case stays provider-scoped: other providers' billing # codes on a 403 remain auth failures. @@ -136,192 +113,766 @@ _XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit" # Structured codes meaning the account cannot serve paid traffic. _BILLING_ERROR_CODES = frozenset({ - "insufficient_quota", - "billing_not_active", - "payment_required", - "insufficient_credits", - "no_usable_credits", - "balance_depleted", - "model_not_supported_on_free_tier", - "member_spend_cap_exceeded", - _XAI_SPENDING_LIMIT_ERROR_CODE, + "insufficient_quota", "billing_not_active", "payment_required", "insufficient_credits", + "no_usable_credits", "balance_depleted", "model_not_supported_on_free_tier", + "member_spend_cap_exceeded", _XAI_SPENDING_LIMIT_ERROR_CODE, }) -# Rate limiting (transient, will resolve). -_RATE_LIMIT_PATTERNS = [ - "rate limit", - "rate_limit", - "too many requests", - "throttled", - "requests per minute", - "tokens per minute", - "requests per day", - "try again in", - "please retry after", +# Rate limiting (transient, will resolve). Bedrock "Throttling error: Too many +# tokens..." also contains the overflow phrase "too many tokens"; rate limit is +# matched first so throttle wins. +_RATE_LIMIT_PATTERNS = ( + "rate limit", "rate_limit", "too many requests", "throttled", "requests per minute", + "tokens per minute", "requests per day", "try again in", "please retry after", "resource_exhausted", "rate increased too quickly", # Alibaba/DashScope throttling - # AWS Bedrock throttling - "throttlingexception", - "too many concurrent requests", - "servicequotaexceededexception", - # Bedrock "Throttling error: Too many tokens..." also contains the overflow - # phrase "too many tokens"; rate limit is matched first so throttle wins. + "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", # AWS Bedrock "throttling", -] +) # Provider-side overload: the credential is valid, the server is busy, so back # off and retry the same key — never rotate. Some providers (Z.AI/Zhipu) reuse # HTTP 429 for this, so the 429 path checks these first. Kept narrow so a # normal "you have been rate-limited" doesn't land here. (#14038, #15297) -_OVERLOADED_PATTERNS = [ - "overloaded", - "temporarily overloaded", - "service is temporarily overloaded", - "service may be temporarily overloaded", - "server is overloaded", - "server overloaded", - "service overloaded", - "service is overloaded", - "upstream overloaded", - "currently overloaded", - "at capacity", - "over capacity", -] +_OVERLOADED_PATTERNS = ( + "overloaded", "temporarily overloaded", "service is temporarily overloaded", + "service may be temporarily overloaded", "server is overloaded", "server overloaded", + "service overloaded", "service is overloaded", "upstream overloaded", "currently overloaded", + "at capacity", "over capacity", +) -# Usage-limit patterns that need disambiguation (billing OR rate_limit). -_USAGE_LIMIT_PATTERNS = [ - "usage limit", - "quota", - "limit exceeded", - "key limit exceeded", -] - -# Signals that a usage limit is transient (periodic quota, not billing). -_USAGE_LIMIT_TRANSIENT_SIGNALS = [ - "try again", - "retry", - "resets at", - "reset in", - "resets in", - "reset after", - "available in", - "wait", - "requests remaining", - "periodic", - "window", - "per minute", - "per second", -] +# Usage-limit patterns that need disambiguation (billing OR rate_limit), and +# the signals that mark such a limit as transient (periodic quota, not billing). +_USAGE_LIMIT_PATTERNS = ("usage limit", "quota", "limit exceeded", "key limit exceeded") +_USAGE_LIMIT_TRANSIENT_SIGNALS = ( + "try again", "retry", "resets at", "reset in", "resets in", "reset after", "available in", + "wait", "requests remaining", "periodic", "window", "per minute", "per second", +) # Payload-too-large detected from message text (proxies embed the status). -_PAYLOAD_TOO_LARGE_PATTERNS = [ - "request entity too large", - "payload too large", - "error code: 413", +_PAYLOAD_TOO_LARGE_PATTERNS = ( + "request entity too large", "payload too large", "error code: 413", "request_too_large", # Anthropic's 413 type, re-wrapped by proxies without a status "request exceeds the maximum size", -] +) # Image-size rejections. Matched on 400 bodies (not 413): providers return a # specific 400 before the whole request hits the size limit (Anthropic: hard -# 5 MB per image, "image exceeds 5 MB maximum"). -_IMAGE_TOO_LARGE_PATTERNS = [ - "image exceeds", # Anthropic: "image exceeds 5 MB maximum" - "image too large", # generic - "image_too_large", # error_code variant - "image size exceeds", # variant - "image dimensions exceed", # Anthropic: "image dimensions exceed max allowed size: 8000 pixels" - "dimensions exceed max allowed size", # Anthropic dimension-cap (wording variant) - "max allowed size: 8000", # Anthropic dimension-cap (explicit pixel ceiling) - # MiniMax Anthropic-compat: "media exceeds size limit: max 10485760 bytes" - # (#76039). A non-image media rejection landing here is harmless: the - # shrink pass finds no image parts and the original error surfaces. - "media exceeds", - "media too large", -] +# 5 MB per image, "image exceeds 5 MB maximum"; dimension cap 8000 px). +# MiniMax Anthropic-compat says "media exceeds size limit" (#76039); a +# non-image media rejection landing here is harmless: the shrink pass finds +# no image parts and the original error surfaces. +_IMAGE_TOO_LARGE_PATTERNS = ( + "image exceeds", "image too large", "image_too_large", "image size exceeds", + "image dimensions exceed", "dimensions exceed max allowed size", "max allowed size: 8000", + "media exceeds", "media too large", +) # Image bytes undecodable (e.g. re-serialized history lost data). Shrinking # can't fix corruption, so these route to strip-and-retry, never shrink. # xAI wordings (#69078); the last is the full sentence on purpose — shorter # fragments also match non-image download failures. -_IMAGE_CORRUPT_PATTERNS = [ - "invalid png image", - "invalid jpeg image", - "base64 string of provided image cannot be decoded", +_IMAGE_CORRUPT_PATTERNS = ( + "invalid png image", "invalid jpeg image", "base64 string of provided image cannot be decoded", "downloaded response does not contain a valid jpg, png, webp, or ico image", -] +) # Providers that reject list-type ``content`` in tool messages with a 400 -# (Xiaomi MiMo, some Alibaba endpoints, OpenAI-compat long tail). Recovery: -# strip image parts from tool messages, remember (provider, model), retry. (#27344) -_MULTIMODAL_TOOL_CONTENT_PATTERNS = [ - # Xiaomi MiMo: {"error":{"code":"400","message":"Param Incorrect","param":"text is not set"}} - "text is not set", - "tool message content must be a string", - "tool content must be a string", - "tool message must be a string", - # OpenAI-compat schema-validation shapes - "expected string, got list", - "expected string, got array", - # Alibaba/DashScope variant +# (Xiaomi MiMo "Param Incorrect ... text is not set", some Alibaba endpoints, +# OpenAI-compat long tail). Recovery: strip image parts from tool messages, +# remember (provider, model), retry. (#27344) +_MULTIMODAL_TOOL_CONTENT_PATTERNS = ( + "text is not set", "tool message content must be a string", "tool content must be a string", + "tool message must be a string", "expected string, got list", "expected string, got array", "tool_call.content must be string", -] +) -_CONTEXT_OVERFLOW_PATTERNS = [ - "context length", - "context size", - "maximum context", - "token limit", - "too many tokens", - "reduce the length", - "exceeds the limit", - "context window", - "prompt is too long", - "prompt exceeds max length", - # Bare "max_tokens" is load-bearing: the output-cap-retry path keys off it. - # Empty-response advisories mentioning it are intercepted earlier by - # _EMPTY_PROVIDER_RESPONSE_PATTERNS, so they never route into compression. - "max_tokens", - "maximum number of tokens", - # vLLM / local inference server patterns - "exceeds the max_model_len", - "max_model_len", - "prompt length", # "engine prompt length X exceeds" - "input is too long", +# Bare "max_tokens" is load-bearing: the output-cap-retry path keys off it. +# Empty-response advisories mentioning it are intercepted earlier by +# _EMPTY_PROVIDER_RESPONSE_PATTERNS, so they never route into compression. +_CONTEXT_OVERFLOW_PATTERNS = ( + "context length", "context size", "maximum context", "token limit", "too many tokens", + "reduce the length", "exceeds the limit", "context window", "prompt is too long", + "prompt exceeds max length", "max_tokens", "maximum number of tokens", + # vLLM / local inference servers + "exceeds the max_model_len", "max_model_len", "prompt length", "input is too long", "maximum model length", - # Ollama patterns - "context length exceeded", - "truncating input", - # llama.cpp / llama-server patterns - "slot context", # "slot context: N tokens, prompt N tokens" - "n_ctx_slot", + # Ollama + "context length exceeded", "truncating input", + # llama.cpp / llama-server ("slot context: N tokens, prompt N tokens") + "slot context", "n_ctx_slot", # Chinese error messages (some providers return these) - "超过最大长度", - "上下文长度", - # Z.AI / Zhipu GLM pattern (English form; error code 1210) + "超过最大长度", "上下文长度", + # Z.AI / Zhipu GLM (English form; error code 1210) "tokens in request more than max tokens allowed", - # AWS Bedrock Converse API error patterns - "input is too long", - "max input token", - "input token", - "exceeds the maximum number of input tokens", + # AWS Bedrock Converse API + "input is too long", "max input token", "input token", "exceeds the maximum number of input tokens", # Together/Fireworks: "Input length N exceeds the maximum allowed input length of M tokens." "maximum allowed input length", -] +) -_MODEL_NOT_FOUND_PATTERNS = [ - "is not a valid model", - "invalid model", - "model not found", - "model_not_found", - "does not exist", - "no such model", - "unknown model", - "unsupported model", +_MODEL_NOT_FOUND_PATTERNS = ( + "is not a valid model", "invalid model", "model not found", "model_not_found", "does not exist", + "no such model", "unknown model", "unsupported model", # OpenRouter 404 when no endpoint for the model supports tool calling; # model_not_found triggers fallback instead of burning retries (#58446). "no endpoints found that support tool use", -] +) + +# Qwen/vLLM chat-template raise_exception("No user query found in messages"). +# Shared by _INVALID_MESSAGE_BODY_PATTERNS (→ format_error) and the llama.cpp +# grammar exclusion guard so the two sites cannot drift. +_NO_USER_QUERY_SIGNAL = "no user query found" + +# Malformed-message-array 400s: deterministic rejections of the *transcript* +# (e.g. a content-less assistant stub after a dead stream). NOT context +# overflow — the input may be tiny — so they must fail fast as format_error +# instead of thrashing the compression loop. Compression cannot invent a +# missing user turn, and local engines may wrap that as a grammar error. +_INVALID_MESSAGE_BODY_PATTERNS = ( + "must have non-empty content", "messages must have non-empty", "invalid_request_body", + "text content blocks must be non-empty", "content field is required", + "messages: at least one message is required", _NO_USER_QUERY_SIGNAL, +) + +# Request-validation signals: malformed request, identical on every retry. +# Some gateways (codex.nekos.me) return these as 5xx, so the 5xx path also +# checks them to avoid a retry flood on a deterministic rejection. +_REQUEST_VALIDATION_PATTERNS = ( + "unknown parameter", "unsupported parameter", "unrecognized request argument", + "invalid_request_error", "unknown_parameter", "unsupported_parameter", +) + +# Parameters Hermes sends on SOME routes only → hosts where sending them is +# deliberate. A rejection from any other host means the provider's own gateway +# injected the field, so the 400 is a server-side flake, not our request shape. +# ``prompt_cache_retention``: only sent for api.meta.ai / bedrock-mantle +# (agent/transports/codex.py); the Codex OAuth backend rejects it spontaneously. +_SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = { + "prompt_cache_retention": ("meta", "muse", "msl", "model-api", "bedrock", "mantle"), +} +_PARAM_REJECTION_WORDS = ("not supported", "unsupported", "unknown", "unrecognized") + +# OpenRouter 404 when the account privacy setting (or per-request +# ``provider.data_collection: deny``) excludes the only endpoint for a model. +# Not model_not_found: the model exists, fallback can't help (account-level), +# and the body already carries the fix URL. +_PROVIDER_POLICY_BLOCKED_PATTERNS = ( + "no endpoints available matching your guardrail", "no endpoints available matching your data policy", + "no endpoints found matching your data policy", +) + +# Per-prompt provider safety-filter blocks (distinct from the account-level +# provider_policy_blocked). Deterministic for the unchanged request, so +# fallback immediately. Each phrase is verbatim from a specific provider — +# never a generic word like "policy" that could collide with billing/auth. +_CONTENT_POLICY_BLOCKED_PATTERNS = ( + # OpenAI Codex (#18028) — message may arrive without an HTTP status + "flagged for possible cybersecurity risk", "trusted access for cyber", + # OpenAI moderation — chat completions / responses + "violates our usage policies", "violates openai's usage policies", "your request was flagged by", + # Anthropic safety system + "prompt was flagged by our safety", "responses cannot be generated due to safety", + # OpenAI-standard token / Azure error code. Deliberately NOT the space + # variant "content filter", which appears in benign echoed config text. + "content_filter", "responsibleaipolicyviolation", + "new_sensitive", # MiniMax output-layer safety filter, "output new_sensitive (1027)" (#32421) +) + +# Auth patterns (non-status-code signals). +_AUTH_PATTERNS = ( + "invalid api key", "invalid_api_key", "gateway_auth_failed", "authentication", "unauthorized", + "forbidden", "invalid token", "token expired", "token revoked", "access denied", +) + +# Provider empty-response advisories (OpenRouter / nano-gpt / similar). Checked +# before context-overflow matching because the text often mentions +# "max_tokens", which used to send healthy sessions into a compression spiral. +_EMPTY_PROVIDER_RESPONSE_PATTERNS = ( + "returned an empty response", "empty response despite retries", "provider returned an empty response", + "model returning empty responses", "empty response stream", +) + +# Timeout wording from generic exception types (RuntimeError from a shim +# wrapping a subprocess timeout) that the type-based heuristics would miss. +_TIMEOUT_MESSAGE_PATTERNS = ( + "timed out", "turn timed out", "request timed out", "deadline exceeded", "operation timed out", + "upstream timed out", +) + +# Connect/DNS failures surfaced by generic exception types with no status, so +# _TRANSPORT_ERROR_TYPES never fires. Deliberately EXCLUDES mid-stream +# disconnect strings — those belong to _SERVER_DISCONNECT_PATTERNS, which may +# route large sessions to compression; a never-established connection cannot +# be an overflow rejection. +_CONNECTION_MESSAGE_PATTERNS = ( + # TCP connect failures + "connection refused", "econnrefused", "no route to host", "network is unreachable", "network unreachable", + # DNS resolution failures (Python, glibc, macOS, Node bridge phrasings) + "name or service not known", "temporary failure in name resolution", "nodename nor servname provided", + "getaddrinfo failed", "getaddrinfo enotfound", "eai_again", + # Node/undici bridge generic network failure (MCP servers, local shims) + "fetch failed", "failed to fetch", + # Envoy/proxy upstream connect failure (cloud gateways) + "upstream connect error", +) + +# SSL type names are listed so provider-wrapped SSL errors (chain lost) still +# classify as transport instead of unknown; OpenAI SDK errors are not +# subclasses of Python builtins. +_TRANSPORT_ERROR_TYPES = frozenset({ + "ReadTimeout", "ConnectTimeout", "PoolTimeout", "ConnectError", "RemoteProtocolError", + "ConnectionError", "ConnectionResetError", "ConnectionAbortedError", "BrokenPipeError", + "TimeoutError", "ReadError", "ServerDisconnectedError", + "SSLError", "SSLZeroReturnError", "SSLWantReadError", "SSLWantWriteError", "SSLEOFError", "SSLSyscallError", + "APIConnectionError", "APITimeoutError", +}) + +# Ambiguous disconnects (no status): transient hiccup OR a gateway dropping an +# oversized request. A large session + one of these → context-overflow path. +_SERVER_DISCONNECT_PATTERNS = ( + "server disconnected", "peer closed connection", "connection reset by peer", "connection was closed", + "network connection lost", "unexpected eof", "incomplete chunked read", +) + +# SSL certificate verification failures are deterministic (proxy, missing CA, +# expired/self-signed cert) — fail fast. Checked BEFORE _SSL_TRANSIENT_PATTERNS +# because these messages usually also contain "[SSL:". +_SSL_CERT_VERIFY_PATTERNS = ( + "certificate verify failed", "certificate_verify_failed", "unable to get local issuer certificate", + "self-signed certificate", "self signed certificate", "certificate has expired", + "hostname mismatch, certificate is not valid", + "unable to verify the first certificate", # Node/undici phrasing (MCP bridges) +) + +# Transient SSL alerts: retry but NOT compression (kept apart from +# _SERVER_DISCONNECT_PATTERNS). Matched on stable substrings because OpenSSL 3 +# changed token separators (SSLV3_ALERT_... → SSL/TLS_ALERT_...); both the +# space-separated human form and the underscore OpenSSL tokens are listed, +# plus the Python ssl module prefix "[SSL: BAD_RECORD_MAC]". +_SSL_TRANSIENT_PATTERNS = ( + "bad record mac", "ssl alert", "tls alert", "ssl handshake failure", "tlsv1 alert", "sslv3 alert", + "bad_record_mac", "ssl_alert", "tls_alert", "tls_alert_internal_error", "[ssl:", +) + + +# ── Verdicts and rule tables ──────────────────────────────────────────── +# +# A verdict is the ClassifiedError kwargs a stage decided on: ``reason`` plus +# the hint overrides (unlisted hints keep the dataclass defaults: retryable +# True, everything else False/empty). Rule tables are ordered +# ``(patterns, verdict)`` pairs matched first-hit; ``verdict`` may be a +# callable of the error message. + +Verdict = Dict[str, Any] + + +def _v(reason: FailoverReason, **hints: Any) -> Verdict: + return {"reason": reason, **hints} + + +_ROTATE_FALLBACK = {"should_rotate_credential": True, "should_fallback": True} + +_V_BILLING = _v(FailoverReason.billing, retryable=False, **_ROTATE_FALLBACK) +_V_RATE_LIMIT = _v(FailoverReason.rate_limit, **_ROTATE_FALLBACK) +_V_OVERLOADED = _v(FailoverReason.overloaded) +_V_SERVER_ERROR = _v(FailoverReason.server_error) +_V_CONTEXT_OVERFLOW = _v(FailoverReason.context_overflow, should_compress=True) +_V_PAYLOAD_TOO_LARGE = _v(FailoverReason.payload_too_large, should_compress=True) +_V_MODEL_NOT_FOUND = _v(FailoverReason.model_not_found, retryable=False, should_fallback=True) +_V_POLICY_BLOCKED = _v(FailoverReason.provider_policy_blocked, retryable=False) +_V_CONTENT_BLOCKED = _v(FailoverReason.content_policy_blocked, retryable=False, should_fallback=True) +_V_FORMAT_ERROR = _v(FailoverReason.format_error, retryable=False, should_fallback=True) +_V_AUTH_ROTATE = _v(FailoverReason.auth, retryable=False, **_ROTATE_FALLBACK) +_V_AUTH_FALLBACK = _v(FailoverReason.auth, retryable=False, should_fallback=True) +_V_TIMEOUT = _v(FailoverReason.timeout) +_V_SSL_CERT = _v(FailoverReason.ssl_cert_verification, retryable=False) +_V_IMAGE_TOO_LARGE = _v(FailoverReason.image_too_large) +_V_IMAGE_CORRUPT = _v(FailoverReason.image_corrupt) +_V_MULTIMODAL = _v(FailoverReason.multimodal_tool_content_unsupported) +_V_INVALID_ENCRYPTED = _v(FailoverReason.invalid_encrypted_content) +_V_UNKNOWN = _v(FailoverReason.unknown) + + +def _billing_hints(error_msg: str) -> Verdict: + """Billing verdict carrying the #82154 ambiguity marker when applicable.""" + ctx: Dict[str, Any] = {} + if any(p in error_msg for p in _UNVERIFIED_BILLING_PATTERNS): + ctx = {"billing_unverified": True, "possible_content_filter": True} + return {**_V_BILLING, "error_context": ctx} + + +def _first_match(error_msg: str, rules: Sequence[tuple[Sequence[str], Any]]) -> Optional[Verdict]: + """Verdict of the first rule whose pattern list hits ``error_msg``.""" + for patterns, verdict in rules: + if any(p in error_msg for p in patterns): + return verdict(error_msg) if callable(verdict) else verdict + return None + + +# Image/tool-content 400s, ordered: multimodal recovery differs from image +# shrink; corrupt bytes need strip-and-retry, not shrink; image-shrink is a +# cheaper recovery than context compression for "exceeds" + "image" bodies. +_IMAGE_TOOL_RULES = ( + (_MULTIMODAL_TOOL_CONTENT_PATTERNS, _V_MULTIMODAL), + (_IMAGE_CORRUPT_PATTERNS, _V_IMAGE_CORRUPT), + (_IMAGE_TOO_LARGE_PATTERNS, _V_IMAGE_TOO_LARGE), +) + +# Overflow signals arriving as 5xx (llama.cpp reports overflow as 500; busy / +# model-load OOM as 503). Empty-response advisories must not enter compression. +_OVERFLOW_AS_5XX_RULES = ( + (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), + (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), +) + +# 404: Nous API surfaces credit depletion as a paid model vanishing from the +# Free Tier (billing, not missing model); OpenRouter policy block before +# model_not_found. +_404_RULES = ( + (_BILLING_PATTERNS, _V_BILLING), + (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), + (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), +) + +# 400 tail after the deterministic request-shape checks. Some providers return +# model-not-found / rate-limit / billing as 400 instead of 404/429/402. +_400_TAIL_RULES = _OVERFLOW_AS_5XX_RULES + ( + (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), + (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), + (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), + (_BILLING_PATTERNS, _billing_hints), +) + +# Status-less message path, head (before usage-limit disambiguation). +_MESSAGE_HEAD_RULES = ((_PAYLOAD_TOO_LARGE_PATTERNS, _V_PAYLOAD_TOO_LARGE),) + _IMAGE_TOOL_RULES + +# Status-less message path, tail. Overload before rate_limit/billing so a +# message-only "overloaded" backs off instead of rotating; auth is +# non-retryable (same key always fails); policy block before model_not_found; +# timeout/connection wording last, classified as transport (never compression). +_MESSAGE_TAIL_RULES = ( + (_OVERLOADED_PATTERNS, _V_OVERLOADED), + (_BILLING_PATTERNS, _billing_hints), + (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), + (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), + (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), + (_AUTH_PATTERNS, _V_AUTH_ROTATE), + (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), + (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), + (_TIMEOUT_MESSAGE_PATTERNS, _V_TIMEOUT), + (_CONNECTION_MESSAGE_PATTERNS, _V_TIMEOUT), +) + +# Structured error code → verdict. The error-code rate_limit verdict rotates +# but does not set should_fallback (unlike the message/status paths). +_ERROR_CODE_VERDICTS: Dict[str, Verdict] = { + **dict.fromkeys(("resource_exhausted", "throttled", "rate_limit_exceeded"), + _v(FailoverReason.rate_limit, should_rotate_credential=True)), + **dict.fromkeys(_BILLING_ERROR_CODES, _V_BILLING), + **dict.fromkeys(("model_not_found", "model_not_available", "invalid_model"), _V_MODEL_NOT_FOUND), + **dict.fromkeys(("context_length_exceeded", "max_tokens_exceeded"), _V_CONTEXT_OVERFLOW), + "invalid_encrypted_content": _V_INVALID_ENCRYPTED, +} + +_5XX_VALIDATION_CODES = {"invalid_request_error", "unknown_parameter", "unsupported_parameter"} +_400_VALIDATION_CODES = {"unknown_parameter", "unsupported_parameter"} +# Generic ``invalid_request_error`` is deliberately NOT a 400 validation +# signal — OpenAI stamps it on genuine overflow 400s too. +_400_VALIDATION_PATTERNS = tuple(p for p in _REQUEST_VALIDATION_PATTERNS if p != "invalid_request_error") + + +# ── Classification pipeline ───────────────────────────────────────────── + +@dataclass +class _Ctx: + """Everything the classifier stages need about one failed call.""" + + error: Exception + status_code: Optional[int] + error_type: str + body: dict + error_code: str + headers: Any + msg: str # lowercased str(error) + body message(s) + provider: str # as passed by the caller + model: str + provider_slug: str # stripped + lowercased + model_slug: str + approx_tokens: int + context_length: int + num_messages: int + + def large_session(self, frac: float, tokens: int, messages: int) -> bool: + """Absolute thresholds only proxy for smaller context windows.""" + return self.approx_tokens > self.context_length * frac or ( + self.context_length <= 256000 and (self.approx_tokens > tokens or self.num_messages > messages) + ) + + +def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: + """First valid plugin classification, so a provider plugin can add or + correct classifications before the built-in pipeline. invoke_hook + isolates callback failures; this guard only covers import/dispatch failure.""" + try: + from hermes_cli.plugins import get_plugin_error_classification + verdict = get_plugin_error_classification( + provider=c.provider, model=c.model, status_code=c.status_code, error_type=c.error_type, + error_code=c.error_code, error_message=c.msg, error_body=c.body, error=c.error, + approx_tokens=c.approx_tokens, context_length=c.context_length, num_messages=c.num_messages, + ) + except Exception as exc: + logger.debug("Plugin error classification unavailable: %s", exc) + return None + if verdict is not None: + logger.info( + "API error classified by plugin hook: %s (provider=%s, status=%s)", + verdict["reason"].value, c.provider, c.status_code, + ) + return verdict + + +def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: + """Highest-priority provider-specific shapes that a status code would misroute.""" + msg, status = c.msg, c.status_code + # Deterministic per-prompt safety refusal. Before status classification so + # a 400 block isn't downgraded to format_error and a status-less block + # isn't left retryable (#18028). + if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): + return _V_CONTENT_BLOCKED + # Anthropic thinking-block 400s: signature mismatch after any transcript + # mutation, or "blocks in the latest assistant message cannot be modified". + # Not gated on provider — OpenRouter proxies Anthropic errors. + if status == 400 and "thinking" in msg and any( + p in msg for p in ("signature", "cannot be modified", "must remain as they were") + ): + return _v(FailoverReason.thinking_signature) + # Anthropic long-context tier gate (429 "extra usage" + "long context"). + if status == 429 and "extra usage" in msg and "long context" in msg: + return _v(FailoverReason.long_context_tier, should_compress=True) + # Anthropic OAuth subscription rejects the 1M-context beta header; + # run_agent rebuilds the client without the beta and retries once. + if status == 400 and "long context beta" in msg and "not yet available" in msg: + return _v(FailoverReason.oauth_long_context_beta_forbidden) + # llama.cpp json-schema-to-grammar rejects regex escapes / ``format`` in + # tool schemas (400); the retry loop strips pattern/format and retries. + # Exclude the Qwen/vLLM "No user query found" template error that local + # engines wrap as "Unable to generate parser for this template" — that is + # a poisoned transcript (→ format_error), not a grammar problem. + grammar_hit = status == 400 and ( + "error parsing grammar" in msg + or "json-schema-to-grammar" in msg + or ("unable to generate parser" in msg and "template" in msg) + ) + if grammar_hit and _NO_USER_QUERY_SIGNAL not in msg: + return _v(FailoverReason.llama_cpp_grammar_pattern) + # xAI Grok subscription entitlement. As HTTP 403 the status path handles + # it; as an SSE ``type=error`` frame there is no status and the message + # matches no pattern list, so it would burn max_retries as ``unknown``. + if "do not have an active grok subscription" in msg or ( + "out of available resources" in msg and "grok" in msg + ): + return _V_AUTH_FALLBACK + return None + + +def _moa_special_cases(c: _Ctx) -> Optional[Verdict]: + # Local MoA streaming adapter-shape bugs are not a provider outage; falling + # back would silently replace the MoA route with a single model (#55933). + if c.provider_slug == "moa" and ( + "'types.SimpleNamespace' object is not iterable" in str(c.error) + or "'types.SimpleNamespace' object has no attribute 'index'" in str(c.error) + ): + return _v(FailoverReason.format_error, retryable=False) + # Persisted MoA preset name that was renamed/deleted — deterministic config error. + from agent.errors import MoAPresetNotFoundError + + if isinstance(c.error, MoAPresetNotFoundError): + return _v(FailoverReason.model_not_found, retryable=False) + return None + + +def _by_error_code(c: _Ctx) -> Optional[Verdict]: + """Structured error codes from the response body.""" + code = c.error_code.lower() + # Deterministic request-validation failures encoded as plain-text + # ``event: error`` SSE data behind HTTP 200: retrying cannot succeed, a + # configured fallback still may. + if code == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in c.msg: + return _V_FORMAT_ERROR + return _ERROR_CODE_VERDICTS.get(code) + + +def _by_message(c: _Ctx) -> Optional[Verdict]: + """Message patterns when no status code settled it.""" + verdict = _first_match(c.msg, _MESSAGE_HEAD_RULES) + if verdict is not None: + return verdict + # Status-less usage limits need the same disambiguation as 402. + if any(p in c.msg for p in _USAGE_LIMIT_PATTERNS): + return _classify_402(c.msg, dict) + return _first_match(c.msg, _MESSAGE_TAIL_RULES) + + +def _by_transport(c: _Ctx) -> Optional[Verdict]: + """SSL, disconnect, circuit-breaker and transport-type heuristics, in that order.""" + msg = c.msg + # Deterministic cert failure → fail fast; transient alert → retry. + # Cert-verify first: those messages also contain "[ssl:". Transient alerts + # are classified before the disconnect check so a large session doesn't + # compress on a flaky TLS handshake. + if any(p in msg for p in _SSL_CERT_VERIFY_PATTERNS): + return _V_SSL_CERT + if any(p in msg for p in _SSL_TRANSIENT_PATTERNS): + return _V_TIMEOUT + # Server disconnect + large session → context overflow. Before the generic + # transport catch: a disconnect on a large session is more likely an + # overflow rejection than a transport hiccup. + if any(p in msg for p in _SERVER_DISCONNECT_PATTERNS) and not c.status_code: + # Reasoning models: a disconnect is far more likely the gateway + # idle-killing a long thinking stream than overflow — never compress + # (and silently drop history) on a phantom overflow (#52310). + from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor + if get_reasoning_stale_timeout_floor(c.model) is not None: + return _V_TIMEOUT + return _V_CONTEXT_OVERFLOW if c.large_session(0.6, 120000, 200) else _V_TIMEOUT + # Stale-call circuit breaker → failover immediately. _check_stale_giveup() + # raises RuntimeError before any network call; as ``unknown`` it would burn + # every retry instantly against the dead provider. + if c.error_type == "RuntimeError" and "consecutive stale attempts" in msg and "aborting this call" in msg: + return _v(FailoverReason.timeout, retryable=False, should_fallback=True) + if c.error_type in _TRANSPORT_ERROR_TYPES or isinstance(c.error, (TimeoutError, ConnectionError, OSError)): + return _V_TIMEOUT + return None + + +def _by_status(c: _Ctx) -> Optional[Verdict]: + """HTTP status code with message-aware refinement.""" + if c.status_code is None: + return None + handler = _STATUS_HANDLERS.get(c.status_code) + if handler is not None: + return handler(c) + if 400 <= c.status_code < 500: + return _V_FORMAT_ERROR + if 500 <= c.status_code < 600: + return _V_SERVER_ERROR + return None + + +# Stage order: plugin hooks → provider-specific special cases → HTTP status → +# MoA shapes → structured error code → message patterns → SSL → disconnect + +# large session → transport types → unknown (retryable with backoff). +_STAGES: Sequence[Callable[[_Ctx], Optional[Verdict]]] = ( + _plugin_verdict, _provider_special_cases, _by_status, _moa_special_cases, + _by_error_code, _by_message, _by_transport, +) + + +def classify_api_error( + error: Exception, + *, + provider: str = "", + model: str = "", + approx_tokens: int = 0, + context_length: int = 200000, + num_messages: int = 0, +) -> ClassifiedError: + """Classify an API error into a structured recovery recommendation (see ``_STAGES``).""" + status_code = _extract_status_code(error) + error_type = type(error).__name__ + # Copilot/GitHub Models RateLimitError may not set .status_code; force 429. + if status_code is None and error_type == "RateLimitError": + status_code = 429 + body = _extract_error_body(error) + c = _Ctx( + error=error, status_code=status_code, error_type=error_type, body=body, + error_code=_extract_error_code(body), headers=_extract_response_headers(error), + msg=_build_error_msg(error, body), provider=provider, model=model, + provider_slug=(provider or "").strip().lower(), model_slug=(model or "").strip().lower(), + approx_tokens=approx_tokens, context_length=context_length, num_messages=num_messages, + ) + verdict = next((v for v in (stage(c) for stage in _STAGES) if v is not None), _V_UNKNOWN) + return ClassifiedError(**{ + "status_code": status_code, "provider": provider, "model": model, + "message": _extract_message(error, body), **verdict, + }) + + +# ── Status code handlers ──────────────────────────────────────────────── + +def _status_403(c: _Ctx) -> Verdict: + # OpenRouter 403 "key limit exceeded" and similar plan/credit exhaustion are billing. + if ( + (c.provider_slug == "xai-oauth" and c.error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE) + or "key limit exceeded" in c.msg + or "spending limit" in c.msg + or any(p in c.msg for p in _BILLING_PATTERNS) + ): + return _V_BILLING + return _V_AUTH_FALLBACK + + +def _status_404(c: _Ctx) -> Verdict: + verdict = _first_match(c.msg, _404_RULES) + if verdict is not None: + return verdict + # Bare id the catalogue only knows prefixed → malformed id (NVIDIA NIM + # "404 page not found"), deterministic (#78796). A generic 404 (wrong + # endpoint path, proxy glitch) stays unknown so the real error surfaces + # instead of model_not_found silently falling back and misreporting. + return _V_MODEL_NOT_FOUND if _model_id_missing_known_prefix(c.model_slug, c.provider_slug) else _V_UNKNOWN + + +def _status_429(c: _Ctx) -> Verdict: + # Z.AI/Zhipu reuse 429 for server-wide overload: back off on the same + # key instead of burning the pool (#14038). + if any(p in c.msg for p in _OVERLOADED_PATTERNS): + return _V_OVERLOADED + # OpenRouter-wrapped upstream 429: the user's key is healthy, so fall back + # to another model rather than rotating/benching the key. + if _is_openrouter_upstream_error(c.body, c.provider_slug): + upstream = _extract_upstream_provider_name(c.body) + return _v( + FailoverReason.upstream_rate_limit, should_fallback=True, + error_context={"upstream_provider": upstream} if upstream else {}, + ) + # Quota walls returned as 429 (Anthropic ``usage_limit_reached``, other + # providers' "quota"/"limit exceeded", explicit billing phrases) are + # billing — but ONLY when the body is not itself an explicit rate-limit + # phrase ("Rate limit exceeded" contains "limit exceeded") and carries + # no reset/retry signal (#93419, #39441). + has_usage_limit = ( + c.error_code.lower() == "usage_limit_reached" + or "usage_limit_reached" in c.msg + or any(p in c.msg for p in _USAGE_LIMIT_PATTERNS) + ) + if ( + (has_usage_limit or any(p in c.msg for p in _BILLING_PATTERNS)) + and not any(p in c.msg for p in _RATE_LIMIT_PATTERNS) + and not _has_usage_limit_transient_signal(c.msg, c.body, c.headers) + ): + return _V_BILLING + return _V_RATE_LIMIT + + +def _status_5xx(c: _Ctx) -> Verdict: + # Deterministic request-validation errors returned as 5xx (codex.nekos.me) + # must fail fast, not retry-flood — unless the rejected parameter was + # injected server-side (see _classify_400). + if ( + any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) + or c.error_code.lower() in _5XX_VALIDATION_CODES + ) and not _is_server_injected_param_rejection(c.msg, c.provider_slug): + return _V_FORMAT_ERROR + return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_SERVER_ERROR + + +def _status_overloaded(c: _Ctx) -> Verdict: + return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED + + +def _classify_402(error_msg: str, result_fn: Callable[..., Any]) -> Any: + """Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing.""" + if ( + any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) + and any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS) + ): + return result_fn(**_V_RATE_LIMIT) + return result_fn(**_V_BILLING) + + +def _classify_400(c: _Ctx) -> Verdict: + """400 Bad Request — image/tool shapes, request-shape rejections, overflow, or generic.""" + msg, code = c.msg, c.error_code.lower() + verdict = _first_match(msg, _IMAGE_TOOL_RULES) + if verdict is not None: + return verdict + # Invalid encrypted reasoning replay blob (OpenAI Responses). Before + # context_overflow: "encrypted content … could not be verified" can trip + # the overflow heuristics. + if ( + code == "invalid_encrypted_content" + or "invalid_encrypted_content" in msg + or ("encrypted content for item" in msg and "could not be verified" in msg) + or "could not decrypt the provided encrypted_content" in msg + ): + return _V_INVALID_ENCRYPTED + # A 400 blaming a field this route never sent (Codex OAuth backend injects + # and then rejects prompt_cache_retention ~20% of the time): transient, + # retry the identical request; never compress. Before the validation branch. + if _is_server_injected_param_rejection(msg, c.provider_slug): + return _V_SERVER_ERROR + # Unsupported/unknown parameter before context_overflow: GPT-5's + # "Unsupported parameter: 'max_tokens'…" contains the overflow pattern. + if any(p in msg for p in _400_VALIDATION_PATTERNS) or code in _400_VALIDATION_CODES: + return _V_FORMAT_ERROR + # Malformed message array (empty-content assistant stub, etc.) before + # context_overflow: the input can be tiny and compression cannot fix it. + # Proxies (litellm/Bedrock) surface it as errorCode=INVALID_REQUEST_BODY. + if any(p in msg for p in _INVALID_MESSAGE_BODY_PATTERNS) or code == "invalid_request_body": + logger.warning( + "Malformed message array 400 (invalid request body) classified as " + "format_error, NOT context overflow — failing fast + falling back " + "instead of entering the compression loop. This usually means an " + "empty-content assistant stub is in the transcript; num_messages=%s " + "approx_tokens=%s. error=%.200s", + c.num_messages, c.approx_tokens, msg, + ) + return _V_FORMAT_ERROR + verdict = _first_match(msg, _400_TAIL_RULES) + if verdict is not None: + return verdict + # Generic 400 + large session → probable context overflow (Anthropic can + # return a bare "Error"). Proxy shapes are recognised so a long, descriptive + # rejection is not mistaken for a bare error. + body_msg = next((m for m in (str(x or "").strip().lower() for x in _body_message_candidates(c.body)) if m), "") + is_generic = len(body_msg) < 30 or body_msg in {"error", ""} + if is_generic and c.large_session(0.4, 80000, 80): + return _V_CONTEXT_OVERFLOW + return _V_FORMAT_ERROR + + +# 401 is not retryable on its own: credential rotation / provider refresh run +# before the retryability check; if they fail, the client-error abort path +# (fallback first) is correct. 408 Request Timeout is retry-safe (RFC 9110 +# §15.5.9) — proxies in front of self-hosted backends emit it when generation +# outruns the read window. Unlisted 4xx → format_error, 5xx → server_error. +_STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = { + 400: _classify_400, + 401: lambda c: _V_AUTH_ROTATE, + 402: lambda c: _classify_402(c.msg, dict), + 403: _status_403, + 404: _status_404, + 408: lambda c: _V_TIMEOUT, + 413: lambda c: _V_PAYLOAD_TOO_LARGE, + 429: _status_429, + 500: _status_5xx, + 502: _status_5xx, + 503: _status_overloaded, + 529: _status_overloaded, +} + + +# ── Helpers ───────────────────────────────────────────────────────────── + +_RESET_FIELDS = ("resets_in_seconds", "resets_at", "reset_at", "retry_after") +_RESET_HEADERS = ("retry-after", "Retry-After", "x-ratelimit-reset", "X-RateLimit-Reset") + + +def _has_usage_limit_transient_signal(error_msg: str, body: dict, response_headers) -> bool: + """Whether a usage-limit response identifies a reset window (message, body fields, or headers).""" + if any(pattern in error_msg for pattern in _USAGE_LIMIT_TRANSIENT_SIGNALS): + return True + payloads = [body] + if isinstance(body, dict) and isinstance(body.get("error"), dict): + payloads.append(body["error"]) + for payload in payloads: + if isinstance(payload, dict) and any(payload.get(f) not in (None, "") for f in _RESET_FIELDS): + return True + if response_headers and hasattr(response_headers, "get"): + return any(response_headers.get(h) not in (None, "") for h in _RESET_HEADERS) + return False def _model_id_missing_known_prefix(model: str, provider: str) -> bool: @@ -343,51 +894,6 @@ def _model_id_missing_known_prefix(model: str, provider: str) -> bool: return False -# Qwen/vLLM chat-template raise_exception("No user query found in messages"). -# Shared by _INVALID_MESSAGE_BODY_PATTERNS (→ format_error) and the llama.cpp -# grammar exclusion guard so the two sites cannot drift. -_NO_USER_QUERY_SIGNAL = "no user query found" - -# Malformed-message-array 400s: deterministic rejections of the *transcript* -# (e.g. a content-less assistant stub after a dead stream). NOT context -# overflow — the input may be tiny — so they must fail fast as format_error -# instead of thrashing the compression loop. -_INVALID_MESSAGE_BODY_PATTERNS = [ - "must have non-empty content", - "messages must have non-empty", - "invalid_request_body", - "text content blocks must be non-empty", - "content field is required", - "messages: at least one message is required", - # Qwen/vLLM templates: no surviving non-empty user turn. Compression - # cannot invent one, and local engines may wrap this as a grammar error. - _NO_USER_QUERY_SIGNAL, -] - -# Request-validation signals: malformed request, identical on every retry. -# Some gateways (codex.nekos.me) return these as 5xx, so the 5xx path also -# checks them to avoid a retry flood on a deterministic rejection. -_REQUEST_VALIDATION_PATTERNS = [ - "unknown parameter", - "unsupported parameter", - "unrecognized request argument", - "invalid_request_error", - "unknown_parameter", - "unsupported_parameter", -] - -# Parameters Hermes sends on SOME routes only → hosts where sending them is -# deliberate. A rejection from any other host means the provider's own gateway -# injected the field, so the 400 is a server-side flake, not our request shape. -# ``prompt_cache_retention``: only sent for api.meta.ai / bedrock-mantle -# (agent/transports/codex.py); the Codex OAuth backend rejects it spontaneously. -_SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = { - "prompt_cache_retention": ("meta", "muse", "msl", "model-api", "bedrock", "mantle"), -} - -_PARAM_REJECTION_WORDS = ("not supported", "unsupported", "unknown", "unrecognized") - - def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool: """True when a 400 blames a one-route-only parameter this route never sends. @@ -405,286 +911,6 @@ def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool: return False -# OpenRouter 404 when the account privacy setting (or per-request -# ``provider.data_collection: deny``) excludes the only endpoint for a model. -# Not model_not_found: the model exists, fallback can't help (account-level), -# and the body already carries the fix URL. -_PROVIDER_POLICY_BLOCKED_PATTERNS = [ - "no endpoints available matching your guardrail", - "no endpoints available matching your data policy", - "no endpoints found matching your data policy", -] - -# Per-prompt provider safety-filter blocks (distinct from the account-level -# provider_policy_blocked). Deterministic for the unchanged request, so -# fallback immediately. Each phrase is verbatim from a specific provider — -# never a generic word like "policy" that could collide with billing/auth. -_CONTENT_POLICY_BLOCKED_PATTERNS = [ - # OpenAI Codex (#18028) — message may arrive without an HTTP status - "flagged for possible cybersecurity risk", - "trusted access for cyber", - # OpenAI moderation — chat completions / responses - "violates our usage policies", - "violates openai's usage policies", - "your request was flagged by", - # Anthropic safety system - "prompt was flagged by our safety", - "responses cannot be generated due to safety", - # OpenAI-standard token / Azure error code. Deliberately NOT the space - # variant "content filter", which appears in benign echoed config text. - "content_filter", - "responsibleaipolicyviolation", - # MiniMax output-layer safety filter, "output new_sensitive (1027)" (#32421) - "new_sensitive", -] - -# Auth patterns (non-status-code signals) -_AUTH_PATTERNS = [ - "invalid api key", - "invalid_api_key", - "gateway_auth_failed", - "authentication", - "unauthorized", - "forbidden", - "invalid token", - "token expired", - "token revoked", - "access denied", -] - -# Provider empty-response advisories (OpenRouter / nano-gpt / similar). Checked -# before context-overflow matching because the text often mentions -# "max_tokens", which used to send healthy sessions into a compression spiral. -_EMPTY_PROVIDER_RESPONSE_PATTERNS = [ - "returned an empty response", - "empty response despite retries", - "provider returned an empty response", - "model returning empty responses", - "empty response stream", -] - -# Timeout wording from generic exception types (RuntimeError from a shim -# wrapping a subprocess timeout) that the type-based heuristics would miss. -_TIMEOUT_MESSAGE_PATTERNS = [ - "timed out", - "turn timed out", - "request timed out", - "deadline exceeded", - "operation timed out", - "upstream timed out", -] - -# Connect/DNS failures surfaced by generic exception types with no status, so -# _TRANSPORT_ERROR_TYPES never fires. Deliberately EXCLUDES mid-stream -# disconnect strings — those belong to _SERVER_DISCONNECT_PATTERNS, which may -# route large sessions to compression; a never-established connection cannot -# be an overflow rejection. -_CONNECTION_MESSAGE_PATTERNS = [ - # TCP connect failures - "connection refused", - "econnrefused", - "no route to host", - "network is unreachable", - "network unreachable", - # DNS resolution failures (Python, glibc, macOS, Node bridge phrasings) - "name or service not known", - "temporary failure in name resolution", - "nodename nor servname provided", - "getaddrinfo failed", - "getaddrinfo enotfound", - "eai_again", - # Node/undici bridge generic network failure (MCP servers, local shims) - "fetch failed", - "failed to fetch", - # Envoy/proxy upstream connect failure (cloud gateways) - "upstream connect error", -] - -_TRANSPORT_ERROR_TYPES = frozenset({ - "ReadTimeout", "ConnectTimeout", "PoolTimeout", - "ConnectError", "RemoteProtocolError", - "ConnectionError", "ConnectionResetError", - "ConnectionAbortedError", "BrokenPipeError", - "TimeoutError", "ReadError", - "ServerDisconnectedError", - # SSL type names listed so provider-wrapped SSL errors (chain lost) still - # classify as transport instead of unknown. - "SSLError", "SSLZeroReturnError", "SSLWantReadError", - "SSLWantWriteError", "SSLEOFError", "SSLSyscallError", - # OpenAI SDK errors (not subclasses of Python builtins) - "APIConnectionError", - "APITimeoutError", -}) - -# Ambiguous disconnects (no status): transient hiccup OR a gateway dropping an -# oversized request. A large session + one of these → context-overflow path. -_SERVER_DISCONNECT_PATTERNS = [ - "server disconnected", - "peer closed connection", - "connection reset by peer", - "connection was closed", - "network connection lost", - "unexpected eof", - "incomplete chunked read", -] - -# SSL certificate verification failures are deterministic (proxy, missing CA, -# expired/self-signed cert) — fail fast. Checked BEFORE _SSL_TRANSIENT_PATTERNS -# because these messages usually also contain "[SSL:". -_SSL_CERT_VERIFY_PATTERNS = [ - "certificate verify failed", # Python ssl module canonical text - "certificate_verify_failed", # OpenSSL error token - "unable to get local issuer certificate", - "self-signed certificate", - "self signed certificate", - "certificate has expired", - "hostname mismatch, certificate is not valid", - "unable to verify the first certificate", # Node/undici phrasing (MCP bridges) -] - -# Transient SSL alerts: retry but NOT compression (kept apart from -# _SERVER_DISCONNECT_PATTERNS). Matched on stable substrings because OpenSSL 3 -# changed token separators (SSLV3_ALERT_... → SSL/TLS_ALERT_...). -_SSL_TRANSIENT_PATTERNS = [ - # Space-separated (human-readable form, Python ssl module, most SDKs) - "bad record mac", - "ssl alert", - "tls alert", - "ssl handshake failure", - "tlsv1 alert", - "sslv3 alert", - # Underscore-separated OpenSSL tokens - "bad_record_mac", - "ssl_alert", - "tls_alert", - "tls_alert_internal_error", - # Python ssl module prefix, e.g. "[SSL: BAD_RECORD_MAC]" - "[ssl:", -] - - -# ── Verdicts and rule tables ──────────────────────────────────────────── -# -# A verdict is ``(reason, hint_overrides)``; overrides not listed take the -# ClassifiedError defaults (retryable=True, everything else False/empty). -# Rule tables are ordered ``(patterns, reason, hints)`` triples matched -# first-hit; ``hints`` may be a callable of the error message. - -Verdict = Tuple[FailoverReason, Dict[str, Any]] -_ROTATE_FALLBACK = {"should_rotate_credential": True, "should_fallback": True} - -_V_BILLING: Verdict = (FailoverReason.billing, {"retryable": False, **_ROTATE_FALLBACK}) -_V_RATE_LIMIT: Verdict = (FailoverReason.rate_limit, dict(_ROTATE_FALLBACK)) -_V_OVERLOADED: Verdict = (FailoverReason.overloaded, {}) -_V_SERVER_ERROR: Verdict = (FailoverReason.server_error, {}) -_V_CONTEXT_OVERFLOW: Verdict = (FailoverReason.context_overflow, {"should_compress": True}) -_V_PAYLOAD_TOO_LARGE: Verdict = (FailoverReason.payload_too_large, {"should_compress": True}) -_V_MODEL_NOT_FOUND: Verdict = (FailoverReason.model_not_found, {"retryable": False, "should_fallback": True}) -_V_POLICY_BLOCKED: Verdict = (FailoverReason.provider_policy_blocked, {"retryable": False}) -_V_FORMAT_ERROR: Verdict = (FailoverReason.format_error, {"retryable": False, "should_fallback": True}) -_V_AUTH_ROTATE: Verdict = (FailoverReason.auth, {"retryable": False, **_ROTATE_FALLBACK}) -_V_AUTH_FALLBACK: Verdict = (FailoverReason.auth, {"retryable": False, "should_fallback": True}) -_V_TIMEOUT: Verdict = (FailoverReason.timeout, {}) -_V_IMAGE_TOO_LARGE: Verdict = (FailoverReason.image_too_large, {}) -_V_IMAGE_CORRUPT: Verdict = (FailoverReason.image_corrupt, {}) -_V_MULTIMODAL: Verdict = (FailoverReason.multimodal_tool_content_unsupported, {}) -_V_INVALID_ENCRYPTED: Verdict = (FailoverReason.invalid_encrypted_content, {}) - - -def _billing_hints(error_msg: str) -> Dict[str, Any]: - """Billing verdict carrying the #82154 ambiguity marker when applicable.""" - return {**_V_BILLING[1], "error_context": _billing_ambiguity_context(error_msg)} - - -def _rule(patterns: Sequence[str], verdict: Verdict, hints=None) -> tuple: - return (patterns, verdict[0], verdict[1] if hints is None else hints) - - -def _emit(result_fn: Callable[..., ClassifiedError], verdict: Verdict) -> ClassifiedError: - return result_fn(verdict[0], **verdict[1]) - - -def _first_match(error_msg: str, rules: Iterable[tuple], result_fn) -> Optional[ClassifiedError]: - """Return the verdict of the first rule whose pattern list hits ``error_msg``.""" - for patterns, reason, hints in rules: - if any(p in error_msg for p in patterns): - overrides: Dict[str, Any] = hints(error_msg) if callable(hints) else hints - return result_fn(reason, **overrides) - return None - - -# Image/tool-content 400s, ordered: multimodal recovery differs from image -# shrink; corrupt bytes need strip-and-retry, not shrink; image-shrink is a -# cheaper recovery than context compression for "exceeds" + "image" bodies. -_IMAGE_TOOL_RULES = ( - _rule(_MULTIMODAL_TOOL_CONTENT_PATTERNS, _V_MULTIMODAL), - _rule(_IMAGE_CORRUPT_PATTERNS, _V_IMAGE_CORRUPT), - _rule(_IMAGE_TOO_LARGE_PATTERNS, _V_IMAGE_TOO_LARGE), -) - -# Overflow signals arriving as 5xx (llama.cpp reports overflow as 500; busy / -# model-load OOM as 503). Empty-response advisories must not enter compression. -_OVERFLOW_AS_5XX_RULES = ( - _rule(_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), - _rule(_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), -) - -# 404: Nous API surfaces credit depletion as a paid model vanishing from the -# Free Tier (billing, not missing model); OpenRouter policy block before -# model_not_found. -_404_RULES = ( - _rule(_BILLING_PATTERNS, _V_BILLING), - _rule(_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), - _rule(_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), -) - -# 400 tail after the deterministic request-shape checks. Some providers return -# model-not-found / rate-limit / billing as 400 instead of 404/429/402. -_400_TAIL_RULES = _OVERFLOW_AS_5XX_RULES + ( - _rule(_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), - _rule(_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), - _rule(_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), - _rule(_BILLING_PATTERNS, _V_BILLING, _billing_hints), -) - -# Status-less message path, head (before usage-limit disambiguation). -_MESSAGE_HEAD_RULES = (_rule(_PAYLOAD_TOO_LARGE_PATTERNS, _V_PAYLOAD_TOO_LARGE),) + _IMAGE_TOOL_RULES - -# Status-less message path, tail. Overload before rate_limit/billing so a -# message-only "overloaded" backs off instead of rotating; auth is -# non-retryable (same key always fails); policy block before model_not_found; -# timeout/connection wording last, classified as transport (never compression). -_MESSAGE_TAIL_RULES = ( - _rule(_OVERLOADED_PATTERNS, _V_OVERLOADED), - _rule(_BILLING_PATTERNS, _V_BILLING, _billing_hints), - _rule(_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), - _rule(_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), - _rule(_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), - _rule(_AUTH_PATTERNS, _V_AUTH_ROTATE), - _rule(_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), - _rule(_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), - _rule(_TIMEOUT_MESSAGE_PATTERNS, _V_TIMEOUT), - _rule(_CONNECTION_MESSAGE_PATTERNS, _V_TIMEOUT), -) - -# Structured error code → verdict. The error-code rate_limit verdict rotates -# but does not set should_fallback (unlike the message/status paths). -_ERROR_CODE_VERDICTS: Dict[str, Verdict] = { - **dict.fromkeys(("resource_exhausted", "throttled", "rate_limit_exceeded"), - (FailoverReason.rate_limit, {"should_rotate_credential": True})), - **dict.fromkeys(_BILLING_ERROR_CODES, _V_BILLING), - **dict.fromkeys(("model_not_found", "model_not_available", "invalid_model"), _V_MODEL_NOT_FOUND), - **dict.fromkeys(("context_length_exceeded", "max_tokens_exceeded"), _V_CONTEXT_OVERFLOW), - "invalid_encrypted_content": _V_INVALID_ENCRYPTED, -} - -_5XX_VALIDATION_CODES = {"invalid_request_error", "unknown_parameter", "unsupported_parameter"} -_400_VALIDATION_CODES = {"unknown_parameter", "unsupported_parameter"} -_400_VALIDATION_PATTERNS = [p for p in _REQUEST_VALIDATION_PATTERNS if p != "invalid_request_error"] - - -# ── Classification pipeline ───────────────────────────────────────────── - def _openrouter_wrapped_message(err_obj: dict) -> str: """Lowercased inner message from OpenRouter's ``error.metadata.raw`` JSON wrapper.""" metadata = err_obj.get("metadata", {}) @@ -724,377 +950,6 @@ def _build_error_msg(error: Exception, body: Any) -> str: return " ".join(parts) -def classify_api_error( - error: Exception, - *, - provider: str = "", - model: str = "", - approx_tokens: int = 0, - context_length: int = 200000, - num_messages: int = 0, -) -> ClassifiedError: - """Classify an API error into a structured recovery recommendation. - - Priority order: plugin hooks → provider-specific special cases → HTTP - status → structured error code → message patterns → SSL → disconnect + - large session → transport types → unknown (retryable with backoff). - """ - status_code = _extract_status_code(error) - error_type = type(error).__name__ - # Copilot/GitHub Models RateLimitError may not set .status_code; force 429. - if status_code is None and error_type == "RateLimitError": - status_code = 429 - body = _extract_error_body(error) - error_code = _extract_error_code(body) - response_headers = _extract_response_headers(error) - error_msg = _build_error_msg(error, body) - provider_lower = (provider or "").strip().lower() - model_lower = (model or "").strip().lower() - - def _result(reason: FailoverReason, **overrides) -> ClassifiedError: - defaults = { - "reason": reason, - "status_code": status_code, - "provider": provider, - "model": model, - "message": _extract_message(error, body), - } - defaults.update(overrides) - return ClassifiedError(**defaults) - - # ── 0. Plugin classifiers (first valid result wins) ───────────── - # Runs before the built-in pipeline so a provider plugin can add or correct - # classifications. invoke_hook isolates callback failures; this guard only - # covers import/dispatch failure. - try: - from hermes_cli.plugins import get_plugin_error_classification - plugin_classification = get_plugin_error_classification( - provider=provider, - model=model, - status_code=status_code, - error_type=error_type, - error_code=error_code, - error_message=error_msg, - error_body=body, - error=error, - approx_tokens=approx_tokens, - context_length=context_length, - num_messages=num_messages, - ) - except Exception as exc: - logger.debug("Plugin error classification unavailable: %s", exc) - plugin_classification = None - if plugin_classification is not None: - reason = plugin_classification.pop("reason") - logger.info( - "API error classified by plugin hook: %s (provider=%s, status=%s)", - reason.value, provider, status_code, - ) - return _result(reason, **plugin_classification) - - # ── 1. Provider-specific patterns (highest priority) ──────────── - - # Deterministic per-prompt safety refusal. Before status classification so - # a 400 block isn't downgraded to format_error and a status-less block - # isn't left retryable (#18028). - if any(p in error_msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): - return _result(FailoverReason.content_policy_blocked, retryable=False, should_fallback=True) - - # Anthropic thinking-block 400s: signature mismatch after any transcript - # mutation, or "blocks in the latest assistant message cannot be modified". - # Not gated on provider — OpenRouter proxies Anthropic errors. - if ( - status_code == 400 - and "thinking" in error_msg - and ( - "signature" in error_msg - or "cannot be modified" in error_msg - or "must remain as they were" in error_msg - ) - ): - return _result(FailoverReason.thinking_signature, retryable=True, should_compress=False) - - # Anthropic long-context tier gate (429 "extra usage" + "long context") - if status_code == 429 and "extra usage" in error_msg and "long context" in error_msg: - return _result(FailoverReason.long_context_tier, retryable=True, should_compress=True) - - # Anthropic OAuth subscription rejects the 1M-context beta header (400 - # "The long context beta is not yet available for this subscription"); - # run_agent rebuilds the client without the beta and retries once. - if status_code == 400 and "long context beta" in error_msg and "not yet available" in error_msg: - return _result(FailoverReason.oauth_long_context_beta_forbidden, retryable=True, should_compress=False) - - # llama.cpp json-schema-to-grammar rejects regex escapes / ``format`` in - # tool schemas (400); the retry loop strips pattern/format and retries. - # Exclude the Qwen/vLLM "No user query found" template error that local - # engines wrap as "Unable to generate parser for this template" — that is - # a poisoned transcript (→ format_error), not a grammar problem. - llama_cpp_grammar_hit = status_code == 400 and ( - "error parsing grammar" in error_msg - or "json-schema-to-grammar" in error_msg - or ("unable to generate parser" in error_msg and "template" in error_msg) - ) - if llama_cpp_grammar_hit and _NO_USER_QUERY_SIGNAL not in error_msg: - return _result(FailoverReason.llama_cpp_grammar_pattern, retryable=True, should_compress=False) - - # xAI Grok subscription entitlement. As HTTP 403 the status path handles - # it; as an SSE ``type=error`` frame there is no status and the message - # matches no pattern list, so it would burn max_retries as ``unknown``. - if ( - "do not have an active grok subscription" in error_msg - or ("out of available resources" in error_msg and "grok" in error_msg) - ): - return _result(FailoverReason.auth, retryable=False, should_fallback=True) - - # ── 2. HTTP status code classification ────────────────────────── - - if status_code is not None: - classified = _classify_by_status( - status_code, error_msg, error_code, body, - provider=provider_lower, model=model_lower, - approx_tokens=approx_tokens, context_length=context_length, - num_messages=num_messages, - response_headers=response_headers, - result_fn=_result, - ) - if classified is not None: - return classified - - # Local MoA streaming adapter-shape bugs are not a provider outage; falling - # back would silently replace the MoA route with a single model (#55933). - if provider_lower == "moa" and ( - "'types.SimpleNamespace' object is not iterable" in str(error) - or "'types.SimpleNamespace' object has no attribute 'index'" in str(error) - ): - return _result(FailoverReason.format_error, retryable=False, should_fallback=False) - - # Persisted MoA preset name that was renamed/deleted — deterministic config error. - from agent.errors import MoAPresetNotFoundError - - if isinstance(error, MoAPresetNotFoundError): - return _result(FailoverReason.model_not_found, retryable=False) - - # ── 3. Error code classification ──────────────────────────────── - - if error_code: - classified = _classify_by_error_code(error_code, error_msg, _result) - if classified is not None: - return classified - - # ── 4. Message pattern matching (no status code) ──────────────── - - classified = _classify_by_message( - error_msg, error_type, - approx_tokens=approx_tokens, - context_length=context_length, - result_fn=_result, - ) - if classified is not None: - return classified - - # ── 5. SSL: deterministic cert failure → fail fast; transient alert → retry - # Cert-verify first: those messages also contain "[ssl:". Transient alerts - # are classified before the disconnect check so a large session doesn't - # compress on a flaky TLS handshake. - if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS): - return _result(FailoverReason.ssl_cert_verification, retryable=False, should_fallback=False) - if any(p in error_msg for p in _SSL_TRANSIENT_PATTERNS): - return _result(FailoverReason.timeout, retryable=True) - - # ── 6. Server disconnect + large session → context overflow ───── - # Before the generic transport catch: a disconnect on a large session is - # more likely an overflow rejection than a transport hiccup. - if any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS) and not status_code: - # Reasoning models: a disconnect is far more likely the gateway - # idle-killing a long thinking stream than overflow — never compress - # (and silently drop history) on a phantom overflow (#52310). - from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor - if get_reasoning_stale_timeout_floor(model) is not None: - return _result(FailoverReason.timeout, retryable=True) - # Absolute thresholds only proxy for smaller context windows. - is_large = approx_tokens > context_length * 0.6 or ( - context_length <= 256000 and (approx_tokens > 120000 or num_messages > 200) - ) - if is_large: - return _result(FailoverReason.context_overflow, retryable=True, should_compress=True) - return _result(FailoverReason.timeout, retryable=True) - - # ── 7. Stale-call circuit breaker → failover immediately ──────── - # _check_stale_giveup() raises RuntimeError before any network call; as - # ``unknown`` it would burn every retry instantly against the dead provider. - if ( - error_type == "RuntimeError" - and "consecutive stale attempts" in error_msg - and "aborting this call" in error_msg - ): - return _result(FailoverReason.timeout, retryable=False, should_fallback=True) - - # ── 8. Transport / timeout heuristics ─────────────────────────── - if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)): - return _result(FailoverReason.timeout, retryable=True) - - # ── 9. Fallback: unknown ──────────────────────────────────────── - return _result(FailoverReason.unknown, retryable=True) - - -# ── Status code classification ────────────────────────────────────────── - -def _classify_by_status( - status_code: int, - error_msg: str, - error_code: str, - body: dict, - *, - provider: str, - model: str, - approx_tokens: int, - context_length: int, - num_messages: int = 0, - response_headers=None, - result_fn, -) -> Optional[ClassifiedError]: - """Classify based on HTTP status code with message-aware refinement.""" - - if status_code == 401: - # Not retryable on its own: credential rotation / provider refresh run - # before the retryability check; if they fail, the client-error abort - # path (fallback first) is correct. - return result_fn(FailoverReason.auth, retryable=False, **_ROTATE_FALLBACK) - - if status_code == 403: - # OpenRouter 403 "key limit exceeded" and similar plan/credit exhaustion are billing. - if ( - (provider == "xai-oauth" and error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE) - or "key limit exceeded" in error_msg - or "spending limit" in error_msg - or any(p in error_msg for p in _BILLING_PATTERNS) - ): - return _emit(result_fn, _V_BILLING) - return _emit(result_fn, _V_AUTH_FALLBACK) - - if status_code == 402: - return _classify_402(error_msg, result_fn) - - if status_code == 404: - classified = _first_match(error_msg, _404_RULES, result_fn) - if classified is not None: - return classified - # Bare id the catalogue only knows prefixed → malformed id (NVIDIA NIM - # "404 page not found"), deterministic (#78796). - if _model_id_missing_known_prefix(model, provider): - return _emit(result_fn, _V_MODEL_NOT_FOUND) - # Generic 404 (wrong endpoint path, proxy glitch): model_not_found would - # silently fall back and misreport; stay unknown so the real error surfaces. - return result_fn(FailoverReason.unknown, retryable=True) - - if status_code == 413: - return result_fn(FailoverReason.payload_too_large, retryable=True, should_compress=True) - - if status_code == 429: - # Z.AI/Zhipu reuse 429 for server-wide overload: back off on the same - # key instead of burning the pool (#14038). - if any(p in error_msg for p in _OVERLOADED_PATTERNS): - return result_fn(FailoverReason.overloaded, retryable=True) - # OpenRouter-wrapped upstream 429: the user's key is healthy, so - # fall back to another model rather than rotating/benching the key. - if _is_openrouter_upstream_error(body, provider): - upstream_provider = _extract_upstream_provider_name(body) - ctx = {"upstream_provider": upstream_provider} if upstream_provider else {} - return result_fn( - FailoverReason.upstream_rate_limit, - retryable=True, - should_rotate_credential=False, - should_fallback=True, - error_context=ctx, - ) - # Quota walls returned as 429 (Anthropic ``usage_limit_reached``, other - # providers' "quota"/"limit exceeded", explicit billing phrases) are - # billing — but ONLY when the body is not itself an explicit rate-limit - # phrase ("Rate limit exceeded" contains "limit exceeded") and carries - # no reset/retry signal (#93419, #39441). - has_usage_limit = ( - error_code.lower() == "usage_limit_reached" - or "usage_limit_reached" in error_msg - or any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) - ) - has_billing = any(p in error_msg for p in _BILLING_PATTERNS) - if ( - (has_billing or has_usage_limit) - and not any(p in error_msg for p in _RATE_LIMIT_PATTERNS) - and not _has_usage_limit_transient_signal(error_msg, body, response_headers) - ): - return _emit(result_fn, _V_BILLING) - return _emit(result_fn, _V_RATE_LIMIT) - - if status_code == 400: - return _classify_400( - error_msg, error_code, body, - provider=provider, model=model, - approx_tokens=approx_tokens, - context_length=context_length, - num_messages=num_messages, - result_fn=result_fn, - ) - - if status_code in {500, 502}: - # Deterministic request-validation errors returned as 5xx - # (codex.nekos.me) must fail fast, not retry-flood — unless the - # rejected parameter was injected server-side (see _classify_400). - if ( - any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS) - or error_code.lower() in _5XX_VALIDATION_CODES - ) and not _is_server_injected_param_rejection(error_msg, provider): - return _emit(result_fn, _V_FORMAT_ERROR) - classified = _first_match(error_msg, _OVERFLOW_AS_5XX_RULES, result_fn) - return classified if classified is not None else result_fn(FailoverReason.server_error, retryable=True) - - if status_code in {503, 529}: - classified = _first_match(error_msg, _OVERFLOW_AS_5XX_RULES, result_fn) - return classified if classified is not None else result_fn(FailoverReason.overloaded, retryable=True) - - # 408 Request Timeout is retry-safe (RFC 9110 §15.5.9) — proxies in front - # of self-hosted backends emit it when generation outruns the read window. - if status_code == 408: - return result_fn(FailoverReason.timeout, retryable=True) - - if 400 <= status_code < 500: - return _emit(result_fn, _V_FORMAT_ERROR) - - if 500 <= status_code < 600: - return result_fn(FailoverReason.server_error, retryable=True) - - return None - - -_RESET_FIELDS = ("resets_in_seconds", "resets_at", "reset_at", "retry_after") -_RESET_HEADERS = ("retry-after", "Retry-After", "x-ratelimit-reset", "X-RateLimit-Reset") - - -def _has_usage_limit_transient_signal(error_msg: str, body: dict, response_headers) -> bool: - """Return whether a usage-limit response identifies a reset window.""" - if any(pattern in error_msg for pattern in _USAGE_LIMIT_TRANSIENT_SIGNALS): - return True - payloads = [body] - if isinstance(body, dict) and isinstance(body.get("error"), dict): - payloads.append(body["error"]) - for payload in payloads: - if isinstance(payload, dict) and any(payload.get(f) not in (None, "") for f in _RESET_FIELDS): - return True - if response_headers and hasattr(response_headers, "get"): - return any(response_headers.get(h) not in (None, "") for h in _RESET_HEADERS) - return False - - -def _classify_402(error_msg: str, result_fn) -> ClassifiedError: - """Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing.""" - if ( - any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) - and any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS) - ): - return _emit(result_fn, _V_RATE_LIMIT) - return _emit(result_fn, _V_BILLING) - - def _body_message_candidates(body: dict) -> Iterator[Any]: """Body message fields in priority order (OpenAI, flat, litellm/Bedrock proxy shapes).""" err_obj = body.get("error", {}) @@ -1105,136 +960,6 @@ def _body_message_candidates(body: dict) -> Iterator[Any]: yield args.get("reason") if isinstance(args, dict) else None -def _classify_400( - error_msg: str, - error_code: str, - body: dict, - *, - provider: str, - model: str, - approx_tokens: int, - context_length: int, - num_messages: int = 0, - result_fn, -) -> ClassifiedError: - """Classify 400 Bad Request — context overflow, format error, or generic.""" - classified = _first_match(error_msg, _IMAGE_TOOL_RULES, result_fn) - if classified is not None: - return classified - - # Invalid encrypted reasoning replay blob (OpenAI Responses). Before - # context_overflow: "encrypted content … could not be verified" can trip - # the overflow heuristics. - error_code_lower = (error_code or "").lower() - if ( - error_code_lower == "invalid_encrypted_content" - or "invalid_encrypted_content" in error_msg - or ("encrypted content for item" in error_msg and "could not be verified" in error_msg) - or "could not decrypt the provided encrypted_content" in error_msg - ): - return result_fn(FailoverReason.invalid_encrypted_content, retryable=True, should_fallback=False) - - # A 400 blaming a field this route never sent (Codex OAuth backend injects - # and then rejects prompt_cache_retention ~20% of the time): transient, - # retry the identical request; never compress. Before the validation branch. - if _is_server_injected_param_rejection(error_msg, provider): - return result_fn(FailoverReason.server_error, retryable=True, should_compress=False) - - # Unsupported/unknown parameter before context_overflow: GPT-5's - # "Unsupported parameter: 'max_tokens'…" contains the overflow pattern - # "max_tokens". Generic ``invalid_request_error`` is deliberately NOT used - # here — OpenAI stamps it on genuine overflow 400s too. - if ( - any(p in error_msg for p in _400_VALIDATION_PATTERNS) - or error_code_lower in _400_VALIDATION_CODES - ): - return _emit(result_fn, _V_FORMAT_ERROR) - - # Malformed message array (empty-content assistant stub, etc.) before - # context_overflow: the input can be tiny and compression cannot fix it. - # Proxies (litellm/Bedrock) surface it as errorCode=INVALID_REQUEST_BODY. - if ( - any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS) - or error_code_lower == "invalid_request_body" - ): - logger.warning( - "Malformed message array 400 (invalid request body) classified as " - "format_error, NOT context overflow — failing fast + falling back " - "instead of entering the compression loop. This usually means an " - "empty-content assistant stub is in the transcript; num_messages=%s " - "approx_tokens=%s. error=%.200s", - num_messages, approx_tokens, error_msg, - ) - return _emit(result_fn, _V_FORMAT_ERROR) - - classified = _first_match(error_msg, _400_TAIL_RULES, result_fn) - if classified is not None: - return classified - - # Generic 400 + large session → probable context overflow (Anthropic can - # return a bare "Error"). Proxy shapes are recognised so a long, descriptive - # rejection is not mistaken for a bare error. - err_body_msg = "" - if isinstance(body, dict): - err_body_msg = next( - (m for m in (str(c or "").strip().lower() for c in _body_message_candidates(body)) if m), - "", - ) - is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""} - # Absolute thresholds only proxy for smaller context windows. - is_large = approx_tokens > context_length * 0.4 or ( - context_length <= 256000 and (approx_tokens > 80000 or num_messages > 80) - ) - if is_generic and is_large: - return result_fn(FailoverReason.context_overflow, retryable=True, should_compress=True) - - return _emit(result_fn, _V_FORMAT_ERROR) - - -# ── Error code classification ─────────────────────────────────────────── - -def _classify_by_error_code( - error_code: str, error_msg: str, result_fn, -) -> Optional[ClassifiedError]: - """Classify by structured error codes from the response body.""" - code_lower = error_code.lower() - - # Deterministic request-validation failures encoded as plain-text - # ``event: error`` SSE data behind HTTP 200: retrying cannot succeed, a - # configured fallback still may. - if code_lower == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in error_msg: - return _emit(result_fn, _V_FORMAT_ERROR) - - verdict = _ERROR_CODE_VERDICTS.get(code_lower) - return _emit(result_fn, verdict) if verdict is not None else None - - -# ── Message pattern classification ────────────────────────────────────── - -def _classify_by_message( - error_msg: str, - error_type: str, - *, - approx_tokens: int, - context_length: int, - result_fn, -) -> Optional[ClassifiedError]: - """Classify based on error message patterns when no status code is available.""" - classified = _first_match(error_msg, _MESSAGE_HEAD_RULES, result_fn) - if classified is not None: - return classified - - # Status-less usage limits need the same disambiguation as 402. - if any(p in error_msg for p in _USAGE_LIMIT_PATTERNS): - if any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS): - return _emit(result_fn, _V_RATE_LIMIT) - return _emit(result_fn, _V_BILLING) - - return _first_match(error_msg, _MESSAGE_TAIL_RULES, result_fn) - - -# ── Helpers ───────────────────────────────────────────────────────────── - def _cause_chain(error: Exception) -> Iterator[Any]: """Yield the error and its __cause__/__context__ chain, at most 5 deep.""" current = error From bb6d1bec67759c38332a199d54ff7836e7434708 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:22:14 -0700 Subject: [PATCH 02/16] refactor(agent/display): unify truncation/quote-scan helpers, dedupe skin lookup; byte-identical output (0 fuzz mismatches) --- agent/display.py | 316 ++++++++++++++++++++--------------------------- 1 file changed, 131 insertions(+), 185 deletions(-) diff --git a/agent/display.py b/agent/display.py index 482fc2b3b6..61b0004691 100644 --- a/agent/display.py +++ b/agent/display.py @@ -12,7 +12,7 @@ import time from dataclasses import dataclass, field from difflib import unified_diff from pathlib import Path -from typing import Any +from typing import Any, Iterator from urllib.parse import urlsplit from utils import safe_json_loads @@ -37,6 +37,15 @@ def _hex_rgb(h: str) -> tuple[int, int, int]: return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) +def _get_skin(): + """Get the active skin config, or None if not available (lazy import avoids cycles).""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin() + except Exception: + return None + + # Diff colors resolve lazily from the skin engine (light/dark aware) and are # cached after the first resolution. _diff_colors_cached: dict[str, str] | None = None @@ -47,37 +56,38 @@ def _diff_ansi() -> dict[str, str]: global _diff_colors_cached if _diff_colors_cached is not None: return _diff_colors_cached - # Defaults that work on dark terminals - dim = "\033[38;2;150;150;150m" - file_c = "\033[38;2;180;160;255m" - hunk = "\033[38;2;120;120;140m" - minus = "\033[38;2;255;255;255;48;2;120;20;20m" - plus = "\033[38;2;255;255;255;48;2;20;90;20m" + + def _fg(r: int, g: int, b: int) -> str: + return f"\033[38;2;{r};{g};{b}m" + + # Defaults that work on dark terminals. + colors = { + "dim": _fg(150, 150, 150), "file": _fg(180, 160, 255), "hunk": _fg(120, 120, 140), + "minus": "\033[38;2;255;255;255;48;2;120;20;20m", "plus": "\033[38;2;255;255;255;48;2;20;90;20m", + } try: - from hermes_cli.skin_engine import get_active_skin - skin = get_active_skin() + skin = _get_skin() - def _hex_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str: + def _skin_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str: h = skin.get_color(key, "") - r, g, b = _hex_rgb(h) if h and len(h) == 7 and h[0] == "#" else fallback_rgb - return f"\033[38;2;{r};{g};{b}m" + return _fg(*(_hex_rgb(h) if h and len(h) == 7 and h[0] == "#" else fallback_rgb)) - dim = _hex_fg("banner_dim", (150, 150, 150)) - file_c = _hex_fg("session_label", (180, 160, 255)) - hunk = _hex_fg("session_border", (120, 120, 140)) - # minus/plus use dark-tinted backgrounds derived from ui_error/ui_ok + colors["dim"] = _skin_fg("banner_dim", (150, 150, 150)) + colors["file"] = _skin_fg("session_label", (180, 160, 255)) + colors["hunk"] = _skin_fg("session_border", (120, 120, 140)) + # minus/plus use dark-tinted backgrounds derived from ui_error/ui_ok. err_h = skin.get_color("ui_error", "#ef5350") ok_h = skin.get_color("ui_ok", "#4caf50") if err_h and len(err_h) == 7: er, eg, eb = _hex_rgb(err_h) - minus = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m" + colors["minus"] = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m" if ok_h and len(ok_h) == 7: or_, og, ob = _hex_rgb(ok_h) - plus = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m" + colors["plus"] = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m" except Exception: pass - _diff_colors_cached = {"dim": dim, "file": file_c, "hunk": hunk, "minus": minus, "plus": plus} - return _diff_colors_cached + _diff_colors_cached = colors + return colors @dataclass @@ -102,15 +112,6 @@ def get_tool_preview_max_len() -> int: return _tool_preview_max_len -def _get_skin(): - """Get the active skin config, or None if not available (lazy import avoids cycles).""" - try: - from hermes_cli.skin_engine import get_active_skin - return get_active_skin() - except Exception: - return None - - def get_skin_tool_prefix() -> str: """Get tool output prefix character from active skin.""" skin = _get_skin() @@ -143,14 +144,22 @@ def _oneline(text: str) -> str: return " ".join(text.split()) +def _tail_trunc(text: str, limit: int) -> str: + """Tail-truncate to ``limit`` chars with ``...`` (0 = unlimited; no guard for limit <= 3).""" + return text[:limit - 3] + "..." if limit > 0 and len(text) > limit else text + + def _truncate_preview(text: str, max_len: int | None) -> str: if max_len and max_len > 0 and len(text) > max_len: - if max_len <= 3: - return "." * max_len - return text[:max_len - 3] + "..." + return "." * max_len if max_len <= 3 else text[:max_len - 3] + "..." return text +def _clip(text: str, n: int) -> str: + """``text[:n]`` plus ``...`` when longer (used inside quoted previews).""" + return f"{text[:n]}{'...' if len(text) > n else ''}" + + @dataclass(frozen=True) class ToolPreview: """A compact tool preview plus presentation facts lost to truncation.""" @@ -160,6 +169,8 @@ class ToolPreview: url: str | None = None +# ── Shell command summarisation ────────────────────────────────────────── + _SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"} _SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"} @@ -168,26 +179,31 @@ def _shell_basename(head: str) -> str: return head.rsplit("/", 1)[-1] if head else "" +def _scan_quoted(text: str) -> Iterator[tuple[int, str, bool]]: + """Yield ``(index, char, inside_quotes)``; a quote closes unless backslash-escaped.""" + quote: str | None = None + for i, ch in enumerate(text): + if quote: + yield i, ch, True + if ch == quote and (i == 0 or text[i - 1] != "\\"): + quote = None + elif ch in {"'", '"'}: + quote = ch + yield i, ch, True + else: + yield i, ch, False + + def _split_shell_words(segment: str) -> list[str]: words: list[str] = [] buf: list[str] = [] - quote: str | None = None - for i, ch in enumerate(segment): - if quote: - buf.append(ch) - if ch == quote and (i == 0 or segment[i - 1] != "\\"): - quote = None - continue - if ch in {"'", '"'}: - quote = ch - buf.append(ch) - continue - if ch.isspace(): + for _, ch, quoted in _scan_quoted(segment): + if not quoted and ch.isspace(): if buf: words.append("".join(buf)) buf = [] - continue - buf.append(ch) + else: + buf.append(ch) if buf: words.append("".join(buf)) return words @@ -204,37 +220,29 @@ def _strip_shell_pipe_tail(segment: str) -> str: def _split_shell_compound(command: str) -> list[str]: + """Split on unquoted ``&&`` / ``||`` / ``;`` / newline, dropping pipe tails per segment.""" segments: list[str] = [] buf: list[str] = [] - quote: str | None = None - i = 0 + skip = False def _flush() -> None: segment = _strip_shell_pipe_tail("".join(buf).strip()) if segment: segments.append(segment) + buf.clear() - while i < len(command): - ch = command[i] - if quote: - buf.append(ch) - if ch == quote and (i == 0 or command[i - 1] != "\\"): - quote = None - i += 1 + for i, ch, quoted in _scan_quoted(command): + if skip: + skip = False continue - if ch in {"'", '"'}: - quote = ch - buf.append(ch) - i += 1 - continue - op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0 - if op_len: + if not quoted and (command.startswith("&&", i) or command.startswith("||", i)): + _flush() + skip = True + continue + if not quoted and ch in {";", "\n"}: _flush() - buf = [] - i += op_len continue buf.append(ch) - i += 1 _flush() return segments @@ -248,6 +256,7 @@ def _shell_head_word(segment: str) -> str: def _clean_shell_segment(segment: str) -> str: + """Drop redirections (``> file``, ``2>&1``) from a segment.""" words = _split_shell_words(segment) out: list[str] = [] i = 0 @@ -268,8 +277,7 @@ def _is_shell_boundary_echo(segment: str) -> bool: words = _split_shell_words(segment) if _shell_basename(words[0] if words else "") != "echo": return False - rest = " ".join(words[1:]) - return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest)) + return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", " ".join(words[1:]))) def summarize_shell_command(command: str) -> str: @@ -283,8 +291,7 @@ def summarize_shell_command(command: str) -> str: core: list[str] = [] for segment in segments: cleaned = _clean_shell_segment(segment) - head = _shell_head_word(cleaned) - if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned): + if cleaned and _shell_head_word(cleaned) not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned): core.append(cleaned) if not core: return original @@ -308,10 +315,10 @@ def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any: """Replace every occurrence of a secret-looking browser_type value with its redacted form. Backends echo the attempted input in error strings/fallback metadata, so the raw - value is swapped for its redacted form before the result reaches logs, callbacks, - the model, or chat history. Normal typed text matches no secret pattern and passes - through unchanged. Redaction is forced regardless of ``security.redact_secrets``: - a typed credential leaking into chat history is a security boundary, not log hygiene. + value is swapped before the result reaches logs, callbacks, the model, or chat + history. Normal typed text matches no secret pattern and passes through unchanged. + Redaction is forced regardless of ``security.redact_secrets``: a typed credential + leaking into chat history is a security boundary, not log hygiene. """ if typed_text is None: return value @@ -337,23 +344,21 @@ def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | No if not isinstance(args, dict): return args if tool_name == "browser_type" and isinstance(args.get("text"), str): - safe_args = dict(args) - safe_args["text"] = redact_sensitive_text(args["text"], force=True) - return safe_args + return {**args, "text": redact_sensitive_text(args["text"], force=True)} return args -def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]: +def _delegate_task_goals(tasks: Any, *, per_goal_len: int) -> list[str]: + """One truncated goal string per dict task (``?`` when missing).""" if not isinstance(tasks, list): - return 0, [] + return [] goals: list[str] = [] for task in tasks: - if not isinstance(task, dict): - continue - raw_goal = task.get("goal") - goal = "?" if raw_goal is None else _oneline(str(raw_goal)) - goals.append(_truncate_preview(goal or "?", per_goal_len)) - return len(goals), goals + if isinstance(task, dict): + raw_goal = task.get("goal") + goal = "?" if raw_goal is None else _oneline(str(raw_goal)) + goals.append(_truncate_preview(goal or "?", per_goal_len)) + return goals def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None: @@ -408,8 +413,8 @@ def _preview_delegate_task(args: dict, max_len: int) -> str | None: return _truncate_preview(action_preview, max_len) tasks = args.get("tasks") if tasks and isinstance(tasks, list): - task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40) - preview = f"{task_count} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" + goals = _delegate_task_goals(tasks, per_goal_len=40) + preview = f"{len(goals)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" return _truncate_preview(preview, max_len) goal = args.get("goal", "") if goal is None: @@ -437,9 +442,7 @@ def _preview_todo_list(args: dict, _max_len: int) -> str: todos_arg = args.get("todos") if todos_arg is None: return "reading task list" - if args.get("merge", False): - return f"updating {len(todos_arg)} task(s)" - return f"planning {len(todos_arg)} task(s)" + return f"{'updating' if args.get('merge', False) else 'planning'} {len(todos_arg)} task(s)" def _preview_shell(key: str): @@ -459,17 +462,11 @@ def _preview_read_file(args: dict, max_len: int) -> str | None: return _truncate_preview(f"{label} {_read_file_line_label(args)}".strip(), max_len) or None -def _preview_session_search(args: dict, _max_len: int) -> str: - query = _oneline(args.get("query", "")) - return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\"" - - def _preview_memory(args: dict, _max_len: int) -> str: action = args.get("action", "") target = args.get("target", "") if action == "add": - content = _oneline(args.get("content", "")) - return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\"" + return f"+{target}: \"{_clip(_oneline(args.get('content', '')), 25)}\"" if action in ("replace", "remove"): old = _oneline(args.get("old_text") or "") or "" return f"{'~' if action == 'replace' else '-'}{target}: \"{old[:20]}\"" @@ -477,11 +474,10 @@ def _preview_memory(args: dict, _max_len: int) -> str: def _preview_send_message(args: dict, _max_len: int) -> str: - target = args.get("target", "?") msg = _oneline(args.get("message", "")) if len(msg) > 20: msg = msg[:17] + "..." - return f"to {target}: \"{msg}\"" + return f"to {args.get('target', '?')}: \"{msg}\"" def _preview_skill_view(args: dict, max_len: int) -> str | None: @@ -503,7 +499,7 @@ _PREVIEW_BUILDERS = { "terminal": _preview_shell("command"), "execute_code": _preview_shell("code"), "read_file": _preview_read_file, - "session_search": _preview_session_search, + "session_search": lambda args, _m: f"recall: \"{_clip(_oneline(args.get('query', '')), 25)}\"", "memory": _preview_memory, "send_message": _preview_send_message, "skill_view": _preview_skill_view, @@ -532,11 +528,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - if isinstance(value, list): value = value[0] if value else "" preview = _oneline(str(value)) - if not preview: - return None - if max_len > 0 and len(preview) > max_len: - preview = preview[:max_len - 3] + "..." - return preview + return _tail_trunc(preview, max_len) if preview else None def prepare_tool_preview( @@ -574,30 +566,16 @@ def prepare_tool_preview( # ========================================================================= _TOOL_VERBS: dict[str, str] = { - "web_search": "Searching the web", - "web_extract": "Reading", - "browser_navigate": "Browsing", - "browser_click": "Clicking", - "browser_type": "Typing", - "read_file": "Reading", - "write_file": "Writing", - "patch": "Editing", - "search_files": "Searching files", - "terminal": "Running", - "execute_code": "Running code", - "image_generate": "Generating image", - "video_generate": "Generating video", - "text_to_speech": "Generating speech", - "vision_analyze": "Looking at the image", + "web_search": "Searching the web", "web_extract": "Reading", + "browser_navigate": "Browsing", "browser_click": "Clicking", "browser_type": "Typing", + "read_file": "Reading", "write_file": "Writing", "patch": "Editing", "search_files": "Searching files", + "terminal": "Running", "execute_code": "Running code", + "image_generate": "Generating image", "video_generate": "Generating video", + "text_to_speech": "Generating speech", "vision_analyze": "Looking at the image", "session_search": "Searching past sessions", - "skill_view": "Reading skill", - "skills_list": "Listing skills", - "skill_manage": "Updating skill", - "delegate_task": "Delegating", - "cronjob_manage": "Scheduling", - "clarify": "Asking", - "memory": "Updating memory", - "todo_list": "Updating tasks", + "skill_view": "Reading skill", "skills_list": "Listing skills", "skill_manage": "Updating skill", + "delegate_task": "Delegating", "cronjob_manage": "Scheduling", "clarify": "Asking", + "memory": "Updating memory", "todo_list": "Updating tasks", } # Verbs that read better without the argument preview appended. _TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({"skills_list", "session_search"}) @@ -619,9 +597,7 @@ def get_tool_verb(tool_name: str) -> str | None: Callers holding a computed preview compose ``f"{verb}{connector}{preview}"`` themselves via :func:`tool_verb_connector`. """ - if not _friendly_tool_labels: - return None - return _TOOL_VERBS.get(tool_name) + return _TOOL_VERBS.get(tool_name) if _friendly_tool_labels else None def tool_verb_connector(tool_name: str) -> str: @@ -646,14 +622,12 @@ def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> if not tool_name or tool_name == "_thinking" or not _friendly_tool_labels: return None verb = _TOOL_VERBS.get(tool_name) - head = f"is {verb[0].lower()}{verb[1:]}" if verb else f"is using {tool_name}" - phrase = head + phrase = f"is {verb[0].lower()}{verb[1:]}" if verb else f"is using {tool_name}" if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW: preview = build_tool_preview(tool_name, args, max_len=None) if preview: # Previews can contain newlines (terminal commands); keep the first line. - preview = preview.splitlines()[0].strip() - phrase = f"{head}{tool_verb_connector(tool_name)}{preview}" + phrase = f"{phrase}{tool_verb_connector(tool_name)}{preview.splitlines()[0].strip()}" if len(phrase) > max_len - 1: return phrase[: max_len - 2].rstrip() + "…" return phrase + "…" @@ -665,15 +639,13 @@ def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> Custom/plugin/MCP tools (or labels disabled) get the raw preview, so this is a drop-in replacement for :func:`build_tool_preview`. """ - verb = _TOOL_VERBS.get(tool_name) if _friendly_tool_labels else None + verb = get_tool_verb(tool_name) if not verb: return build_tool_preview(tool_name, args, max_len=max_len) if tool_name in _TOOL_VERBS_NO_PREVIEW: return verb preview = build_tool_preview(tool_name, args, max_len=max_len) - if not preview: - return verb - return f"{verb}{tool_verb_connector(tool_name)}{preview}" + return f"{verb}{tool_verb_connector(tool_name)}{preview}" if preview else verb # ========================================================================= @@ -753,9 +725,7 @@ def _result_succeeded(result: str | None) -> bool: data = safe_json_loads(result) if not isinstance(data, dict) or data.get("error"): return False - if "success" in data: - return bool(data.get("success")) - return True + return bool(data.get("success")) if "success" in data else True def _diff_from_snapshot(snapshot: LocalEditSnapshot | None) -> str | None: @@ -769,14 +739,11 @@ def _diff_from_snapshot(snapshot: LocalEditSnapshot | None) -> str | None: if before == after: continue display_path = _display_diff_path(path) - diff = "".join( - unified_diff( - [] if before is None else before.splitlines(keepends=True), - [] if after is None else after.splitlines(keepends=True), - fromfile=f"a/{display_path}", - tofile=f"b/{display_path}", - ) - ) + diff = "".join(unified_diff( + [] if before is None else before.splitlines(keepends=True), + [] if after is None else after.splitlines(keepends=True), + fromfile=f"a/{display_path}", tofile=f"b/{display_path}", + )) if diff: chunks.append(diff) if not chunks: @@ -834,13 +801,11 @@ def _render_inline_unified_diff(diff: str) -> list[str]: if from_file or to_file: rendered.append(f"{_diff_ansi()['file']}{from_file or 'a/?'} → {to_file or 'b/?'}{_ANSI_RESET}") continue - for prefix, color in _DIFF_LINE_COLORS: - if raw_line.startswith(prefix): - rendered.append(f"{_diff_ansi()[color]}{raw_line}{_ANSI_RESET}") - break - else: - if raw_line: - rendered.append(raw_line) + color = next((c for prefix, c in _DIFF_LINE_COLORS if raw_line.startswith(prefix)), None) + if color: + rendered.append(f"{_diff_ansi()[color]}{raw_line}{_ANSI_RESET}") + elif raw_line: + rendered.append(raw_line) return rendered @@ -1115,13 +1080,10 @@ def _trim_error(msg: str) -> str: """Shrink an error message for inline display (long 'File not found' paths -> filename).""" msg = msg.strip() if "File not found:" in msg: - _, _, tail = msg.partition("File not found:") - tail = tail.strip() + tail = msg.partition("File not found:")[2].strip() if "/" in tail: msg = f"File not found: {tail.rsplit('/', 1)[-1]}" - if len(msg) > _ERROR_SUFFIX_MAX_LEN: - msg = msg[: _ERROR_SUFFIX_MAX_LEN - 3] + "..." - return msg + return _tail_trunc(msg, _ERROR_SUFFIX_MAX_LEN) def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]: @@ -1136,18 +1098,12 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] exit_code = data.get("exit_code") if exit_code is not None and exit_code != 0: err_msg = data.get("error") - if err_msg: - return True, f" [{_trim_error(str(err_msg))}]" - return True, f" [exit {exit_code}]" + return True, f" [{_trim_error(str(err_msg))}]" if err_msg else f" [exit {exit_code}]" return False, "" if isinstance(data, dict): # Memory: distinguish "store full" from real errors. - if ( - tool_name == "memory" - and data.get("success") is False - and "exceed the limit" in data.get("error", "") - ): + if tool_name == "memory" and data.get("success") is False and "exceed the limit" in data.get("error", ""): return True, " [full]" err = data.get("error") or data.get("message") if err and (data.get("success") is False or "error" in data): @@ -1168,20 +1124,14 @@ def _domain(url: str) -> str: def _cute_trunc(s) -> str: """Tail-truncate to the configured preview cap (0 = unlimited).""" - s = str(s) - limit = _tool_preview_max_len - if limit == 0: - return s - return (s[:limit-3] + "...") if len(s) > limit else s + return _tail_trunc(str(s), _tool_preview_max_len) def _cute_path(p) -> str: """Head-truncate a path to the configured preview cap, keeping the filename end.""" p = str(p) limit = _tool_preview_max_len - if limit == 0: - return p - return ("..." + p[-(limit-3):]) if len(p) > limit else p + return ("..." + p[-(limit-3):]) if limit and len(p) > limit else p def _cute_web_extract(a: dict, _r) -> str: @@ -1265,9 +1215,9 @@ def _cute_delegate(a: dict, _r) -> str: return f"┊ 🔀 delegate {_cute_trunc(action_preview)}" tasks = a.get("tasks") if tasks and isinstance(tasks, list): - task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30) + goals = _delegate_task_goals(tasks, per_goal_len=30) detail = " | ".join(goals) if goals else "parallel" - return f"┊ 🔀 delegate {task_count or len(tasks)}x: {_cute_trunc(detail)}" + return f"┊ 🔀 delegate {len(goals) or len(tasks)}x: {_cute_trunc(detail)}" return f"┊ 🔀 delegate {_cute_trunc(a.get('goal', ''))}" @@ -1288,16 +1238,12 @@ _CUTE_LINES = { "read_file": lambda a, r: f"┊ 📖 read {_cute_trunc(build_tool_preview('read_file', a) or a.get('path', ''))}", "write_file": lambda a, r: f"┊ ✍️ write {_cute_path(a.get('path', ''))}", "patch": lambda a, r: f"┊ 🔧 patch {_cute_path(a.get('path', ''))}", - "search_files": lambda a, r: ( - f"┊ 🔎 {'find' if a.get('target', 'content') == 'files' else 'grep':9} {_cute_trunc(a.get('pattern', ''))}" - ), + "search_files": lambda a, r: f"┊ 🔎 {'find' if a.get('target', 'content') == 'files' else 'grep':9} {_cute_trunc(a.get('pattern', ''))}", "browser_navigate": lambda a, r: f"┊ 🌐 navigate {_cute_trunc(_domain(a.get('url', '')))}", "browser_snapshot": lambda a, r: f"┊ 📸 snapshot {'full' if a.get('full') else 'compact'}", "browser_click": lambda a, r: f"┊ 👆 click {a.get('ref', '?')}", "browser_type": lambda a, r: f"┊ ⌨️ type \"{_cute_trunc(a.get('text', ''))}\"", - "browser_scroll": lambda a, r: ( - f"┊ {_SCROLL_ARROWS.get(a.get('direction', 'down'), '↓')} scroll {a.get('direction', 'down')}" - ), + "browser_scroll": lambda a, r: f"┊ {_SCROLL_ARROWS.get(a.get('direction', 'down'), '↓')} scroll {a.get('direction', 'down')}", "browser_back": lambda a, r: "┊ ◀️ back ", "browser_press": lambda a, r: f"┊ ⌨️ press {a.get('key', '?')}", "browser_get_images": lambda a, r: "┊ 🖼️ images extracting", From a5129759e3eff4ca4156ce670c4fa92e09c5be38 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:27:55 -0700 Subject: [PATCH 03/16] refactor(agent/error_classifier): pack pattern/rule tables, unify cause-chain + OpenRouter body readers --- agent/error_classifier.py | 362 ++++++++++++++++---------------------- 1 file changed, 147 insertions(+), 215 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 0b0bfe17db..e25227bf3f 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -24,38 +24,29 @@ PROVIDER_STREAM_NON_JSON_ERROR_CODE = "provider_stream_non_json_data" class FailoverReason(enum.Enum): """Why an API call failed — determines recovery strategy.""" - auth = "auth" # Transient auth (401/403) — refresh/rotate auth_permanent = "auth_permanent" # Auth failed after refresh — abort - billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate upstream_rate_limit = "upstream_rate_limit" # Aggregator's upstream model 429 — fallback model, key is healthy - overloaded = "overloaded" # 503/529 — provider overloaded, backoff server_error = "server_error" # 500/502 — internal server error, retry - timeout = "timeout" # Connection/read timeout — rebuild client + retry ssl_cert_verification = "ssl_cert_verification" # Deterministic TLS chain failure — fail fast with guidance - context_overflow = "context_overflow" # Context too large — compress, not failover payload_too_large = "payload_too_large" # 413 — compress payload image_too_large = "image_too_large" # Native image part exceeds provider's per-image limit — shrink and retry image_corrupt = "image_corrupt" # Provider can't decode image bytes — strip and retry (shrinking won't help) - model_not_found = "model_not_found" # 404 or invalid model — fallback to different model provider_policy_blocked = "provider_policy_blocked" # Aggregator account data/privacy policy excluded the only endpoint content_policy_blocked = "content_policy_blocked" # Provider safety filter rejected this prompt — don't retry unchanged - format_error = "format_error" # 400 bad request — abort or strip + retry invalid_encrypted_content = "invalid_encrypted_content" # Responses replay blob rejected — strip replay state and retry multimodal_tool_content_unsupported = "multimodal_tool_content_unsupported" # Provider rejected list content in tool messages — downgrade to text - thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth rejects 1M beta — disable beta and retry llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp grammar rejects regex `pattern`/`format` — strip from tools and retry - unknown = "unknown" # Unclassifiable — retry with backoff @@ -88,15 +79,14 @@ class ClassifiedError: # ── Pattern tables (lowercased substrings) ────────────────────────────── -# Billing exhaustion (not transient rate limit). +# Billing exhaustion (not transient rate limit). "out of extra usage" is the +# Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400). _BILLING_PATTERNS = ( "insufficient credits", "insufficient_quota", "insufficient balance", "credit balance", "credits exhausted", "credits have been exhausted", "requires available credits", "account balance is too low", "no usable credits", "top up your credits", "payment required", - "billing hard limit", "exceeded your current quota", "account is deactivated", - "plan does not include", - "out of extra usage", # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400) - "out of funds", "run out of funds", "balance_depleted", + "billing hard limit", "exceeded your current quota", "account is deactivated", "plan does not include", + "out of extra usage", "out of funds", "run out of funds", "balance_depleted", "model_not_supported_on_free_tier", "not available on the free tier", ) @@ -120,14 +110,12 @@ _BILLING_ERROR_CODES = frozenset({ # Rate limiting (transient, will resolve). Bedrock "Throttling error: Too many # tokens..." also contains the overflow phrase "too many tokens"; rate limit is -# matched first so throttle wins. +# matched first so throttle wins. "rate increased too quickly" is DashScope. _RATE_LIMIT_PATTERNS = ( "rate limit", "rate_limit", "too many requests", "throttled", "requests per minute", - "tokens per minute", "requests per day", "try again in", "please retry after", - "resource_exhausted", - "rate increased too quickly", # Alibaba/DashScope throttling - "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", # AWS Bedrock - "throttling", + "tokens per minute", "requests per day", "try again in", "please retry after", "resource_exhausted", + "rate increased too quickly", "throttlingexception", "too many concurrent requests", + "servicequotaexceededexception", "throttling", ) # Provider-side overload: the credential is valid, the server is busy, so back @@ -149,10 +137,10 @@ _USAGE_LIMIT_TRANSIENT_SIGNALS = ( "wait", "requests remaining", "periodic", "window", "per minute", "per second", ) -# Payload-too-large detected from message text (proxies embed the status). +# Payload-too-large detected from message text (proxies embed the status); +# "request_too_large" is Anthropic's 413 type re-wrapped without a status. _PAYLOAD_TOO_LARGE_PATTERNS = ( - "request entity too large", "payload too large", "error code: 413", - "request_too_large", # Anthropic's 413 type, re-wrapped by proxies without a status + "request entity too large", "payload too large", "error code: 413", "request_too_large", "request exceeds the maximum size", ) @@ -163,9 +151,8 @@ _PAYLOAD_TOO_LARGE_PATTERNS = ( # non-image media rejection landing here is harmless: the shrink pass finds # no image parts and the original error surfaces. _IMAGE_TOO_LARGE_PATTERNS = ( - "image exceeds", "image too large", "image_too_large", "image size exceeds", - "image dimensions exceed", "dimensions exceed max allowed size", "max allowed size: 8000", - "media exceeds", "media too large", + "image exceeds", "image too large", "image_too_large", "image size exceeds", "image dimensions exceed", + "dimensions exceed max allowed size", "max allowed size: 8000", "media exceeds", "media too large", ) # Image bytes undecodable (e.g. re-serialized history lost data). Shrinking @@ -190,33 +177,28 @@ _MULTIMODAL_TOOL_CONTENT_PATTERNS = ( # Bare "max_tokens" is load-bearing: the output-cap-retry path keys off it. # Empty-response advisories mentioning it are intercepted earlier by # _EMPTY_PROVIDER_RESPONSE_PATTERNS, so they never route into compression. +# Groups: generic; vLLM/local servers; Ollama; llama.cpp ("slot context: N +# tokens"); Chinese wordings; Z.AI/Zhipu (code 1210); Bedrock Converse; +# Together/Fireworks ("Input length N exceeds the maximum allowed input length"). _CONTEXT_OVERFLOW_PATTERNS = ( "context length", "context size", "maximum context", "token limit", "too many tokens", "reduce the length", "exceeds the limit", "context window", "prompt is too long", "prompt exceeds max length", "max_tokens", "maximum number of tokens", - # vLLM / local inference servers - "exceeds the max_model_len", "max_model_len", "prompt length", "input is too long", - "maximum model length", - # Ollama + "exceeds the max_model_len", "max_model_len", "prompt length", "input is too long", "maximum model length", "context length exceeded", "truncating input", - # llama.cpp / llama-server ("slot context: N tokens, prompt N tokens") "slot context", "n_ctx_slot", - # Chinese error messages (some providers return these) "超过最大长度", "上下文长度", - # Z.AI / Zhipu GLM (English form; error code 1210) "tokens in request more than max tokens allowed", - # AWS Bedrock Converse API "input is too long", "max input token", "input token", "exceeds the maximum number of input tokens", - # Together/Fireworks: "Input length N exceeds the maximum allowed input length of M tokens." "maximum allowed input length", ) +# "no endpoints found that support tool use" is OpenRouter's 404 when no +# endpoint supports tool calling; model_not_found triggers fallback instead of +# burning retries (#58446). _MODEL_NOT_FOUND_PATTERNS = ( "is not a valid model", "invalid model", "model not found", "model_not_found", "does not exist", - "no such model", "unknown model", "unsupported model", - # OpenRouter 404 when no endpoint for the model supports tool calling; - # model_not_found triggers fallback instead of burning retries (#58446). - "no endpoints found that support tool use", + "no such model", "unknown model", "unsupported model", "no endpoints found that support tool use", ) # Qwen/vLLM chat-template raise_exception("No user query found in messages"). @@ -253,6 +235,14 @@ _SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = { } _PARAM_REJECTION_WORDS = ("not supported", "unsupported", "unknown", "unrecognized") +# Anthropic thinking-block 400 wordings (see _provider_special_cases). +_THINKING_MUTATION_WORDS = ("signature", "cannot be modified", "must remain as they were") + +# Local MoA streaming adapter-shape bugs (see _moa_special_cases). +_MOA_ADAPTER_SHAPE_BUGS = ( + "'types.SimpleNamespace' object is not iterable", "'types.SimpleNamespace' object has no attribute 'index'", +) + # OpenRouter 404 when the account privacy setting (or per-request # ``provider.data_collection: deny``) excludes the only endpoint for a model. # Not model_not_found: the model exists, fallback can't help (account-level), @@ -265,18 +255,16 @@ _PROVIDER_POLICY_BLOCKED_PATTERNS = ( # Per-prompt provider safety-filter blocks (distinct from the account-level # provider_policy_blocked). Deterministic for the unchanged request, so # fallback immediately. Each phrase is verbatim from a specific provider — -# never a generic word like "policy" that could collide with billing/auth. +# never a generic word like "policy" that could collide with billing/auth: +# OpenAI Codex cyber flags (#18028, may arrive without an HTTP status); OpenAI +# moderation; Anthropic safety system; the OpenAI-standard/Azure token +# "content_filter" (deliberately NOT the space variant, which appears in +# benign echoed config text); MiniMax "output new_sensitive (1027)" (#32421). _CONTENT_POLICY_BLOCKED_PATTERNS = ( - # OpenAI Codex (#18028) — message may arrive without an HTTP status "flagged for possible cybersecurity risk", "trusted access for cyber", - # OpenAI moderation — chat completions / responses "violates our usage policies", "violates openai's usage policies", "your request was flagged by", - # Anthropic safety system "prompt was flagged by our safety", "responses cannot be generated due to safety", - # OpenAI-standard token / Azure error code. Deliberately NOT the space - # variant "content filter", which appears in benign echoed config text. - "content_filter", "responsibleaipolicyviolation", - "new_sensitive", # MiniMax output-layer safety filter, "output new_sensitive (1027)" (#32421) + "content_filter", "responsibleaipolicyviolation", "new_sensitive", ) # Auth patterns (non-status-code signals). @@ -304,16 +292,13 @@ _TIMEOUT_MESSAGE_PATTERNS = ( # _TRANSPORT_ERROR_TYPES never fires. Deliberately EXCLUDES mid-stream # disconnect strings — those belong to _SERVER_DISCONNECT_PATTERNS, which may # route large sessions to compression; a never-established connection cannot -# be an overflow rejection. +# be an overflow rejection. Groups: TCP connect; DNS (Python/glibc/macOS/Node); +# Node/undici bridge (MCP servers, local shims); Envoy/proxy upstream connect. _CONNECTION_MESSAGE_PATTERNS = ( - # TCP connect failures "connection refused", "econnrefused", "no route to host", "network is unreachable", "network unreachable", - # DNS resolution failures (Python, glibc, macOS, Node bridge phrasings) "name or service not known", "temporary failure in name resolution", "nodename nor servname provided", "getaddrinfo failed", "getaddrinfo enotfound", "eai_again", - # Node/undici bridge generic network failure (MCP servers, local shims) "fetch failed", "failed to fetch", - # Envoy/proxy upstream connect failure (cloud gateways) "upstream connect error", ) @@ -337,12 +322,12 @@ _SERVER_DISCONNECT_PATTERNS = ( # SSL certificate verification failures are deterministic (proxy, missing CA, # expired/self-signed cert) — fail fast. Checked BEFORE _SSL_TRANSIENT_PATTERNS -# because these messages usually also contain "[SSL:". +# because these messages usually also contain "[SSL:". Last entry is the +# Node/undici phrasing (MCP bridges). _SSL_CERT_VERIFY_PATTERNS = ( "certificate verify failed", "certificate_verify_failed", "unable to get local issuer certificate", "self-signed certificate", "self signed certificate", "certificate has expired", - "hostname mismatch, certificate is not valid", - "unable to verify the first certificate", # Node/undici phrasing (MCP bridges) + "hostname mismatch, certificate is not valid", "unable to verify the first certificate", ) # Transient SSL alerts: retry but NOT compression (kept apart from @@ -414,34 +399,29 @@ def _first_match(error_msg: str, rules: Sequence[tuple[Sequence[str], Any]]) -> # shrink; corrupt bytes need strip-and-retry, not shrink; image-shrink is a # cheaper recovery than context compression for "exceeds" + "image" bodies. _IMAGE_TOOL_RULES = ( - (_MULTIMODAL_TOOL_CONTENT_PATTERNS, _V_MULTIMODAL), - (_IMAGE_CORRUPT_PATTERNS, _V_IMAGE_CORRUPT), + (_MULTIMODAL_TOOL_CONTENT_PATTERNS, _V_MULTIMODAL), (_IMAGE_CORRUPT_PATTERNS, _V_IMAGE_CORRUPT), (_IMAGE_TOO_LARGE_PATTERNS, _V_IMAGE_TOO_LARGE), ) # Overflow signals arriving as 5xx (llama.cpp reports overflow as 500; busy / # model-load OOM as 503). Empty-response advisories must not enter compression. _OVERFLOW_AS_5XX_RULES = ( - (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), - (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), + (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), ) # 404: Nous API surfaces credit depletion as a paid model vanishing from the # Free Tier (billing, not missing model); OpenRouter policy block before # model_not_found. _404_RULES = ( - (_BILLING_PATTERNS, _V_BILLING), - (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), + (_BILLING_PATTERNS, _V_BILLING), (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), ) # 400 tail after the deterministic request-shape checks. Some providers return # model-not-found / rate-limit / billing as 400 instead of 404/429/402. _400_TAIL_RULES = _OVERFLOW_AS_5XX_RULES + ( - (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), - (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), - (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), - (_BILLING_PATTERNS, _billing_hints), + (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), + (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), (_BILLING_PATTERNS, _billing_hints), ) # Status-less message path, head (before usage-limit disambiguation). @@ -452,16 +432,11 @@ _MESSAGE_HEAD_RULES = ((_PAYLOAD_TOO_LARGE_PATTERNS, _V_PAYLOAD_TOO_LARGE),) + _ # non-retryable (same key always fails); policy block before model_not_found; # timeout/connection wording last, classified as transport (never compression). _MESSAGE_TAIL_RULES = ( - (_OVERLOADED_PATTERNS, _V_OVERLOADED), - (_BILLING_PATTERNS, _billing_hints), - (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), - (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), - (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), - (_AUTH_PATTERNS, _V_AUTH_ROTATE), - (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), - (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), - (_TIMEOUT_MESSAGE_PATTERNS, _V_TIMEOUT), - (_CONNECTION_MESSAGE_PATTERNS, _V_TIMEOUT), + (_OVERLOADED_PATTERNS, _V_OVERLOADED), (_BILLING_PATTERNS, _billing_hints), + (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), + (_CONTEXT_OVERFLOW_PATTERNS, _V_CONTEXT_OVERFLOW), (_AUTH_PATTERNS, _V_AUTH_ROTATE), + (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), + (_TIMEOUT_MESSAGE_PATTERNS, _V_TIMEOUT), (_CONNECTION_MESSAGE_PATTERNS, _V_TIMEOUT), ) # Structured error code → verdict. The error-code rate_limit verdict rotates @@ -543,9 +518,7 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: # Anthropic thinking-block 400s: signature mismatch after any transcript # mutation, or "blocks in the latest assistant message cannot be modified". # Not gated on provider — OpenRouter proxies Anthropic errors. - if status == 400 and "thinking" in msg and any( - p in msg for p in ("signature", "cannot be modified", "must remain as they were") - ): + if status == 400 and "thinking" in msg and any(p in msg for p in _THINKING_MUTATION_WORDS): return _v(FailoverReason.thinking_signature) # Anthropic long-context tier gate (429 "extra usage" + "long context"). if status == 429 and "extra usage" in msg and "long context" in msg: @@ -559,19 +532,15 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: # Exclude the Qwen/vLLM "No user query found" template error that local # engines wrap as "Unable to generate parser for this template" — that is # a poisoned transcript (→ format_error), not a grammar problem. - grammar_hit = status == 400 and ( - "error parsing grammar" in msg - or "json-schema-to-grammar" in msg - or ("unable to generate parser" in msg and "template" in msg) + grammar_hit = "error parsing grammar" in msg or "json-schema-to-grammar" in msg or ( + "unable to generate parser" in msg and "template" in msg ) - if grammar_hit and _NO_USER_QUERY_SIGNAL not in msg: + if status == 400 and grammar_hit and _NO_USER_QUERY_SIGNAL not in msg: return _v(FailoverReason.llama_cpp_grammar_pattern) # xAI Grok subscription entitlement. As HTTP 403 the status path handles # it; as an SSE ``type=error`` frame there is no status and the message # matches no pattern list, so it would burn max_retries as ``unknown``. - if "do not have an active grok subscription" in msg or ( - "out of available resources" in msg and "grok" in msg - ): + if "do not have an active grok subscription" in msg or ("out of available resources" in msg and "grok" in msg): return _V_AUTH_FALLBACK return None @@ -579,17 +548,12 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: def _moa_special_cases(c: _Ctx) -> Optional[Verdict]: # Local MoA streaming adapter-shape bugs are not a provider outage; falling # back would silently replace the MoA route with a single model (#55933). - if c.provider_slug == "moa" and ( - "'types.SimpleNamespace' object is not iterable" in str(c.error) - or "'types.SimpleNamespace' object has no attribute 'index'" in str(c.error) - ): + if c.provider_slug == "moa" and any(s in str(c.error) for s in _MOA_ADAPTER_SHAPE_BUGS): return _v(FailoverReason.format_error, retryable=False) # Persisted MoA preset name that was renamed/deleted — deterministic config error. from agent.errors import MoAPresetNotFoundError - if isinstance(c.error, MoAPresetNotFoundError): - return _v(FailoverReason.model_not_found, retryable=False) - return None + return _v(FailoverReason.model_not_found, retryable=False) if isinstance(c.error, MoAPresetNotFoundError) else None def _by_error_code(c: _Ctx) -> Optional[Verdict]: @@ -653,11 +617,7 @@ def _by_status(c: _Ctx) -> Optional[Verdict]: handler = _STATUS_HANDLERS.get(c.status_code) if handler is not None: return handler(c) - if 400 <= c.status_code < 500: - return _V_FORMAT_ERROR - if 500 <= c.status_code < 600: - return _V_SERVER_ERROR - return None + return _V_FORMAT_ERROR if 400 <= c.status_code < 500 else _V_SERVER_ERROR if 500 <= c.status_code < 600 else None # Stage order: plugin hooks → provider-specific special cases → HTTP status → @@ -670,13 +630,8 @@ _STAGES: Sequence[Callable[[_Ctx], Optional[Verdict]]] = ( def classify_api_error( - error: Exception, - *, - provider: str = "", - model: str = "", - approx_tokens: int = 0, - context_length: int = 200000, - num_messages: int = 0, + error: Exception, *, provider: str = "", model: str = "", + approx_tokens: int = 0, context_length: int = 200000, num_messages: int = 0, ) -> ClassifiedError: """Classify an API error into a structured recovery recommendation (see ``_STAGES``).""" status_code = _extract_status_code(error) @@ -703,12 +658,8 @@ def classify_api_error( def _status_403(c: _Ctx) -> Verdict: # OpenRouter 403 "key limit exceeded" and similar plan/credit exhaustion are billing. - if ( - (c.provider_slug == "xai-oauth" and c.error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE) - or "key limit exceeded" in c.msg - or "spending limit" in c.msg - or any(p in c.msg for p in _BILLING_PATTERNS) - ): + xai_spend = c.provider_slug == "xai-oauth" and c.error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE + if xai_spend or any(p in c.msg for p in ("key limit exceeded", "spending limit") + _BILLING_PATTERNS): return _V_BILLING return _V_AUTH_FALLBACK @@ -733,22 +684,18 @@ def _status_429(c: _Ctx) -> Verdict: # to another model rather than rotating/benching the key. if _is_openrouter_upstream_error(c.body, c.provider_slug): upstream = _extract_upstream_provider_name(c.body) - return _v( - FailoverReason.upstream_rate_limit, should_fallback=True, - error_context={"upstream_provider": upstream} if upstream else {}, - ) + ctx = {"upstream_provider": upstream} if upstream else {} + return _v(FailoverReason.upstream_rate_limit, should_fallback=True, error_context=ctx) # Quota walls returned as 429 (Anthropic ``usage_limit_reached``, other # providers' "quota"/"limit exceeded", explicit billing phrases) are # billing — but ONLY when the body is not itself an explicit rate-limit # phrase ("Rate limit exceeded" contains "limit exceeded") and carries # no reset/retry signal (#93419, #39441). - has_usage_limit = ( - c.error_code.lower() == "usage_limit_reached" - or "usage_limit_reached" in c.msg - or any(p in c.msg for p in _USAGE_LIMIT_PATTERNS) + quota_wall = c.error_code.lower() == "usage_limit_reached" or any( + p in c.msg for p in ("usage_limit_reached",) + _USAGE_LIMIT_PATTERNS + _BILLING_PATTERNS ) if ( - (has_usage_limit or any(p in c.msg for p in _BILLING_PATTERNS)) + quota_wall and not any(p in c.msg for p in _RATE_LIMIT_PATTERNS) and not _has_usage_limit_transient_signal(c.msg, c.body, c.headers) ): @@ -760,10 +707,8 @@ def _status_5xx(c: _Ctx) -> Verdict: # Deterministic request-validation errors returned as 5xx (codex.nekos.me) # must fail fast, not retry-flood — unless the rejected parameter was # injected server-side (see _classify_400). - if ( - any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) - or c.error_code.lower() in _5XX_VALIDATION_CODES - ) and not _is_server_injected_param_rejection(c.msg, c.provider_slug): + validation = any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) or c.error_code.lower() in _5XX_VALIDATION_CODES + if validation and not _is_server_injected_param_rejection(c.msg, c.provider_slug): return _V_FORMAT_ERROR return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_SERVER_ERROR @@ -774,12 +719,10 @@ def _status_overloaded(c: _Ctx) -> Verdict: def _classify_402(error_msg: str, result_fn: Callable[..., Any]) -> Any: """Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing.""" - if ( - any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) - and any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS) - ): - return result_fn(**_V_RATE_LIMIT) - return result_fn(**_V_BILLING) + transient = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) and any( + p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS + ) + return result_fn(**(_V_RATE_LIMIT if transient else _V_BILLING)) def _classify_400(c: _Ctx) -> Verdict: @@ -791,12 +734,9 @@ def _classify_400(c: _Ctx) -> Verdict: # Invalid encrypted reasoning replay blob (OpenAI Responses). Before # context_overflow: "encrypted content … could not be verified" can trip # the overflow heuristics. - if ( - code == "invalid_encrypted_content" - or "invalid_encrypted_content" in msg - or ("encrypted content for item" in msg and "could not be verified" in msg) - or "could not decrypt the provided encrypted_content" in msg - ): + if code == "invalid_encrypted_content" or "invalid_encrypted_content" in msg or ( + "encrypted content for item" in msg and "could not be verified" in msg + ) or "could not decrypt the provided encrypted_content" in msg: return _V_INVALID_ENCRYPTED # A 400 blaming a field this route never sent (Codex OAuth backend injects # and then rejects prompt_cache_retention ~20% of the time): transient, @@ -839,18 +779,9 @@ def _classify_400(c: _Ctx) -> Verdict: # §15.5.9) — proxies in front of self-hosted backends emit it when generation # outruns the read window. Unlisted 4xx → format_error, 5xx → server_error. _STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = { - 400: _classify_400, - 401: lambda c: _V_AUTH_ROTATE, - 402: lambda c: _classify_402(c.msg, dict), - 403: _status_403, - 404: _status_404, - 408: lambda c: _V_TIMEOUT, - 413: lambda c: _V_PAYLOAD_TOO_LARGE, - 429: _status_429, - 500: _status_5xx, - 502: _status_5xx, - 503: _status_overloaded, - 529: _status_overloaded, + 400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict), + 403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE, + 429: _status_429, 500: _status_5xx, 502: _status_5xx, 503: _status_overloaded, 529: _status_overloaded, } @@ -911,20 +842,28 @@ def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool: return False +def _error_obj(body: Any) -> dict: + """``body["error"]`` when it is a dict, else ``{}``.""" + err = body.get("error") if isinstance(body, dict) else None + return err if isinstance(err, dict) else {} + + +def _json_dict(text: Any) -> Optional[dict]: + """Parse a JSON object string; None for non-strings, blanks, invalid JSON or non-objects.""" + if not (isinstance(text, str) and text.strip()): + return None + try: + inner = json.loads(text) + except (json.JSONDecodeError, TypeError): + return None + return inner if isinstance(inner, dict) else None + + def _openrouter_wrapped_message(err_obj: dict) -> str: """Lowercased inner message from OpenRouter's ``error.metadata.raw`` JSON wrapper.""" metadata = err_obj.get("metadata", {}) - raw = metadata.get("raw") or "" if isinstance(metadata, dict) else "" - if not (isinstance(raw, str) and raw.strip()): - return "" - try: - inner = json.loads(raw) - except (json.JSONDecodeError, TypeError): - return "" - inner_err = inner.get("error", {}) if isinstance(inner, dict) else None - if isinstance(inner_err, dict): - return str(inner_err.get("message") or "").lower() - return "" + inner = _json_dict(metadata.get("raw")) if isinstance(metadata, dict) else None + return str(_error_obj(inner).get("message") or "").lower() if inner else "" def _build_error_msg(error: Exception, body: Any) -> str: @@ -936,12 +875,9 @@ def _build_error_msg(error: Exception, body: Any) -> str: raw_msg = str(error).lower() body_msg = metadata_msg = "" if isinstance(body, dict): - err_obj = body.get("error", {}) - if isinstance(err_obj, dict): - body_msg = str(err_obj.get("message") or "").lower() - metadata_msg = _openrouter_wrapped_message(err_obj) - if not body_msg: - body_msg = str(body.get("message") or "").lower() + err_obj = _error_obj(body) + body_msg = str(err_obj.get("message") or "").lower() or str(body.get("message") or "").lower() + metadata_msg = _openrouter_wrapped_message(err_obj) if err_obj else "" parts = [raw_msg] if body_msg and body_msg not in raw_msg: parts.append(body_msg) @@ -952,61 +888,68 @@ def _build_error_msg(error: Exception, body: Any) -> str: def _body_message_candidates(body: dict) -> Iterator[Any]: """Body message fields in priority order (OpenAI, flat, litellm/Bedrock proxy shapes).""" - err_obj = body.get("error", {}) - yield err_obj.get("message") if isinstance(err_obj, dict) else None + yield _error_obj(body).get("message") yield body.get("message") yield body.get("errorMessage") args = body.get("errorArgs") yield args.get("reason") if isinstance(args, dict) else None -def _cause_chain(error: Exception) -> Iterator[Any]: - """Yield the error and its __cause__/__context__ chain, at most 5 deep.""" +def _from_cause_chain(error: Exception, pick: Callable[[Any], Any], default: Any) -> Any: + """First non-None ``pick(exc)`` over the error and its __cause__/__context__ chain (max 5 deep).""" current = error for _ in range(5): - yield current + found = pick(current) + if found is not None: + return found cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None) if cause is None or cause is current: - return + break current = cause + return default -def _extract_status_code(error: Exception) -> Optional[int]: - """Walk the error and its cause chain to find an HTTP status code.""" - for current in _cause_chain(error): - code = getattr(current, "status_code", None) - if isinstance(code, int): - return code - code = getattr(current, "status", None) # some SDKs use .status - if isinstance(code, int) and 100 <= code < 600: - return code +def _status_of(exc: Any) -> Optional[int]: + code = getattr(exc, "status_code", None) + if isinstance(code, int): + return code + code = getattr(exc, "status", None) # some SDKs use .status + return code if isinstance(code, int) and 100 <= code < 600 else None + + +def _body_of(exc: Any) -> Optional[dict]: + body = getattr(exc, "body", None) + if isinstance(body, dict): + return body + response = getattr(exc, "response", None) + if response is not None: + try: + json_body = response.json() + if isinstance(json_body, dict): + return json_body + except Exception: + pass return None +def _headers_of(exc: Any) -> Any: + headers = getattr(getattr(exc, "response", None), "headers", None) + return headers if headers and hasattr(headers, "get") else None + + +def _extract_status_code(error: Exception) -> Optional[int]: + """HTTP status code from the error or its cause chain.""" + return _from_cause_chain(error, _status_of, None) + + def _extract_error_body(error: Exception) -> dict: - """Extract the structured error body from an SDK exception or its cause chain.""" - for current in _cause_chain(error): - body = getattr(current, "body", None) - if isinstance(body, dict): - return body - response = getattr(current, "response", None) - if response is not None: - try: - json_body = response.json() - if isinstance(json_body, dict): - return json_body - except Exception: - pass - return {} + """Structured error body from an SDK exception or its cause chain.""" + return _from_cause_chain(error, _body_of, {}) def _extract_response_headers(error: Exception): - """Walk the error and its cause chain to find response headers.""" - for current in _cause_chain(error): - headers = getattr(getattr(current, "response", None), "headers", None) - if headers and hasattr(headers, "get"): - return headers - return {} + """Response headers from the error or its cause chain.""" + return _from_cause_chain(error, _headers_of, {}) def _code_from_payload(payload: Any, top_keys: Sequence[str], peek_message: bool) -> str: @@ -1024,11 +967,7 @@ def _code_from_payload(payload: Any, top_keys: Sequence[str], peek_message: bool return code.strip() message = error_obj.get("message") if peek_message and isinstance(message, str) and message.strip().startswith("{"): - try: - inner = json.loads(message) - except (json.JSONDecodeError, TypeError): - inner = None - nested_code = _code_from_payload(inner, ("code", "error_code"), False) + nested_code = _code_from_payload(_json_dict(message), ("code", "error_code"), False) if nested_code: return nested_code code = next((payload.get(k) for k in top_keys if payload.get(k)), "") @@ -1058,11 +997,7 @@ def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: The user's OpenRouter key is healthy — the upstream provider failed — so credential rotation is the wrong recovery. """ - if not isinstance(body, dict): - return False - err = body.get("error") - if not isinstance(err, dict): - return False + err = _error_obj(body) if str(err.get("message") or "").strip().lower() != "provider returned error": return False if (provider or "").strip().lower() == "openrouter": @@ -1074,9 +1009,6 @@ def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: def _extract_upstream_provider_name(body: Any) -> Optional[str]: """Pull the upstream provider name out of OpenRouter's error metadata.""" - err = body.get("error") if isinstance(body, dict) else None - metadata = err.get("metadata") if isinstance(err, dict) else None + metadata = _error_obj(body).get("metadata") name = metadata.get("provider_name") if isinstance(metadata, dict) else None - if isinstance(name, str) and name.strip(): - return name.strip() - return None + return name.strip() if isinstance(name, str) and name.strip() else None From d4781581ee3c4169e52053c94d85a40b92087c61 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:29:56 -0700 Subject: [PATCH 04/16] refactor(agent/display): pack constant tables, collapse spinner idle branches and preview wrappers --- agent/display.py | 122 +++++++++++++++++------------------------------ 1 file changed, 43 insertions(+), 79 deletions(-) diff --git a/agent/display.py b/agent/display.py index 61b0004691..2e3e9c14c3 100644 --- a/agent/display.py +++ b/agent/display.py @@ -99,6 +99,7 @@ class LocalEditSnapshot: # Configurable tool preview length; set once at startup from display.tool_preview_length. _tool_preview_max_len: int = 0 # 0 = unlimited +_friendly_tool_labels: bool = True def set_tool_preview_max_len(n: int) -> None: @@ -112,6 +113,12 @@ def get_tool_preview_max_len() -> int: return _tool_preview_max_len +def set_friendly_tool_labels(enabled: bool) -> None: + """Toggle friendly human-phrased tool labels (display.friendly_tool_labels).""" + global _friendly_tool_labels + _friendly_tool_labels = bool(enabled) + + def get_skin_tool_prefix() -> str: """Get tool output prefix character from active skin.""" skin = _get_skin() @@ -378,16 +385,12 @@ def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None: _PRIMARY_ARGS = { - "terminal": "command", "web_search": "query", "web_extract": "urls", - "read_file": "path", "write_file": "path", "patch": "path", - "search_files": "pattern", "browser_navigate": "url", - "browser_click": "ref", "browser_type": "text", - "image_generate": "prompt", "text_to_speech": "text", - "vision_analyze": "question", - "skill_view": "name", "skills_list": "category", - "cronjob_manage": "action", - "execute_code": "code", "browser_exec": "code", "delegate_task": "goal", - "clarify": "question", "skill_manage": "name", + "terminal": "command", "web_search": "query", "web_extract": "urls", "read_file": "path", + "write_file": "path", "patch": "path", "search_files": "pattern", "browser_navigate": "url", + "browser_click": "ref", "browser_type": "text", "image_generate": "prompt", "text_to_speech": "text", + "vision_analyze": "question", "skill_view": "name", "skills_list": "category", "cronjob_manage": "action", + "execute_code": "code", "browser_exec": "code", "delegate_task": "goal", "clarify": "question", + "skill_manage": "name", } _FALLBACK_PREVIEW_KEYS = ("query", "text", "command", "path", "name", "prompt", "code", "goal") @@ -417,25 +420,18 @@ def _preview_delegate_task(args: dict, max_len: int) -> str | None: preview = f"{len(goals)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" return _truncate_preview(preview, max_len) goal = args.get("goal", "") - if goal is None: - return None - return _truncate_preview(_oneline(str(goal)), max_len) or None + return None if goal is None else _truncate_preview(_oneline(str(goal)), max_len) or None def _preview_process_manage(args: dict, _max_len: int) -> str | None: - action = args.get("action", "") - sid = args.get("session_id", "") - data = args.get("data", "") - timeout_val = args.get("timeout") - parts = [str(action) if action else ""] - if sid: - parts.append(str(sid)[:16]) - if data: - parts.append(f'"{_oneline(str(data)[:20])}"') - if timeout_val and action == "wait": - parts.append(f"{timeout_val}s") - parts = [p for p in parts if p] - return " ".join(parts) if parts else None + action, sid, data, timeout_val = (args.get(k, d) for k, d in (("action", ""), ("session_id", ""), ("data", ""), ("timeout", None))) + parts = [ + str(action) if action else "", + str(sid)[:16] if sid else "", + f'"{_oneline(str(data)[:20])}"' if data else "", + f"{timeout_val}s" if timeout_val and action == "wait" else "", + ] + return " ".join(p for p in parts if p) or None def _preview_todo_list(args: dict, _max_len: int) -> str: @@ -448,9 +444,7 @@ def _preview_todo_list(args: dict, _max_len: int) -> str: def _preview_shell(key: str): def _build(args: dict, max_len: int) -> str | None: command = args.get(key) - if command is None: - return None - return _truncate_preview(summarize_shell_command(str(command)), max_len) or None + return None if command is None else _truncate_preview(summarize_shell_command(str(command)), max_len) or None return _build @@ -492,17 +486,12 @@ def _preview_skill_view(args: dict, max_len: int) -> str | None: # Tool-specific preview builders: f(args, max_len) -> preview. Tools not listed # fall through to the primary-argument lookup in build_tool_preview. _PREVIEW_BUILDERS = { - "browser_exec": _preview_browser_exec, - "delegate_task": _preview_delegate_task, - "process_manage": _preview_process_manage, - "todo_list": _preview_todo_list, - "terminal": _preview_shell("command"), - "execute_code": _preview_shell("code"), - "read_file": _preview_read_file, - "session_search": lambda args, _m: f"recall: \"{_clip(_oneline(args.get('query', '')), 25)}\"", - "memory": _preview_memory, - "send_message": _preview_send_message, + "browser_exec": _preview_browser_exec, "delegate_task": _preview_delegate_task, + "process_manage": _preview_process_manage, "todo_list": _preview_todo_list, + "terminal": _preview_shell("command"), "execute_code": _preview_shell("code"), + "read_file": _preview_read_file, "memory": _preview_memory, "send_message": _preview_send_message, "skill_view": _preview_skill_view, + "session_search": lambda args, _m: f"recall: \"{_clip(_oneline(args.get('query', '')), 25)}\"", } @@ -582,15 +571,6 @@ _TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({"skills_list", "session_sear # Verbs joined to the preview with " for " (search-style phrasing). _TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({"web_search", "search_files"}) -_friendly_tool_labels: bool = True - - -def set_friendly_tool_labels(enabled: bool) -> None: - """Toggle friendly human-phrased tool labels (display.friendly_tool_labels).""" - global _friendly_tool_labels - _friendly_tool_labels = bool(enabled) - - def get_tool_verb(tool_name: str) -> str | None: """Friendly verb for a built-in tool, or None (labels disabled / no curated verb). @@ -887,32 +867,21 @@ class KawaiiSpinner: """Animated spinner with kawaii faces for CLI feedback during tool execution.""" SPINNERS = { - 'dots': ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], - 'bounce': ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], - 'grow': ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂'], - 'arrows': ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], - 'star': ['✶', '✷', '✸', '✹', '✺', '✹', '✸', '✷'], - 'moon': ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], - 'pulse': ['◜', '◠', '◝', '◞', '◡', '◟'], - 'brain': ['🧠', '💭', '💡', '✨', '💫', '🌟', '💡', '💭'], - 'sparkle': ['⁺', '˚', '*', '✧', '✦', '✧', '*', '˚'], + 'dots': list('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'), 'bounce': list('⠁⠂⠄⡀⢀⠠⠐⠈'), 'grow': list('▁▂▃▄▅▆▇█▇▆▅▄▃▂'), + 'arrows': list('←↖↑↗→↘↓↙'), 'star': list('✶✷✸✹✺✹✸✷'), 'moon': list('🌑🌒🌓🌔🌕🌖🌗🌘'), + 'pulse': list('◜◠◝◞◡◟'), 'brain': list('🧠💭💡✨💫🌟💡💭'), 'sparkle': list('⁺˚*✧✦✧*˚'), } - KAWAII_WAITING = [ - "(。◕‿◕。)", "(◕‿◕✿)", "٩(◕‿◕。)۶", "(✿◠‿◠)", "( ˘▽˘)っ", - "♪(´ε` )", "(◕ᴗ◕✿)", "ヾ(^∇^)", "(≧◡≦)", "(★ω★)", + "(。◕‿◕。)", "(◕‿◕✿)", "٩(◕‿◕。)۶", "(✿◠‿◠)", "( ˘▽˘)っ", "♪(´ε` )", "(◕ᴗ◕✿)", "ヾ(^∇^)", "(≧◡≦)", "(★ω★)", ] - KAWAII_THINKING = [ - "(。•́︿•̀。)", "(◔_◔)", "(¬‿¬)", "( •_•)>⌐■-■", "(⌐■_■)", - "(´・_・`)", "◉_◉", "(°ロ°)", "( ˘⌣˘)♡", "ヽ(>∀<☆)☆", + "(。•́︿•̀。)", "(◔_◔)", "(¬‿¬)", "( •_•)>⌐■-■", "(⌐■_■)", "(´・_・`)", "◉_◉", "(°ロ°)", "( ˘⌣˘)♡", "ヽ(>∀<☆)☆", "٩(๑❛ᴗ❛๑)۶", "(⊙_⊙)", "(¬_¬)", "( ͡° ͜ʖ ͡°)", "ಠ_ಠ", ] - THINKING_VERBS = [ - "pondering", "contemplating", "musing", "cogitating", "ruminating", - "deliberating", "mulling", "reflecting", "processing", "reasoning", - "analyzing", "computing", "synthesizing", "formulating", "brainstorming", + "pondering", "contemplating", "musing", "cogitating", "ruminating", "deliberating", "mulling", + "reflecting", "processing", "reasoning", "analyzing", "computing", "synthesizing", "formulating", + "brainstorming", ] @staticmethod @@ -977,12 +946,9 @@ class KawaiiSpinner: return False def _is_patch_stdout_proxy(self) -> bool: - """True when stdout is prompt_toolkit's StdoutProxy. - - StdoutProxy queues writes and injects newlines around each flush, so the - \\r overwrite never lands: each frame would land on its own line. The CLI - drives its own TUI spinner widget in that mode, so we stay silent. - """ + """True when stdout is prompt_toolkit's StdoutProxy, which queues writes and + injects newlines around each flush so the \\r overwrite never lands; the CLI + drives its own TUI spinner widget in that mode, so we stay silent.""" try: from prompt_toolkit.patch_stdout import StdoutProxy return isinstance(self._out, StdoutProxy) @@ -990,16 +956,14 @@ class KawaiiSpinner: return False def _animate(self): + tty = self._is_tty # Non-TTY (Docker, systemd, pipe): log once instead of spamming frames. - if not self._is_tty: + if not tty: self._write(f" [tool] {self.message}", flush=True) - while self.running: - time.sleep(0.5) - return # Under patch_stdout the \r animation would overdraw the TUI status bar. - if self._is_patch_stdout_proxy(): + if not tty or self._is_patch_stdout_proxy(): while self.running: - time.sleep(0.1) + time.sleep(0.5 if not tty else 0.1) return skin = _get_skin() wings = skin.get_spinner_wings() if skin else [] From 282688e37e4792ba559bafa67c3bd317cd483ced Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:34:22 -0700 Subject: [PATCH 05/16] refactor(agent/error_surface,i18n): fold layer resolution into helper, collapse guard ladders (strings untouched) --- agent/error_surface.py | 49 ++++++++++------------------- agent/i18n.py | 70 ++++++++++++++---------------------------- 2 files changed, 40 insertions(+), 79 deletions(-) diff --git a/agent/error_surface.py b/agent/error_surface.py index 0e041b4791..146583b8cf 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -2,16 +2,11 @@ Maps the internal failure taxonomy (``FailoverReason`` values carried in turn results as ``failure_reason``, or raw exceptions from the turn dispatcher) -onto a small, stable wire descriptor:: - - {"layer": , "code": , "retryable": } - -Layers (wire values): provider (model API rejected/failed the call), endpoint -(user-configured custom/local endpoint transport failure), streaming (SSE -dropped mid-turn), auth, billing (fallback signal; clients usually have a -richer ``billing_block``), gateway (local runtime errored), disk (disk full / -persistence failure). - +onto a small, stable wire descriptor ``{"layer", "code", "retryable"}``. +Layers: provider (model API rejected/failed), endpoint (user-configured +custom/local endpoint transport failure), streaming (SSE dropped mid-turn), +auth, billing (fallback signal; clients usually have a richer +``billing_block``), gateway (local runtime errored), disk (disk full). Dependency-light and NEVER raises: surfacing diagnostics must not break the error path it describes. Descriptors are advisory — clients fall back to string sniffing when absent or partial. @@ -35,10 +30,7 @@ LAYER_DISK = "disk" # failure_reason → UI layer. Unlisted reasons fall back to LAYER_PROVIDER: # every FailoverReason comes from classifying a provider call. _REASON_TO_LAYER = { - "auth": LAYER_AUTH, - "auth_permanent": LAYER_AUTH, - "billing": LAYER_BILLING, - "billing_unverified": LAYER_BILLING, + "auth": LAYER_AUTH, "auth_permanent": LAYER_AUTH, "billing": LAYER_BILLING, "billing_unverified": LAYER_BILLING, } # Failures between us and the base_url (not a provider verdict); on a @@ -102,6 +94,15 @@ def _disk_full(candidate: Any) -> bool: return False +def _result_layer(reason: str, error_text: str, provider: str) -> str: + layer = _REASON_TO_LAYER.get(reason) + if layer is not None: + return layer + if reason in _TRANSPORT_REASONS and _is_custom_endpoint(provider): + return LAYER_ENDPOINT + return LAYER_STREAMING if _looks_like_stream_drop(error_text) else LAYER_PROVIDER + + def build_error_surface_from_result(result: Any, provider: str = "", model: str = "") -> Optional[dict]: """Descriptor for a returned-error turn result (``failed=True`` dicts). @@ -115,35 +116,23 @@ def build_error_surface_from_result(result: Any, provider: str = "", model: str reason = str(result.get("failure_reason") or "").strip() if not error_text and not reason: return None - # Disk-full wins outright: the fix (free space) is unrelated to the # provider stack; hermes_state owns the pattern list. if error_text and _disk_full(error_text): return _surface(LAYER_DISK, "disk_full", False, provider, model) - if result.get("billing_block") or reason in ("billing", "billing_unverified"): return _surface(LAYER_BILLING, reason or "billing", False, provider, model) - if not reason: # Failed result without a classified reason (legacy paths). if _looks_like_stream_drop(error_text): return _surface(LAYER_STREAMING, "stream_drop", True, provider, model) return _surface(LAYER_PROVIDER, "unknown", True, provider, model) - - layer = _REASON_TO_LAYER.get(reason) - if layer is None: - if reason in _TRANSPORT_REASONS and _is_custom_endpoint(provider): - layer = LAYER_ENDPOINT - elif _looks_like_stream_drop(error_text): - layer = LAYER_STREAMING - else: - layer = LAYER_PROVIDER # Prefer the classifier's own verdict (``failure_retryable``); the # reason-set fallback covers older results. retryable = result.get("failure_retryable") if not isinstance(retryable, bool): retryable = reason not in _NON_RETRYABLE_REASONS - return _surface(layer, reason, retryable, provider, model) + return _surface(_result_layer(reason, error_text, provider), reason, retryable, provider, model) except Exception: # pragma: no cover — never break the error path logger.debug("error_surface: result classification failed", exc_info=True) return None @@ -157,13 +146,9 @@ def build_error_surface_from_exception(exc: BaseException, provider: str = "", m """ try: message = str(exc) or type(exc).__name__ - if _disk_full(exc): return _surface(LAYER_DISK, "disk_full", False, provider, model) - - exc_module = type(exc).__module__ or "" - api_like = exc_module.split(".")[0] in _API_EXC_MODULE_PREFIXES or hasattr(exc, "status_code") - + api_like = (type(exc).__module__ or "").split(".")[0] in _API_EXC_MODULE_PREFIXES or hasattr(exc, "status_code") if not api_like or not isinstance(exc, Exception): return _surface(LAYER_GATEWAY, type(exc).__name__, True, provider, model) diff --git a/agent/i18n.py b/agent/i18n.py index 8402df3064..33a63e2785 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -66,27 +66,20 @@ def _locales_dir() -> Path: return candidate logger.warning( "HERMES_BUNDLED_LOCALES points to a non-directory path (%s); " - "falling back to bundled/source locale resolution", - override, + "falling back to bundled/source locale resolution", override, ) return Path(__file__).resolve().parent.parent / "locales" def _normalize_lang(value: Any) -> str: """Map a user-supplied value (code, alias, or regional tag like ``zh-CN``) to a supported code, else default.""" - if not isinstance(value, str): - return DEFAULT_LANGUAGE - key = value.strip().lower() - if not key: - return DEFAULT_LANGUAGE + key = value.strip().lower() if isinstance(value, str) else "" if key in SUPPORTED_LANGUAGES: return key if key in _LANGUAGE_ALIASES: return _LANGUAGE_ALIASES[key] base = key.split("-", 1)[0] # strip region suffix - if base in SUPPORTED_LANGUAGES: - return base - return DEFAULT_LANGUAGE + return base if base in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE def _cache_catalog(lang: str, flat: dict[str, str]) -> dict[str, str]: @@ -103,20 +96,18 @@ def _load_catalog(lang: str) -> dict[str, str]: return cached path = _locales_dir() / f"{lang}.yaml" + flat: dict[str, str] = {} if not path.is_file(): logger.debug("i18n catalog missing for %s at %s", lang, path) - return _cache_catalog(lang, {}) - - try: - import yaml - with path.open("r", encoding="utf-8") as f: - raw = yaml.safe_load(f) or {} - except Exception as exc: - logger.warning("Failed to load i18n catalog %s: %s", path, exc) - return _cache_catalog(lang, {}) - - flat: dict[str, str] = {} - _flatten_into(raw, "", flat) + else: + try: + import yaml + with path.open("r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + except Exception as exc: + logger.warning("Failed to load i18n catalog %s: %s", path, exc) + else: + _flatten_into(raw, "", flat) return _cache_catalog(lang, flat) @@ -124,8 +115,7 @@ def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None: # Non-string, non-dict leaves are ignored -- catalogs are text-only. if isinstance(node, dict): for key, value in node.items(): - child_key = f"{prefix}.{key}" if prefix else str(key) - _flatten_into(value, child_key, out) + _flatten_into(value, f"{prefix}.{key}" if prefix else str(key), out) elif isinstance(node, str): out[prefix] = node @@ -154,9 +144,7 @@ def reset_language_cache() -> None: def get_language() -> str: """Resolve the active language using env > config > default order.""" env_lang = os.environ.get("HERMES_LANGUAGE") - if env_lang: - return _normalize_lang(env_lang) - return _config_language_cached() or DEFAULT_LANGUAGE + return _normalize_lang(env_lang) if env_lang else _config_language_cached() or DEFAULT_LANGUAGE def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str: @@ -167,30 +155,18 @@ def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str: """ target = _normalize_lang(lang) if lang else get_language() value = _load_catalog(target).get(key) - if value is None and target != DEFAULT_LANGUAGE: value = _load_catalog(DEFAULT_LANGUAGE).get(key) - if value is None: logger.debug("i18n miss: key=%r lang=%r", key, target) value = key - - if format_kwargs: - try: - return value.format(**format_kwargs) - except (KeyError, IndexError, ValueError) as exc: - logger.warning( - "i18n format failed for key=%r lang=%r kwargs=%r: %s", - key, target, format_kwargs, exc, - ) - return value - return value + if not format_kwargs: + return value + try: + return value.format(**format_kwargs) + except (KeyError, IndexError, ValueError) as exc: + logger.warning("i18n format failed for key=%r lang=%r kwargs=%r: %s", key, target, format_kwargs, exc) + return value -__all__ = [ - "SUPPORTED_LANGUAGES", - "DEFAULT_LANGUAGE", - "t", - "get_language", - "reset_language_cache", -] +__all__ = ["SUPPORTED_LANGUAGES", "DEFAULT_LANGUAGE", "t", "get_language", "reset_language_cache"] From eac3b11dde11b64f3fb6d5be6e27a307f6d785d9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:49:20 -0700 Subject: [PATCH 06/16] refactor(agent/error_classifier,display): compact WHY comments to 1-3 lines, collapse guard ladders and small wrappers --- agent/display.py | 122 ++++++-------- agent/error_classifier.py | 327 +++++++++++++++----------------------- 2 files changed, 171 insertions(+), 278 deletions(-) diff --git a/agent/display.py b/agent/display.py index 2e3e9c14c3..2d25046500 100644 --- a/agent/display.py +++ b/agent/display.py @@ -33,6 +33,15 @@ def _display_url(value: Any) -> str: return value.strip() if isinstance(value, str) else "" +def _http_url(candidate: str) -> str | None: + """``candidate`` when it parses as an absolute http(s) URL, else None.""" + try: + parsed = urlsplit(candidate) + except ValueError: + return None + return candidate if parsed.scheme.lower() in {"http", "https"} and parsed.netloc else None + + def _hex_rgb(h: str) -> tuple[int, int, int]: return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) @@ -217,13 +226,13 @@ def _split_shell_words(segment: str) -> list[str]: def _strip_shell_pipe_tail(segment: str) -> str: + """Drop a trailing ``| head/tail/wc/sort/uniq ...`` pipeline stage.""" words = _split_shell_words(segment) - out: list[str] = [] for i, word in enumerate(words): if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS: + words = words[:i] break - out.append(word) - return " ".join(out).strip() + return " ".join(words).strip() def _split_shell_compound(command: str) -> list[str]: @@ -255,11 +264,11 @@ def _split_shell_compound(command: str) -> list[str]: def _shell_head_word(segment: str) -> str: + """Command name of a segment, skipping leading ``VAR=value`` assignments.""" words = _split_shell_words(segment) - index = 0 - while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]): - index += 1 - return _shell_basename(words[index] if index < len(words) else "") + while words and re.match(r"^[A-Za-z_]\w*=", words[0]): + words.pop(0) + return _shell_basename(words[0] if words else "") def _clean_shell_segment(segment: str) -> str: @@ -302,20 +311,15 @@ def summarize_shell_command(command: str) -> str: core.append(cleaned) if not core: return original - if len(core) == 1: - return core[0] count = len(core) - 1 - return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}" + return core[0] if not count else f"{core[0]} + {count} {'command' if count == 1 else 'commands'}" def _read_file_line_label(args: dict) -> str: - offset = args.get("offset") - limit = args.get("limit") + offset, limit = args.get("offset"), args.get("limit") if not isinstance(offset, int) or offset <= 0: return "" - if not isinstance(limit, int) or limit <= 1: - return f"L{offset}" - return f"L{offset}-{offset + limit - 1}" + return f"L{offset}-{offset + limit - 1}" if isinstance(limit, int) and limit > 1 else f"L{offset}" def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any: @@ -370,18 +374,11 @@ def _delegate_task_goals(tasks: Any, *, per_goal_len: int) -> list[str]: def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None: """User-friendly step label from browser_exec code's leading comment.""" - code = str(args.get("code", "") or "").strip() - if not code: - return None - first = code.split("\n", 1)[0].strip() - if not first.startswith("#"): - return None - label = first.lstrip("#").strip() + first = str(args.get("code", "") or "").strip().split("\n", 1)[0].strip() + label = first.lstrip("#").strip() if first.startswith("#") else "" if not label: return None - if len(label) > max_chars: - label = label[: max_chars - 1] + "…" - return label + return label[: max_chars - 1] + "…" if len(label) > max_chars else label _PRIMARY_ARGS = { @@ -477,10 +474,8 @@ def _preview_send_message(args: dict, _max_len: int) -> str: def _preview_skill_view(args: dict, max_len: int) -> str | None: name = _oneline(str(args.get("name") or "")) file_path = args.get("file_path") - if file_path: - file_path = _oneline(str(file_path)) - return _truncate_preview(f"{name} → {file_path}" if name else file_path, max_len) or None - return _truncate_preview(name, max_len) or None + label = (f"{name} → {_oneline(str(file_path))}" if name else _oneline(str(file_path))) if file_path else name + return _truncate_preview(label, max_len) or None # Tool-specific preview builders: f(args, max_len) -> preview. Tools not listed @@ -536,15 +531,7 @@ def prepare_tool_preview( full_text = build_tool_preview(tool_name, args, max_len=0) or fallback text = _truncate_preview(full_text, max_len) truncated = text != full_text - url = None - if truncated: - candidate = _display_url(full_text) - try: - parsed = urlsplit(candidate) - except ValueError: - parsed = None - if parsed and parsed.scheme.lower() in {"http", "https"} and parsed.netloc: - url = candidate + url = _http_url(_display_url(full_text)) if truncated else None return ToolPreview(text=text, truncated=truncated, url=url) @@ -669,13 +656,11 @@ def _resolve_skill_manage_paths(args: dict) -> list[Path]: return [] skill_dir = Path(existing["path"]) file_path = args.get("file_path") - if action in {"edit", "patch"}: - return [skill_dir / file_path] if file_path else [skill_dir / "SKILL.md"] - if action in {"write_file", "remove_file"}: - return [skill_dir / file_path] if file_path else [] if action == "delete": return [path for path in sorted(skill_dir.rglob("*")) if path.is_file()] - return [] + if file_path and action in {"edit", "patch", "write_file", "remove_file"}: + return [skill_dir / file_path] + return [skill_dir / "SKILL.md"] if action in {"edit", "patch"} else [] def _resolve_local_edit_paths(tool_name: str, function_args: dict | None) -> list[Path]: @@ -741,10 +726,9 @@ def extract_edit_diff( """Extract a unified diff from a file-edit tool result.""" if tool_name == "patch" and result: data = safe_json_loads(result) - if isinstance(data, dict): - diff = data.get("diff") - if isinstance(diff, str) and diff.strip(): - return diff + diff = data.get("diff") if isinstance(data, dict) else None + if isinstance(diff, str) and diff.strip(): + return diff if tool_name not in {"write_file", "patch", "skill_manage"} or not _result_succeeded(result): return None return _diff_from_snapshot(snapshot) @@ -897,17 +881,9 @@ class KawaiiSpinner: pass return fallback - @classmethod - def get_waiting_faces(cls) -> list: - return cls._skin_spinner_list("waiting_faces", cls.KAWAII_WAITING) - - @classmethod - def get_thinking_faces(cls) -> list: - return cls._skin_spinner_list("thinking_faces", cls.KAWAII_THINKING) - - @classmethod - def get_thinking_verbs(cls) -> list: - return cls._skin_spinner_list("thinking_verbs", cls.THINKING_VERBS) + get_waiting_faces = classmethod(lambda cls: cls._skin_spinner_list("waiting_faces", cls.KAWAII_WAITING)) + get_thinking_faces = classmethod(lambda cls: cls._skin_spinner_list("thinking_faces", cls.KAWAII_THINKING)) + get_thinking_verbs = classmethod(lambda cls: cls._skin_spinner_list("thinking_verbs", cls.THINKING_VERBS)) def __init__(self, message: str = "", spinner_type: str = 'dots', print_fn=None): self.message = message @@ -1019,10 +995,7 @@ class KawaiiSpinner: self._write(f"\r{self._clear_line_blanks()}\r", end='', flush=True) if final_message: elapsed = f" ({time.time() - self.start_time:.1f}s)" if self.start_time else "" - if is_tty: - self._write(f" {final_message}", flush=True) - else: - self._write(f" [done] {final_message}{elapsed}", flush=True) + self._write(f" {final_message}" if is_tty else f" [done] {final_message}{elapsed}", flush=True) def __enter__(self): self.start() @@ -1058,12 +1031,11 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] # Terminal: non-zero exit code is the canonical failure signal. if tool_name == "terminal": - if isinstance(data, dict): - exit_code = data.get("exit_code") - if exit_code is not None and exit_code != 0: - err_msg = data.get("error") - return True, f" [{_trim_error(str(err_msg))}]" if err_msg else f" [exit {exit_code}]" - return False, "" + exit_code = data.get("exit_code") if isinstance(data, dict) else None + if exit_code is None or exit_code == 0: + return False, "" + err_msg = data.get("error") + return True, f" [{_trim_error(str(err_msg))}]" if err_msg else f" [exit {exit_code}]" if isinstance(data, dict): # Memory: distinguish "store full" from real errors. @@ -1111,15 +1083,11 @@ def _cute_web_extract(a: dict, _r) -> str: def _cute_todo_list(a: dict, result) -> str: todos_arg = a.get("todos") total = done = 0 - if result: - try: - data = safe_json_loads(result) - if data: - s = data.get("summary", {}) - total = s.get("total", 0) - done = s.get("completed", 0) - except Exception: - pass + try: + summary = (safe_json_loads(result) or {}).get("summary", {}) if result else {} + total, done = summary.get("total", 0), summary.get("completed", 0) + except Exception: + pass if todos_arg is None: detail = f"{done}/{total} task(s)" if total > 0 else "reading tasks" elif a.get("merge", False): diff --git a/agent/error_classifier.py b/agent/error_classifier.py index e25227bf3f..d28cdb2530 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -90,15 +90,13 @@ _BILLING_PATTERNS = ( "model_not_supported_on_free_tier", "not available on the free tier", ) -# Billing matches that are NOT proof of exhaustion: Anthropic returns the same -# "out of extra usage" body for a content-filter rejection (#82154). Verdict -# stays ``billing`` but error_context marks it unverified so surfaces hedge -# and the credential pool uses a short cooldown instead of the 1h bench. +# Not proof of exhaustion: Anthropic returns the same "out of extra usage" body +# for a content-filter rejection (#82154). Verdict stays ``billing`` but is +# marked unverified so surfaces hedge and the pool uses a short cooldown. _UNVERIFIED_BILLING_PATTERNS = ("out of extra usage",) -# xAI's explicit Grok credit-exhaustion code, returned as HTTP 403 rather than -# 402. The 403 special case stays provider-scoped: other providers' billing -# codes on a 403 remain auth failures. +# xAI's Grok credit-exhaustion code arrives as HTTP 403, not 402. Provider- +# scoped on purpose: other providers' billing codes on a 403 stay auth failures. _XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit" # Structured codes meaning the account cannot serve paid traffic. @@ -108,9 +106,8 @@ _BILLING_ERROR_CODES = frozenset({ "member_spend_cap_exceeded", _XAI_SPENDING_LIMIT_ERROR_CODE, }) -# Rate limiting (transient, will resolve). Bedrock "Throttling error: Too many -# tokens..." also contains the overflow phrase "too many tokens"; rate limit is -# matched first so throttle wins. "rate increased too quickly" is DashScope. +# Transient rate limiting. Bedrock "Throttling error: Too many tokens" also +# contains an overflow phrase; rate limit is matched first so throttle wins. _RATE_LIMIT_PATTERNS = ( "rate limit", "rate_limit", "too many requests", "throttled", "requests per minute", "tokens per minute", "requests per day", "try again in", "please retry after", "resource_exhausted", @@ -118,10 +115,9 @@ _RATE_LIMIT_PATTERNS = ( "servicequotaexceededexception", "throttling", ) -# Provider-side overload: the credential is valid, the server is busy, so back -# off and retry the same key — never rotate. Some providers (Z.AI/Zhipu) reuse -# HTTP 429 for this, so the 429 path checks these first. Kept narrow so a -# normal "you have been rate-limited" doesn't land here. (#14038, #15297) +# Server busy, credential fine: back off on the same key, never rotate. Z.AI/ +# Zhipu reuse HTTP 429 for this, so the 429 path checks these first. Kept narrow +# so a plain "you have been rate-limited" doesn't land here. (#14038, #15297) _OVERLOADED_PATTERNS = ( "overloaded", "temporarily overloaded", "service is temporarily overloaded", "service may be temporarily overloaded", "server is overloaded", "server overloaded", @@ -137,49 +133,41 @@ _USAGE_LIMIT_TRANSIENT_SIGNALS = ( "wait", "requests remaining", "periodic", "window", "per minute", "per second", ) -# Payload-too-large detected from message text (proxies embed the status); -# "request_too_large" is Anthropic's 413 type re-wrapped without a status. +# 413 detected from message text (proxies embed the status or re-wrap +# Anthropic's "request_too_large" type without one). _PAYLOAD_TOO_LARGE_PATTERNS = ( "request entity too large", "payload too large", "error code: 413", "request_too_large", "request exceeds the maximum size", ) -# Image-size rejections. Matched on 400 bodies (not 413): providers return a -# specific 400 before the whole request hits the size limit (Anthropic: hard -# 5 MB per image, "image exceeds 5 MB maximum"; dimension cap 8000 px). -# MiniMax Anthropic-compat says "media exceeds size limit" (#76039); a -# non-image media rejection landing here is harmless: the shrink pass finds -# no image parts and the original error surfaces. +# Per-image size/dimension 400s (Anthropic 5 MB / 8000 px; MiniMax "media +# exceeds size limit" #76039) — a specific 400 before the request hits 413. A +# non-image media hit is harmless: the shrink pass finds no image parts. _IMAGE_TOO_LARGE_PATTERNS = ( "image exceeds", "image too large", "image_too_large", "image size exceeds", "image dimensions exceed", "dimensions exceed max allowed size", "max allowed size: 8000", "media exceeds", "media too large", ) -# Image bytes undecodable (e.g. re-serialized history lost data). Shrinking -# can't fix corruption, so these route to strip-and-retry, never shrink. -# xAI wordings (#69078); the last is the full sentence on purpose — shorter -# fragments also match non-image download failures. +# Undecodable image bytes → strip-and-retry, never shrink. xAI wordings +# (#69078); the last is the full sentence because shorter fragments also match +# non-image download failures. _IMAGE_CORRUPT_PATTERNS = ( "invalid png image", "invalid jpeg image", "base64 string of provided image cannot be decoded", "downloaded response does not contain a valid jpg, png, webp, or ico image", ) -# Providers that reject list-type ``content`` in tool messages with a 400 -# (Xiaomi MiMo "Param Incorrect ... text is not set", some Alibaba endpoints, -# OpenAI-compat long tail). Recovery: strip image parts from tool messages, -# remember (provider, model), retry. (#27344) +# 400s rejecting list-type ``content`` in tool messages (Xiaomi MiMo "text is +# not set", Alibaba, OpenAI-compat long tail). Recovery: strip image parts from +# tool messages, remember (provider, model), retry. (#27344) _MULTIMODAL_TOOL_CONTENT_PATTERNS = ( "text is not set", "tool message content must be a string", "tool content must be a string", "tool message must be a string", "expected string, got list", "expected string, got array", "tool_call.content must be string", ) -# Bare "max_tokens" is load-bearing: the output-cap-retry path keys off it. -# Empty-response advisories mentioning it are intercepted earlier by -# _EMPTY_PROVIDER_RESPONSE_PATTERNS, so they never route into compression. -# Groups: generic; vLLM/local servers; Ollama; llama.cpp ("slot context: N -# tokens"); Chinese wordings; Z.AI/Zhipu (code 1210); Bedrock Converse; -# Together/Fireworks ("Input length N exceeds the maximum allowed input length"). +# Bare "max_tokens" is load-bearing: the output-cap-retry path keys off it; +# empty-response advisories mentioning it are intercepted earlier. Groups: +# generic; vLLM; Ollama; llama.cpp; Chinese; Z.AI (1210); Bedrock; Together. _CONTEXT_OVERFLOW_PATTERNS = ( "context length", "context size", "maximum context", "token limit", "too many tokens", "reduce the length", "exceeds the limit", "context window", "prompt is too long", @@ -193,43 +181,37 @@ _CONTEXT_OVERFLOW_PATTERNS = ( "maximum allowed input length", ) -# "no endpoints found that support tool use" is OpenRouter's 404 when no -# endpoint supports tool calling; model_not_found triggers fallback instead of -# burning retries (#58446). +# Last entry: OpenRouter 404 when no endpoint supports tool calling — +# model_not_found triggers fallback instead of burning retries (#58446). _MODEL_NOT_FOUND_PATTERNS = ( "is not a valid model", "invalid model", "model not found", "model_not_found", "does not exist", "no such model", "unknown model", "unsupported model", "no endpoints found that support tool use", ) -# Qwen/vLLM chat-template raise_exception("No user query found in messages"). -# Shared by _INVALID_MESSAGE_BODY_PATTERNS (→ format_error) and the llama.cpp -# grammar exclusion guard so the two sites cannot drift. +# Qwen/vLLM chat-template "No user query found". Shared by the invalid-body +# table (→ format_error) and the llama.cpp grammar guard so they cannot drift. _NO_USER_QUERY_SIGNAL = "no user query found" -# Malformed-message-array 400s: deterministic rejections of the *transcript* -# (e.g. a content-less assistant stub after a dead stream). NOT context -# overflow — the input may be tiny — so they must fail fast as format_error -# instead of thrashing the compression loop. Compression cannot invent a -# missing user turn, and local engines may wrap that as a grammar error. +# Deterministic rejections of the *transcript* (e.g. a content-less assistant +# stub after a dead stream). NOT overflow — input may be tiny and compression +# cannot invent a missing turn — so fail fast as format_error. _INVALID_MESSAGE_BODY_PATTERNS = ( "must have non-empty content", "messages must have non-empty", "invalid_request_body", "text content blocks must be non-empty", "content field is required", "messages: at least one message is required", _NO_USER_QUERY_SIGNAL, ) -# Request-validation signals: malformed request, identical on every retry. -# Some gateways (codex.nekos.me) return these as 5xx, so the 5xx path also -# checks them to avoid a retry flood on a deterministic rejection. +# Malformed request, identical on every retry. Some gateways (codex.nekos.me) +# return these as 5xx, so the 5xx path also checks them. _REQUEST_VALIDATION_PATTERNS = ( "unknown parameter", "unsupported parameter", "unrecognized request argument", "invalid_request_error", "unknown_parameter", "unsupported_parameter", ) -# Parameters Hermes sends on SOME routes only → hosts where sending them is -# deliberate. A rejection from any other host means the provider's own gateway -# injected the field, so the 400 is a server-side flake, not our request shape. -# ``prompt_cache_retention``: only sent for api.meta.ai / bedrock-mantle -# (agent/transports/codex.py); the Codex OAuth backend rejects it spontaneously. +# Parameters Hermes sends on SOME routes only → hosts where that is deliberate. +# A rejection from any other host means the provider's gateway injected the +# field itself: a server-side flake, not our request shape. prompt_cache_retention +# is only sent for api.meta.ai / bedrock-mantle (agent/transports/codex.py). _SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = { "prompt_cache_retention": ("meta", "muse", "msl", "model-api", "bedrock", "mantle"), } @@ -243,23 +225,18 @@ _MOA_ADAPTER_SHAPE_BUGS = ( "'types.SimpleNamespace' object is not iterable", "'types.SimpleNamespace' object has no attribute 'index'", ) -# OpenRouter 404 when the account privacy setting (or per-request -# ``provider.data_collection: deny``) excludes the only endpoint for a model. -# Not model_not_found: the model exists, fallback can't help (account-level), -# and the body already carries the fix URL. +# OpenRouter 404 when the account data policy excludes the only endpoint. Not +# model_not_found: the model exists, fallback can't help, body has the fix URL. _PROVIDER_POLICY_BLOCKED_PATTERNS = ( "no endpoints available matching your guardrail", "no endpoints available matching your data policy", "no endpoints found matching your data policy", ) -# Per-prompt provider safety-filter blocks (distinct from the account-level -# provider_policy_blocked). Deterministic for the unchanged request, so -# fallback immediately. Each phrase is verbatim from a specific provider — -# never a generic word like "policy" that could collide with billing/auth: -# OpenAI Codex cyber flags (#18028, may arrive without an HTTP status); OpenAI -# moderation; Anthropic safety system; the OpenAI-standard/Azure token -# "content_filter" (deliberately NOT the space variant, which appears in -# benign echoed config text); MiniMax "output new_sensitive (1027)" (#32421). +# Per-prompt safety-filter blocks: deterministic for the unchanged request, so +# fallback immediately. Each phrase is verbatim from one provider (Codex cyber +# flags #18028, OpenAI moderation, Anthropic safety, Azure token, MiniMax +# #32421) — never a generic word like "policy" that collides with billing/auth. +# "content_filter" deliberately excludes the space variant seen in echoed config. _CONTENT_POLICY_BLOCKED_PATTERNS = ( "flagged for possible cybersecurity risk", "trusted access for cyber", "violates our usage policies", "violates openai's usage policies", "your request was flagged by", @@ -273,27 +250,23 @@ _AUTH_PATTERNS = ( "forbidden", "invalid token", "token expired", "token revoked", "access denied", ) -# Provider empty-response advisories (OpenRouter / nano-gpt / similar). Checked -# before context-overflow matching because the text often mentions -# "max_tokens", which used to send healthy sessions into a compression spiral. +# Empty-response advisories (OpenRouter / nano-gpt). Checked before overflow +# because the text often mentions "max_tokens" (caused compression spirals). _EMPTY_PROVIDER_RESPONSE_PATTERNS = ( "returned an empty response", "empty response despite retries", "provider returned an empty response", "model returning empty responses", "empty response stream", ) -# Timeout wording from generic exception types (RuntimeError from a shim -# wrapping a subprocess timeout) that the type-based heuristics would miss. +# Timeout wording from generic exception types the type heuristics would miss. _TIMEOUT_MESSAGE_PATTERNS = ( "timed out", "turn timed out", "request timed out", "deadline exceeded", "operation timed out", "upstream timed out", ) -# Connect/DNS failures surfaced by generic exception types with no status, so -# _TRANSPORT_ERROR_TYPES never fires. Deliberately EXCLUDES mid-stream -# disconnect strings — those belong to _SERVER_DISCONNECT_PATTERNS, which may -# route large sessions to compression; a never-established connection cannot -# be an overflow rejection. Groups: TCP connect; DNS (Python/glibc/macOS/Node); -# Node/undici bridge (MCP servers, local shims); Envoy/proxy upstream connect. +# Connect/DNS failures from generic exception types with no status. EXCLUDES +# mid-stream disconnects (_SERVER_DISCONNECT_PATTERNS may route large sessions +# to compression; a never-established connection cannot be an overflow). +# Groups: TCP connect; DNS (Python/glibc/macOS/Node); undici bridge; Envoy. _CONNECTION_MESSAGE_PATTERNS = ( "connection refused", "econnrefused", "no route to host", "network is unreachable", "network unreachable", "name or service not known", "temporary failure in name resolution", "nodename nor servname provided", @@ -302,9 +275,8 @@ _CONNECTION_MESSAGE_PATTERNS = ( "upstream connect error", ) -# SSL type names are listed so provider-wrapped SSL errors (chain lost) still -# classify as transport instead of unknown; OpenAI SDK errors are not -# subclasses of Python builtins. +# SSL names keep provider-wrapped SSL errors (chain lost) as transport, not +# unknown; OpenAI SDK errors are not subclasses of Python builtins. _TRANSPORT_ERROR_TYPES = frozenset({ "ReadTimeout", "ConnectTimeout", "PoolTimeout", "ConnectError", "RemoteProtocolError", "ConnectionError", "ConnectionResetError", "ConnectionAbortedError", "BrokenPipeError", @@ -320,21 +292,17 @@ _SERVER_DISCONNECT_PATTERNS = ( "network connection lost", "unexpected eof", "incomplete chunked read", ) -# SSL certificate verification failures are deterministic (proxy, missing CA, -# expired/self-signed cert) — fail fast. Checked BEFORE _SSL_TRANSIENT_PATTERNS -# because these messages usually also contain "[SSL:". Last entry is the -# Node/undici phrasing (MCP bridges). +# Deterministic cert failures (proxy, missing CA, expired/self-signed) — fail +# fast. Checked BEFORE _SSL_TRANSIENT_PATTERNS: these also contain "[SSL:". _SSL_CERT_VERIFY_PATTERNS = ( "certificate verify failed", "certificate_verify_failed", "unable to get local issuer certificate", "self-signed certificate", "self signed certificate", "certificate has expired", "hostname mismatch, certificate is not valid", "unable to verify the first certificate", ) -# Transient SSL alerts: retry but NOT compression (kept apart from -# _SERVER_DISCONNECT_PATTERNS). Matched on stable substrings because OpenSSL 3 -# changed token separators (SSLV3_ALERT_... → SSL/TLS_ALERT_...); both the -# space-separated human form and the underscore OpenSSL tokens are listed, -# plus the Python ssl module prefix "[SSL: BAD_RECORD_MAC]". +# Transient SSL alerts: retry but NOT compression (kept apart from disconnects). +# Both space and underscore forms because OpenSSL 3 changed token separators +# (SSLV3_ALERT_... → SSL/TLS_ALERT_...); "[ssl:" is the Python ssl prefix. _SSL_TRANSIENT_PATTERNS = ( "bad record mac", "ssl alert", "tls alert", "ssl handshake failure", "tlsv1 alert", "sslv3 alert", "bad_record_mac", "ssl_alert", "tls_alert", "tls_alert_internal_error", "[ssl:", @@ -342,12 +310,10 @@ _SSL_TRANSIENT_PATTERNS = ( # ── Verdicts and rule tables ──────────────────────────────────────────── -# # A verdict is the ClassifiedError kwargs a stage decided on: ``reason`` plus -# the hint overrides (unlisted hints keep the dataclass defaults: retryable -# True, everything else False/empty). Rule tables are ordered -# ``(patterns, verdict)`` pairs matched first-hit; ``verdict`` may be a -# callable of the error message. +# hint overrides (unlisted hints keep dataclass defaults). Rule tables are +# ordered ``(patterns, verdict)`` pairs matched first-hit; ``verdict`` may be +# a callable of the error message. Verdict = Dict[str, Any] @@ -395,9 +361,8 @@ def _first_match(error_msg: str, rules: Sequence[tuple[Sequence[str], Any]]) -> return None -# Image/tool-content 400s, ordered: multimodal recovery differs from image -# shrink; corrupt bytes need strip-and-retry, not shrink; image-shrink is a -# cheaper recovery than context compression for "exceeds" + "image" bodies. +# Image/tool-content 400s, ordered: multimodal recovery ≠ image shrink; corrupt +# bytes need strip not shrink; image-shrink is cheaper than context compression. _IMAGE_TOOL_RULES = ( (_MULTIMODAL_TOOL_CONTENT_PATTERNS, _V_MULTIMODAL), (_IMAGE_CORRUPT_PATTERNS, _V_IMAGE_CORRUPT), (_IMAGE_TOO_LARGE_PATTERNS, _V_IMAGE_TOO_LARGE), @@ -410,8 +375,7 @@ _OVERFLOW_AS_5XX_RULES = ( ) # 404: Nous API surfaces credit depletion as a paid model vanishing from the -# Free Tier (billing, not missing model); OpenRouter policy block before -# model_not_found. +# Free Tier (billing, not missing model); policy block before model_not_found. _404_RULES = ( (_BILLING_PATTERNS, _V_BILLING), (_PROVIDER_POLICY_BLOCKED_PATTERNS, _V_POLICY_BLOCKED), (_MODEL_NOT_FOUND_PATTERNS, _V_MODEL_NOT_FOUND), @@ -427,10 +391,9 @@ _400_TAIL_RULES = _OVERFLOW_AS_5XX_RULES + ( # Status-less message path, head (before usage-limit disambiguation). _MESSAGE_HEAD_RULES = ((_PAYLOAD_TOO_LARGE_PATTERNS, _V_PAYLOAD_TOO_LARGE),) + _IMAGE_TOOL_RULES -# Status-less message path, tail. Overload before rate_limit/billing so a -# message-only "overloaded" backs off instead of rotating; auth is -# non-retryable (same key always fails); policy block before model_not_found; -# timeout/connection wording last, classified as transport (never compression). +# Status-less tail. Overload before rate_limit/billing so "overloaded" backs off +# instead of rotating; policy block before model_not_found; timeout/connection +# wording last, classified as transport (never compression). _MESSAGE_TAIL_RULES = ( (_OVERLOADED_PATTERNS, _V_OVERLOADED), (_BILLING_PATTERNS, _billing_hints), (_RATE_LIMIT_PATTERNS, _V_RATE_LIMIT), (_EMPTY_PROVIDER_RESPONSE_PATTERNS, _V_SERVER_ERROR), @@ -450,10 +413,10 @@ _ERROR_CODE_VERDICTS: Dict[str, Verdict] = { "invalid_encrypted_content": _V_INVALID_ENCRYPTED, } -_5XX_VALIDATION_CODES = {"invalid_request_error", "unknown_parameter", "unsupported_parameter"} -_400_VALIDATION_CODES = {"unknown_parameter", "unsupported_parameter"} # Generic ``invalid_request_error`` is deliberately NOT a 400 validation # signal — OpenAI stamps it on genuine overflow 400s too. +_400_VALIDATION_CODES = {"unknown_parameter", "unsupported_parameter"} +_5XX_VALIDATION_CODES = _400_VALIDATION_CODES | {"invalid_request_error"} _400_VALIDATION_PATTERNS = tuple(p for p in _REQUEST_VALIDATION_PATTERNS if p != "invalid_request_error") @@ -486,9 +449,9 @@ class _Ctx: def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: - """First valid plugin classification, so a provider plugin can add or - correct classifications before the built-in pipeline. invoke_hook - isolates callback failures; this guard only covers import/dispatch failure.""" + """First valid plugin classification (runs before the built-in pipeline so a + provider plugin can add or correct verdicts). invoke_hook isolates callback + failures; this guard only covers import/dispatch failure.""" try: from hermes_cli.plugins import get_plugin_error_classification verdict = get_plugin_error_classification( @@ -510,36 +473,31 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: """Highest-priority provider-specific shapes that a status code would misroute.""" msg, status = c.msg, c.status_code - # Deterministic per-prompt safety refusal. Before status classification so - # a 400 block isn't downgraded to format_error and a status-less block - # isn't left retryable (#18028). + # Safety refusal before status classification so a 400 block isn't downgraded + # to format_error and a status-less block isn't left retryable (#18028). if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): return _V_CONTENT_BLOCKED - # Anthropic thinking-block 400s: signature mismatch after any transcript - # mutation, or "blocks in the latest assistant message cannot be modified". - # Not gated on provider — OpenRouter proxies Anthropic errors. + # Anthropic thinking-block 400s (signature mismatch after transcript + # mutation). Not gated on provider — OpenRouter proxies Anthropic errors. if status == 400 and "thinking" in msg and any(p in msg for p in _THINKING_MUTATION_WORDS): return _v(FailoverReason.thinking_signature) # Anthropic long-context tier gate (429 "extra usage" + "long context"). if status == 429 and "extra usage" in msg and "long context" in msg: return _v(FailoverReason.long_context_tier, should_compress=True) - # Anthropic OAuth subscription rejects the 1M-context beta header; - # run_agent rebuilds the client without the beta and retries once. + # Anthropic OAuth rejects the 1M beta header; run_agent retries without it. if status == 400 and "long context beta" in msg and "not yet available" in msg: return _v(FailoverReason.oauth_long_context_beta_forbidden) - # llama.cpp json-schema-to-grammar rejects regex escapes / ``format`` in - # tool schemas (400); the retry loop strips pattern/format and retries. - # Exclude the Qwen/vLLM "No user query found" template error that local - # engines wrap as "Unable to generate parser for this template" — that is - # a poisoned transcript (→ format_error), not a grammar problem. + # llama.cpp grammar rejects regex ``pattern``/``format`` in tool schemas; the + # retry loop strips them. Exclude the Qwen/vLLM "No user query found" error + # local engines wrap as "Unable to generate parser for this template" — + # that is a poisoned transcript (→ format_error), not a grammar problem. grammar_hit = "error parsing grammar" in msg or "json-schema-to-grammar" in msg or ( "unable to generate parser" in msg and "template" in msg ) if status == 400 and grammar_hit and _NO_USER_QUERY_SIGNAL not in msg: return _v(FailoverReason.llama_cpp_grammar_pattern) - # xAI Grok subscription entitlement. As HTTP 403 the status path handles - # it; as an SSE ``type=error`` frame there is no status and the message - # matches no pattern list, so it would burn max_retries as ``unknown``. + # xAI Grok entitlement as an SSE ``type=error`` frame: no status, matches no + # pattern list, would otherwise burn max_retries as ``unknown``. if "do not have an active grok subscription" in msg or ("out of available resources" in msg and "grok" in msg): return _V_AUTH_FALLBACK return None @@ -559,9 +517,8 @@ def _moa_special_cases(c: _Ctx) -> Optional[Verdict]: def _by_error_code(c: _Ctx) -> Optional[Verdict]: """Structured error codes from the response body.""" code = c.error_code.lower() - # Deterministic request-validation failures encoded as plain-text - # ``event: error`` SSE data behind HTTP 200: retrying cannot succeed, a - # configured fallback still may. + # Request-validation failure as plain-text ``event: error`` SSE data behind + # HTTP 200: retrying cannot succeed, a configured fallback still may. if code == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in c.msg: return _V_FORMAT_ERROR return _ERROR_CODE_VERDICTS.get(code) @@ -581,28 +538,22 @@ def _by_message(c: _Ctx) -> Optional[Verdict]: def _by_transport(c: _Ctx) -> Optional[Verdict]: """SSL, disconnect, circuit-breaker and transport-type heuristics, in that order.""" msg = c.msg - # Deterministic cert failure → fail fast; transient alert → retry. - # Cert-verify first: those messages also contain "[ssl:". Transient alerts - # are classified before the disconnect check so a large session doesn't - # compress on a flaky TLS handshake. + # Cert failure → fail fast (checked first: also contains "[ssl:"); transient + # alert → retry, before disconnects so a flaky handshake never compresses. if any(p in msg for p in _SSL_CERT_VERIFY_PATTERNS): return _V_SSL_CERT if any(p in msg for p in _SSL_TRANSIENT_PATTERNS): return _V_TIMEOUT - # Server disconnect + large session → context overflow. Before the generic - # transport catch: a disconnect on a large session is more likely an - # overflow rejection than a transport hiccup. + # Disconnect + large session → probable overflow rejection, not a hiccup. if any(p in msg for p in _SERVER_DISCONNECT_PATTERNS) and not c.status_code: - # Reasoning models: a disconnect is far more likely the gateway - # idle-killing a long thinking stream than overflow — never compress - # (and silently drop history) on a phantom overflow (#52310). + # Reasoning models: far more likely the gateway idle-killed a long + # thinking stream — never compress on a phantom overflow (#52310). from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor if get_reasoning_stale_timeout_floor(c.model) is not None: return _V_TIMEOUT return _V_CONTEXT_OVERFLOW if c.large_session(0.6, 120000, 200) else _V_TIMEOUT - # Stale-call circuit breaker → failover immediately. _check_stale_giveup() - # raises RuntimeError before any network call; as ``unknown`` it would burn - # every retry instantly against the dead provider. + # Stale-call circuit breaker (_check_stale_giveup RuntimeError before any + # network call): as ``unknown`` it would burn every retry instantly. if c.error_type == "RuntimeError" and "consecutive stale attempts" in msg and "aborting this call" in msg: return _v(FailoverReason.timeout, retryable=False, should_fallback=True) if c.error_type in _TRANSPORT_ERROR_TYPES or isinstance(c.error, (TimeoutError, ConnectionError, OSError)): @@ -642,7 +593,7 @@ def classify_api_error( body = _extract_error_body(error) c = _Ctx( error=error, status_code=status_code, error_type=error_type, body=body, - error_code=_extract_error_code(body), headers=_extract_response_headers(error), + error_code=_extract_error_code(body), headers=_from_cause_chain(error, _headers_of, {}), msg=_build_error_msg(error, body), provider=provider, model=model, provider_slug=(provider or "").strip().lower(), model_slug=(model or "").strip().lower(), approx_tokens=approx_tokens, context_length=context_length, num_messages=num_messages, @@ -668,10 +619,9 @@ def _status_404(c: _Ctx) -> Verdict: verdict = _first_match(c.msg, _404_RULES) if verdict is not None: return verdict - # Bare id the catalogue only knows prefixed → malformed id (NVIDIA NIM - # "404 page not found"), deterministic (#78796). A generic 404 (wrong - # endpoint path, proxy glitch) stays unknown so the real error surfaces - # instead of model_not_found silently falling back and misreporting. + # Bare id the catalogue only knows prefixed → malformed id (NVIDIA NIM "404 + # page not found", #78796). A generic 404 (wrong path, proxy glitch) stays + # unknown so the real error surfaces instead of a silent misreported fallback. return _V_MODEL_NOT_FOUND if _model_id_missing_known_prefix(c.model_slug, c.provider_slug) else _V_UNKNOWN @@ -680,17 +630,15 @@ def _status_429(c: _Ctx) -> Verdict: # key instead of burning the pool (#14038). if any(p in c.msg for p in _OVERLOADED_PATTERNS): return _V_OVERLOADED - # OpenRouter-wrapped upstream 429: the user's key is healthy, so fall back - # to another model rather than rotating/benching the key. + # OpenRouter-wrapped upstream 429: the key is healthy — fall back, don't bench. if _is_openrouter_upstream_error(c.body, c.provider_slug): upstream = _extract_upstream_provider_name(c.body) ctx = {"upstream_provider": upstream} if upstream else {} return _v(FailoverReason.upstream_rate_limit, should_fallback=True, error_context=ctx) - # Quota walls returned as 429 (Anthropic ``usage_limit_reached``, other - # providers' "quota"/"limit exceeded", explicit billing phrases) are - # billing — but ONLY when the body is not itself an explicit rate-limit - # phrase ("Rate limit exceeded" contains "limit exceeded") and carries - # no reset/retry signal (#93419, #39441). + # Quota walls as 429 (Anthropic ``usage_limit_reached``, "quota", billing + # phrases) are billing ONLY when the body is not itself a rate-limit phrase + # ("Rate limit exceeded" contains "limit exceeded") and carries no reset/ + # retry signal (#93419, #39441). quota_wall = c.error_code.lower() == "usage_limit_reached" or any( p in c.msg for p in ("usage_limit_reached",) + _USAGE_LIMIT_PATTERNS + _BILLING_PATTERNS ) @@ -704,9 +652,8 @@ def _status_429(c: _Ctx) -> Verdict: def _status_5xx(c: _Ctx) -> Verdict: - # Deterministic request-validation errors returned as 5xx (codex.nekos.me) - # must fail fast, not retry-flood — unless the rejected parameter was - # injected server-side (see _classify_400). + # Request-validation errors as 5xx (codex.nekos.me) fail fast instead of + # retry-flooding — unless the parameter was injected server-side. validation = any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) or c.error_code.lower() in _5XX_VALIDATION_CODES if validation and not _is_server_injected_param_rejection(c.msg, c.provider_slug): return _V_FORMAT_ERROR @@ -731,25 +678,21 @@ def _classify_400(c: _Ctx) -> Verdict: verdict = _first_match(msg, _IMAGE_TOOL_RULES) if verdict is not None: return verdict - # Invalid encrypted reasoning replay blob (OpenAI Responses). Before - # context_overflow: "encrypted content … could not be verified" can trip - # the overflow heuristics. + # Invalid encrypted reasoning replay blob (OpenAI Responses); before + # overflow because "encrypted content … could not be verified" trips it. if code == "invalid_encrypted_content" or "invalid_encrypted_content" in msg or ( "encrypted content for item" in msg and "could not be verified" in msg ) or "could not decrypt the provided encrypted_content" in msg: return _V_INVALID_ENCRYPTED - # A 400 blaming a field this route never sent (Codex OAuth backend injects - # and then rejects prompt_cache_retention ~20% of the time): transient, - # retry the identical request; never compress. Before the validation branch. + # 400 blaming a field this route never sent (Codex OAuth injects then rejects + # prompt_cache_retention ~20% of the time): transient, retry identical request. if _is_server_injected_param_rejection(msg, c.provider_slug): return _V_SERVER_ERROR - # Unsupported/unknown parameter before context_overflow: GPT-5's - # "Unsupported parameter: 'max_tokens'…" contains the overflow pattern. + # Before overflow: GPT-5's "Unsupported parameter: 'max_tokens'" contains it. if any(p in msg for p in _400_VALIDATION_PATTERNS) or code in _400_VALIDATION_CODES: return _V_FORMAT_ERROR - # Malformed message array (empty-content assistant stub, etc.) before - # context_overflow: the input can be tiny and compression cannot fix it. - # Proxies (litellm/Bedrock) surface it as errorCode=INVALID_REQUEST_BODY. + # Malformed message array before overflow: input can be tiny and compression + # cannot fix it. litellm/Bedrock proxies use errorCode=INVALID_REQUEST_BODY. if any(p in msg for p in _INVALID_MESSAGE_BODY_PATTERNS) or code == "invalid_request_body": logger.warning( "Malformed message array 400 (invalid request body) classified as " @@ -763,9 +706,8 @@ def _classify_400(c: _Ctx) -> Verdict: verdict = _first_match(msg, _400_TAIL_RULES) if verdict is not None: return verdict - # Generic 400 + large session → probable context overflow (Anthropic can - # return a bare "Error"). Proxy shapes are recognised so a long, descriptive - # rejection is not mistaken for a bare error. + # Generic 400 + large session → probable overflow (Anthropic can return a + # bare "Error"); proxy shapes are read so a descriptive rejection isn't "bare". body_msg = next((m for m in (str(x or "").strip().lower() for x in _body_message_candidates(c.body)) if m), "") is_generic = len(body_msg) < 30 or body_msg in {"error", ""} if is_generic and c.large_session(0.4, 80000, 80): @@ -773,11 +715,10 @@ def _classify_400(c: _Ctx) -> Verdict: return _V_FORMAT_ERROR -# 401 is not retryable on its own: credential rotation / provider refresh run -# before the retryability check; if they fail, the client-error abort path -# (fallback first) is correct. 408 Request Timeout is retry-safe (RFC 9110 -# §15.5.9) — proxies in front of self-hosted backends emit it when generation -# outruns the read window. Unlisted 4xx → format_error, 5xx → server_error. +# 401 not retryable on its own: rotation/refresh run before the retryability +# check, then the client-error abort path (fallback first) is correct. 408 is +# retry-safe (RFC 9110 §15.5.9; proxies emit it when generation outruns the +# read window). Unlisted 4xx → format_error, 5xx → server_error. _STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = { 400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict), 403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE, @@ -809,10 +750,8 @@ def _has_usage_limit_transient_signal(error_msg: str, body: dict, response_heade def _model_id_missing_known_prefix(model: str, provider: str) -> bool: """True when a bare model id is only known to the provider as ``vendor/id``. - NVIDIA NIM answers a bare id with a naked ``404 page not found``; the - curated catalogue tells that apart from a bad endpoint. Never guesses: an - id absent from the catalogue returns False so real endpoint problems keep - their retryable ``unknown`` classification. + Never guesses: an id absent from the curated catalogue returns False so real + endpoint problems keep their retryable ``unknown`` classification. """ name = (model or "").strip() if not name or "/" in name: @@ -828,9 +767,8 @@ def _model_id_missing_known_prefix(model: str, provider: str) -> bool: def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool: """True when a 400 blames a one-route-only parameter this route never sends. - Conservative: fires only for known parameters AND only when ``provider`` - is not a route that sends them, so a genuine client-side bad parameter - (``max_tokens`` on GPT-5) still fails fast as ``format_error``. + Conservative: known parameters only, and only when ``provider`` is not a + sender, so a genuine bad parameter (``max_tokens`` on GPT-5) stays format_error. """ if not error_msg: return False @@ -867,11 +805,8 @@ def _openrouter_wrapped_message(err_obj: dict) -> str: def _build_error_msg(error: Exception, body: Any) -> str: - """Lowercased str(error) + body message + OpenRouter-wrapped upstream message. - - str(error) alone may omit the body (OpenAI SDK's APIStatusError.__str__ - returns only the first arg), so body text is appended for pattern matching. - """ + """Lowercased str(error) + body message + OpenRouter-wrapped upstream message + (OpenAI SDK's APIStatusError.__str__ omits the body, so it is appended).""" raw_msg = str(error).lower() body_msg = metadata_msg = "" if isinstance(body, dict): @@ -947,17 +882,10 @@ def _extract_error_body(error: Exception) -> dict: return _from_cause_chain(error, _body_of, {}) -def _extract_response_headers(error: Exception): - """Response headers from the error or its cause chain.""" - return _from_cause_chain(error, _headers_of, {}) - - def _code_from_payload(payload: Any, top_keys: Sequence[str], peek_message: bool) -> str: """Code/type from ``payload.error`` or a top-level key; ``"400"`` is not a code. - - With ``peek_message``, a JSON string in ``error.message`` is parsed for a - nested code (Responses API surfaces ``invalid_encrypted_content`` this way). - """ + ``peek_message`` also parses a JSON ``error.message`` for a nested code + (Responses API surfaces ``invalid_encrypted_content`` this way).""" if not isinstance(payload, dict): return "" error_obj = payload.get("error", {}) @@ -992,11 +920,8 @@ def _extract_message(error: Exception, body: dict) -> str: def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: - """Detect OpenRouter's "Provider returned error" wrapper around an upstream failure. - - The user's OpenRouter key is healthy — the upstream provider failed — so - credential rotation is the wrong recovery. - """ + """OpenRouter's "Provider returned error" wrapper: the key is healthy, the + upstream failed, so credential rotation is the wrong recovery.""" err = _error_obj(body) if str(err.get("message") or "").strip().lower() != "provider returned error": return False From 7b87a7c0ad21fc2313b13b4b231013f54492c37a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:58:29 -0700 Subject: [PATCH 07/16] refactor(agent/display): table-drive diff color resolution, compact docstrings and section banners --- agent/display.py | 140 +++++++++++++++++++---------------------------- 1 file changed, 56 insertions(+), 84 deletions(-) diff --git a/agent/display.py b/agent/display.py index 2d25046500..35ea6b1f9d 100644 --- a/agent/display.py +++ b/agent/display.py @@ -60,39 +60,44 @@ def _get_skin(): _diff_colors_cached: dict[str, str] | None = None +# Foreground diff colors: key -> (skin color key, dark-terminal fallback RGB). +_DIFF_FG = { + "dim": ("banner_dim", (150, 150, 150)), "file": ("session_label", (180, 160, 255)), + "hunk": ("session_border", (120, 120, 140)), +} +# Background diff colors (white text on a dark tint of the skin's ui_error/ui_ok): +# key -> (skin key, default hex, dominant channel index, dark-terminal fallback). +_DIFF_BG = { + "minus": ("ui_error", "#ef5350", 0, "\033[38;2;255;255;255;48;2;120;20;20m"), + "plus": ("ui_ok", "#4caf50", 1, "\033[38;2;255;255;255;48;2;20;90;20m"), +} + + +def _fg(r: int, g: int, b: int) -> str: + return f"\033[38;2;{r};{g};{b}m" + + +def _tinted_bg(rgb: tuple[int, int, int], dominant: int) -> str: + r, g, b = (max(v // 2, 20) if i == dominant else max(v // 4, 10) for i, v in enumerate(rgb)) + return f"\033[38;2;255;255;255;48;2;{r};{g};{b}m" + + def _diff_ansi() -> dict[str, str]: """Return ANSI escapes for diff display, resolved from the active skin.""" global _diff_colors_cached if _diff_colors_cached is not None: return _diff_colors_cached - - def _fg(r: int, g: int, b: int) -> str: - return f"\033[38;2;{r};{g};{b}m" - - # Defaults that work on dark terminals. - colors = { - "dim": _fg(150, 150, 150), "file": _fg(180, 160, 255), "hunk": _fg(120, 120, 140), - "minus": "\033[38;2;255;255;255;48;2;120;20;20m", "plus": "\033[38;2;255;255;255;48;2;20;90;20m", - } + colors = {k: _fg(*rgb) for k, (_, rgb) in _DIFF_FG.items()} | {k: v[3] for k, v in _DIFF_BG.items()} try: skin = _get_skin() - - def _skin_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str: - h = skin.get_color(key, "") - return _fg(*(_hex_rgb(h) if h and len(h) == 7 and h[0] == "#" else fallback_rgb)) - - colors["dim"] = _skin_fg("banner_dim", (150, 150, 150)) - colors["file"] = _skin_fg("session_label", (180, 160, 255)) - colors["hunk"] = _skin_fg("session_border", (120, 120, 140)) - # minus/plus use dark-tinted backgrounds derived from ui_error/ui_ok. - err_h = skin.get_color("ui_error", "#ef5350") - ok_h = skin.get_color("ui_ok", "#4caf50") - if err_h and len(err_h) == 7: - er, eg, eb = _hex_rgb(err_h) - colors["minus"] = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m" - if ok_h and len(ok_h) == 7: - or_, og, ob = _hex_rgb(ok_h) - colors["plus"] = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m" + for key, (skin_key, fallback) in _DIFF_FG.items(): + h = skin.get_color(skin_key, "") + colors[key] = _fg(*(_hex_rgb(h) if h and len(h) == 7 and h[0] == "#" else fallback)) + bg_hex = {key: skin.get_color(skin_key, default) for key, (skin_key, default, _, _) in _DIFF_BG.items()} + for key, (_, _, dominant, _) in _DIFF_BG.items(): + h = bg_hex[key] + if h and len(h) == 7: + colors[key] = _tinted_bg(_hex_rgb(h), dominant) except Exception: pass _diff_colors_cached = colors @@ -151,9 +156,7 @@ def get_tool_emoji(tool_name: str, default: str = "⚡") -> str: return default -# ========================================================================= -# Tool preview (one-line summary of a tool call's primary argument) -# ========================================================================= +# ── Tool preview (one-line summary of a tool call's primary argument) ───── def _oneline(text: str) -> str: """Collapse whitespace (including newlines) to single spaces.""" @@ -325,11 +328,9 @@ def _read_file_line_label(args: dict) -> str: def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any: """Replace every occurrence of a secret-looking browser_type value with its redacted form. - Backends echo the attempted input in error strings/fallback metadata, so the raw - value is swapped before the result reaches logs, callbacks, the model, or chat - history. Normal typed text matches no secret pattern and passes through unchanged. - Redaction is forced regardless of ``security.redact_secrets``: a typed credential - leaking into chat history is a security boundary, not log hygiene. + Backends echo the attempted input in error strings/metadata, so it is swapped before + reaching logs, callbacks, the model, or chat history. Forced regardless of + ``security.redact_secrets``: a leaked typed credential is a security boundary. """ if typed_text is None: return value @@ -522,12 +523,8 @@ def prepare_tool_preview( fallback: str, max_len: int, ) -> ToolPreview: - """Build one canonical compact preview plus explicit truncation/URL metadata. - - The uncapped preview is rebuilt from the arguments so an upstream display cap - cannot discard its link target; platforms get truncation/URL facts explicitly - instead of inferring them from the rendered text. - """ + """Compact preview plus explicit truncation/URL facts (the uncapped preview is + rebuilt from the arguments so an upstream display cap cannot drop its link target).""" full_text = build_tool_preview(tool_name, args, max_len=0) or fallback text = _truncate_preview(full_text, max_len) truncated = text != full_text @@ -535,11 +532,9 @@ def prepare_tool_preview( return ToolPreview(text=text, truncated=truncated, url=url) -# ========================================================================= -# Friendly tool labels: "web_search " -> "Searching the web for ". +# ── Friendly tool labels: "web_search " -> "Searching the web for " ── # Curated built-ins only — we know each core tool's semantics so the verb is fixed, # not computed; custom/plugin/MCP tools have no entry and fall back to the raw preview. -# ========================================================================= _TOOL_VERBS: dict[str, str] = { "web_search": "Searching the web", "web_extract": "Reading", @@ -559,11 +554,8 @@ _TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({"skills_list", "session_sear _TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({"web_search", "search_files"}) def get_tool_verb(tool_name: str) -> str | None: - """Friendly verb for a built-in tool, or None (labels disabled / no curated verb). - - Callers holding a computed preview compose ``f"{verb}{connector}{preview}"`` - themselves via :func:`tool_verb_connector`. - """ + """Friendly verb for a built-in tool, or None (labels disabled / no curated verb); + callers compose ``f"{verb}{tool_verb_connector(tool)}{preview}"`` themselves.""" return _TOOL_VERBS.get(tool_name) if _friendly_tool_labels else None @@ -578,13 +570,11 @@ def verb_drops_preview(tool_name: str) -> bool: def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None: - """Lowercase "is …" phrase for platform status lines (e.g. Slack setStatus). + """Lowercase "is …" phrase following the bot's display name (Slack setStatus). - Phrased to follow the bot's display name ("Hermes is running …"). ``args=None`` - gives a verb-only phrase (``display.live_status: verb`` keeps argument previews - out of shared channels). Returns None for ``_thinking`` or when friendly labels - are disabled so callers fall back to their static default. Default ``max_len`` - stays under Slack's ~50-char status truncation. + ``args=None`` gives a verb-only phrase (``display.live_status: verb`` keeps previews out + of shared channels). None for ``_thinking`` or disabled labels so callers use their + static default. Default ``max_len`` stays under Slack's ~50-char status truncation. """ if not tool_name or tool_name == "_thinking" or not _friendly_tool_labels: return None @@ -601,11 +591,8 @@ def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None: - """Human-phrased label ("Searching the web for ...") for curated built-ins. - - Custom/plugin/MCP tools (or labels disabled) get the raw preview, so this is a - drop-in replacement for :func:`build_tool_preview`. - """ + """Human-phrased label ("Searching the web for ...") for curated built-ins; other + tools (or labels disabled) get the raw preview, so it is a drop-in for build_tool_preview.""" verb = get_tool_verb(tool_name) if not verb: return build_tool_preview(tool_name, args, max_len=max_len) @@ -615,9 +602,7 @@ def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> return f"{verb}{tool_verb_connector(tool_name)}{preview}" if preview else verb -# ========================================================================= -# Inline diff previews for write actions -# ========================================================================= +# ── Inline diff previews for write actions ──────────────────────────────── def _resolved_path(path: str) -> Path: """Resolve a possibly-relative filesystem path against the current cwd.""" @@ -843,9 +828,7 @@ def render_edit_diff_with_delta( return _emit_inline_diff("\n".join(rendered_lines), print_fn) -# ========================================================================= -# KawaiiSpinner -# ========================================================================= +# ── KawaiiSpinner ───────────────────────────────────────────────────────── class KawaiiSpinner: """Animated spinner with kawaii faces for CLI feedback during tool execution.""" @@ -893,10 +876,8 @@ class KawaiiSpinner: self.frame_idx = 0 self.start_time = None self.last_line_len = 0 - # When set, all output bypasses self._out so silenced agents stay silent. - self._print_fn = print_fn - # Capture stdout NOW, before any child redirect_stdout(devnull) replaces it. - self._out = sys.stdout + self._print_fn = print_fn # when set, bypasses self._out so silenced agents stay silent + self._out = sys.stdout # captured NOW, before any child redirect_stdout(devnull) replaces it def _write(self, text: str, end: str = '\n', flush: bool = False): """Write via print_fn when supplied, else to the stdout captured at creation.""" @@ -972,15 +953,11 @@ class KawaiiSpinner: self.message = new_message def _clear_line_blanks(self) -> str: - # Clear with spaces (not \033[K) to avoid garbled escapes under patch_stdout. - return ' ' * max(self.last_line_len + 5, 40) + return ' ' * max(self.last_line_len + 5, 40) # spaces, not \033[K: garbles under patch_stdout def print_above(self, text: str): - """Print a line above the spinner; the next tick redraws the spinner below it. - - Works inside redirect_stdout(devnull) because _write targets the stdout - captured at spinner creation, not the current sys.stdout. - """ + """Print a line above the spinner (next tick redraws it below). Works inside + redirect_stdout(devnull) because _write targets the stdout captured at creation.""" if not self.running: self._write(f" {text}", flush=True) return @@ -1006,9 +983,7 @@ class KawaiiSpinner: return False -# ========================================================================= -# Cute tool message (completion line that replaces the spinner) -# ========================================================================= +# ── Cute tool message (completion line that replaces the spinner) ───────── _ERROR_SUFFIX_MAX_LEN = 48 @@ -1199,11 +1174,8 @@ _CUTE_LINES = { def _get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: - """Formatted tool completion line for CLI quiet mode: ``| {emoji} {verb:9} {detail} {duration}``. - - Failed tool calls get an informational suffix from :func:`_detect_tool_failure`; - the leading ``┊`` is swapped for the active skin's tool prefix. - """ + """Tool completion line for CLI quiet mode: ``| {emoji} {verb:9} {detail} {duration}``, plus a + failure suffix from :func:`_detect_tool_failure`; the leading ``┊`` becomes the skin's tool prefix.""" args = redact_tool_args_for_display(tool_name, args) or args is_failure, failure_suffix = _detect_tool_failure(tool_name, result) skin_prefix = get_skin_tool_prefix() From cec7f8e2f07c0fc4a08960b69509fe96a6b95c92 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:02:40 -0700 Subject: [PATCH 08/16] refactor(agent/display): tighten preview/cute renderer bodies --- agent/display.py | 48 ++++++++++++++++-------------------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/agent/display.py b/agent/display.py index 35ea6b1f9d..7d30103783 100644 --- a/agent/display.py +++ b/agent/display.py @@ -142,18 +142,14 @@ def get_skin_tool_prefix() -> str: def get_tool_emoji(tool_name: str, default: str = "⚡") -> str: """Display emoji for a tool: skin ``tool_emojis`` override, then registry, then *default*.""" skin = _get_skin() - if skin and skin.tool_emojis: - override = skin.tool_emojis.get(tool_name) - if override: - return override + override = skin.tool_emojis.get(tool_name) if skin and skin.tool_emojis else None + if override: + return override try: from tools.registry import registry - emoji = registry.get_emoji(tool_name, default="") - if emoji: - return emoji + return registry.get_emoji(tool_name, default="") or default except Exception: - pass - return default + return default # ── Tool preview (one-line summary of a tool call's primary argument) ───── @@ -434,9 +430,8 @@ def _preview_process_manage(args: dict, _max_len: int) -> str | None: def _preview_todo_list(args: dict, _max_len: int) -> str: todos_arg = args.get("todos") - if todos_arg is None: - return "reading task list" - return f"{'updating' if args.get('merge', False) else 'planning'} {len(todos_arg)} task(s)" + verb = "updating" if args.get("merge", False) else "planning" + return "reading task list" if todos_arg is None else f"{verb} {len(todos_arg)} task(s)" def _preview_shell(key: str): @@ -455,8 +450,7 @@ def _preview_read_file(args: dict, max_len: int) -> str | None: def _preview_memory(args: dict, _max_len: int) -> str: - action = args.get("action", "") - target = args.get("target", "") + action, target = args.get("action", ""), args.get("target", "") if action == "add": return f"+{target}: \"{_clip(_oneline(args.get('content', '')), 25)}\"" if action in ("replace", "remove"): @@ -466,10 +460,7 @@ def _preview_memory(args: dict, _max_len: int) -> str: def _preview_send_message(args: dict, _max_len: int) -> str: - msg = _oneline(args.get("message", "")) - if len(msg) > 20: - msg = msg[:17] + "..." - return f"to {args.get('target', '?')}: \"{msg}\"" + return f"to {args.get('target', '?')}: \"{_tail_trunc(_oneline(args.get('message', '')), 20)}\"" def _preview_skill_view(args: dict, max_len: int) -> str | None: @@ -1073,8 +1064,7 @@ def _cute_todo_list(a: dict, result) -> str: def _cute_memory(a: dict, _r) -> str: - action = a.get("action", "?") - target = a.get("target", "") + action, target = a.get("action", "?"), a.get("target", "") if action == "add": return f"┊ 🧠 memory +{target}: \"{_cute_trunc(a.get('content', ''))}\"" if action in ("replace", "remove"): @@ -1084,10 +1074,8 @@ def _cute_memory(a: dict, _r) -> str: def _cute_skill_view(a: dict, _r) -> str: - label = a.get("name", "") - file_path = a.get("file_path") - if file_path: - label = f"{label} → {file_path}" if label else str(file_path) + label, file_path = a.get("name", ""), a.get("file_path") + label = (f"{label} → {file_path}" if label else str(file_path)) if file_path else label return f"┊ 📚 skill {_cute_trunc(label)}" @@ -1103,17 +1091,14 @@ def _cute_cronjob(a: dict, _r) -> str: def _cute_execute_code(a: dict, _r) -> str: - code = a.get("code", "") - first_line = code.strip().split("\n")[0] if code.strip() else "" - return f"┊ 🐍 exec {_cute_trunc(first_line)}" + code = a.get("code", "").strip() + return f"┊ 🐍 exec {_cute_trunc(code.split(chr(10))[0] if code else '')}" def _cute_browser_exec(a: dict, _r) -> str: # Leading `# …` comment becomes the step label; code stays collapsed behind the preview cap. label = _browser_exec_step_label(a) - if label is not None: - return f"┊ 🌐 browser {label}" - return f"┊ 🌐 browser {_cute_trunc(' '.join(str(a.get('code', '') or '').split()))}" + return f"┊ 🌐 browser {_cute_trunc(_oneline(str(a.get('code', '') or ''))) if label is None else label}" def _cute_delegate(a: dict, _r) -> str: @@ -1129,8 +1114,7 @@ def _cute_delegate(a: dict, _r) -> str: def _cute_process_manage(a: dict, _r) -> str: - action = a.get("action", "?") - sid = a.get("session_id", "")[:12] + action, sid = a.get("action", "?"), a.get("session_id", "")[:12] return f"┊ ⚙️ proc {'ls processes' if action == 'list' else f'{action} {sid}'}" From b9cd14d7552e57123cc3c6ba81a79627cd577a8f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:06:49 -0700 Subject: [PATCH 09/16] refactor(agent/error_classifier): derive _Ctx fields in __post_init__, collapse extractor guards --- agent/error_classifier.py | 102 +++++++++++++++----------------------- 1 file changed, 41 insertions(+), 61 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index d28cdb2530..abea68b4e3 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -428,19 +428,22 @@ class _Ctx: error: Exception status_code: Optional[int] - error_type: str body: dict - error_code: str - headers: Any - msg: str # lowercased str(error) + body message(s) - provider: str # as passed by the caller + msg: str # lowercased str(error) + body message(s) + provider: str # as passed by the caller model: str - provider_slug: str # stripped + lowercased - model_slug: str approx_tokens: int context_length: int num_messages: int + def __post_init__(self) -> None: + self.error_type = type(self.error).__name__ + self.error_code = _extract_error_code(self.body) + self.code = self.error_code.lower() + self.headers = _from_cause_chain(self.error, _headers_of, {}) + self.provider_slug = (self.provider or "").strip().lower() + self.model_slug = (self.model or "").strip().lower() + def large_session(self, frac: float, tokens: int, messages: int) -> bool: """Absolute thresholds only proxy for smaller context windows.""" return self.approx_tokens > self.context_length * frac or ( @@ -516,12 +519,11 @@ def _moa_special_cases(c: _Ctx) -> Optional[Verdict]: def _by_error_code(c: _Ctx) -> Optional[Verdict]: """Structured error codes from the response body.""" - code = c.error_code.lower() # Request-validation failure as plain-text ``event: error`` SSE data behind # HTTP 200: retrying cannot succeed, a configured fallback still may. - if code == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in c.msg: + if c.code == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in c.msg: return _V_FORMAT_ERROR - return _ERROR_CODE_VERDICTS.get(code) + return _ERROR_CODE_VERDICTS.get(c.code) def _by_message(c: _Ctx) -> Optional[Verdict]: @@ -562,13 +564,12 @@ def _by_transport(c: _Ctx) -> Optional[Verdict]: def _by_status(c: _Ctx) -> Optional[Verdict]: - """HTTP status code with message-aware refinement.""" - if c.status_code is None: + """HTTP status code with message-aware refinement (unlisted 4xx/5xx → generic).""" + status = c.status_code + if status is None: return None - handler = _STATUS_HANDLERS.get(c.status_code) - if handler is not None: - return handler(c) - return _V_FORMAT_ERROR if 400 <= c.status_code < 500 else _V_SERVER_ERROR if 500 <= c.status_code < 600 else None + default = _V_FORMAT_ERROR if 400 <= status < 500 else _V_SERVER_ERROR if 500 <= status < 600 else None + return _STATUS_HANDLERS[status](c) if status in _STATUS_HANDLERS else default # Stage order: plugin hooks → provider-specific special cases → HTTP status → @@ -586,17 +587,13 @@ def classify_api_error( ) -> ClassifiedError: """Classify an API error into a structured recovery recommendation (see ``_STAGES``).""" status_code = _extract_status_code(error) - error_type = type(error).__name__ # Copilot/GitHub Models RateLimitError may not set .status_code; force 429. - if status_code is None and error_type == "RateLimitError": + if status_code is None and type(error).__name__ == "RateLimitError": status_code = 429 body = _extract_error_body(error) c = _Ctx( - error=error, status_code=status_code, error_type=error_type, body=body, - error_code=_extract_error_code(body), headers=_from_cause_chain(error, _headers_of, {}), - msg=_build_error_msg(error, body), provider=provider, model=model, - provider_slug=(provider or "").strip().lower(), model_slug=(model or "").strip().lower(), - approx_tokens=approx_tokens, context_length=context_length, num_messages=num_messages, + error, status_code, body, _build_error_msg(error, body), provider, model, + approx_tokens, context_length, num_messages, ) verdict = next((v for v in (stage(c) for stage in _STAGES) if v is not None), _V_UNKNOWN) return ClassifiedError(**{ @@ -609,7 +606,7 @@ def classify_api_error( def _status_403(c: _Ctx) -> Verdict: # OpenRouter 403 "key limit exceeded" and similar plan/credit exhaustion are billing. - xai_spend = c.provider_slug == "xai-oauth" and c.error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE + xai_spend = c.provider_slug == "xai-oauth" and c.code == _XAI_SPENDING_LIMIT_ERROR_CODE if xai_spend or any(p in c.msg for p in ("key limit exceeded", "spending limit") + _BILLING_PATTERNS): return _V_BILLING return _V_AUTH_FALLBACK @@ -639,7 +636,7 @@ def _status_429(c: _Ctx) -> Verdict: # phrases) are billing ONLY when the body is not itself a rate-limit phrase # ("Rate limit exceeded" contains "limit exceeded") and carries no reset/ # retry signal (#93419, #39441). - quota_wall = c.error_code.lower() == "usage_limit_reached" or any( + quota_wall = c.code == "usage_limit_reached" or any( p in c.msg for p in ("usage_limit_reached",) + _USAGE_LIMIT_PATTERNS + _BILLING_PATTERNS ) if ( @@ -654,16 +651,12 @@ def _status_429(c: _Ctx) -> Verdict: def _status_5xx(c: _Ctx) -> Verdict: # Request-validation errors as 5xx (codex.nekos.me) fail fast instead of # retry-flooding — unless the parameter was injected server-side. - validation = any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) or c.error_code.lower() in _5XX_VALIDATION_CODES + validation = any(p in c.msg for p in _REQUEST_VALIDATION_PATTERNS) or c.code in _5XX_VALIDATION_CODES if validation and not _is_server_injected_param_rejection(c.msg, c.provider_slug): return _V_FORMAT_ERROR return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_SERVER_ERROR -def _status_overloaded(c: _Ctx) -> Verdict: - return _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED - - def _classify_402(error_msg: str, result_fn: Callable[..., Any]) -> Any: """Disambiguate 402: "usage limit, try again in 5 minutes" is a periodic quota, not billing.""" transient = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) and any( @@ -674,7 +667,7 @@ def _classify_402(error_msg: str, result_fn: Callable[..., Any]) -> Any: def _classify_400(c: _Ctx) -> Verdict: """400 Bad Request — image/tool shapes, request-shape rejections, overflow, or generic.""" - msg, code = c.msg, c.error_code.lower() + msg, code = c.msg, c.code verdict = _first_match(msg, _IMAGE_TOOL_RULES) if verdict is not None: return verdict @@ -722,7 +715,9 @@ def _classify_400(c: _Ctx) -> Verdict: _STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = { 400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict), 403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE, - 429: _status_429, 500: _status_5xx, 502: _status_5xx, 503: _status_overloaded, 529: _status_overloaded, + 429: _status_429, 500: _status_5xx, 502: _status_5xx, + 503: lambda c: _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED, + 529: lambda c: _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED, } @@ -736,12 +731,9 @@ def _has_usage_limit_transient_signal(error_msg: str, body: dict, response_heade """Whether a usage-limit response identifies a reset window (message, body fields, or headers).""" if any(pattern in error_msg for pattern in _USAGE_LIMIT_TRANSIENT_SIGNALS): return True - payloads = [body] - if isinstance(body, dict) and isinstance(body.get("error"), dict): - payloads.append(body["error"]) - for payload in payloads: - if isinstance(payload, dict) and any(payload.get(f) not in (None, "") for f in _RESET_FIELDS): - return True + payloads = [p for p in (body, _error_obj(body)) if isinstance(p, dict)] + if any(payload.get(f) not in (None, "") for payload in payloads for f in _RESET_FIELDS): + return True if response_headers and hasattr(response_headers, "get"): return any(response_headers.get(h) not in (None, "") for h in _RESET_HEADERS) return False @@ -758,7 +750,6 @@ def _model_id_missing_known_prefix(model: str, provider: str) -> bool: return False try: from hermes_cli.model_normalize import suggest_prefixed_model_id - return bool(suggest_prefixed_model_id((provider or "").strip(), name)) except Exception: return False @@ -770,13 +761,10 @@ def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool: Conservative: known parameters only, and only when ``provider`` is not a sender, so a genuine bad parameter (``max_tokens`` on GPT-5) stays format_error. """ - if not error_msg: - return False provider_slug = (provider or "").strip().lower() for param, senders in _SERVER_INJECTED_PARAM_SENDERS.items(): - if param not in error_msg or not any(w in error_msg for w in _PARAM_REJECTION_WORDS): - continue - return not any(sender in provider_slug for sender in senders) + if error_msg and param in error_msg and any(w in error_msg for w in _PARAM_REJECTION_WORDS): + return not any(sender in provider_slug for sender in senders) return False @@ -857,14 +845,11 @@ def _body_of(exc: Any) -> Optional[dict]: if isinstance(body, dict): return body response = getattr(exc, "response", None) - if response is not None: - try: - json_body = response.json() - if isinstance(json_body, dict): - return json_body - except Exception: - pass - return None + try: + json_body = response.json() if response is not None else None + except Exception: + return None + return json_body if isinstance(json_body, dict) else None def _headers_of(exc: Any) -> Any: @@ -899,11 +884,8 @@ def _code_from_payload(payload: Any, top_keys: Sequence[str], peek_message: bool if nested_code: return nested_code code = next((payload.get(k) for k in top_keys if payload.get(k)), "") - if isinstance(code, (str, int)): - text = str(code).strip() - if text and text != "400": - return text - return "" + text = str(code).strip() if isinstance(code, (str, int)) else "" + return text if text and text != "400" else "" def _extract_error_code(body: dict) -> str: @@ -913,10 +895,8 @@ def _extract_error_code(body: dict) -> str: def _extract_message(error: Exception, body: dict) -> str: """Extract the most informative error message (structured body first).""" - for msg in _body_message_candidates(body or {}): - if isinstance(msg, str) and msg.strip(): - return msg.strip()[:500] - return str(error)[:500] + msg = next((m for m in _body_message_candidates(body or {}) if isinstance(m, str) and m.strip()), None) + return (msg.strip() if msg else str(error))[:500] def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: From 923252f43620d4e9b7744e7a0d44c115baab8476 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:09:38 -0700 Subject: [PATCH 10/16] refactor(agent/display): compact diff snapshot/section helpers and multi-line signatures --- agent/display.py | 77 +++++++++++++----------------------------------- 1 file changed, 21 insertions(+), 56 deletions(-) diff --git a/agent/display.py b/agent/display.py index 7d30103783..386270fda4 100644 --- a/agent/display.py +++ b/agent/display.py @@ -507,13 +507,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - return _tail_trunc(preview, max_len) if preview else None -def prepare_tool_preview( - tool_name: str, - args: dict | None, - *, - fallback: str, - max_len: int, -) -> ToolPreview: +def prepare_tool_preview(tool_name: str, args: dict | None, *, fallback: str, max_len: int) -> ToolPreview: """Compact preview plus explicit truncation/URL facts (the uncapped preview is rebuilt from the arguments so an upstream display cap cannot drop its link target).""" full_text = build_tool_preview(tool_name, args, max_len=0) or fallback @@ -643,12 +637,10 @@ def _resolve_local_edit_paths(tool_name: str, function_args: dict | None) -> lis """Resolve local filesystem targets for write-capable tools.""" if not isinstance(function_args, dict): return [] - if tool_name in {"write_file", "patch"}: - path = function_args.get("path") - return [_resolved_path(path)] if path else [] if tool_name == "skill_manage": return _resolve_skill_manage_paths(function_args) - return [] + path = function_args.get("path") if tool_name in {"write_file", "patch"} else None + return [_resolved_path(path)] if path else [] def capture_local_edit_snapshot(tool_name: str, function_args: dict | None) -> LocalEditSnapshot | None: @@ -661,9 +653,7 @@ def capture_local_edit_snapshot(tool_name: str, function_args: dict | None) -> L def _result_succeeded(result: str | None) -> bool: """Conservatively detect whether a tool result represents success.""" - if not result: - return False - data = safe_json_loads(result) + data = safe_json_loads(result) if result else None if not isinstance(data, dict) or data.get("error"): return False return bool(data.get("success")) if "success" in data else True @@ -675,29 +665,22 @@ def _diff_from_snapshot(snapshot: LocalEditSnapshot | None) -> str | None: return None chunks: list[str] = [] for path in snapshot.paths: - before = snapshot.before.get(str(path)) - after = _snapshot_text(path) + before, after = snapshot.before.get(str(path)), _snapshot_text(path) if before == after: continue display_path = _display_diff_path(path) diff = "".join(unified_diff( - [] if before is None else before.splitlines(keepends=True), - [] if after is None else after.splitlines(keepends=True), + (before or "").splitlines(keepends=True), (after or "").splitlines(keepends=True), fromfile=f"a/{display_path}", tofile=f"b/{display_path}", )) if diff: - chunks.append(diff) - if not chunks: - return None - return "".join(chunk if chunk.endswith("\n") else chunk + "\n" for chunk in chunks) + chunks.append(diff if diff.endswith("\n") else diff + "\n") + return "".join(chunks) or None def extract_edit_diff( - tool_name: str, - result: str | None, - *, - function_args: dict | None = None, - snapshot: LocalEditSnapshot | None = None, + tool_name: str, result: str | None, *, + function_args: dict | None = None, snapshot: LocalEditSnapshot | None = None, ) -> str | None: """Extract a unified diff from a file-edit tool result.""" if tool_name == "patch" and result: @@ -730,8 +713,7 @@ _DIFF_LINE_COLORS = (("@@", "hunk"), ("-", "minus"), ("+", "plus"), (" ", "dim") def _render_inline_unified_diff(diff: str) -> list[str]: """Render unified diff lines in Hermes' inline transcript style.""" rendered: list[str] = [] - from_file = None - to_file = None + from_file = to_file = None for raw_line in diff.splitlines(): if raw_line.startswith("--- "): from_file = raw_line[4:].strip() @@ -751,30 +733,21 @@ def _render_inline_unified_diff(diff: str) -> list[str]: def _split_unified_diff_sections(diff: str) -> list[str]: """Split a unified diff into per-file sections.""" - sections: list[list[str]] = [] - current: list[str] = [] + sections: list[list[str]] = [[]] for line in diff.splitlines(): - if line.startswith("--- ") and current: - sections.append(current) - current = [line] - continue - current.append(line) - if current: - sections.append(current) + if line.startswith("--- ") and sections[-1]: + sections.append([]) + sections[-1].append(line) return ["\n".join(section) for section in sections if section] def _summarize_rendered_diff_sections( - diff: str, - *, - max_files: int = _MAX_INLINE_DIFF_FILES, - max_lines: int = _MAX_INLINE_DIFF_LINES, + diff: str, *, max_files: int = _MAX_INLINE_DIFF_FILES, max_lines: int = _MAX_INLINE_DIFF_LINES, ) -> list[str]: """Render diff sections while capping file count and total line count.""" sections = _split_unified_diff_sections(diff) rendered: list[str] = [] - omitted_files = 0 - omitted_lines = 0 + omitted_files = omitted_lines = 0 for idx, section in enumerate(sections): section_lines = _render_inline_unified_diff(section) remaining_budget = max_lines - len(rendered) @@ -800,12 +773,8 @@ def _summarize_rendered_diff_sections( def render_edit_diff_with_delta( - tool_name: str, - result: str | None, - *, - function_args: dict | None = None, - snapshot: LocalEditSnapshot | None = None, - print_fn=None, + tool_name: str, result: str | None, *, + function_args: dict | None = None, snapshot: LocalEditSnapshot | None = None, print_fn=None, ) -> bool: """Render an edit diff inline without taking over the terminal UI.""" diff = extract_edit_diff(tool_name, result, function_args=function_args, snapshot=snapshot) @@ -1155,9 +1124,7 @@ _CUTE_LINES = { } -def _get_cute_tool_message( - tool_name: str, args: dict, duration: float, result: str | None = None, -) -> str: +def _get_cute_tool_message(tool_name: str, args: dict, duration: float, result: str | None = None) -> str: """Tool completion line for CLI quiet mode: ``| {emoji} {verb:9} {detail} {duration}``, plus a failure suffix from :func:`_detect_tool_failure`; the leading ``┊`` becomes the skin's tool prefix.""" args = redact_tool_args_for_display(tool_name, args) or args @@ -1174,9 +1141,7 @@ def _get_cute_tool_message( return line if not is_failure else f"{line}{failure_suffix}" -def get_cute_tool_message( - tool_name: str, args: dict, duration: float, result: str | None = None, -) -> str: +def get_cute_tool_message(tool_name: str, args: dict, duration: float, result: str | None = None) -> str: """Render a completion label without letting cosmetic failures escape.""" try: return _get_cute_tool_message(tool_name, args, duration, result=result) From 81c3420d1ead3437f1e6c0cafe92d2b22cc0e388 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:14:14 -0700 Subject: [PATCH 11/16] refactor(agent/display): compact spinner frame loop, failure detection and cute renderers --- agent/display.py | 86 ++++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 54 deletions(-) diff --git a/agent/display.py b/agent/display.py index 386270fda4..2c41650a0c 100644 --- a/agent/display.py +++ b/agent/display.py @@ -816,13 +816,9 @@ class KawaiiSpinner: """Return the active skin's ``spinner[key]`` list, or *fallback* when absent/empty.""" try: skin = _get_skin() - if skin: - values = skin.spinner.get(key, []) - if values: - return values + return (skin.spinner.get(key, []) if skin else None) or fallback except Exception: - pass - return fallback + return fallback get_waiting_faces = classmethod(lambda cls: cls._skin_spinner_list("waiting_faces", cls.KAWAII_WAITING)) get_thinking_faces = classmethod(lambda cls: cls._skin_spinner_list("thinking_faces", cls.KAWAII_THINKING)) @@ -832,10 +828,8 @@ class KawaiiSpinner: self.message = message self.spinner_frames = self.SPINNERS.get(spinner_type, self.SPINNERS['dots']) self.running = False - self.thread = None - self.frame_idx = 0 - self.start_time = None - self.last_line_len = 0 + self.thread = self.start_time = None + self.frame_idx = self.last_line_len = 0 self._print_fn = print_fn # when set, bypasses self._out so silenced agents stay silent self._out = sys.stdout # captured NOW, before any child redirect_stdout(devnull) replaces it @@ -889,14 +883,10 @@ class KawaiiSpinner: time.sleep(0.1) continue frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)] - elapsed = time.time() - self.start_time - if wings: - left, right = wings[self.frame_idx % len(wings)] - line = f" {left} {frame} {self.message} {right} ({elapsed:.1f}s)" - else: - line = f" {frame} {self.message} ({elapsed:.1f}s)" - pad = max(self.last_line_len - len(line), 0) - self._write(f"\r{line}{' ' * pad}", end='', flush=True) + elapsed = f"({time.time() - self.start_time:.1f}s)" + left, right = wings[self.frame_idx % len(wings)] if wings else ("", "") + line = f" {left} {frame} {self.message} {right} {elapsed}" if wings else f" {frame} {self.message} {elapsed}" + self._write(f"\r{line}{' ' * max(self.last_line_len - len(line), 0)}", end='', flush=True) self.last_line_len = len(line) self.frame_idx += 1 time.sleep(0.12) @@ -918,10 +908,7 @@ class KawaiiSpinner: def print_above(self, text: str): """Print a line above the spinner (next tick redraws it below). Works inside redirect_stdout(devnull) because _write targets the stdout captured at creation.""" - if not self.running: - self._write(f" {text}", flush=True) - return - self._write(f"\r{self._clear_line_blanks()}\r {text}", flush=True) + self._write(f"\r{self._clear_line_blanks()}\r {text}" if self.running else f" {text}", flush=True) def stop(self, final_message: str = None): self.running = False @@ -973,18 +960,17 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return True, f" [{_trim_error(str(err_msg))}]" if err_msg else f" [exit {exit_code}]" if isinstance(data, dict): + failed = data.get("success") is False # Memory: distinguish "store full" from real errors. - if tool_name == "memory" and data.get("success") is False and "exceed the limit" in data.get("error", ""): + if tool_name == "memory" and failed and "exceed the limit" in data.get("error", ""): return True, " [full]" err = data.get("error") or data.get("message") - if err and (data.get("success") is False or "error" in data): + if err and (failed or "error" in data): return True, f" [{_trim_error(str(err))}]" - # Multimodal results (dicts) are successes; failures arrive as JSON-encoded strings. - if not isinstance(result, str): - return False, "" - lower = result[:500].lower() - if '"error"' in lower or '"failed"' in lower or result.startswith("Error"): + if isinstance(result, str) and ( + '"error"' in result[:500].lower() or '"failed"' in result[:500].lower() or result.startswith("Error") + ): return True, " [error]" return False, "" @@ -1007,12 +993,11 @@ def _cute_path(p) -> str: def _cute_web_extract(a: dict, _r) -> str: urls = a.get("urls", []) - if urls: - url = _display_url(urls[0] if isinstance(urls, list) else urls) - if url: - extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else "" - return f"┊ 📄 fetch {_cute_trunc(_domain(url))}{extra}" - return "┊ 📄 fetch pages" + url = _display_url(urls[0] if isinstance(urls, list) else urls) if urls else "" + if not url: + return "┊ 📄 fetch pages" + extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else "" + return f"┊ 📄 fetch {_cute_trunc(_domain(url))}{extra}" def _cute_todo_list(a: dict, result) -> str: @@ -1051,12 +1036,10 @@ def _cute_skill_view(a: dict, _r) -> str: def _cute_cronjob(a: dict, _r) -> str: action = a.get("action", "?") if action == "create": - skills = a.get("skills") or ([] if not a.get("skill") else [a.get("skill")]) + skills = a.get("skills") or ([a.get("skill")] if a.get("skill") else []) label = a.get("name") or (skills[0] if skills else None) or a.get("prompt", "task") return f"┊ ⏰ cron create {_cute_trunc(label)}" - if action == "list": - return "┊ ⏰ cron listing" - return f"┊ ⏰ cron {action} {a.get('job_id', '')}" + return "┊ ⏰ cron listing" if action == "list" else f"┊ ⏰ cron {action} {a.get('job_id', '')}" def _cute_execute_code(a: dict, _r) -> str: @@ -1072,14 +1055,15 @@ def _cute_browser_exec(a: dict, _r) -> str: def _cute_delegate(a: dict, _r) -> str: action_preview = _delegate_action_preview(a) - if action_preview is not None: - return f"┊ 🔀 delegate {_cute_trunc(action_preview)}" tasks = a.get("tasks") - if tasks and isinstance(tasks, list): + if action_preview is not None: + detail = action_preview + elif tasks and isinstance(tasks, list): goals = _delegate_task_goals(tasks, per_goal_len=30) - detail = " | ".join(goals) if goals else "parallel" - return f"┊ 🔀 delegate {len(goals) or len(tasks)}x: {_cute_trunc(detail)}" - return f"┊ 🔀 delegate {_cute_trunc(a.get('goal', ''))}" + detail = f"{len(goals) or len(tasks)}x: " + (" | ".join(goals) if goals else "parallel") + else: + detail = a.get("goal", "") + return f"┊ 🔀 delegate {_cute_trunc(detail)}" def _cute_process_manage(a: dict, _r) -> str: @@ -1129,16 +1113,10 @@ def _get_cute_tool_message(tool_name: str, args: dict, duration: float, result: failure suffix from :func:`_detect_tool_failure`; the leading ``┊`` becomes the skin's tool prefix.""" args = redact_tool_args_for_display(tool_name, args) or args is_failure, failure_suffix = _detect_tool_failure(tool_name, result) - skin_prefix = get_skin_tool_prefix() render = _CUTE_LINES.get(tool_name) - if render is not None: - body = render(args, result) - else: - body = f"┊ ⚡ {tool_name[:9]:9} {_cute_trunc(build_tool_preview(tool_name, args) or '')}" - line = f"{body} {duration:.1f}s" - if skin_prefix != "┊": - line = line.replace("┊", skin_prefix, 1) - return line if not is_failure else f"{line}{failure_suffix}" + body = render(args, result) if render else f"┊ ⚡ {tool_name[:9]:9} {_cute_trunc(build_tool_preview(tool_name, args) or '')}" + line = f"{body} {duration:.1f}s".replace("┊", get_skin_tool_prefix(), 1) + return f"{line}{failure_suffix}" if is_failure else line def get_cute_tool_message(tool_name: str, args: dict, duration: float, result: str | None = None) -> str: From 5f37b25c886d06c2ca22fac6f5dc4ca163ca33ab Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:15:41 -0700 Subject: [PATCH 12/16] refactor(agent/display): keep delegate task-count prefix outside the preview cap (fuzz-caught) --- agent/display.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/agent/display.py b/agent/display.py index 2c41650a0c..97be479fe7 100644 --- a/agent/display.py +++ b/agent/display.py @@ -1057,13 +1057,11 @@ def _cute_delegate(a: dict, _r) -> str: action_preview = _delegate_action_preview(a) tasks = a.get("tasks") if action_preview is not None: - detail = action_preview - elif tasks and isinstance(tasks, list): + return f"┊ 🔀 delegate {_cute_trunc(action_preview)}" + if tasks and isinstance(tasks, list): goals = _delegate_task_goals(tasks, per_goal_len=30) - detail = f"{len(goals) or len(tasks)}x: " + (" | ".join(goals) if goals else "parallel") - else: - detail = a.get("goal", "") - return f"┊ 🔀 delegate {_cute_trunc(detail)}" + return f"┊ 🔀 delegate {len(goals) or len(tasks)}x: {_cute_trunc(' | '.join(goals) if goals else 'parallel')}" + return f"┊ 🔀 delegate {_cute_trunc(a.get('goal', ''))}" def _cute_process_manage(a: dict, _r) -> str: From b4b3baeda155ca6698eba40e1ef70febdb96134c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:19:34 -0700 Subject: [PATCH 13/16] refactor(agent/display,error_classifier): simplify shell tokenizers, share abort-fallback verdict hints --- agent/display.py | 52 ++++++++++++++------------------------- agent/error_classifier.py | 35 ++++++++++++-------------- 2 files changed, 34 insertions(+), 53 deletions(-) diff --git a/agent/display.py b/agent/display.py index 97be479fe7..0627480457 100644 --- a/agent/display.py +++ b/agent/display.py @@ -210,18 +210,13 @@ def _scan_quoted(text: str) -> Iterator[tuple[int, str, bool]]: def _split_shell_words(segment: str) -> list[str]: - words: list[str] = [] - buf: list[str] = [] + parts: list[list[str]] = [[]] for _, ch, quoted in _scan_quoted(segment): if not quoted and ch.isspace(): - if buf: - words.append("".join(buf)) - buf = [] + parts.append([]) else: - buf.append(ch) - if buf: - words.append("".join(buf)) - return words + parts[-1].append(ch) + return ["".join(p) for p in parts if p] def _strip_shell_pipe_tail(segment: str) -> str: @@ -236,30 +231,20 @@ def _strip_shell_pipe_tail(segment: str) -> str: def _split_shell_compound(command: str) -> list[str]: """Split on unquoted ``&&`` / ``||`` / ``;`` / newline, dropping pipe tails per segment.""" - segments: list[str] = [] - buf: list[str] = [] + raw: list[list[str]] = [[]] skip = False - - def _flush() -> None: - segment = _strip_shell_pipe_tail("".join(buf).strip()) - if segment: - segments.append(segment) - buf.clear() - for i, ch, quoted in _scan_quoted(command): if skip: skip = False - continue - if not quoted and (command.startswith("&&", i) or command.startswith("||", i)): - _flush() + elif not quoted and (command.startswith("&&", i) or command.startswith("||", i)): + raw.append([]) skip = True - continue - if not quoted and ch in {";", "\n"}: - _flush() - continue - buf.append(ch) - _flush() - return segments + elif not quoted and ch in {";", "\n"}: + raw.append([]) + else: + raw[-1].append(ch) + segments = (_strip_shell_pipe_tail("".join(buf).strip()) for buf in raw) + return [s for s in segments if s] def _shell_head_word(segment: str) -> str: @@ -278,13 +263,12 @@ def _clean_shell_segment(segment: str) -> str: while i < len(words): word = words[i] if re.match(r"^\d*(?:>>?|<)$", word): - i += 2 - continue - if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word): + i += 2 # operator + target + elif re.match(r"^\d*(?:>&|<&)\d+$", word): + i += 1 + else: + out.append(word) i += 1 - continue - out.append(word) - i += 1 return " ".join(out).strip() diff --git a/agent/error_classifier.py b/agent/error_classifier.py index abea68b4e3..45c310f6b8 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -323,26 +323,23 @@ def _v(reason: FailoverReason, **hints: Any) -> Verdict: _ROTATE_FALLBACK = {"should_rotate_credential": True, "should_fallback": True} +_ABORT_FALLBACK = {"retryable": False, "should_fallback": True} +_R = FailoverReason -_V_BILLING = _v(FailoverReason.billing, retryable=False, **_ROTATE_FALLBACK) -_V_RATE_LIMIT = _v(FailoverReason.rate_limit, **_ROTATE_FALLBACK) -_V_OVERLOADED = _v(FailoverReason.overloaded) -_V_SERVER_ERROR = _v(FailoverReason.server_error) -_V_CONTEXT_OVERFLOW = _v(FailoverReason.context_overflow, should_compress=True) -_V_PAYLOAD_TOO_LARGE = _v(FailoverReason.payload_too_large, should_compress=True) -_V_MODEL_NOT_FOUND = _v(FailoverReason.model_not_found, retryable=False, should_fallback=True) -_V_POLICY_BLOCKED = _v(FailoverReason.provider_policy_blocked, retryable=False) -_V_CONTENT_BLOCKED = _v(FailoverReason.content_policy_blocked, retryable=False, should_fallback=True) -_V_FORMAT_ERROR = _v(FailoverReason.format_error, retryable=False, should_fallback=True) -_V_AUTH_ROTATE = _v(FailoverReason.auth, retryable=False, **_ROTATE_FALLBACK) -_V_AUTH_FALLBACK = _v(FailoverReason.auth, retryable=False, should_fallback=True) -_V_TIMEOUT = _v(FailoverReason.timeout) -_V_SSL_CERT = _v(FailoverReason.ssl_cert_verification, retryable=False) -_V_IMAGE_TOO_LARGE = _v(FailoverReason.image_too_large) -_V_IMAGE_CORRUPT = _v(FailoverReason.image_corrupt) -_V_MULTIMODAL = _v(FailoverReason.multimodal_tool_content_unsupported) -_V_INVALID_ENCRYPTED = _v(FailoverReason.invalid_encrypted_content) -_V_UNKNOWN = _v(FailoverReason.unknown) +_V_BILLING = _v(_R.billing, retryable=False, **_ROTATE_FALLBACK) +_V_RATE_LIMIT = _v(_R.rate_limit, **_ROTATE_FALLBACK) +_V_AUTH_ROTATE = _v(_R.auth, retryable=False, **_ROTATE_FALLBACK) +_V_AUTH_FALLBACK = _v(_R.auth, **_ABORT_FALLBACK) +_V_MODEL_NOT_FOUND = _v(_R.model_not_found, **_ABORT_FALLBACK) +_V_CONTENT_BLOCKED = _v(_R.content_policy_blocked, **_ABORT_FALLBACK) +_V_FORMAT_ERROR = _v(_R.format_error, **_ABORT_FALLBACK) +_V_POLICY_BLOCKED = _v(_R.provider_policy_blocked, retryable=False) +_V_SSL_CERT = _v(_R.ssl_cert_verification, retryable=False) +_V_CONTEXT_OVERFLOW = _v(_R.context_overflow, should_compress=True) +_V_PAYLOAD_TOO_LARGE = _v(_R.payload_too_large, should_compress=True) +_V_OVERLOADED, _V_SERVER_ERROR, _V_TIMEOUT, _V_UNKNOWN = map(_v, (_R.overloaded, _R.server_error, _R.timeout, _R.unknown)) +_V_IMAGE_TOO_LARGE, _V_IMAGE_CORRUPT = _v(_R.image_too_large), _v(_R.image_corrupt) +_V_MULTIMODAL, _V_INVALID_ENCRYPTED = _v(_R.multimodal_tool_content_unsupported), _v(_R.invalid_encrypted_content) def _billing_hints(error_msg: str) -> Verdict: From b2876bcba4bb822f5cdc67309447e822585e59d4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:21:47 -0700 Subject: [PATCH 14/16] refactor(agent/display,error_surface): flatten preview/label/status-phrase branches, dict-build surface descriptors --- agent/display.py | 54 +++++++++++++----------------------------- agent/error_surface.py | 11 +++------ 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/agent/display.py b/agent/display.py index 0627480457..dcf2cf39e7 100644 --- a/agent/display.py +++ b/agent/display.py @@ -28,8 +28,7 @@ _MAX_INLINE_DIFF_LINES = 80 def _display_url(value: Any) -> str: """Extract a display-only URL without assuming model argument types.""" - if isinstance(value, dict): - value = value.get("url") or value.get("href") + value = (value.get("url") or value.get("href")) if isinstance(value, dict) else value return value.strip() if isinstance(value, str) else "" @@ -312,12 +311,8 @@ def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any: reaching logs, callbacks, the model, or chat history. Forced regardless of ``security.redact_secrets``: a leaked typed credential is a security boundary. """ - if typed_text is None: - return value - needle = str(typed_text) - if needle == "": - return value - redacted = redact_sensitive_text(needle, force=True) + needle = "" if typed_text is None else str(typed_text) + redacted = redact_sensitive_text(needle, force=True) if needle else needle if redacted == needle: return value if isinstance(value, str): @@ -344,13 +339,8 @@ def _delegate_task_goals(tasks: Any, *, per_goal_len: int) -> list[str]: """One truncated goal string per dict task (``?`` when missing).""" if not isinstance(tasks, list): return [] - goals: list[str] = [] - for task in tasks: - if isinstance(task, dict): - raw_goal = task.get("goal") - goal = "?" if raw_goal is None else _oneline(str(raw_goal)) - goals.append(_truncate_preview(goal or "?", per_goal_len)) - return goals + raw_goals = (task.get("goal") for task in tasks if isinstance(task, dict)) + return [_truncate_preview(("?" if g is None else _oneline(str(g))) or "?", per_goal_len) for g in raw_goals] def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None: @@ -402,13 +392,9 @@ def _preview_delegate_task(args: dict, max_len: int) -> str | None: def _preview_process_manage(args: dict, _max_len: int) -> str | None: - action, sid, data, timeout_val = (args.get(k, d) for k, d in (("action", ""), ("session_id", ""), ("data", ""), ("timeout", None))) - parts = [ - str(action) if action else "", - str(sid)[:16] if sid else "", - f'"{_oneline(str(data)[:20])}"' if data else "", - f"{timeout_val}s" if timeout_val and action == "wait" else "", - ] + action, sid, data, timeout_val = (args.get(k) for k in ("action", "session_id", "data", "timeout")) + parts = [str(action) if action else "", str(sid)[:16] if sid else "", + f'"{_oneline(str(data)[:20])}"' if data else "", f"{timeout_val}s" if timeout_val and action == "wait" else ""] return " ".join(p for p in parts if p) or None @@ -476,18 +462,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - if not args: return None args = redact_tool_args_for_display(tool_name, args) or args - builder = _PREVIEW_BUILDERS.get(tool_name) if builder is not None: return builder(args, max_len) - key = _PRIMARY_ARGS.get(tool_name) or next((k for k in _FALLBACK_PREVIEW_KEYS if k in args), None) if not key or key not in args: return None value = args[key] - if isinstance(value, list): - value = value[0] if value else "" - preview = _oneline(str(value)) + preview = _oneline(str((value[0] if value else "") if isinstance(value, list) else value)) return _tail_trunc(preview, max_len) if preview else None @@ -549,25 +531,21 @@ def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> return None verb = _TOOL_VERBS.get(tool_name) phrase = f"is {verb[0].lower()}{verb[1:]}" if verb else f"is using {tool_name}" - if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW: - preview = build_tool_preview(tool_name, args, max_len=None) - if preview: - # Previews can contain newlines (terminal commands); keep the first line. - phrase = f"{phrase}{tool_verb_connector(tool_name)}{preview.splitlines()[0].strip()}" - if len(phrase) > max_len - 1: - return phrase[: max_len - 2].rstrip() + "…" - return phrase + "…" + preview = build_tool_preview(tool_name, args, max_len=None) if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW else None + if preview: # previews can contain newlines (terminal commands); keep the first line + phrase = f"{phrase}{tool_verb_connector(tool_name)}{preview.splitlines()[0].strip()}" + return phrase[: max_len - 2].rstrip() + "…" if len(phrase) > max_len - 1 else phrase + "…" def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None: """Human-phrased label ("Searching the web for ...") for curated built-ins; other tools (or labels disabled) get the raw preview, so it is a drop-in for build_tool_preview.""" verb = get_tool_verb(tool_name) - if not verb: - return build_tool_preview(tool_name, args, max_len=max_len) - if tool_name in _TOOL_VERBS_NO_PREVIEW: + if verb and tool_name in _TOOL_VERBS_NO_PREVIEW: return verb preview = build_tool_preview(tool_name, args, max_len=max_len) + if not verb: + return preview return f"{verb}{tool_verb_connector(tool_name)}{preview}" if preview else verb diff --git a/agent/error_surface.py b/agent/error_surface.py index 146583b8cf..666dce3145 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -70,19 +70,14 @@ def _is_custom_endpoint(provider: Optional[str]) -> bool: def _looks_like_stream_drop(message: str) -> bool: - msg = message.lower() - return any(fragment in msg for fragment in _STREAM_DROP_FRAGMENTS) + return any(fragment in message.lower() for fragment in _STREAM_DROP_FRAGMENTS) def _surface(layer: str, code: str, retryable: bool, provider: str = "", model: str = "") -> dict: - out = {"layer": layer, "code": code, "retryable": bool(retryable)} # Identity captured at classification time, so clients report the session # that actually failed — not whatever the composer points at later. - if provider: - out["provider"] = provider - if model: - out["model"] = model - return out + identity = {k: v for k, v in (("provider", provider), ("model", model)) if v} + return {"layer": layer, "code": code, "retryable": bool(retryable), **identity} def _disk_full(candidate: Any) -> bool: From ea3a1cec8c88cb73abd1f8b9e859e53596909615 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:28:25 -0700 Subject: [PATCH 15/16] refactor(agent/error_classifier,display,i18n): flatten remaining guard ladders and log-call formatting --- agent/display.py | 21 +++++----- agent/error_classifier.py | 80 ++++++++++++++++----------------------- agent/i18n.py | 13 +++---- 3 files changed, 47 insertions(+), 67 deletions(-) diff --git a/agent/display.py b/agent/display.py index dcf2cf39e7..f289da9df6 100644 --- a/agent/display.py +++ b/agent/display.py @@ -46,7 +46,7 @@ def _hex_rgb(h: str) -> tuple[int, int, int]: def _get_skin(): - """Get the active skin config, or None if not available (lazy import avoids cycles).""" + """Active skin config, or None when unavailable (lazy import avoids cycles).""" try: from hermes_cli.skin_engine import get_active_skin return get_active_skin() @@ -380,9 +380,9 @@ def _preview_browser_exec(args: dict, max_len: int) -> str | None: def _preview_delegate_task(args: dict, max_len: int) -> str | None: action_preview = _delegate_action_preview(args) + tasks = args.get("tasks") if action_preview is not None: return _truncate_preview(action_preview, max_len) - tasks = args.get("tasks") if tasks and isinstance(tasks, list): goals = _delegate_task_goals(tasks, per_goal_len=40) preview = f"{len(goals)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" @@ -413,10 +413,8 @@ def _preview_shell(key: str): def _preview_read_file(args: dict, max_len: int) -> str | None: path = args.get("path") or args.get("file") or args.get("filepath") - if path is None: - return None - label = Path(str(path).replace("\\", "/")).name or str(path) - return _truncate_preview(f"{label} {_read_file_line_label(args)}".strip(), max_len) or None + label = (Path(str(path).replace("\\", "/")).name or str(path)) if path is not None else None + return None if label is None else _truncate_preview(f"{label} {_read_file_line_label(args)}".strip(), max_len) or None def _preview_memory(args: dict, _max_len: int) -> str: @@ -531,7 +529,8 @@ def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> return None verb = _TOOL_VERBS.get(tool_name) phrase = f"is {verb[0].lower()}{verb[1:]}" if verb else f"is using {tool_name}" - preview = build_tool_preview(tool_name, args, max_len=None) if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW else None + with_preview = args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW + preview = build_tool_preview(tool_name, args, max_len=None) if with_preview else None if preview: # previews can contain newlines (terminal commands); keep the first line phrase = f"{phrase}{tool_verb_connector(tool_name)}{preview.splitlines()[0].strip()}" return phrase[: max_len - 2].rstrip() + "…" if len(phrase) > max_len - 1 else phrase + "…" @@ -660,8 +659,7 @@ def _emit_inline_diff(diff_text: str, print_fn) -> bool: if print_fn is None or not diff_text: return False try: - print_fn(" ┊ review diff") - for line in diff_text.rstrip("\n").splitlines(): + for line in [" ┊ review diff", *diff_text.rstrip("\n").splitlines()]: print_fn(line) return True except Exception: @@ -819,9 +817,8 @@ class KawaiiSpinner: return False def _is_patch_stdout_proxy(self) -> bool: - """True when stdout is prompt_toolkit's StdoutProxy, which queues writes and - injects newlines around each flush so the \\r overwrite never lands; the CLI - drives its own TUI spinner widget in that mode, so we stay silent.""" + """True for prompt_toolkit's StdoutProxy: it injects newlines around each flush so the + \\r overwrite never lands, and the CLI drives its own TUI spinner widget in that mode.""" try: from prompt_toolkit.patch_stdout import StdoutProxy return isinstance(self._out, StdoutProxy) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 45c310f6b8..0b8871feed 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -403,7 +403,7 @@ _MESSAGE_TAIL_RULES = ( # but does not set should_fallback (unlike the message/status paths). _ERROR_CODE_VERDICTS: Dict[str, Verdict] = { **dict.fromkeys(("resource_exhausted", "throttled", "rate_limit_exceeded"), - _v(FailoverReason.rate_limit, should_rotate_credential=True)), + _v(_R.rate_limit, should_rotate_credential=True)), **dict.fromkeys(_BILLING_ERROR_CODES, _V_BILLING), **dict.fromkeys(("model_not_found", "model_not_available", "invalid_model"), _V_MODEL_NOT_FOUND), **dict.fromkeys(("context_length_exceeded", "max_tokens_exceeded"), _V_CONTEXT_OVERFLOW), @@ -463,10 +463,8 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: logger.debug("Plugin error classification unavailable: %s", exc) return None if verdict is not None: - logger.info( - "API error classified by plugin hook: %s (provider=%s, status=%s)", - verdict["reason"].value, c.provider, c.status_code, - ) + logger.info("API error classified by plugin hook: %s (provider=%s, status=%s)", + verdict["reason"].value, c.provider, c.status_code) return verdict @@ -480,13 +478,13 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: # Anthropic thinking-block 400s (signature mismatch after transcript # mutation). Not gated on provider — OpenRouter proxies Anthropic errors. if status == 400 and "thinking" in msg and any(p in msg for p in _THINKING_MUTATION_WORDS): - return _v(FailoverReason.thinking_signature) + return _v(_R.thinking_signature) # Anthropic long-context tier gate (429 "extra usage" + "long context"). if status == 429 and "extra usage" in msg and "long context" in msg: - return _v(FailoverReason.long_context_tier, should_compress=True) + return _v(_R.long_context_tier, should_compress=True) # Anthropic OAuth rejects the 1M beta header; run_agent retries without it. if status == 400 and "long context beta" in msg and "not yet available" in msg: - return _v(FailoverReason.oauth_long_context_beta_forbidden) + return _v(_R.oauth_long_context_beta_forbidden) # llama.cpp grammar rejects regex ``pattern``/``format`` in tool schemas; the # retry loop strips them. Exclude the Qwen/vLLM "No user query found" error # local engines wrap as "Unable to generate parser for this template" — @@ -495,7 +493,7 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: "unable to generate parser" in msg and "template" in msg ) if status == 400 and grammar_hit and _NO_USER_QUERY_SIGNAL not in msg: - return _v(FailoverReason.llama_cpp_grammar_pattern) + return _v(_R.llama_cpp_grammar_pattern) # xAI Grok entitlement as an SSE ``type=error`` frame: no status, matches no # pattern list, would otherwise burn max_retries as ``unknown``. if "do not have an active grok subscription" in msg or ("out of available resources" in msg and "grok" in msg): @@ -507,11 +505,10 @@ def _moa_special_cases(c: _Ctx) -> Optional[Verdict]: # Local MoA streaming adapter-shape bugs are not a provider outage; falling # back would silently replace the MoA route with a single model (#55933). if c.provider_slug == "moa" and any(s in str(c.error) for s in _MOA_ADAPTER_SHAPE_BUGS): - return _v(FailoverReason.format_error, retryable=False) + return _v(_R.format_error, retryable=False) # Persisted MoA preset name that was renamed/deleted — deterministic config error. from agent.errors import MoAPresetNotFoundError - - return _v(FailoverReason.model_not_found, retryable=False) if isinstance(c.error, MoAPresetNotFoundError) else None + return _v(_R.model_not_found, retryable=False) if isinstance(c.error, MoAPresetNotFoundError) else None def _by_error_code(c: _Ctx) -> Optional[Verdict]: @@ -524,14 +521,13 @@ def _by_error_code(c: _Ctx) -> Optional[Verdict]: def _by_message(c: _Ctx) -> Optional[Verdict]: - """Message patterns when no status code settled it.""" - verdict = _first_match(c.msg, _MESSAGE_HEAD_RULES) - if verdict is not None: - return verdict - # Status-less usage limits need the same disambiguation as 402. - if any(p in c.msg for p in _USAGE_LIMIT_PATTERNS): - return _classify_402(c.msg, dict) - return _first_match(c.msg, _MESSAGE_TAIL_RULES) + """Message patterns when no status code settled it; status-less usage + limits get the same disambiguation as 402.""" + head = _first_match(c.msg, _MESSAGE_HEAD_RULES) + if head is not None: + return head + usage_limit = any(p in c.msg for p in _USAGE_LIMIT_PATTERNS) + return _classify_402(c.msg, dict) if usage_limit else _first_match(c.msg, _MESSAGE_TAIL_RULES) def _by_transport(c: _Ctx) -> Optional[Verdict]: @@ -539,10 +535,9 @@ def _by_transport(c: _Ctx) -> Optional[Verdict]: msg = c.msg # Cert failure → fail fast (checked first: also contains "[ssl:"); transient # alert → retry, before disconnects so a flaky handshake never compresses. - if any(p in msg for p in _SSL_CERT_VERIFY_PATTERNS): - return _V_SSL_CERT - if any(p in msg for p in _SSL_TRANSIENT_PATTERNS): - return _V_TIMEOUT + ssl = _first_match(msg, ((_SSL_CERT_VERIFY_PATTERNS, _V_SSL_CERT), (_SSL_TRANSIENT_PATTERNS, _V_TIMEOUT))) + if ssl is not None: + return ssl # Disconnect + large session → probable overflow rejection, not a hiccup. if any(p in msg for p in _SERVER_DISCONNECT_PATTERNS) and not c.status_code: # Reasoning models: far more likely the gateway idle-killed a long @@ -554,10 +549,9 @@ def _by_transport(c: _Ctx) -> Optional[Verdict]: # Stale-call circuit breaker (_check_stale_giveup RuntimeError before any # network call): as ``unknown`` it would burn every retry instantly. if c.error_type == "RuntimeError" and "consecutive stale attempts" in msg and "aborting this call" in msg: - return _v(FailoverReason.timeout, retryable=False, should_fallback=True) - if c.error_type in _TRANSPORT_ERROR_TYPES or isinstance(c.error, (TimeoutError, ConnectionError, OSError)): - return _V_TIMEOUT - return None + return _v(_R.timeout, **_ABORT_FALLBACK) + transport = c.error_type in _TRANSPORT_ERROR_TYPES or isinstance(c.error, (TimeoutError, ConnectionError, OSError)) + return _V_TIMEOUT if transport else None def _by_status(c: _Ctx) -> Optional[Verdict]: @@ -593,10 +587,8 @@ def classify_api_error( approx_tokens, context_length, num_messages, ) verdict = next((v for v in (stage(c) for stage in _STAGES) if v is not None), _V_UNKNOWN) - return ClassifiedError(**{ - "status_code": status_code, "provider": provider, "model": model, - "message": _extract_message(error, body), **verdict, - }) + base = {"status_code": status_code, "provider": provider, "model": model, "message": _extract_message(error, body)} + return ClassifiedError(**{**base, **verdict}) # ── Status code handlers ──────────────────────────────────────────────── @@ -604,9 +596,8 @@ def classify_api_error( def _status_403(c: _Ctx) -> Verdict: # OpenRouter 403 "key limit exceeded" and similar plan/credit exhaustion are billing. xai_spend = c.provider_slug == "xai-oauth" and c.code == _XAI_SPENDING_LIMIT_ERROR_CODE - if xai_spend or any(p in c.msg for p in ("key limit exceeded", "spending limit") + _BILLING_PATTERNS): - return _V_BILLING - return _V_AUTH_FALLBACK + billing = xai_spend or any(p in c.msg for p in ("key limit exceeded", "spending limit") + _BILLING_PATTERNS) + return _V_BILLING if billing else _V_AUTH_FALLBACK def _status_404(c: _Ctx) -> Verdict: @@ -628,7 +619,7 @@ def _status_429(c: _Ctx) -> Verdict: if _is_openrouter_upstream_error(c.body, c.provider_slug): upstream = _extract_upstream_provider_name(c.body) ctx = {"upstream_provider": upstream} if upstream else {} - return _v(FailoverReason.upstream_rate_limit, should_fallback=True, error_context=ctx) + return _v(_R.upstream_rate_limit, should_fallback=True, error_context=ctx) # Quota walls as 429 (Anthropic ``usage_limit_reached``, "quota", billing # phrases) are billing ONLY when the body is not itself a rate-limit phrase # ("Rate limit exceeded" contains "limit exceeded") and carries no reset/ @@ -636,11 +627,8 @@ def _status_429(c: _Ctx) -> Verdict: quota_wall = c.code == "usage_limit_reached" or any( p in c.msg for p in ("usage_limit_reached",) + _USAGE_LIMIT_PATTERNS + _BILLING_PATTERNS ) - if ( - quota_wall - and not any(p in c.msg for p in _RATE_LIMIT_PATTERNS) - and not _has_usage_limit_transient_signal(c.msg, c.body, c.headers) - ): + explicit_rate_limit = any(p in c.msg for p in _RATE_LIMIT_PATTERNS) + if quota_wall and not explicit_rate_limit and not _has_usage_limit_transient_signal(c.msg, c.body, c.headers): return _V_BILLING return _V_RATE_LIMIT @@ -685,12 +673,10 @@ def _classify_400(c: _Ctx) -> Verdict: # cannot fix it. litellm/Bedrock proxies use errorCode=INVALID_REQUEST_BODY. if any(p in msg for p in _INVALID_MESSAGE_BODY_PATTERNS) or code == "invalid_request_body": logger.warning( - "Malformed message array 400 (invalid request body) classified as " - "format_error, NOT context overflow — failing fast + falling back " - "instead of entering the compression loop. This usually means an " - "empty-content assistant stub is in the transcript; num_messages=%s " - "approx_tokens=%s. error=%.200s", - c.num_messages, c.approx_tokens, msg, + "Malformed message array 400 (invalid request body) classified as format_error, NOT context " + "overflow — failing fast + falling back instead of entering the compression loop. This usually " + "means an empty-content assistant stub is in the transcript; num_messages=%s approx_tokens=%s. " + "error=%.200s", c.num_messages, c.approx_tokens, msg, ) return _V_FORMAT_ERROR verdict = _first_match(msg, _400_TAIL_RULES) diff --git a/agent/i18n.py b/agent/i18n.py index 33a63e2785..4fe61acf0d 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -60,10 +60,9 @@ def _locales_dir() -> Path: the path it looked at rather than raise. """ override = os.getenv("HERMES_BUNDLED_LOCALES", "").strip() + if override and Path(override).is_dir(): + return Path(override) if override: - candidate = Path(override) - if candidate.is_dir(): - return candidate logger.warning( "HERMES_BUNDLED_LOCALES points to a non-directory path (%s); " "falling back to bundled/source locale resolution", override, @@ -125,13 +124,11 @@ def _config_language_cached() -> str | None: """``display.language`` from config.yaml, read once per process (``t()`` is a hot path).""" try: from hermes_cli.config import load_config_readonly - cfg = load_config_readonly() - lang = (cfg.get("display") or {}).get("language") - if lang: - return _normalize_lang(lang) + lang = (load_config_readonly().get("display") or {}).get("language") + return _normalize_lang(lang) if lang else None except Exception as exc: logger.debug("Could not read display.language from config: %s", exc) - return None + return None def reset_language_cache() -> None: From ae2953ef0175702957dfb1968607796edc503c78 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:32:53 -0700 Subject: [PATCH 16/16] refactor(agent/display,error_surface,i18n): final guard-ladder collapse --- agent/display.py | 8 +++----- agent/error_surface.py | 13 +++++-------- agent/i18n.py | 17 ++++++++--------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/agent/display.py b/agent/display.py index f289da9df6..598553bfae 100644 --- a/agent/display.py +++ b/agent/display.py @@ -552,15 +552,14 @@ def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> def _resolved_path(path: str) -> Path: """Resolve a possibly-relative filesystem path against the current cwd.""" - candidate = Path(os.path.expanduser(path)) - return candidate if candidate.is_absolute() else Path.cwd() / candidate + return Path.cwd() / Path(os.path.expanduser(path)) # a `/` with an absolute rhs keeps the rhs def _snapshot_text(path: Path) -> str | None: """Return UTF-8 file content, or None for missing/unreadable files.""" try: return path.read_text(encoding="utf-8") - except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError, OSError): + except (UnicodeDecodeError, OSError): # FileNotFoundError/IsADirectoryError are OSErrors return None @@ -853,8 +852,7 @@ class KawaiiSpinner: def start(self): if self.running: return - self.running = True - self.start_time = time.time() + self.running, self.start_time = True, time.time() self.thread = threading.Thread(target=self._animate, daemon=True) self.thread.start() diff --git a/agent/error_surface.py b/agent/error_surface.py index 666dce3145..1c6532f708 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -90,9 +90,8 @@ def _disk_full(candidate: Any) -> bool: def _result_layer(reason: str, error_text: str, provider: str) -> str: - layer = _REASON_TO_LAYER.get(reason) - if layer is not None: - return layer + if reason in _REASON_TO_LAYER: + return _REASON_TO_LAYER[reason] if reason in _TRANSPORT_REASONS and _is_custom_endpoint(provider): return LAYER_ENDPOINT return LAYER_STREAMING if _looks_like_stream_drop(error_text) else LAYER_PROVIDER @@ -117,11 +116,9 @@ def build_error_surface_from_result(result: Any, provider: str = "", model: str return _surface(LAYER_DISK, "disk_full", False, provider, model) if result.get("billing_block") or reason in ("billing", "billing_unverified"): return _surface(LAYER_BILLING, reason or "billing", False, provider, model) - if not reason: - # Failed result without a classified reason (legacy paths). - if _looks_like_stream_drop(error_text): - return _surface(LAYER_STREAMING, "stream_drop", True, provider, model) - return _surface(LAYER_PROVIDER, "unknown", True, provider, model) + if not reason: # failed result without a classified reason (legacy paths) + drop = _looks_like_stream_drop(error_text) + return _surface(LAYER_STREAMING if drop else LAYER_PROVIDER, "stream_drop" if drop else "unknown", True, provider, model) # Prefer the classifier's own verdict (``failure_retryable``); the # reason-set fallback covers older results. retryable = result.get("failure_retryable") diff --git a/agent/i18n.py b/agent/i18n.py index 4fe61acf0d..02f0c97036 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -98,15 +98,14 @@ def _load_catalog(lang: str) -> dict[str, str]: flat: dict[str, str] = {} if not path.is_file(): logger.debug("i18n catalog missing for %s at %s", lang, path) - else: - try: - import yaml - with path.open("r", encoding="utf-8") as f: - raw = yaml.safe_load(f) or {} - except Exception as exc: - logger.warning("Failed to load i18n catalog %s: %s", path, exc) - else: - _flatten_into(raw, "", flat) + return _cache_catalog(lang, flat) + try: + import yaml + with path.open("r", encoding="utf-8") as f: + _flatten_into(yaml.safe_load(f) or {}, "", flat) + except Exception as exc: + logger.warning("Failed to load i18n catalog %s: %s", path, exc) + flat = {} return _cache_catalog(lang, flat)