fix(fallback): turnul nu se mai pierde cand Claude e la limita
Detectia de rate limit functiona, dar `_local_fallback_reply` putea intoarce None din patru locuri fara nicio linie de log — userul primea `Claude CLI error (exit 1): You've hit your session limit` pe Discord in loc de raspuns, si nu se putea afla ulterior din log care branch a picat. - daca runda de unelte iese goala, turnul se reface fara unelte in loc sa fie abandonat - fiecare return None ramas e logat - daca nici modelul local nu raspunde, mesajul e „Claude e la limita…" plus ora de reset, nu eroarea bruta a CLI-ului - cron: job-urile heartbeat* tac la limita (last_status: rate_limited); celelalte trimit o singura linie scurta, ca sa se vada rularea sarita - is_rate_limit_error / rate_limit_detail mutate in claude_session.py, folosite si de scheduler (care nu poate importa router-ul) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzZAMzbyQbzFdsrVoXijBg
This commit is contained in:
@@ -9,6 +9,7 @@ tracked in sessions/active.json.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -37,6 +38,27 @@ DEFAULT_TIMEOUT = 300 # seconds
|
||||
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||
|
||||
# Claude CLI rate-limit errors look like:
|
||||
# "Claude CLI error (exit 1): You've hit your session limit · resets 10:50am (UTC)"
|
||||
# (same phrasing for the total subscription limit, not just the per-session one).
|
||||
# Lives here rather than in router.py because the scheduler needs it too and
|
||||
# must not import the router.
|
||||
RATE_LIMIT_RE = re.compile(r"hit your .*limit", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_rate_limit_error(err: Exception | str) -> bool:
|
||||
"""True if *err* is the Claude CLI refusing because a limit was reached."""
|
||||
return bool(RATE_LIMIT_RE.search(str(err)))
|
||||
|
||||
|
||||
def rate_limit_detail(err: Exception | str) -> str:
|
||||
"""The bare limit notice, without the `Claude CLI error (exit 1):` wrapper."""
|
||||
text = str(err)
|
||||
if text.startswith("Claude CLI error (") and "):" in text:
|
||||
text = text.split("):", 1)[1]
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-channel mutex for send_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,9 @@ from src.claude_session import (
|
||||
get_active_session,
|
||||
list_sessions,
|
||||
set_session_model,
|
||||
is_rate_limit_error as _is_rate_limit_error,
|
||||
rate_limit_detail as _rate_limit_detail,
|
||||
RATE_LIMIT_RE as _RATE_LIMIT_RE,
|
||||
VALID_MODELS,
|
||||
)
|
||||
from src.jsonlock import read_locked, write_locked
|
||||
@@ -89,11 +92,6 @@ def _get_config() -> Config:
|
||||
return _config
|
||||
|
||||
|
||||
# Claude CLI rate-limit errors look like:
|
||||
# "Claude CLI error (exit 1): You've hit your session limit · resets 10:50am (UTC)"
|
||||
# (also seen for the total subscription limit, not just per-session — same phrasing).
|
||||
_RATE_LIMIT_RE = re.compile(r"hit your .*limit", re.IGNORECASE)
|
||||
|
||||
_LOCAL_FALLBACK_SYSTEM_PROMPT = (
|
||||
"Ești Echo, asistentul personal al lui Marius. Răspunde direct și la "
|
||||
"obiect, în limba în care a fost scris mesajul. Când ți se cere ceva — o "
|
||||
@@ -248,10 +246,6 @@ def _forced_tool(text: str) -> tuple[str, dict] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_rate_limit_error(err: Exception) -> bool:
|
||||
return bool(_RATE_LIMIT_RE.search(str(err)))
|
||||
|
||||
|
||||
def _call_local_llm(
|
||||
url: str,
|
||||
messages: list[dict],
|
||||
@@ -301,9 +295,11 @@ def _local_fallback_reply(
|
||||
prefix = _LOCAL_MANUAL_PREFIX if manual else _LOCAL_FALLBACK_PREFIX
|
||||
cfg = _get_config().get("local_fallback", {}) or {}
|
||||
if not cfg.get("enabled"):
|
||||
log.warning("Local fallback requested but local_fallback.enabled is false")
|
||||
return None
|
||||
url = cfg.get("url")
|
||||
if not url:
|
||||
log.error("Local fallback enabled but local_fallback.url is missing")
|
||||
return None
|
||||
|
||||
if channel_id:
|
||||
@@ -358,6 +354,7 @@ def _local_fallback_reply(
|
||||
log.error("Local fallback LLM failed: %s", e)
|
||||
return None
|
||||
if message is None:
|
||||
log.error("Local fallback returned no message object (url=%s)", url)
|
||||
return None
|
||||
|
||||
answer = (message.get("content") or "").strip()
|
||||
@@ -365,8 +362,19 @@ def _local_fallback_reply(
|
||||
|
||||
if calls:
|
||||
answer = _run_fallback_tools(url, messages, message, calls, local_fallback_tools)
|
||||
if answer is None:
|
||||
return None
|
||||
if not answer:
|
||||
# The tool round yielded nothing usable (synthesis came back
|
||||
# empty). Returning None here dropped the whole turn and the user
|
||||
# saw the raw Claude rate-limit error instead of a reply — answer
|
||||
# conversationally rather than giving up.
|
||||
log.warning(
|
||||
"Fallback tool round produced no answer for %r — retrying tool-free",
|
||||
text[:60],
|
||||
)
|
||||
answer = _conversational_reply(
|
||||
url, system_prompt, history, text,
|
||||
fewshot=_is_creative_request(text),
|
||||
)
|
||||
else:
|
||||
# No tool was called, so this is a plain answer — and carrying the tool
|
||||
# definitions degrades those: with them "cat fac 128/4?" comes back as
|
||||
@@ -380,6 +388,7 @@ def _local_fallback_reply(
|
||||
) or answer
|
||||
|
||||
if not answer:
|
||||
log.error("Local fallback produced an empty answer for %r", text[:60])
|
||||
return None
|
||||
if channel_id:
|
||||
fallback_history.append(channel_id, text, answer)
|
||||
@@ -605,6 +614,14 @@ def route_message(
|
||||
if fallback is not None:
|
||||
_set_last_response(channel_id, fallback)
|
||||
return fallback, False
|
||||
log.error(
|
||||
"Local fallback unavailable for channel %s — surfacing the limit notice",
|
||||
channel_id,
|
||||
)
|
||||
return (
|
||||
"⚠️ Claude e la limită, iar modelul local nu a răspuns.\n"
|
||||
f"{_rate_limit_detail(e)}"
|
||||
), False
|
||||
return f"Error: {e}", False
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.claude_session import (
|
||||
is_rate_limit_error,
|
||||
rate_limit_detail as _rate_limit_detail,
|
||||
CLAUDE_BIN,
|
||||
PROJECT_ROOT,
|
||||
VALID_MODELS,
|
||||
@@ -440,9 +442,25 @@ class Scheduler:
|
||||
logger.error("Job '%s' timed out", name)
|
||||
|
||||
except (RuntimeError, json.JSONDecodeError) as exc:
|
||||
job["last_status"] = "error"
|
||||
result_text = f"[cron:{name}] Error: {exc}"
|
||||
logger.error("Job '%s' failed: %s", name, exc)
|
||||
if is_rate_limit_error(exc):
|
||||
# A rate limit is not a job failure to debug — dumping the raw
|
||||
# `Claude CLI error (exit 1): ...` into the channel on every
|
||||
# run until the window resets is pure noise. Heartbeats stay
|
||||
# silent (they only speak when something is actionable);
|
||||
# other jobs get one short line so a skipped run is visible.
|
||||
job["last_status"] = "rate_limited"
|
||||
logger.warning("Job '%s' skipped — Claude at limit: %s", name, exc)
|
||||
if name.startswith("heartbeat"):
|
||||
result_text = ""
|
||||
else:
|
||||
result_text = (
|
||||
f"[cron:{name}] Claude e la limită — job sărit. "
|
||||
f"{_rate_limit_detail(exc)}"
|
||||
)
|
||||
else:
|
||||
job["last_status"] = "error"
|
||||
result_text = f"[cron:{name}] Error: {exc}"
|
||||
logger.error("Job '%s' failed: %s", name, exc)
|
||||
|
||||
except Exception as exc:
|
||||
job["last_status"] = "error"
|
||||
|
||||
Reference in New Issue
Block a user