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.
149 lines
6.8 KiB
Python
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`."
|
|
)
|