mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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)
97 lines
4.4 KiB
Python
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__)
|