Files
hermes-agent/hermes_cli/cli_chat_error_copy.py
Teknium f5900bf66e fix: a chat whose launch API key changed says so and names /new, not /retry
A local session never sends a different key than it started with (#139615).
When the config.yaml secret on its route changes and the backend restarts,
every turn and model pick on that chat is refused with
launch_credentials_unavailable. That policy is unchanged. The copy was the bug:

- Turn (every surface): the refusal escaped TurnRunner.run_sync as an
  exception into the generic agent-error handler, so the reply read
  "Something went wrong ... launch_credentials_unavailable (...). Use /retry
  to try again" plus a false "Some actions may already have run" notice.
  /retry can never succeed here. run_sync now settles it like the gone-folder
  refusal: a failed, never-run turn (api_calls 0) whose text says the key the
  chat started with changed, that /retry fails the same way, and that /new
  starts a chat on the current key; stamped failure_reason
  launch_credentials_unavailable, failure_retryable false. The generic handler
  path (a refusal raised later in the turn) uses the same copy and stamp.
- agent/error_surface maps the code to the gateway layer (no Switch provider).
- Desktop: an error card for the code (Start new session, no Retry), and the
  model-menu/model-option toast (#143133) shows the card's sentence with a
  Start new session button instead of the raw code.
- TUI: describeRpcError rewrites the bare code (model pick refusal) to the
  same remedy.
- Classic CLI: refusal_text (/compress, /worktree) and a refused /model print
  the remedy instead of the bare code; --api-key on resume that does not match
  now says a session never switches keys and to start a new one.

Copy: gateway.errors.launch_credentials_unavailable in all 17 locales,
userMessages.rpc.launchCredentialsChanged in all TUI catalogs, the Desktop
card in all 9 catalogs carrying error codes. The over-cap de/es/fr/ja/zh
Desktop catalogs move their refusal card rows into <lang>_error_codes.ts
siblings (the ru shape), so each shrinks while gaining the entry.

Refs #143133 (its raw toast; the reporter's trigger without a config change is not root-caused here)
2026-10-24 11:47:35 -07:00

97 lines
4.4 KiB
Python

"""Plain-language copy for chat-turn failures shown in the CLI response panel.
The chat panel used to echo ``Error: HTTP 401: Invalid API key`` as the assistant's answer. These
helpers map the classifier verdict (``agent/error_classifier.py``) to WHAT happened + WHAT TO DO,
and demote the raw provider text to a ``Details:`` line. Copy lives in the i18n catalog under
``cli.error.*`` and is resolved at render time for the active language.
"""
from __future__ import annotations
from agent.i18n import t
_SUMMARY_LIMIT = 120
# FailoverReason.value -> catalog key suffix. Reasons sharing copy point at one key.
_REASON_KEYS: dict[str, str] = {
"auth": "auth",
"auth_permanent": "auth",
"billing": "billing",
"model_not_found": "model_not_found",
"rate_limit": "rate_limit",
"upstream_rate_limit": "rate_limit",
"upstream_blocked": "upstream_blocked",
"overloaded": "overloaded",
"server_error": "server_error",
"timeout": "timeout",
}
_UNKNOWN_KEY = "unknown"
def _short(text: str, limit: int | None = _SUMMARY_LIMIT) -> str:
first = (text or "").strip().splitlines()[0] if (text or "").strip() else ""
return first if limit is None or len(first) <= limit else first[: limit - 1].rstrip() + "…"
def refusal_text(error: BaseException | str) -> str:
"""The refusal as the client shows it: the reason code, plus the check that fired when the owner
named one (``invalid_params (toolsets: unknown or invalid toolset name: 'mcp-codegraph')``).
The gateway sends ``field``/``detail`` in the JSON-RPC error's ``data``
(``gateway/session_controls.py``), and ``hermes_cli.gateway_client.rpc_error`` carries the
bounded scalars onto ``GatewayRPCError.data``. A refusal from anywhere else has no ``data`` and
reads exactly as before."""
if str(error) == "launch_credentials_unavailable":
# The chat's launch key changed: every retry repeats the refusal, so name the way out.
from agent.turn_failure_copy import launch_credentials_copy
return launch_credentials_copy(error)
data = getattr(error, "data", None)
data = data if isinstance(data, dict) else {}
field, detail = str(data.get("field") or "").strip(), str(data.get("detail") or "").strip()
if not field and not detail:
return str(error)
return f"{error} ({field or 'unnamed field'}{': ' + detail if detail else ''})"
def chat_error_response(
error: Exception | str, *, provider: str = "", model: str = "", failure_reason: str | None = None,
) -> str:
"""Two-line panel text: plain sentence with the fix command, then ``Details: <raw>``.
``failure_reason`` is the verdict the turn loop already stamped on its result
(``agent/turn_failure_copy.stamp_failure``). When present it is used as-is instead of
re-classifying a summarised string (which has no status code and almost always lands on
'unknown'); for the loop's own site codes the ``error`` text is already the user-facing copy,
so it is returned verbatim rather than wrapped and demoted to a Details line."""
reason = str(failure_reason or "").strip()
if reason:
from agent.turn_failure_copy import SITE_FAILURE_CODES
text = str(error or "").strip()
if reason in SITE_FAILURE_CODES and text:
return text
else:
from agent.error_classifier import classify_api_error
exc = error if isinstance(error, Exception) else Exception(str(error))
reason = classify_api_error(exc, provider=provider or "", model=model or "").reason.value
lead = t(
f"cli.error.{_REASON_KEYS.get(reason, _UNKNOWN_KEY)}",
provider=provider or t("cli.error.the_provider"),
model=model or t("cli.error.the_current_model"),
)
return t("cli.error.with_details", lead=lead, details=_short(refusal_text(error), 300))
def agent_init_failure_message(error: BaseException) -> str:
"""Copy for a failed AIAgent build on first message: the user's turn was dropped.
A gateway that cannot be reached keeps its whole first line: its discovery detail is already
bounded (``DiscoveryError.bounded_detail``) and ends in the repair command, which the summary
limit would cut mid-path."""
from hermes_cli.gateway_client import GatewayUnavailableError
limit = None if isinstance(error, GatewayUnavailableError) else _SUMMARY_LIMIT
return t("cli.error.agent_init_failed", error=_short(refusal_text(error), limit) or type(error).__name__)