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:
2026-08-23 14:52:44 +00:00
parent a1d39637c8
commit b97edd5184
7 changed files with 147 additions and 17 deletions

View File

@@ -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