Files
hermes-agent/cron/scheduler_failure_copy.py
teknium1 2c948e6aa2 fix(agent): an upstream account ban relayed in a 200 stream fails once instead of retrying as an outage
OpenRouter relays OpenAI's "this user has been blocked for a previous
policy violation" as HTTP 200 + an SSE error event. The SDK raises a
status-less APIError that no classifier rule matched, so it landed in
the retryable `unknown` bucket: every retry was re-sent (3 streamed
requests by default), a configured fallback only engaged after the
ladder, and the user was told the provider "looks temporarily
unavailable. Wait a minute and send /retry".

- error_classifier: match the ban phrase status-agnostically in
  _provider_special_cases -> provider_policy_blocked (non-retryable,
  fallback, no credential rotation: every key on a banned account is
  banned; a 403 variant is not a bad key).
- turn_failure_copy: provider_policy_blocked chat copy now covers an
  account block as well as data/privacy settings; add its cause gloss
  so cron and subagent notices explain it instead of printing raw text.
- cron: provider_policy_blocked action (retrying won't help; pin another
  model).
- docs: FAQ entry for the reply; auto-recovery ladder exclusion list.
2026-09-23 10:40:08 -07:00

149 lines
6.8 KiB
Python

"""Plain-language copy for the one-line cron failure notice delivered to a job's chat.
The scheduler classifies the failure text through ``agent.error_classifier.classify_api_error``
(one classifier for the whole app, no cron-local regex ladder) and looks the verdict up here.
Every notice says WHAT happened and WHAT TO DO, and names the exact ``hermes cron`` command plus
the real output directory — "cron output" alone sent operators hunting.
"""
from __future__ import annotations
import re
from typing import Any, Optional
from hermes_constants import display_hermes_home
def cron_output_dir_display(job_id: str) -> str:
"""User-facing path of a job's saved run output (profile-aware)."""
return f"{display_hermes_home()}/cron/output/{job_id}/"
_HTTP_STATUS_IN_TEXT = re.compile(r"(?:\bHTTP\b|\bError code\b|\bstatus(?: code)?\b)\W{0,3}(\b[45]\d\d\b)", re.I)
_LEADING_EXC_TYPE = re.compile(r"^(?:[\w.]+\.)?([A-Z]\w*(?:Error|Timeout|Exception))\s*:")
def classify_cron_failure_reason(text: str) -> str:
"""``FailoverReason`` value for a cron failure string (``"unknown"`` when unclassifiable).
The scheduler only has ``str(exc)``, so the status code and exception type the classifier
keys on are rebuilt from the text: a whole-token HTTP code after ``HTTP`` / ``Error code`` /
``status`` (a bare ``429`` inside a job id or hash never counts, #83188) and a leading
``ReadTimeout:``-style type prefix."""
from agent.error_classifier import classify_api_error
status = _HTTP_STATUS_IN_TEXT.search(text)
type_name = _LEADING_EXC_TYPE.match(text)
exc_cls = type(type_name.group(1), (Exception,), {}) if type_name else Exception
exc = exc_cls(text)
if status:
exc.status_code = int(status.group(1))
return classify_api_error(exc).reason.value
# What happened, per reason: the one gloss table shared with subagent notices lives in
# agent/turn_failure_copy.py so the two never drift; the job is the subject here.
def _provider_failure_cause(reason: str) -> Optional[str]:
from agent.turn_failure_copy import failure_cause_gloss
return failure_cause_gloss(reason, subject="this job", possessive="the job's")
_TRANSIENT_REASONS = frozenset({"timeout", "rate_limit", "upstream_rate_limit", "overloaded", "server_error"})
# Reason -> what to do. Transient reasons get the backup-provider clause from the scheduler
# (it knows whether a fallback chain is configured) instead of a fixed sentence.
_PROVIDER_FAILURE_ACTION: dict[str, str] = {
"billing": (
"Top up or wait for the limit to reset, or pin another provider with "
"`hermes cron edit {job_id} --provider <name>`."
),
"auth": (
"Sign in again with /login (or `{relogin}` in a terminal), or pin a "
"working provider with `hermes cron edit {job_id} --provider <name>`, then "
"`hermes cron run {job_id}` to retry."
),
"model_not_found": "Pick another model with `hermes cron edit {job_id} --model <name>`.",
"upstream_blocked": (
"A firewall in front of the provider blocked the request (not your key): set a User-Agent "
"via `extra_headers` on the provider's custom_providers entry, or pin another provider with "
"`hermes cron edit {job_id} --provider <name>`."
),
"context_overflow": "Shorten the job's prompt with `hermes cron edit {job_id} --prompt <text>`.",
}
_PROVIDER_FAILURE_ACTION["auth_permanent"] = _PROVIDER_FAILURE_ACTION["auth"]
_PROVIDER_FAILURE_ACTION["billing_unverified"] = _PROVIDER_FAILURE_ACTION["billing"]
_PROVIDER_FAILURE_ACTION["payload_too_large"] = _PROVIDER_FAILURE_ACTION["context_overflow"]
_PROVIDER_FAILURE_ACTION["content_policy_blocked"] = (
"Reword the job's prompt with `hermes cron edit {job_id} --prompt <text>`, or pick another "
"model with `hermes cron edit {job_id} --model <name>`."
)
_PROVIDER_FAILURE_ACTION["provider_policy_blocked"] = (
"Retrying won't help: check the account's status and data/privacy settings with the provider, "
"or pin another model with `hermes cron edit {job_id} --model <name>`."
)
_DEFAULT_FAILURE_ACTION = "Run it again with `hermes cron run {job_id}`, or edit it with `hermes cron edit {job_id}`."
def provider_failure_notice(
job_name: str, job_id: str, reason: str, *, backup_provider_phrase: str, provider: Any = None,
) -> Optional[str]:
"""The notice for a provider-shaped ``reason``, or None when the reason is not one.
``provider`` is the job's pinned slug (if any) so the auth action names its exact sign-in."""
cause = _provider_failure_cause(reason)
if cause is None:
return None
if reason in _TRANSIENT_REASONS:
action = (
f"{backup_provider_phrase} It will run again at its next scheduled time; "
f"`hermes cron run {job_id}` tries now."
)
else:
from agent.turn_failure_copy import relogin_command_hint
action = _PROVIDER_FAILURE_ACTION.get(reason, _DEFAULT_FAILURE_ACTION).format(
job_id=job_id, relogin=relogin_command_hint(provider))
return (
f"⚠️ Cron '{job_name}' failed: {cause}. {action} "
f"Run log: `hermes cron runs {job_id}`."
)
def generic_failure_notice(job_name: str, job_id: str, cleaned_error: str) -> str:
"""Unclassified failure: the cleaned error text plus where to look and what to do."""
return (
f"⚠️ Cron '{job_name}' failed: {cleaned_error}. "
f"See the full run with `hermes cron runs {job_id}` (output saved under "
f"{cron_output_dir_display(job_id)}); run it again with `hermes cron run {job_id}`, "
f"edit it with `hermes cron edit {job_id}`, or pause it with `hermes cron pause {job_id}`."
)
def script_timeout_notice(job_name: str, job_id: str) -> str:
return (
f"⚠️ Cron '{job_name}' failed: its script timed out. No model was invoked. "
f"Check the script's output under {cron_output_dir_display(job_id)} or `hermes cron runs {job_id}`, "
f"then run it again with `hermes cron run {job_id}`."
)
def inactivity_notice(job_name: str, job_id: str) -> str:
return (
f"⚠️ Cron '{job_name}' failed: the job stalled — it stopped doing anything for too long "
f"and was cut off. Check what it was doing in the saved output under "
f"{cron_output_dir_display(job_id)} (`hermes cron runs {job_id}`), then run it again with "
f"`hermes cron run {job_id}`."
)
def blocked_config_notice(job_name: str, reason: str) -> str:
"""One-time notice when the pre-run configuration check refused to start the job."""
reason = reason.rstrip()
if reason and reason[-1] not in ".!?":
reason += "."
return (
f"⛔ Cron '{job_name}' did not run: {reason} Nothing was charged. Hermes will try again at "
"the next scheduled time and will not repeat this alert; check with "
"`hermes cron doctor`."
)