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: