diff --git a/.ruff.toml b/.ruff.toml index a3df4546..63c92f5f 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -12,6 +12,5 @@ extend-exclude = [ "agents/dog_agent/data/action/dog_behaviour.py" = ["E402"] "app/action/action_framework/run_actions_tests.py" = ["E402"] "app/config.py" = ["E402"] -"app/llm_interface.py" = ["E402"] "app/main.py" = ["E402"] "craftos_integrations/__init__.py" = ["E402"] diff --git a/agent_core/core/embedding_interface.py b/agent_core/core/embedding_interface.py index 6b543949..2806254d 100644 --- a/agent_core/core/embedding_interface.py +++ b/agent_core/core/embedding_interface.py @@ -22,7 +22,7 @@ from agent_core.core.models.types import InterfaceType from agent_core.utils.logger import logger -from agent_core.core.llm.google_gemini_client import GeminiAPIError, GeminiClient +from agent_core.core.llm.google_gemini_client import GeminiClient class EmbeddingInterface: @@ -91,26 +91,42 @@ def get_embedding(self, text: str) -> Optional[List[float]]: raise RuntimeError(f"Unknown provider {self.provider!r}") # ───────────────────── Provider-specific helpers ─────────────────── + def _log_classified(self, tag: str, e: Exception) -> None: + """Log *e* through the shared classifier instead of raw str(e).""" + from agent_core.core.impl.llm.errors import classify_llm_error + + info = classify_llm_error(e, provider=self.provider, model=self.model) + logger.error(f"[EMBEDDING] {tag}: {info.message}") + + @staticmethod + def _not_initialised(provider: str, client_name: str) -> "ClassifiedError": + from agent_core.core.errors import ClassifiedError + from agent_core.core.impl.llm.errors import classify_llm_error + + return ClassifiedError( + classify_llm_error( + RuntimeError(f"{client_name} client was not initialised."), + provider=provider, + ) + ) + def _get_openai_embedding(self, text: str) -> Optional[List[float]]: try: response = self.client.embeddings.create(model=self.model, input=text) # OpenAI returns: response.data[0].embedding return response.data[0].embedding # type: ignore[attr-defined] except Exception as e: - logger.exception(f"Error calling OpenAI Embedding API: {e}") + self._log_classified("OpenAI", e) return None def _get_gemini_embedding(self, text: str) -> Optional[List[float]]: if not self._gemini_client: - raise RuntimeError("Gemini client was not initialised.") + raise self._not_initialised("gemini", "Gemini") try: return self._gemini_client.embed_text(self.model, text=text) - except GeminiAPIError as e: - logger.exception(f"Gemini rejected the embedding request: {e}") - return None except Exception as e: - logger.exception(f"Error calling Gemini Embedding API: {e}") + self._log_classified("Gemini", e) return None def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: @@ -137,7 +153,7 @@ def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: return None return data.get("embedding") except Exception as e: - logger.exception(f"Error calling BytePlus Embedding API: {e}") + self._log_classified("BytePlus", e) return None def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: @@ -148,7 +164,7 @@ def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: (Converse doesn't expose embeddings). """ if not self._bedrock_client: - raise RuntimeError("Bedrock client was not initialised.") + raise self._not_initialised("bedrock", "Bedrock") try: import json as _json @@ -165,7 +181,7 @@ def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: result = _json.loads(raw) return result.get("embedding") except Exception as e: - logger.exception(f"Error calling Bedrock Embedding API: {e}") + self._log_classified("Bedrock", e) return None def _get_ollama_embedding(self, text: str) -> Optional[List[float]]: @@ -181,5 +197,5 @@ def _get_ollama_embedding(self, text: str) -> Optional[List[float]]: # Ollama returns {"embedding": [floats]} return result.get("embedding", None) except Exception as e: - logger.exception(f"Error calling Ollama Embedding API: {e}") + self._log_classified("Ollama", e) return None diff --git a/agent_core/core/errors.py b/agent_core/core/errors.py new file mode 100644 index 00000000..3daf1a5c --- /dev/null +++ b/agent_core/core/errors.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +""" +Shared error-catalogue primitives. + +`agent_core` never imports from `app` (the dependency runs the other way), so +the category/severity/action vocabulary shared between the LLM classifier +(`agent_core/core/impl/llm/errors.py`) and app-layer call sites +(`app/errors/codebook.py`) lives here. + +`LLMErrorInfo` (in the LLM package) is intentionally NOT made a subclass of +`ErrorInfo` — its `provider` field is positional/non-default and reordering it +behind new defaulted base fields would break its existing consumers. Both +satisfy `ErrorInfoLike` structurally instead. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field, asdict +from enum import Enum +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +class Severity(str, Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" # aborts a run + + +class ErrorCategory(str, Enum): + AUTH = "auth" # 401/403 — bad/missing key, key revoked + CREDIT = "credit" # 402, "insufficient_quota", "credit_balance_too_low" + RATE_LIMIT = "rate_limit" # 429 — transient + QUOTA = "quota" # 429 + monthly/account scope (separable from per-min) + MODEL = "model" # 404, "model_not_found" + BAD_REQUEST = "bad_request" # 400 — request malformed (context overflow, etc.) + BLOCKED = "blocked" # safety filter (Gemini/Anthropic) + SERVER = "server" # 5xx, "overloaded_error" + CONNECTION = "connection" # network / timeout / DNS + UNKNOWN = "unknown" + # App-layer categories, not produced by the LLM classifier: + VALIDATION = "validation" # malformed input outside an LLM call + NOT_FOUND = "not_found" + CONFIG = "config" # local misconfiguration (e.g. no key set, before any network call) + PERMISSION = "permission" # local/file/OS permission issues + INTERNAL = "internal" # unexpected/bug-shaped exception + + +# Categories where retrying the same request essentially never succeeds — +# these should fail fast instead of consuming a retry budget. RATE_LIMIT, +# SERVER, CONNECTION, and UNKNOWN are left out deliberately: they're the +# genuinely transient cases retries exist for. +FAIL_FAST_CATEGORIES = frozenset( + { + ErrorCategory.AUTH, + ErrorCategory.CREDIT, + ErrorCategory.QUOTA, + ErrorCategory.MODEL, + ErrorCategory.BLOCKED, + ErrorCategory.BAD_REQUEST, + ErrorCategory.CONFIG, + } +) + + +def is_transient(category: ErrorCategory) -> bool: + """Whether retrying the same request has a real chance of succeeding.""" + return category not in FAIL_FAST_CATEGORIES + + +@dataclass +class ErrorAction: + """A clickable affordance attached to an error. + + `url` opens in a new tab; `action` is a frontend-resolved verb such as + "open_settings_model" — handled by the chat component, not by URL nav. + Exactly one of url/action should be set. + """ + + label: str + url: Optional[str] = None + action: Optional[str] = None + + +@dataclass +class ErrorInfo: + """Generic app-wide structured error, for call sites outside the LLM + provider classifier (which uses the richer `LLMErrorInfo`).""" + + category: ErrorCategory + code: str + title: str + message: str + severity: Severity = Severity.ERROR + actions: List[ErrorAction] = field(default_factory=list) + raw_message: Optional[str] = None + context: Dict[str, Any] = field(default_factory=dict) + + @property + def is_transient(self) -> bool: + return is_transient(self.category) + + def to_dict(self) -> Dict[str, Any]: + d = asdict(self) + d["category"] = self.category.value + d["severity"] = self.severity.value + return d + + +@runtime_checkable +class ErrorInfoLike(Protocol): + """Structural type both `ErrorInfo` and `LLMErrorInfo` satisfy.""" + + category: ErrorCategory + title: str + message: str + actions: List[ErrorAction] + + +class ClassifiedError(Exception): + """Wraps a classified `ErrorInfoLike`. + + Presentation code (see `app/agent_base.py:_handle_react_error`) uses the + presence of this type anywhere in an exception's `__cause__`/`__context__` + chain — or an `LLMConsecutiveFailureError` with a populated + `last_error_info` — to tell a recognized, user-actionable failure ("minor" + tier: bad key, no credits, misconfigured provider) apart from a genuinely + unexpected crash ("critical" tier: unclassified bugs, broken agent loop). + Raise this instead of a bare `RuntimeError` at any call site that already + knows what went wrong. + """ + + def __init__(self, info: ErrorInfoLike): + self.info = info + super().__init__(info.message) + + +# ─── Redaction ────────────────────────────────────────────────────────── +# Ported from the now-removed app/security/error_handler.py — applied to raw +# upstream/exception text before it's echoed to the UI (e.g. UNKNOWN/BAD_REQUEST +# fallback messages), not to the curated, hand-written catalogue strings. + +_REDACT_PATTERNS = [ + re.compile(r"/[^/\s]+\.py"), # file paths + re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"), # emails + re.compile(r"://[^/\s]+"), # URLs/hostnames + re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"), # IPv4 addresses +] + + +def redact(raw: str, max_length: int = 500) -> str: + """Strip file paths/emails/hostnames/IPs from raw exception text.""" + text = raw + for pattern in _REDACT_PATTERNS: + text = pattern.sub("[REDACTED]", text) + if len(text) > max_length: + text = text[:max_length] + "..." + return text diff --git a/agent_core/core/impl/action/router.py b/agent_core/core/impl/action/router.py index e33b6698..2883bc26 100644 --- a/agent_core/core/impl/action/router.py +++ b/agent_core/core/impl/action/router.py @@ -19,6 +19,7 @@ from agent_core.core.protocols.llm import LLMInterfaceProtocol from agent_core.core.impl.llm import LLMCallType from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError +from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo from agent_core.core.prompts import SELECT_ACTION_PROMPT from agent_core.utils.logger import logger @@ -377,13 +378,24 @@ async def _prompt_for_decision( raise except RuntimeError as e: # LLM provider error (empty response, API error, auth failure, etc.) + # — a recognized, user-actionable failure, not a code bug. The + # attempt-number bookkeeping stays in the log only; the + # user-facing message (ClassifiedError.info.message) stays + # short and skips it. error_msg = str(e) logger.error( f"[ACTION ROUTER] LLM provider error on attempt {attempt + 1}: {error_msg}" ) - last_error = RuntimeError( - f"Unable to generate action decision on attempt {attempt + 1}: {error_msg}. " - f"Check LLM configuration, API credentials, and service availability." + last_error = ClassifiedError( + ErrorInfo( + category=ErrorCategory.UNKNOWN, + code="ACTION_DECISION_FAILED", + title="Action decision failed", + message=( + f"{error_msg.rstrip('.')}. Check LLM configuration, " + f"API credentials, and service availability." + ), + ) ) # After 3 attempts, give up if attempt >= max_retries - 1: diff --git a/agent_core/core/impl/image_gen/interface.py b/agent_core/core/impl/image_gen/interface.py index 8afec5a8..7eecb435 100644 --- a/agent_core/core/impl/image_gen/interface.py +++ b/agent_core/core/impl/image_gen/interface.py @@ -57,16 +57,17 @@ } -def _classify_error(provider: str, exc: Exception, model: str) -> str: - """Render *exc* as a human-readable error string via the shared catalog. +def _classified_error(provider: str, exc: Exception, model: str) -> "ClassifiedError": + """Classify *exc* via the shared catalog and wrap it as a ClassifiedError. Import deferred to call time — agent_core must stay importable without the host `app` package (all app.* imports in this package are function-local by convention). """ - from app.i18n import classify_provider_error + from agent_core.core.errors import ClassifiedError + from app.i18n import classify_provider_error_info - return classify_provider_error(exc, provider=provider, model=model) + return ClassifiedError(classify_provider_error_info(exc, provider=provider, model=model)) # ── File-path helpers ───────────────────────────────────────────────────────── @@ -370,7 +371,7 @@ def _openai_generate( quality=quality, ) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc usage = getattr(response, "usage", None) if usage is not None: @@ -485,7 +486,7 @@ def _gemini_generate( safety_settings=safety_settings, ) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc usage_md = result.get("usage_metadata") or {} if usage_md: @@ -502,9 +503,19 @@ def _gemini_generate( if not images_data: block_reason = result.get("block_reason") if block_reason: - raise RuntimeError( - f"Gemini blocked the request (safety filter: {block_reason}). " - "Try modifying your prompt or adjusting safety_filter_level." + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + + raise ClassifiedError( + ErrorInfo( + category=ErrorCategory.BLOCKED, + code="IMAGE_GEN_BLOCKED", + title="Blocked by safety filter", + message=( + f"Gemini blocked the request (safety filter: {block_reason}). " + "Try modifying your prompt or adjusting safety_filter_level." + ), + severity=Severity.ERROR, + ) ) raise RuntimeError( "Gemini returned no image data — try rephrasing your prompt or " diff --git a/agent_core/core/impl/llm/errors.py b/agent_core/core/impl/llm/errors.py index 87a0ef1b..2ea4ec29 100644 --- a/agent_core/core/impl/llm/errors.py +++ b/agent_core/core/impl/llm/errors.py @@ -21,9 +21,28 @@ from __future__ import annotations from dataclasses import dataclass, field, asdict -from enum import Enum from typing import Any, Dict, List, Optional +from agent_core.core.errors import ( + ErrorAction, + ErrorCategory, + Severity, + is_transient, + redact, +) + +__all__ = [ + "ErrorCategory", + "ErrorAction", + "Severity", + "is_transient", + "LLMErrorInfo", + "LLMConsecutiveFailureError", + "classify_llm_error", + "classify_llm_error_message", + "provider_display_name", +] + # Optional provider SDK imports — kept defensive so missing extras don't # break the classifier path. @@ -49,33 +68,9 @@ # ─── Public taxonomy ────────────────────────────────────────────────── - - -class ErrorCategory(str, Enum): - AUTH = "auth" # 401/403 — bad/missing key, key revoked - CREDIT = "credit" # 402, "insufficient_quota", "credit_balance_too_low" - RATE_LIMIT = "rate_limit" # 429 — transient - QUOTA = "quota" # 429 + monthly/account scope (separable from per-min) - MODEL = "model" # 404, "model_not_found" - BAD_REQUEST = "bad_request" # 400 — request malformed (context overflow, etc.) - BLOCKED = "blocked" # safety filter (Gemini/Anthropic) - SERVER = "server" # 5xx, "overloaded_error" - CONNECTION = "connection" # network / timeout / DNS - UNKNOWN = "unknown" - - -@dataclass -class ErrorAction: - """A clickable affordance attached to an error. - - `url` opens in a new tab; `action` is a frontend-resolved verb such as - "open_settings_model" — handled by the chat component, not by URL nav. - Exactly one of url/action should be set. - """ - - label: str - url: Optional[str] = None - action: Optional[str] = None +# ErrorCategory/ErrorAction/Severity/is_transient live in agent_core.core.errors +# (imported above) so app-layer, non-LLM call sites can share the same +# vocabulary without agent_core depending on app. @dataclass @@ -91,10 +86,19 @@ class LLMErrorInfo: actions: List[ErrorAction] = field(default_factory=list) raw_message: Optional[str] = None # truncated raw upstream text for "Show details" request_id: Optional[str] = None # for support tickets + # Appended fields (kept trailing/defaulted so existing positional/keyword + # construction call sites don't break): + code: Optional[str] = None # stable id, e.g. "LLM_AUTH" — auto-derived, see classify_llm_error() + severity: Severity = Severity.ERROR + + @property + def is_transient(self) -> bool: + return is_transient(self.category) def to_dict(self) -> Dict[str, Any]: d = asdict(self) d["category"] = self.category.value + d["severity"] = self.severity.value return d @@ -157,13 +161,24 @@ def provider_display_name(provider: Optional[str]) -> str: MSG_CONNECTION = "Could not reach the provider. Check your network connection." MSG_GENERIC = "Something went wrong calling the AI service." MSG_CONSECUTIVE_FAILURE = "Aborted after consecutive failures." +MSG_FAILED_IMMEDIATELY = "This error can't be fixed by retrying." + + +# Deterministic, auto-derived error code per category — one per ErrorCategory +# value, zero manual maintenance. Not meant to be as fine-grained as a +# per-provider codebook; just enough for log correlation and future +# frontend/i18n lookups. +def _code_for_category(category: ErrorCategory) -> str: + return f"LLM_{category.value.upper()}" # ─── Consecutive-failure exception (preserves last classified info) ─── class LLMConsecutiveFailureError(Exception): - """Raised when LLM calls fail too many times consecutively. + """Raised when LLM calls fail too many times consecutively — or, for + non-transient categories (see FAIL_FAST_CATEGORIES), on the very first + failure. Carries the last classified `LLMErrorInfo` (when known) so the UI can surface the *cause* of the failures, not just the count. @@ -174,11 +189,18 @@ def __init__( failure_count: int, last_error: Optional[Exception] = None, last_error_info: Optional[LLMErrorInfo] = None, + is_immediate: bool = False, ): self.failure_count = failure_count self.last_error = last_error self.last_error_info = last_error_info - message = MSG_CONSECUTIVE_FAILURE.format(count=failure_count) + # Any raise site with failure_count <= 1 is, by definition, a single + # failure — never say "consecutive failures" for one failure, even if + # a call site forgot to pass is_immediate explicitly (e.g. a hard + # per-call timeout raised directly with count=1, not routed through + # LLMInterface._register_failure's fail-fast categorization). + self.is_immediate = is_immediate or failure_count <= 1 + message = MSG_FAILED_IMMEDIATELY if self.is_immediate else MSG_CONSECUTIVE_FAILURE if last_error: message += f" Last error: {last_error}" super().__init__(message) @@ -215,7 +237,9 @@ def classify_llm_error( if info is None: # Don't fabricate a generic message — the raw exception text is # almost always more informative than any stub we could write. - raw = _truncate(str(error)) or "AI service error" + # Redacted since, unlike the curated per-category messages below, + # this echoes the exception's own text verbatim to the UI. + raw = redact(_truncate(str(error)) or "AI service error") info = LLMErrorInfo( category=ErrorCategory.UNKNOWN, title="AI service error", @@ -226,6 +250,8 @@ def classify_llm_error( if model and info.model is None: info.model = model + if info.code is None: + info.code = _code_for_category(info.category) return info @@ -266,8 +292,16 @@ def _try_classify( if requests is not None and isinstance(error, requests.exceptions.RequestException): return _classify_requests(error, provider) - # Gemini's custom error type (raised by our REST client) + # Local precondition failures — raised before any network call (no API + # key configured, so the provider client was never constructed). Must be + # checked before the Gemini substring sniff below: "Gemini client was + # not initialised." would otherwise match "Gemini" and get misclassified + # as a Gemini API-shaped error. msg = str(error) + if isinstance(error, RuntimeError) and "was not initialised" in msg: + return _classify_local_config(error, provider or "unknown") + + # Gemini's custom error type (raised by our REST client) if "Gemini" in msg or "promptFeedback" in msg or "blocked" in msg.lower(): return _classify_gemini_runtime(error, provider or "gemini") @@ -403,6 +437,21 @@ def _classify_openai_compat(exc: Exception, provider: str) -> LLMErrorInfo: # error text in their native language when routed via OpenRouter. category = _refine_category_from_localised(raw_message, category) + # OpenAI's SDK raises the same RateLimitError (429) for both actual + # rate-limiting AND quota/credit exhaustion — normally disambiguated by + # `code == "insufficient_quota"` above, but some accounts/providers + # return 429 with a plain-language credit message and no matching + # structured code. "Rate limited... try again shortly" is actively wrong + # advice when the account is just out of funds, so fall back to sniffing + # the raw text. + if category == ErrorCategory.RATE_LIMIT: + raw_lower = raw_message.lower() + if any( + k in raw_lower + for k in ("no credits", "out of credits", "insufficient_quota", "insufficient quota", "credit balance", "credits remaining") + ): + category = ErrorCategory.CREDIT + # ── Retry-After ──────────────────────────────────────────────── retry_after = _retry_after_seconds(exc) @@ -673,6 +722,24 @@ def _classify_httpx_connection(exc: Exception, provider: Optional[str]) -> LLMEr ) +def _classify_local_config(exc: Exception, provider: str) -> LLMErrorInfo: + """Local precondition failures raised before any network call — e.g. no + API key configured, so the provider client was never constructed. These + are permanent local misconfigurations (CONFIG, fail-fast — see + FAIL_FAST_CATEGORIES), never something a provider actually returned, so + the message is built directly from the raw text instead of going through + the SDK/HTTP-response composition path below.""" + raw = str(exc).strip() + message = f"{raw.rstrip('.')}. Check LLM configuration, API credentials, and service availability." + return LLMErrorInfo( + category=ErrorCategory.CONFIG, + title="Provider not configured", + message=message, + provider=provider, + raw_message=raw, + ) + + def _classify_gemini_runtime(exc: Exception, provider: str) -> LLMErrorInfo: """Gemini's GeminiAPIError — raised when the response shape signals an issue that isn't an HTTP failure (e.g. promptFeedback.blockReason).""" @@ -794,7 +861,7 @@ def _retry_after_seconds(exc: Exception) -> Optional[int]: ErrorCategory.CREDIT: "Out of credits", ErrorCategory.RATE_LIMIT: "Rate limited", ErrorCategory.QUOTA: "Quota exceeded", - ErrorCategory.MODEL: "Incorrect model id", + ErrorCategory.MODEL: "Incorrect model ID", ErrorCategory.BAD_REQUEST: "Bad request", ErrorCategory.BLOCKED: "Blocked by safety filter", ErrorCategory.SERVER: "Provider service unavailable", @@ -920,7 +987,7 @@ def _append_hint( if category == ErrorCategory.MODEL: if "settings" in raw_lower: return f"{base}." - return f"{base}. Use a correct model in Settings." + return f"{base}. Set a valid LLM model in Settings." if category == ErrorCategory.BLOCKED: return f"{base}. Edit your prompt and retry." diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index 945cb82a..d369d9ba 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -30,9 +30,12 @@ get_cache_config, get_cache_metrics, ) +from agent_core.core.errors import ErrorCategory, FAIL_FAST_CATEGORIES from agent_core.core.impl.llm.errors import ( LLMConsecutiveFailureError, + LLMErrorInfo, classify_llm_error, + provider_display_name, ) from agent_core.core.hooks import ( GetTokenCountHook, @@ -108,6 +111,46 @@ def _model_supports_prefill(model: str) -> bool: return True +def _generic_empty_response_detail(provider: str, model: str) -> str: + """Fallback detail text for an empty LLM response that carries neither a + classified `error_info_obj` nor a raw `error` string. Shared by + `_generate_response_sync` and `_finalize_session_response` — previously + each had its own near-identical text that had drifted apart in wording. + """ + return ( + f"LLM returned empty response. " + f"Provider: {provider}, Model: {model}. " + f"This may indicate: API authentication failure, invalid API key, rate limiting, " + f"connection timeout, or LLM service unavailability. " + f"Check your credentials and API status." + ) + + +def _byteplus_blocked_reason(result: Dict[str, Any]) -> Optional[str]: + """Best-effort detection of content-filter/moderation blocking in a + BytePlus Responses API result that came back with empty content but no + HTTP-level error (status 200, `choices`/`output` just empty). + + Mirrors OpenAI's Responses API `status` / `incomplete_details.reason` + shape, which BytePlus's docs describe this endpoint as following — not + independently verified against a live blocked response, so this only + fires on an unambiguous signal and otherwise returns None, leaving the + existing generic empty-response handling untouched. + """ + status = result.get("status") + if status == "incomplete": + reason = (result.get("incomplete_details") or {}).get("reason") + if reason: + return str(reason) + error = result.get("error") + if isinstance(error, dict): + code = str(error.get("code") or "").lower() + message = str(error.get("message") or "") + if any(k in code for k in ("content_filter", "moderation", "safety")): + return message or code + return None + + class LLMInterface: """LLM interface with multi-provider support and hook-based customization. @@ -493,6 +536,46 @@ def _begin_call( ) # ─────────────────────────── Public helpers ──────────────────────────── + + def _register_failure( + self, + *, + error_info: Optional[LLMErrorInfo], + raw_error: Optional[Exception] = None, + ) -> None: + """Single chokepoint for consecutive-failure bookkeeping. + + Non-transient categories (bad key, out of credits, invalid model, + blocked content, malformed request — see FAIL_FAST_CATEGORIES) abort + immediately: retrying the same request with the same error can't + succeed. Transient categories (rate-limit, server, connection, + unclassified) keep the existing 5-attempt budget. + + Always raises `LLMConsecutiveFailureError` when the run should abort; + otherwise returns normally so the caller can continue its own retry + path. + """ + category = error_info.category if error_info else ErrorCategory.UNKNOWN + if category in FAIL_FAST_CATEGORIES: + logger.critical( + f"[LLM ABORT] Non-transient category={category.value} — failing fast " + f"instead of retrying." + ) + raise LLMConsecutiveFailureError( + 1, last_error=raw_error, last_error_info=error_info, is_immediate=True + ) + + self._consecutive_failures += 1 + logger.warning( + f"[LLM CONSECUTIVE FAILURE] Count: " + f"{self._consecutive_failures}/{self._max_consecutive_failures} " + f"(category={category.value})" + ) + if self._consecutive_failures >= self._max_consecutive_failures: + raise LLMConsecutiveFailureError( + self._consecutive_failures, last_error=raw_error, last_error_info=error_info + ) + def _generate_response_sync( self, system_prompt: Optional[str] = None, @@ -554,30 +637,25 @@ def _generate_response_sync( elif error_msg: error_detail = f"LLM provider returned error: {error_msg}" else: - error_detail = ( - f"LLM returned empty response. " - f"Provider: {self.provider}, Model: {self.model}. " - f"This may indicate: API authentication failure, invalid API key, rate limiting, " - f"connection timeout, or LLM service unavailability. " - f"Check your credentials and API status." + error_detail = _generic_empty_response_detail( + self.provider, self.model ) logger.error(f"[LLM ERROR] {error_detail}") - # Track consecutive failure - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures}" + # Registers/raises based on category (fail-fast vs retry + # budget) — see _register_failure. Attaches the classified + # info so the agent_base error handler can show the *cause* + # of the failure(s), not just a retry count. raw_error is + # passed even when error_info is None (e.g. BytePlus's + # cache path returning empty content with no exception) so + # a fatal LLMConsecutiveFailureError still carries *some* + # detail instead of falling back to a bare, disconnected + # "Aborted after consecutive failures." — see + # app/agent_base.py:_classify_react_error. + self._register_failure( + error_info=error_info, raw_error=RuntimeError(error_detail) ) - if self._consecutive_failures >= self._max_consecutive_failures: - # Attach the underlying classified info so the agent_base - # error handler can show the *cause* of the 5 failures - # (e.g. "rate-limited on Google AI Studio") instead of a - # meta-message about retry counts. - raise LLMConsecutiveFailureError( - self._consecutive_failures, - last_error_info=error_info, - ) # Use _EmptyResponse so the outer except-Exception block does NOT - # re-increment the counter for this same call (double-counting bug). + # re-register this same call (double-counting bug). raise _EmptyResponse(error_detail) # Success - reset consecutive failure counter @@ -600,25 +678,14 @@ def _generate_response_sync( # Failure already counted above; convert back to RuntimeError for callers. raise RuntimeError(str(e)) from None except Exception as e: - # Track consecutive failure for any other exception - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures} | Error: {e}" - ) - if self._consecutive_failures >= self._max_consecutive_failures: - # Classify on the way out so the fatal-failure handler can - # surface the cause, not just the count. - try: - info = classify_llm_error( - e, provider=self.provider, model=self.model - ) - except Exception: - info = None - raise LLMConsecutiveFailureError( - self._consecutive_failures, - last_error=e, - last_error_info=info, - ) from e + # Classify on every failure now (not just once the retry budget + # is exhausted) so non-transient categories can fail fast. + try: + info = classify_llm_error(e, provider=self.provider, model=self.model) + except Exception: + info = None + logger.error(f"[LLM ERROR] {e}") + self._register_failure(error_info=info, raw_error=e) raise @profile("llm_generate_response", OperationCategory.LLM) @@ -894,11 +961,10 @@ def _finalize_session_response( """Shared tail for the session-cache provider branches. Mirrors the failure handling in `_generate_response_sync`: an empty - response is treated as a failure, the consecutive-failure counter is - tracked, and the classified cause is surfaced (raising - `LLMConsecutiveFailureError` once the threshold is hit so the agent - aborts instead of retrying forever). On success the counter resets and - the cleaned content is returned. + response is treated as a failure and routed through + `_register_failure` (fail-fast for non-transient categories, retry + budget otherwise). On success the counter resets and the cleaned + content is returned. """ content = (response.get("content") or "").strip() if not content: @@ -909,21 +975,15 @@ def _finalize_session_response( elif error_msg: error_detail = f"LLM provider returned error: {error_msg}" else: - error_detail = ( - f"LLM returned empty response. " - f"Provider: {self.provider}, Model: {self.model}. " - f"This may indicate an API error or service unavailability." + error_detail = _generic_empty_response_detail( + self.provider, self.model ) logger.error(f"[LLM ERROR] {error_detail}") - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: " - f"{self._consecutive_failures}/{self._max_consecutive_failures}" + # See _generate_response_sync's equivalent call for why + # raw_error is always passed, even when error_info is None. + self._register_failure( + error_info=error_info, raw_error=RuntimeError(error_detail) ) - if self._consecutive_failures >= self._max_consecutive_failures: - raise LLMConsecutiveFailureError( - self._consecutive_failures, last_error_info=error_info - ) raise RuntimeError(error_detail) # Success - reset consecutive failure counter @@ -1758,6 +1818,18 @@ def _generate_openai( cache_type = f"automatic_{call_type}" if call_type else "automatic" try: + if not self.client: + # No API key configured (or client construction failed) — + # shared by openai/minimax/deepseek/moonshot/grok/openrouter/ + # glm/fugu, all of which route through this method. Without + # this guard, `self.client.chat...` below raises a bare + # "'NoneType' object has no attribute 'chat'" — matches the + # explicit "client was not initialised" pattern already used + # for Anthropic/Gemini/Bedrock, so it classifies as CONFIG + # and fails fast instead of a confusing crash. + raise RuntimeError( + f"{provider_display_name(self.provider)} client was not initialised." + ) if messages_override is not None: messages: List[Dict[str, Any]] = messages_override else: @@ -1890,7 +1962,7 @@ def _generate_openai( status = "success" except Exception as exc: exc_obj = exc - logger.error(f"Error calling OpenAI API: {exc}") + logger.debug(f"Error calling OpenAI API: {exc}") total_tokens = token_count_input + token_count_output @@ -1939,7 +2011,6 @@ def _generate_openai( except Exception: pass result["content"] = "" - logger.error(f"[OPENAI_ERROR] {error_str}") else: result["content"] = content or "" @@ -1979,7 +2050,7 @@ def _generate_ollama( status = "success" except Exception as exc: exc_obj = exc - logger.error(f"Error calling Ollama API: {exc}") + logger.debug(f"Error calling Ollama API: {exc}") self._call_log_to_db( system_prompt, @@ -2012,7 +2083,6 @@ def _generate_ollama( except Exception: pass result["content"] = "" - logger.error(f"[OLLAMA_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2150,7 +2220,7 @@ def _generate_gemini( logger.error(f"Gemini API rejected the prompt: {exc}") except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Gemini API: {exc}") + logger.debug(f"Error calling Gemini API: {exc}") self._call_log_to_db( system_prompt, @@ -2189,7 +2259,6 @@ def _generate_gemini( except Exception: pass result["content"] = "" - logger.error(f"[GEMINI_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2247,6 +2316,14 @@ def _generate_byteplus_with_prefix_cache( # Parse response (Responses API format) content = self._parse_responses_api_content(result) + if not content: + blocked_reason = _byteplus_blocked_reason(result) + if blocked_reason: + raise RuntimeError( + f"Response was blocked by the provider's content filter " + f"({blocked_reason})." + ) + # Token usage from Responses API usage = result.get("usage") or {} token_count_input = int(usage.get("input_tokens", 0)) @@ -2306,10 +2383,10 @@ def _generate_byteplus_with_prefix_cache( return self._generate_byteplus_standard(system_prompt, user_prompt) else: exc_obj = e - logger.error(f"Error calling BytePlus Responses API: {e}") + logger.debug(f"Error calling BytePlus Responses API: {e}") except Exception as exc: exc_obj = exc - logger.error(f"Error calling BytePlus Responses API: {exc}") + logger.debug(f"Error calling BytePlus Responses API: {exc}") self._call_log_to_db( system_prompt, @@ -2331,11 +2408,23 @@ def _generate_byteplus_with_prefix_cache( cached_tokens or 0, ) - return { + result_out: Dict[str, Any] = { "tokens_used": total_tokens or 0, - "content": content or "", "cached_tokens": cached_tokens or 0, } + if exc_obj: + error_str = f"{type(exc_obj).__name__}: {str(exc_obj)}" + result_out["error"] = error_str + try: + result_out["error_info_obj"] = classify_llm_error( + exc_obj, provider=self.provider, model=self.model + ) + except Exception: + pass + result_out["content"] = "" + else: + result_out["content"] = content or "" + return result_out def _parse_responses_api_content(self, result: Dict[str, Any]) -> str: """Parse content from BytePlus Responses API response. @@ -2418,6 +2507,13 @@ def _generate_byteplus_standard( or choices[0].get("delta", {}).get("content", "") or "" ).strip() + if not content and choices[0].get("finish_reason") == "content_filter": + # OpenAI-compatible signal for moderation-blocked output — + # HTTP 200 with empty content, otherwise indistinguishable + # from a generic empty response. + raise RuntimeError( + "Response was blocked by the provider's content filter." + ) total_tokens = int(result.get("usage", {}).get("total_tokens", 0)) @@ -2429,7 +2525,7 @@ def _generate_byteplus_standard( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling BytePlus API: {exc}") + logger.debug(f"Error calling BytePlus API: {exc}") self._call_log_to_db( system_prompt, @@ -2467,7 +2563,6 @@ def _generate_byteplus_standard( except Exception: pass result["content"] = "" - logger.error(f"[BYTEPLUS_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2619,7 +2714,7 @@ def _generate_anthropic( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Anthropic API: {exc}") + logger.debug(f"Error calling Anthropic API: {exc}") self._call_log_to_db( system_prompt, @@ -2659,7 +2754,6 @@ def _generate_anthropic( except Exception: pass result["content"] = "" - logger.error(f"[ANTHROPIC_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2822,7 +2916,7 @@ def _generate_bedrock( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Bedrock Converse API: {exc}") + logger.debug(f"Error calling Bedrock Converse API: {exc}") self._call_log_to_db( system_prompt, @@ -2857,7 +2951,6 @@ def _generate_bedrock( except Exception: pass result["content"] = "" - logger.error(f"[BEDROCK_ERROR] {error_str}") else: result["content"] = content or "" return result diff --git a/agent_core/core/impl/video_gen/interface.py b/agent_core/core/impl/video_gen/interface.py index 57c404ae..55844af3 100644 --- a/agent_core/core/impl/video_gen/interface.py +++ b/agent_core/core/impl/video_gen/interface.py @@ -78,16 +78,17 @@ _AUDIO_CAPABLE_PROVIDERS = {"gemini", "openai", "byteplus"} # all three honor it -def _classify_error(provider: str, exc: Exception, model: str) -> str: - """Render *exc* as a human-readable error string via the shared catalog. +def _classified_error(provider: str, exc: Exception, model: str) -> "ClassifiedError": + """Classify *exc* via the shared catalog and wrap it as a ClassifiedError. Import deferred to call time — agent_core must stay importable without the host `app` package (all app.* imports in this package are function-local by convention). """ - from app.i18n import classify_provider_error + from agent_core.core.errors import ClassifiedError + from app.i18n import classify_provider_error_info - return classify_provider_error(exc, provider=provider, model=model) + return ClassifiedError(classify_provider_error_info(exc, provider=provider, model=model)) # ── File / image helpers ───────────────────────────────────────────────────── @@ -523,10 +524,8 @@ def _openai_generate( pass if not paths: - raise RuntimeError( - _classify_error( - "openai", first_error or RuntimeError("no result"), self.model - ) + raise _classified_error( + "openai", first_error or RuntimeError("no result"), self.model ) return paths @@ -538,7 +537,7 @@ def _poll_openai_video(self, video_id: str, poll_timeout_seconds: int) -> Any: try: obj = self.client.videos.retrieve(video_id) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc status = getattr(obj, "status", None) if status == "completed": @@ -570,7 +569,7 @@ def _download_openai_video(self, video_id: str) -> bytes: try: content = self.client.videos.download_content(video_id) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc # The SDK may return bytes directly or an HTTPResponse-like object. if isinstance(content, bytes): @@ -718,7 +717,7 @@ def _gemini_generate( # generate_audio intentionally omitted — see comment above. ) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc operation_name = op.get("name") if not operation_name: @@ -740,9 +739,19 @@ def _gemini_generate( or final.get("error", {}).get("message") ) if block_reason: - raise RuntimeError( - f"Gemini Veo blocked or returned no samples ({block_reason}). " - "Try modifying your prompt or adjusting person_generation." + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + + raise ClassifiedError( + ErrorInfo( + category=ErrorCategory.BLOCKED, + code="VIDEO_GEN_BLOCKED", + title="Blocked by safety filter", + message=( + f"Gemini Veo blocked or returned no samples ({block_reason}). " + "Try modifying your prompt or adjusting person_generation." + ), + severity=Severity.ERROR, + ) ) raise RuntimeError( "Gemini Veo returned no video samples — try rephrasing your prompt " @@ -769,9 +778,7 @@ def _gemini_generate( try: data = self._gemini_client.download_video(uri, timeout=180) except Exception as exc: - raise RuntimeError( - _classify_error("gemini", exc, self.model) - ) from exc + raise _classified_error("gemini", exc, self.model) from exc elif inline: data = base64.b64decode(inline) else: @@ -800,7 +807,7 @@ def _poll_gemini_operation( try: op = self._gemini_client.poll_video_operation(operation_name) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc if op.get("done"): err = op.get("error") @@ -947,10 +954,8 @@ def _byteplus_generate( ) if not paths: - raise RuntimeError( - _classify_error( - "byteplus", first_error or RuntimeError("no result"), self.model - ) + raise _classified_error( + "byteplus", first_error or RuntimeError("no result"), self.model ) return paths @@ -970,7 +975,7 @@ def _byteplus_submit( timeout=60, ) except Exception as exc: - raise RuntimeError(_classify_error("byteplus", exc, self.model)) from exc + raise _classified_error("byteplus", exc, self.model) from exc if not r.ok: try: @@ -1014,9 +1019,7 @@ def _byteplus_poll( ) r.raise_for_status() except Exception as exc: - raise RuntimeError( - _classify_error("byteplus", exc, self.model) - ) from exc + raise _classified_error("byteplus", exc, self.model) from exc data = r.json() status = (data.get("status") or "").lower() diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index 34dde4cf..5db0114c 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -313,8 +313,12 @@ def describe_image_bytes( logger.info(f"[LLM RECV] {cleaned}") return cleaned except Exception as e: - logger.error(f"[ERROR] {e}") - raise + from agent_core.core.errors import ClassifiedError + from agent_core.core.impl.llm.errors import classify_llm_error + + info = classify_llm_error(e, provider=self.provider, model=self.model) + logger.error(f"[VLM] {info.message}") + raise ClassifiedError(info) from e async def generate_response_async( self, diff --git a/agent_core/core/models/chatgpt_subscription_client.py b/agent_core/core/models/chatgpt_subscription_client.py index 30fd29bf..3d376612 100644 --- a/agent_core/core/models/chatgpt_subscription_client.py +++ b/agent_core/core/models/chatgpt_subscription_client.py @@ -599,6 +599,8 @@ def _translate_backend_error(exc: Exception, model: str) -> Exception: entitlement. Surface that as a plan-explanation rather than a model-config error so the user knows to upgrade or switch auth. """ + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + text = str(exc) if "ChatGPT account" not in text and "not supported when using Codex" not in text: return exc @@ -612,15 +614,25 @@ def _translate_backend_error(exc: Exception, model: str) -> Exception: except Exception: pass if plan == "free" or not plan: - return RuntimeError( + message = ( "ChatGPT subscription is connected but this account has no Plus/Pro/Team " "plan — the Codex backend rejects all models for Free-tier accounts. " "Upgrade at chat.openai.com, disconnect the subscription in Settings, " "or switch back to API-key auth." ) - return RuntimeError( - f"ChatGPT subscription rejected model {model!r}: {text}. " - "Try a different model from the subscription list, or switch to API-key auth." + else: + message = ( + f"ChatGPT subscription rejected model {model!r}: {text}. " + "Try a different model from the subscription list, or switch to API-key auth." + ) + return ClassifiedError( + ErrorInfo( + category=ErrorCategory.CONFIG, + code="CHATGPT_SUBSCRIPTION_REJECTED", + title="Subscription plan rejected", + message=message, + severity=Severity.ERROR, + ) ) diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index 462c4a20..91cec198 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -350,7 +350,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for OpenAI") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="OpenAI")) return { "provider": provider, @@ -371,7 +373,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for Gemini") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="Gemini")) return { "provider": provider, @@ -389,7 +393,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for Anthropic") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="Anthropic")) return { "provider": provider, @@ -407,7 +413,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for BytePlus") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="BytePlus")) return { "provider": provider, @@ -498,7 +506,14 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError(f"API key required for {provider}") + from app.errors import CatalogError, make_error + + raise CatalogError( + make_error( + "CONFIG_NO_API_KEY", + provider=_PROVIDER_DISPLAY.get(provider, provider), + ) + ) return { "provider": provider, diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 4013d20d..c325c025 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -410,10 +410,11 @@ The harness already handles certain failures so you do not have to. Recognizing - Recovery: the timeout is final for that invocation. Either retry with smaller scope (fewer rows, narrower regex, smaller batch) or split the work into multiple actions. **LLM consecutive-failure circuit breaker** ([agent_core/core/impl/llm/errors.py](agent_core/core/impl/llm/errors.py), [agent_core/core/impl/llm/interface.py](agent_core/core/impl/llm/interface.py)) -- After repeated consecutive LLM failures (auth, network, etc.), the harness raises `LLMConsecutiveFailureError`. -- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **automatically cancels the task** via `task_manager.mark_task_cancel(...)`. The agent's last instruction is cached in `_llm_retry_instructions[session_id]` for retry-after-fix. -- A `LLM_FATAL_ERROR` UI event is emitted so the user sees a clear failure dialog. -- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE` ("LLM calls have failed N consecutive times. Task aborted to prevent infinite retries."), the task is already gone. Do NOT try to re-create it. The user must check their LLM configuration. +- Non-transient categories (auth, credit, quota, model, blocked, bad request) raise `LLMConsecutiveFailureError` immediately on the first failure — retrying the same request can't fix them. Transient categories (rate-limit, server, connection, unclassified) get a 5-attempt retry budget before the same error is raised. +- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **halts the run** (`_emit_run_state(session_id, False)`) rather than cancelling the task outright. +- Presentation splits into two tiers: a recognized/classified failure (bad key, no credits, misconfigured provider — anything carrying an `ErrorInfo`, via `LLMConsecutiveFailureError.last_error_info` or a `ClassifiedError`) shows as a short, calm "system"-style message; anything unclassified shows as a red "error" message with full detail — see [agent_core/core/errors.py](agent_core/core/errors.py). +- There is no Retry/Change Model button — the user resumes by sending a normal chat message (e.g. "continue"). `_handle_chat_message` resets the failure counter on any new message, so this just works. +- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE`/`MSG_FAILED_IMMEDIATELY`, the run has halted and is waiting on the user's next message. Do NOT try to keep working. **Action limit (`max_actions_per_task`, minimum 5)** ([agent_core/core/state/types.py](agent_core/core/state/types.py)) - Tracked in `STATE.get_agent_property("action_count")` against `max_actions_per_task`. diff --git a/app/agent_base.py b/app/agent_base.py index e28775c4..96e15bd2 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -67,10 +67,15 @@ from app.internal_action_interface import InternalActionInterface from app.llm import LLMInterface -from agent_core.core.impl.llm.errors import ( - classify_llm_error_message, - LLMConsecutiveFailureError, +from agent_core.core.errors import ( + ClassifiedError, + ErrorCategory, + ErrorInfo, + ErrorInfoLike, + Severity, + redact, ) +from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError from app.vlm_interface import VLMInterface from app.image_gen_interface import ImageGenInterface from app.video_gen_interface import VideoGenInterface @@ -243,9 +248,6 @@ def __init__( data_dir=data_dir, chroma_path=chroma_path ) - # Stores original run instructions keyed by session_id for LLM retry after failure - self._llm_retry_instructions: dict[str, str] = {} - # LLM + prompt plumbing (may be deferred if API key not yet configured) self.llm = LLMInterface( provider=llm_provider, @@ -1337,87 +1339,164 @@ def _sanitize_session_title(response: Optional[str]) -> str: # ----- Error Handling ----- - async def _handle_react_error( - self, + @staticmethod + def _classify_react_error( error: Exception, - session_id: str, - action_output: dict, - ) -> None: - """Handle errors during react execution.""" - tb = traceback.format_exc() - logger.error(f"[REACT ERROR] {error}\n{tb}") + ) -> tuple[bool, LLMConsecutiveFailureError | None, ErrorInfoLike | None]: + """Walk the exception chain (__cause__, __context__) once, looking for: - if not session_id or not self.event_stream_manager: - return + - `LLMConsecutiveFailureError` — the run is fatally halted (5 failed + attempts, or an immediate fail-fast category). Carries the *cause* + of the failure(s) in `.last_error_info` when known. + - `ClassifiedError` — a recognized, user-actionable failure that + didn't hit the consecutive-failure threshold (e.g. the action + router's own 3-attempt budget on an LLM provider error). Doesn't + halt the run. + + Anything else is a genuinely unclassified exception — presentation + treats it as a critical, "broken agent loop" failure. - # Walk the exception chain (__cause__, __context__) to detect the - # fatal-LLM case. We need the LLMConsecutiveFailureError to surface - # the *cause* of the 5 failures (e.g. "rate-limited on Google AI - # Studio"), not the meta-message about retry counts. - is_fatal_llm_error = False - fatal_exc: LLMConsecutiveFailureError | None = None + Returns (is_fatal, fatal_exc_or_None, classified_info_or_None). + """ seen: set[int] = set() exc: BaseException | None = error while exc is not None and id(exc) not in seen: seen.add(id(exc)) if isinstance(exc, LLMConsecutiveFailureError): - is_fatal_llm_error = True - fatal_exc = exc - break + info = exc.last_error_info or AgentBase._consecutive_failure_fallback_info(exc) + return True, exc, info + if isinstance(exc, ClassifiedError): + return False, None, exc.info cause = exc.__cause__ or exc.__context__ if cause is None or cause is exc: break exc = cause + return False, None, None + + @staticmethod + def _consecutive_failure_fallback_info( + exc: LLMConsecutiveFailureError, + ) -> Optional[ErrorInfo]: + """Built when a fatal `LLMConsecutiveFailureError` has no classified + `last_error_info` but does carry a raw `last_error` (e.g. BytePlus + returning an empty response with no exception to classify — see + agent_core/core/impl/llm/interface.py's empty-response handling). + + Folds the "gave up after repeated failures" fact into the SAME + message as the underlying cause, minor/system tier, instead of + showing it as a second, disconnected "Aborted after consecutive + failures." bubble with no information about what actually failed. + Returns None only when there's truly nothing to show (falls back to + the critical/unclassified tier). + """ + if exc.last_error is None: + return None + raw = str(exc.last_error).rstrip(".") + suffix = ( + "This can't be fixed by retrying." + if exc.is_immediate + else "Gave up after repeated failures." + ) + return ErrorInfo( + category=ErrorCategory.UNKNOWN, + code="LLM_CONSECUTIVE_FAILURE", + title="Repeated failures", + message=f"{raw}. {suffix}", + ) + + @staticmethod + def _critical_fallback_info(raw_message: str) -> ErrorInfo: + """Built when NO recognized/classified error info is available — + i.e. a genuinely unexpected exception, not a known LLM/config + problem. Shown with full (redacted) technical detail and critical + (red) styling, per the "minor vs critical" presentation split: + recognized failures (bad key, no credits, misconfigured provider) + get a short, calm message; unrecognized ones get the raw detail so + it's clear something actually broke.""" + return ErrorInfo( + category=ErrorCategory.INTERNAL, + code="INTERNAL_UNCLASSIFIED", + title="Unexpected error", + message=redact(raw_message), + severity=Severity.CRITICAL, + ) - if ( - is_fatal_llm_error - and fatal_exc is not None - and fatal_exc.last_error_info is not None - ): - cause_msg = fatal_exc.last_error_info.message - user_message = f"Aborted after consecutive failures. {cause_msg}" - elif is_fatal_llm_error and fatal_exc is not None: - user_message = str(fatal_exc) + async def _handle_react_error( + self, + error: Exception, + session_id: str, + action_output: dict, + ) -> None: + """Handle errors during react execution. + + Presentation is split into two tiers: + - Minor/user errors (bad key, no credits, invalid model, a + misconfigured provider) — a short, actionable message using the + calm "system" bubble style, no raw exception text. + - Critical failures (anything not recognized as a classified LLM/ + config problem — a genuine bug or crash) — full error detail with + the red "error" styling. + + This is independent of whether the run halts: only a fatal + `LLMConsecutiveFailureError` halts the run (5 failed attempts, or an + immediate fail-fast category); everything else lets the react loop + continue to the next turn while still telling the user what happened. + """ + is_fatal, fatal_exc, classified_info = self._classify_react_error(error) + is_critical = classified_info is None + if is_critical: + # Nothing further down the stack classified/logged this in + # detail — this is the only place a full traceback gets + # captured, so it's worth the ERROR level here. + tb = traceback.format_exc() + logger.error(f"[REACT ERROR] {error}\n{tb}") + raw = str(fatal_exc) if fatal_exc is not None else (str(error) or "AI service error") + info = self._critical_fallback_info(raw) else: - try: - user_message = classify_llm_error_message(error) - except Exception: - user_message = str(error) or "AI service error" + # Already logged with good detail by whichever layer classified + # it (interface.py / router.py) — avoid a second traceback dump. + logger.debug(f"[REACT ERROR] {error}") + info = classified_info + + if not session_id or not self.event_stream_manager: + return try: logger.debug("[REACT ERROR] Logging to event stream") + # event_type=EventType.INTERNAL (not ERROR): this event stays in + # the session stream for LLM self-correction/audit context, but + # EventType.ERROR IS dispatched by EventTransformer (see + # transformer.py's _DISPATCH) regardless of display_message, so + # using it here would let the background event watcher + # (ui_controller._watch_agent_events) render a second, undesired + # chat bubble a poll cycle after the one displayed directly + # below. EventType.INTERNAL maps to _build_hidden and is never + # surfaced — the same pattern already used by + # _send_limit_choice_message. self.event_stream_manager.log( "error", - f"[REACT] {type(error).__name__}: {user_message}", - event_type=EventType.ERROR, - display_message=user_message, + f"[REACT] {type(error).__name__}: {info.message}", + event_type=EventType.INTERNAL, + display_message=None, task_id=session_id, ) self.state_manager.bump_event_stream() - if is_fatal_llm_error: - # Stop the run instead of re-queueing to prevent infinite retries. + if is_fatal: + # Stop the run instead of re-queueing to prevent infinite + # retries. The user resumes by sending a normal chat message + # — _handle_chat_message already resets the failure counter + # on intake, so no separate Retry action is needed. logger.warning( f"[REACT ERROR] LLMConsecutiveFailureError — halting run for " f"session {session_id}." ) self._emit_run_state(session_id, False) - self._llm_retry_instructions[session_id] = ( - "Continue where you left off — the previous attempt was " - "aborted by an AI-provider failure." - ) - if self.ui_controller: - from app.ui_layer.events import UIEvent, UIEventType - - self.ui_controller.event_bus.emit( - UIEvent( - type=UIEventType.LLM_FATAL_ERROR, - data={"session_id": session_id}, - task_id=session_id, - ) - ) + await self._display_react_error(session_id, info, critical=is_critical) else: - # Recoverable turn error: continue the run so the LLM sees - # the error event and can adapt. + # Recoverable turn error: still tell the user what happened, + # but let the run continue so the LLM sees the error event + # and can adapt. + await self._display_react_error(session_id, info, critical=is_critical) await self.trigger_service.emit( TriggerSpec( source=TriggerSource.RUN_CONTINUATION, @@ -1436,6 +1515,32 @@ async def _handle_react_error( exc_info=True, ) + async def _display_react_error( + self, session_id: str, info: ErrorInfoLike, *, critical: bool + ) -> None: + """Show a single error bubble: calm "system" styling for a + recognized, user-actionable failure; red "error" styling with full + detail for an unclassified/critical one. + + Displayed directly via the chat component (like + `_send_limit_choice_message`) instead of round-tripping through a + `UIEvent` on the event bus, so there's no ordering race with the + (invisible) event-stream log entry above. + """ + if not (self.ui_controller and self.ui_controller.active_adapter): + logger.warning("[REACT ERROR] No active UI adapter - error not displayed") + return + from app.ui_layer.components.error_message import build_error_chat_message + + chat = self.ui_controller.active_adapter.chat_component + message = build_error_chat_message( + info, + sender="Error" if critical else "System", + session_id=session_id, + style="error" if critical else "system", + ) + await chat.append_message(message) + # ----- Agent Limits ----- async def _check_agent_limits(self, session_id: str) -> bool: @@ -1453,7 +1558,12 @@ async def _check_agent_limits(self, session_id: str) -> bool: self.event_stream_manager.log( "warning", f"Action limit reached: 100% of the maximum actions ({max_actions} actions) has been used. Waiting for user decision.", - event_type=EventType.SYSTEM, + # EventType.INTERNAL (not SYSTEM): this is context-only — + # EventType.SYSTEM IS dispatched to a chat bubble by + # EventTransformer regardless of display_message, which + # would double up with _send_limit_choice_message's own + # chat bubble below. + event_type=EventType.INTERNAL, display_message=None, task_id=session_id, ) @@ -1467,7 +1577,10 @@ async def _check_agent_limits(self, session_id: str) -> bool: self.event_stream_manager.log( "warning", f"Token limit reached: 100% of the maximum tokens ({max_tokens} tokens) has been used. Waiting for user decision.", - event_type=EventType.SYSTEM, + # See the action-limit branch above: EventType.INTERNAL, + # not SYSTEM, to avoid a second chat bubble alongside + # _send_limit_choice_message's. + event_type=EventType.INTERNAL, display_message=None, task_id=session_id, ) @@ -1519,19 +1632,13 @@ async def _send_limit_choice_message( # Display message with options directly in the chat UI (awaited). if self.ui_controller and self.ui_controller.active_adapter: try: - from app.ui_layer.components.types import ChatMessage, ChatMessageOption + from app.ui_layer.components.types import ChatMessage + from app.ui_layer.components.error_message import continue_stop_options from app.onboarding import onboarding_manager import time as _time agent_name = onboarding_manager.state.agent_name or "Agent" - options = [ - ChatMessageOption( - label="Continue", value="continue_limit", style="primary" - ), - ChatMessageOption( - label="Stop", value="abort_limit", style="danger" - ), - ] + options = continue_stop_options() await self.ui_controller.active_adapter.chat_component.append_message( ChatMessage( sender=agent_name, @@ -1540,6 +1647,7 @@ async def _send_limit_choice_message( timestamp=_time.time(), session_id=session_id, options=options, + requires_choice=True, ) ) except Exception as e: @@ -1613,28 +1721,6 @@ async def handle_limit_abort(self, session_id: str) -> None: ) self.state_manager.bump_event_stream() - async def handle_llm_retry(self, session_id: str) -> None: - """Retry after a fatal LLM failure. Resets the failure counter and resumes the run.""" - self._llm_retry_instructions.pop(session_id, None) - try: - self.llm.reset_failure_counter() - except Exception as e: - logger.debug(f"[LLM_RETRY] Could not reset failure counter: {e}") - - self._emit_run_state(session_id or MAIN_SESSION_ID, True) - await self.trigger_service.emit( - TriggerSpec( - source=TriggerSource.RUN_CONTINUATION, - description=( - "Retry: the previous attempt was aborted by an AI-provider " - "failure. Continue the work from where you left off based " - "on the event stream." - ), - priority=5, - session_id=session_id or MAIN_SESSION_ID, - ) - ) - # ===================================== # Message intake # ===================================== @@ -2016,7 +2102,11 @@ async def _handle_prompt_enhance(self, user_message: str) -> str: result = json.loads(response) return result.get("enhanced_prompt", "") except Exception as e: - logger.error(f"{classify_provider_error(error=e)}") + logger.error( + classify_provider_error( + e, provider=self.llm.provider, model=getattr(self.llm, "model", "") or "" + ) + ) # ===================================== # Hooks diff --git a/app/data/action/integrations/integration_management.py b/app/data/action/integrations/integration_management.py index b416c566..dd773b8f 100644 --- a/app/data/action/integrations/integration_management.py +++ b/app/data/action/integrations/integration_management.py @@ -374,7 +374,15 @@ def connect_integration(input_data: dict) -> dict: } except Exception as e: - return {"status": "error", "message": f"Connection failed: {str(e)}"} + from app.errors import make_error + + info = make_error("CONNECTION_FAILED", target=integration_id, detail=str(e)) + return { + "status": "error", + "message": info.message, + "error_category": info.category.value, + "error_code": info.code, + } @action( diff --git a/app/data/action/web_fetch.py b/app/data/action/web_fetch.py index cd418e06..554ffd81 100644 --- a/app/data/action/web_fetch.py +++ b/app/data/action/web_fetch.py @@ -99,6 +99,7 @@ def web_fetch(input_data: dict) -> dict: import tempfile from urllib.parse import urlparse from datetime import datetime, timezone + from app.errors import make_error as catalog_make_error # --- Helper functions (must be inside for sandboxed execution) --- @@ -425,9 +426,11 @@ def save_content_file(content, file_url, sess_id): error_type = type(e).__name__ if "Timeout" in error_type: - msg = f"Request timed out after {timeout} seconds." + msg = catalog_make_error("CONNECTION_TIMEOUT", target=url).message elif "ConnectionError" in error_type: - msg = f"Connection error: {str(e)}" + msg = catalog_make_error( + "CONNECTION_FAILED", target=url, detail=str(e) + ).message elif "HTTPError" in error_type: msg = f"HTTP error: {str(e)}" else: diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index a849129f..8b2edca9 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -462,10 +462,11 @@ The harness already handles certain failures so you do not have to. Recognizing - Recovery: the timeout is final for that invocation. Either retry with smaller scope (fewer rows, narrower regex, smaller batch) or split the work into multiple actions. **LLM consecutive-failure circuit breaker** ([agent_core/core/impl/llm/errors.py](agent_core/core/impl/llm/errors.py), [agent_core/core/impl/llm/interface.py](agent_core/core/impl/llm/interface.py)) -- After repeated consecutive LLM failures (auth, network, etc.), the harness raises `LLMConsecutiveFailureError`. -- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **automatically cancels the task** via `task_manager.mark_task_cancel(...)`. The agent's last instruction is cached in `_llm_retry_instructions[session_id]` for retry-after-fix. -- A `LLM_FATAL_ERROR` UI event is emitted so the user sees a clear failure dialog. -- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE` ("LLM calls have failed N consecutive times. Task aborted to prevent infinite retries."), the task is already gone. Do NOT try to re-create it. The user must check their LLM configuration. +- Non-transient categories (auth, credit, quota, model, blocked, bad request) raise `LLMConsecutiveFailureError` immediately on the first failure — retrying the same request can't fix them. Transient categories (rate-limit, server, connection, unclassified) get a 5-attempt retry budget before the same error is raised. +- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **halts the run** (`_emit_run_state(session_id, False)`) rather than cancelling the task outright. +- Presentation splits into two tiers: a recognized/classified failure (bad key, no credits, misconfigured provider — anything carrying an `ErrorInfo`, via `LLMConsecutiveFailureError.last_error_info` or a `ClassifiedError`) shows as a short, calm "system"-style message; anything unclassified shows as a red "error" message with full detail — see [agent_core/core/errors.py](agent_core/core/errors.py). +- There is no Retry/Change Model button — the user resumes by sending a normal chat message (e.g. "continue"). `_handle_chat_message` resets the failure counter on any new message, so this just works. +- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE`/`MSG_FAILED_IMMEDIATELY`, the run has halted and is waiting on the user's next message. Do NOT try to keep working. **Action limit (`max_actions_per_task`, minimum 5)** ([agent_core/core/state/types.py](agent_core/core/state/types.py)) - Tracked in `STATE.get_agent_property("action_count")` against `max_actions_per_task`. diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 00000000..a4dc43be --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +"""App-layer error catalogue — see app/errors/codebook.py.""" + +from app.errors.codebook import CatalogError, make_error + +__all__ = ["CatalogError", "make_error"] diff --git a/app/errors/codebook.py b/app/errors/codebook.py new file mode 100644 index 00000000..800fce65 --- /dev/null +++ b/app/errors/codebook.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +App-layer error codebook. + +Curated, representative entries for the highest-duplication non-LLM call +sites (see docs/error_handling_report.md and the error-catalogue plan). This +is deliberately a small proof-of-adoption set, not exhaustive coverage of +every hand-rolled error string in the app. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, List + +from agent_core.core.errors import ( + ClassifiedError, + ErrorAction, + ErrorCategory, + ErrorInfo, + Severity, + redact, +) + + +@dataclass(frozen=True) +class _Spec: + category: ErrorCategory + severity: Severity + title: str + message_template: str + actions: Callable[..., List[ErrorAction]] = lambda **_: [] + + +def _settings_action(**_kwargs) -> List[ErrorAction]: + return [ErrorAction(label="Open settings", action="open_settings_model")] + + +_CODEBOOK: Dict[str, _Spec] = { + "CONFIG_NO_API_KEY": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="No API key configured", + message_template="No {provider} API key configured. Add one in Settings.", + actions=_settings_action, + ), + "CONFIG_INVALID_API_KEY": _Spec( + category=ErrorCategory.AUTH, + severity=Severity.ERROR, + title="Invalid API key", + message_template="The {provider} API key was rejected. Check your key in Settings.", + actions=_settings_action, + ), + "CONNECTION_FAILED": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Connection failed", + message_template="Could not reach {target}. {detail}", + ), + "CONNECTION_TIMEOUT": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Request timed out", + message_template="{target} did not respond in time. Try again.", + ), + "VLM_PROVIDER_UNAVAILABLE": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="Vision model unavailable", + message_template=( + "VLM is not available for provider '{provider}'. Switch VLM provider " + "in Settings to one that supports vision (e.g. anthropic, openai, " + "gemini, byteplus)." + ), + actions=_settings_action, + ), + "VLM_PROVIDER_NOT_INITIALIZED": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="Vision model not configured", + message_template=( + "VLM for provider '{provider}' is not initialized. Check that the " + "API key is configured in Settings." + ), + actions=_settings_action, + ), + "PROXY_ERROR": _Spec( + category=ErrorCategory.SERVER, + severity=Severity.ERROR, + title="Proxy request failed", + message_template="{detail}", + ), + "SUBAGENT_TIMEOUT": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Sub-agent call timed out", + message_template="The sub-agent LLM call did not respond within {timeout}s.", + ), +} + + +def make_error(code: str, **fmt_kwargs) -> ErrorInfo: + """Build a structured `ErrorInfo` from a codebook entry. + + `fmt_kwargs` fill the entry's message template (e.g. `provider=`, + `target=`). A missing key raises `KeyError` here, at the call site, + rather than shipping a broken `"{provider}"` literal to the UI. + + `detail` is redacted before formatting — by convention it's raw + exception text (`str(e)`), unlike `provider`/`target` which are + semantic, already-user-known values. + """ + spec = _CODEBOOK.get(code) + if spec is None: + raise KeyError(f"Unknown error code {code!r} — add it to app/errors/codebook.py") + if "detail" in fmt_kwargs: + fmt_kwargs["detail"] = redact(str(fmt_kwargs["detail"])) + message = spec.message_template.format(**fmt_kwargs) + return ErrorInfo( + category=spec.category, + code=code, + title=spec.title, + message=message, + severity=spec.severity, + actions=spec.actions(**fmt_kwargs), + ) + + +class CatalogError(ClassifiedError): + """Drop-in replacement for `raise RuntimeError(f"...")` at call sites + that have been migrated onto the codebook.""" diff --git a/app/errors/web.py b/app/errors/web.py new file mode 100644 index 00000000..fc89b7f7 --- /dev/null +++ b/app/errors/web.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""aiohttp helper for returning a classified error as a JSON response.""" + +from __future__ import annotations + +from aiohttp import web + +from agent_core.core.errors import ErrorInfoLike + + +def error_json_response(info: ErrorInfoLike, status: int) -> web.Response: + """Build a `web.json_response` from a classified error. + + Keeps the existing `"error"` string key (so current frontend `fetch` + consumers that only read `.error` keep working unchanged) and adds + `error_category`/`error_code` additively. + """ + code = getattr(info, "code", None) + return web.json_response( + { + "error": info.message, + "error_category": info.category.value, + **({"error_code": code} if code else {}), + }, + status=status, + ) diff --git a/app/i18n/__init__.py b/app/i18n/__init__.py index 6638d932..fac368dd 100644 --- a/app/i18n/__init__.py +++ b/app/i18n/__init__.py @@ -14,6 +14,11 @@ classify_provider_error(exc, *, provider, model="") -> str Map a raw exception to a human-readable, locale-aware error string. +classify_provider_error_info(exc, *, provider, model="") -> ErrorInfo + Same classification, returned as a structured ErrorInfo (category, + severity, actions preserved) for callers that raise ClassifiedError + instead of just logging a string. + Adding a new provider --------------------- Add one entry to ``_PROVIDER_DISPLAY`` in agent_core/core/impl/llm/errors.py. @@ -30,6 +35,7 @@ import json from pathlib import Path +from agent_core.core.errors import ErrorInfo from agent_core.core.impl.llm.errors import ( ErrorCategory, classify_llm_error, @@ -93,28 +99,55 @@ def classify_provider_error( ) -> str: """Map *exc* to a human-readable, locale-aware error string. + Thin wrapper over ``classify_provider_error_info`` for callers that only + need the rendered string. + """ + return classify_provider_error_info(exc, provider=provider, model=model).message + + +def classify_provider_error_info( + exc: Exception, + *, + provider: str, + model: str = "", +) -> ErrorInfo: + """Map *exc* to a structured, locale-aware ``ErrorInfo``. + Classification (status codes, structured bodies, SDK exception types, - CJK error text) is done by ``classify_llm_error``; this function only - renders the resulting category through the locale catalog. + CJK error text) is done by ``classify_llm_error``; this function renders + the resulting category through the locale catalog for ``.message`` while + preserving category/severity/actions for callers that want to raise a + classified exception (see ``ClassifiedError``) instead of just logging a + string. """ info = classify_llm_error(exc, provider=provider, model=model or None) label = provider_display_name(provider) key = _CATEGORY_KEYS.get(info.category) if key: - return t(key, provider_label=label, model=model or "the requested model") - - if info.category is ErrorCategory.CONNECTION: + message = t(key, provider_label=label, model=model or "the requested model") + elif info.category is ErrorCategory.CONNECTION: low = (info.raw_message or str(exc)).lower() if "timeout" in low or "timed out" in low: - return t("provider_timeout", provider_label=label) - return t("provider_connection", provider_label=label) - - # BAD_REQUEST / SERVER / UNKNOWN — generic template, with the upstream - # detail appended so misclassified 400s and provider outages surface - # their cause. raw_message is already truncated by the classifier. - result = t("provider_generic", provider_label=label) - detail = (info.raw_message or "").strip() - if detail: - result = f"{result}: {detail}" - return result + message = t("provider_timeout", provider_label=label) + else: + message = t("provider_connection", provider_label=label) + else: + # BAD_REQUEST / SERVER / UNKNOWN — generic template, with the + # upstream detail appended so misclassified 400s and provider + # outages surface their cause. raw_message is already truncated by + # the classifier. + message = t("provider_generic", provider_label=label) + detail = (info.raw_message or "").strip() + if detail: + message = f"{message}: {detail}" + + return ErrorInfo( + category=info.category, + code=info.code or f"LLM_{info.category.value.upper()}", + title=info.title, + message=message, + severity=info.severity, + actions=info.actions, + raw_message=info.raw_message, + ) diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index 87724327..97a5feea 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -138,17 +138,15 @@ def _ensure_vlm_available(cls) -> None: if not cls.vlm_interface.is_initialized: from agent_core.core.models.model_registry import MODEL_REGISTRY from agent_core.core.models.types import InterfaceType + from app.errors import CatalogError, make_error provider = cls.vlm_interface.provider or "unknown" if MODEL_REGISTRY.get(provider, {}).get(InterfaceType.VLM) is None: - raise RuntimeError( - f"VLM is not available for provider '{provider}'. " - "Switch VLM provider in setting to the one " - "that supports vision (e.g. anthropic, openai, gemini, byteplus)." + raise CatalogError( + make_error("VLM_PROVIDER_UNAVAILABLE", provider=provider) ) - raise RuntimeError( - f"VLM for provider '{provider}' is not initialized. " - "Check that the API key is configured in app/config/settings.json." + raise CatalogError( + make_error("VLM_PROVIDER_NOT_INITIALIZED", provider=provider) ) @classmethod diff --git a/app/living_ui/integration_bridge.py b/app/living_ui/integration_bridge.py index d52caecb..c667b90c 100644 --- a/app/living_ui/integration_bridge.py +++ b/app/living_ui/integration_bridge.py @@ -17,6 +17,9 @@ from aiohttp import web import httpx +from app.errors import make_error +from app.errors.web import error_json_response + if TYPE_CHECKING: from app.living_ui.manager import LivingUIManager @@ -148,10 +151,14 @@ async def _handle_proxy(self, request: web.Request) -> web.Response: ) except httpx.TimeoutException: - return web.json_response({"error": "External API timeout"}, status=504) + return error_json_response( + make_error("CONNECTION_TIMEOUT", target="the integration API"), status=504 + ) except Exception as e: logger.error(f"[INTEGRATION_BRIDGE] Proxy error: {e}") - return web.json_response({"error": f"Proxy error: {str(e)}"}, status=502) + return error_json_response( + make_error("PROXY_ERROR", detail=f"Proxy error: {str(e)}"), status=502 + ) async def _handle_llm(self, request: web.Request) -> web.Response: """Proxy LLM completion request through CraftBot's configured LLM.""" diff --git a/app/llm_interface.py b/app/llm_interface.py deleted file mode 100644 index 33ada511..00000000 --- a/app/llm_interface.py +++ /dev/null @@ -1,2582 +0,0 @@ -# -*- coding: utf-8 -*- -""" -app.llm_interface - -All LLM calls have to go through this interface -Currently support llm call to open ai api, google gemini, and remote call to Ollama -""" - -from __future__ import annotations - -import asyncio -import hashlib -import logging -import os -import re -import requests -from dataclasses import dataclass -from enum import Enum -from typing import Any, Dict, List, Optional - - -# ─────────────────────────── LLM Call Types for Session Caching ─────────────────────────── -class LLMCallType(str, Enum): - """Types of LLM calls for session cache keying. - - Each call type gets its own session cache within a task, so that - different prompt structures (reasoning vs action selection) don't - pollute each other's KV cache. - """ - - REASONING = "reasoning" - ACTION_SELECTION = "action_selection" - GUI_REASONING = "gui_reasoning" - GUI_ACTION_SELECTION = "gui_action_selection" - - -from app.models.factory import ModelFactory -from app.models.types import InterfaceType -from app.google_gemini_client import GeminiAPIError, GeminiClient -from app.state.agent_state import get_session_props -from agent_core import profile, OperationCategory - -# Logging setup — fall back to a basic logger if the project‑level logger -# is not available (e.g. when running this file standalone). -try: - from app.logger import logger # type: ignore -except Exception: # pragma: no cover - logger = logging.getLogger(__name__) - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - -# Shared definition lives in agent_core so the LLM/VLM limit counters stay in -# sync. Aliased to the existing private name to keep call sites unchanged. -from agent_core.utils.token import billable_tokens as _billable_tokens - - -# ─────────────────────────── Shared Cache Configuration ─────────────────────────── -@dataclass -class CacheConfig: - """Shared cache configuration for all LLM providers. - - This configuration is used by both BytePlus (prefix/session caches) and - Anthropic (ephemeral cache_control). - - Attributes: - prefix_cache_ttl: TTL for prefix caches in seconds (BytePlus only). - Anthropic uses a fixed 5-minute TTL for ephemeral caches. - session_cache_ttl: TTL for session caches in seconds (BytePlus only). - min_cache_tokens: Minimum system prompt length (chars) for caching. - Rough approximation: 500 chars ≈ 1024 tokens. - """ - - prefix_cache_ttl: int = 3600 # 1 hour default - session_cache_ttl: int = 7200 # 2 hours for long tasks - min_cache_tokens: int = 500 # ~1024 tokens minimum - - @classmethod - def from_env(cls) -> "CacheConfig": - """Load cache configuration from environment variables.""" - return cls( - prefix_cache_ttl=int(os.getenv("CACHE_PREFIX_TTL", "3600")), - session_cache_ttl=int(os.getenv("CACHE_SESSION_TTL", "7200")), - min_cache_tokens=int(os.getenv("CACHE_MIN_TOKENS", "500")), - ) - - -# Global cache configuration instance -_cache_config: Optional[CacheConfig] = None - - -def get_cache_config() -> CacheConfig: - """Get the global cache configuration, initializing from env if needed.""" - global _cache_config - if _cache_config is None: - _cache_config = CacheConfig.from_env() - return _cache_config - - -# ─────────────────────────── Cache Metrics Tracking ─────────────────────────── -@dataclass -class CacheMetricsEntry: - """Metrics for a single cache operation type.""" - - total_calls: int = 0 - cache_hits: int = 0 - cache_misses: int = 0 - tokens_cached: int = 0 - tokens_uncached: int = 0 - - @property - def hit_rate(self) -> float: - """Calculate cache hit rate as percentage.""" - if self.total_calls == 0: - return 0.0 - return (self.cache_hits / self.total_calls) * 100 - - @property - def token_cache_rate(self) -> float: - """Calculate percentage of tokens served from cache.""" - total = self.tokens_cached + self.tokens_uncached - if total == 0: - return 0.0 - return (self.tokens_cached / total) * 100 - - -class CacheMetrics: - """Tracks cache effectiveness metrics per provider and operation type. - - Usage: - metrics = CacheMetrics() - metrics.record_hit("byteplus", "prefix", cached_tokens=500, total_tokens=800) - metrics.record_miss("byteplus", "session") - print(metrics.get_summary()) - """ - - def __init__(self) -> None: - # Structure: provider -> cache_type -> CacheMetricsEntry - self._metrics: Dict[str, Dict[str, CacheMetricsEntry]] = {} - - def _get_entry(self, provider: str, cache_type: str) -> CacheMetricsEntry: - """Get or create metrics entry for provider/cache_type.""" - if provider not in self._metrics: - self._metrics[provider] = {} - if cache_type not in self._metrics[provider]: - self._metrics[provider][cache_type] = CacheMetricsEntry() - return self._metrics[provider][cache_type] - - def record_hit( - self, - provider: str, - cache_type: str, - cached_tokens: int = 0, - total_tokens: int = 0, - ) -> None: - """Record a cache hit with optional token counts.""" - entry = self._get_entry(provider, cache_type) - entry.total_calls += 1 - entry.cache_hits += 1 - entry.tokens_cached += cached_tokens - entry.tokens_uncached += max(0, total_tokens - cached_tokens) - - logger.info( - f"[CACHE METRICS] {provider}/{cache_type}: HIT " - f"(cached={cached_tokens}, total={total_tokens}, " - f"hit_rate={entry.hit_rate:.1f}%, token_cache_rate={entry.token_cache_rate:.1f}%)" - ) - - def record_miss( - self, - provider: str, - cache_type: str, - total_tokens: int = 0, - ) -> None: - """Record a cache miss.""" - entry = self._get_entry(provider, cache_type) - entry.total_calls += 1 - entry.cache_misses += 1 - entry.tokens_uncached += total_tokens - - logger.info( - f"[CACHE METRICS] {provider}/{cache_type}: MISS " - f"(total={total_tokens}, hit_rate={entry.hit_rate:.1f}%)" - ) - - def get_summary(self) -> str: - """Get a formatted summary of all cache metrics.""" - lines = ["=" * 60, "CACHE METRICS SUMMARY", "=" * 60] - - for provider, cache_types in self._metrics.items(): - lines.append(f"\n{provider.upper()}:") - for cache_type, entry in cache_types.items(): - lines.append( - f" {cache_type}:" - f"\n Calls: {entry.total_calls} " - f"(hits={entry.cache_hits}, misses={entry.cache_misses})" - f"\n Hit Rate: {entry.hit_rate:.1f}%" - f"\n Tokens Cached: {entry.tokens_cached:,}" - f"\n Tokens Uncached: {entry.tokens_uncached:,}" - f"\n Token Cache Rate: {entry.token_cache_rate:.1f}%" - ) - - lines.append("=" * 60) - return "\n".join(lines) - - def reset(self) -> None: - """Reset all metrics.""" - self._metrics.clear() - - -# Global cache metrics instance -_cache_metrics: Optional[CacheMetrics] = None - - -def get_cache_metrics() -> CacheMetrics: - """Get the global cache metrics instance.""" - global _cache_metrics - if _cache_metrics is None: - _cache_metrics = CacheMetrics() - return _cache_metrics - - -# ─────────────────────────── BytePlus Constants ─────────────────────────── -# Maximum input length for BytePlus API (in tokens) -BYTEPLUS_MAX_INPUT_TOKENS = 229376 - - -class BytePlusContextOverflowError(Exception): - """Raised when BytePlus API rejects input due to context length exceeding maximum.""" - - pass - - -# ─────────────────────────── BytePlus Cache Manager ─────────────────────────── -class BytePlusCacheManager: - """Manages both prefix and session caches for BytePlus Responses API. - - Uses the Responses API with `previous_response_id` chaining instead of - the Context API. This approach is recommended by BytePlus for better - cache control and reliability. - - Prefix Cache: - - For independent calls (event summarization, triggers, etc.) - - Static system prompt cached, varying user prompts - - Keyed by hash of system prompt - - First request: caching={"type": "enabled", "prefix": True} - - Subsequent requests: previous_response_id + caching={"type": "disabled"} - (prefix stays static, not updated with new responses) - - Session Cache: - - For task/GUI calls where context APPENDS over time - - Context grows with each call (multi-turn-like) - - Keyed by composite key: task_id:call_type - - Each call type (reasoning, action_selection, etc.) gets its own session - - First request: caching={"type": "enabled", "prefix": True} - - Subsequent requests: previous_response_id + caching={"type": "enabled"} - (context continues to grow with each response) - """ - - def __init__(self, api_key: str, base_url: str, model: str) -> None: - self.api_key = api_key - self.base_url = base_url - self.model = model - # Prefix cache: prompt_hash -> response_id (for independent calls) - # Stores the initial response_id to chain subsequent requests - self._prefix_cache_registry: Dict[str, str] = {} - # Session cache: "task_id:call_type" -> response_id (for task/GUI calls) - # Each call type within a task gets its own session cache - # The response_id is updated after each call to maintain the chain - self._session_cache_registry: Dict[str, str] = {} - # Use shared cache configuration - self._config = get_cache_config() - - # ─────────────────── Session Key Helper ─────────────────── - - def _make_session_key(self, task_id: str, call_type: str) -> str: - """Create composite key for session cache: task_id:call_type""" - return f"{task_id}:{call_type}" - - # ─────────────────── Responses API Call ─────────────────── - - def _call_responses_api( - self, - input_messages: List[Dict[str, str]], - temperature: float, - max_tokens: int, - previous_response_id: Optional[str] = None, - caching_enabled: bool = True, - caching_prefix: bool = False, - json_mode: bool = False, - ) -> Dict[str, Any]: - """Make a request to BytePlus Responses API. - - Args: - input_messages: List of message dicts with "role" and "content". - temperature: Sampling temperature. - max_tokens: Maximum tokens in response. - previous_response_id: ID of previous response to chain from (for caching). - caching_enabled: Whether to enable caching for this request. - caching_prefix: Whether this is a prefix cache (True) or session cache (False). - json_mode: Whether to enforce JSON output format. - - Returns: - Raw response dict from the API including 'id' and 'output'. - - Raises: - requests.HTTPError: If the API call fails. - """ - url = f"{self.base_url.rstrip('/')}/responses" - payload: Dict[str, Any] = { - "model": self.model, - "input": input_messages, - "temperature": temperature, - } - - # Enable JSON mode if requested - if json_mode: - payload["text"] = {"format": {"type": "json_object"}} - - # IMPORTANT: max_output_tokens is NOT supported when caching.prefix is set - # Only add max_output_tokens when NOT using prefix caching - if not caching_prefix: - payload["max_output_tokens"] = max_tokens - - # Add previous_response_id if chaining from cached context - if previous_response_id: - payload["previous_response_id"] = previous_response_id - - # Add caching configuration - caching_config: Dict[str, Any] = { - "type": "enabled" if caching_enabled else "disabled", - } - if caching_prefix: - caching_config["prefix"] = True - payload["caching"] = caching_config - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - - # Log the request - logger.info(f"[BYTEPLUS REQUEST] URL: {url}") - logger.info( - f"[BYTEPLUS REQUEST] Payload: {self._sanitize_payload_for_logging(payload)}" - ) - - response = requests.post(url, json=payload, headers=headers, timeout=600) - - # Log the response status - logger.info(f"[BYTEPLUS RESPONSE] Status: {response.status_code}") - - # Try to log response body even on error - try: - response_json = response.json() - logger.info(f"[BYTEPLUS RESPONSE] Body: {response_json}") - except Exception as json_err: - logger.warning(f"[BYTEPLUS RESPONSE] Failed to parse JSON: {json_err}") - logger.info( - f"[BYTEPLUS RESPONSE] Raw text: {response.text[:1000]}" - ) # First 1000 chars - response.raise_for_status() - return {} - - # Check for context overflow error before raising status - if response.status_code == 400: - error_info = response_json.get("error", {}) - error_message = error_info.get("message", "") - # Detect "Input length X exceeds the maximum length Y" error - if "exceeds the maximum length" in error_message: - logger.warning(f"[BYTEPLUS] Context overflow detected: {error_message}") - raise BytePlusContextOverflowError(error_message) - - response.raise_for_status() - return response_json - - def _sanitize_payload_for_logging(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """Sanitize payload for logging by truncating long content.""" - sanitized = {} - for key, value in payload.items(): - if key == "input": - # Truncate message content for readability - sanitized[key] = [] - for msg in value: - truncated_msg = { - "role": msg.get("role"), - "content": msg.get("content", "")[:200] + "..." - if len(msg.get("content", "")) > 200 - else msg.get("content", ""), - } - sanitized[key].append(truncated_msg) - else: - sanitized[key] = value - return sanitized - - # ─────────────────── Prefix Cache Methods ─────────────────── - - def get_or_create_prefix_cache( - self, - system_prompt: str, - user_prompt: str, - temperature: float, - max_tokens: int, - call_type: Optional[str] = None, - ) -> Dict[str, Any]: - """Get response using prefix cache, creating cache on first call. - - For prefix cache, the system prompt is cached and reused. - On the first call, we use caching={"type": "enabled"} (without prefix flag) - to get a response AND enable automatic caching. - On subsequent calls, we use previous_response_id with caching={"type": "disabled"} - to use the cached prefix without growing the context. - - IMPORTANT: Do NOT use caching.prefix=True on first call - that tells BytePlus - to ONLY create a cache without generating output (output_tokens=0). - - Args: - system_prompt: The static system prompt to cache. - user_prompt: The user prompt for this request. - temperature: Sampling temperature. - max_tokens: Maximum tokens in response. - call_type: Type of LLM call (e.g., "reasoning"). Used to enable JSON mode. - - Returns: - Response dict with 'id', 'output', 'usage', etc. - """ - prompt_hash = hashlib.sha256(system_prompt.encode()).hexdigest()[:16] - - # Always enable JSON mode for all calls - json_mode = True - - if prompt_hash in self._prefix_cache_registry: - # Use existing prefix cache - chain from stored response_id - # Use caching disabled since prefix should stay static - logger.debug(f"[CACHE] Using prefix cache for hash {prompt_hash}") - response_id = self._prefix_cache_registry[prompt_hash] - return self._call_responses_api( - input_messages=[{"role": "user", "content": user_prompt}], - temperature=temperature, - max_tokens=max_tokens, - previous_response_id=response_id, - caching_enabled=False, # Don't update the cache, just use it - caching_prefix=False, - json_mode=json_mode, - ) - - # First call - use regular caching (NOT prefix=True which returns no output) - logger.info(f"[CACHE] Creating prefix cache for hash {prompt_hash}") - result = self._call_responses_api( - input_messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - temperature=temperature, - max_tokens=max_tokens, - previous_response_id=None, - caching_enabled=True, # Enable caching, response will be cached automatically - caching_prefix=False, # Do NOT use prefix=True - it returns no output! - json_mode=json_mode, - ) - - # Store the response_id for future requests - response_id = result.get("id") - if response_id: - self._prefix_cache_registry[prompt_hash] = response_id - logger.info( - f"[CACHE] Created prefix cache {response_id} for hash {prompt_hash}" - ) - - return result - - def invalidate_prefix_cache(self, system_prompt: str) -> None: - """Remove prefix cache entry (e.g., when cache expired).""" - prompt_hash = hashlib.sha256(system_prompt.encode()).hexdigest()[:16] - removed = self._prefix_cache_registry.pop(prompt_hash, None) - if removed: - logger.info( - f"[CACHE] Invalidated prefix cache {removed} for hash {prompt_hash}" - ) - - # ─────────────────── Session Cache Methods ─────────────────── - - def create_session_cache( - self, - task_id: str, - call_type: str, - system_prompt: str, - user_prompt: str, - temperature: float, - max_tokens: int, - ) -> Dict[str, Any]: - """Create a new session cache for a specific call type within a task. - - Called on first LLM call for this task/call_type combination. - The cache will accumulate context as the task progresses. - Each call type (reasoning, action_selection, etc.) gets its own session cache. - - IMPORTANT: Do NOT use caching.prefix=True on first call - that tells BytePlus - to ONLY create a cache without generating output (output_tokens=0). - - Args: - task_id: Unique identifier for the task. - call_type: Type of LLM call (e.g., "reasoning", "action_selection"). - system_prompt: Initial system prompt for the session. - user_prompt: The user prompt for this first request. - temperature: Sampling temperature. - max_tokens: Maximum tokens in response. - - Returns: - Response dict with 'id', 'output', 'usage', etc. - """ - session_key = self._make_session_key(task_id, call_type) - if session_key in self._session_cache_registry: - logger.warning( - f"[CACHE] Session cache already exists for {session_key}, using existing" - ) - return self.chat_with_session( - task_id, call_type, user_prompt, temperature, max_tokens - ) - - logger.info(f"[CACHE] Creating session cache for {session_key}") - result = self._call_responses_api( - input_messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - temperature=temperature, - max_tokens=max_tokens, - previous_response_id=None, - caching_enabled=True, # Enable caching, response will be cached automatically - caching_prefix=False, # Do NOT use prefix=True - it returns no output! - ) - - # Store the response_id for session chaining - response_id = result.get("id") - if response_id: - self._session_cache_registry[session_key] = response_id - logger.info( - f"[CACHE] Created session cache {response_id} for {session_key}" - ) - - return result - - def chat_with_session( - self, - task_id: str, - call_type: str, - user_prompt: str, - temperature: float, - max_tokens: int, - ) -> Dict[str, Any]: - """Send a message using existing session cache. - - The context grows with each call as we chain responses. - The response_id is updated after each call to maintain the growing context. - - Args: - task_id: Unique identifier for the task. - call_type: Type of LLM call (e.g., "reasoning", "action_selection"). - user_prompt: The user prompt to send. - temperature: Sampling temperature. - max_tokens: Maximum tokens in response. - - Returns: - Response dict with 'id', 'output', 'usage', etc. - - Raises: - ValueError: If no session cache exists for the given task/call_type. - """ - session_key = self._make_session_key(task_id, call_type) - previous_response_id = self._session_cache_registry.get(session_key) - - if not previous_response_id: - raise ValueError(f"No session cache found for {session_key}") - - logger.debug(f"[CACHE] Using session cache for {session_key}") - result = self._call_responses_api( - input_messages=[{"role": "user", "content": user_prompt}], - temperature=temperature, - max_tokens=max_tokens, - previous_response_id=previous_response_id, - caching_enabled=True, # Keep caching enabled to grow context - caching_prefix=False, - ) - - # Update the stored response_id to chain the next request - new_response_id = result.get("id") - if new_response_id: - self._session_cache_registry[session_key] = new_response_id - logger.debug( - f"[CACHE] Updated session cache for {session_key}: {new_response_id}" - ) - - return result - - def get_session_cache(self, task_id: str, call_type: str) -> Optional[str]: - """Get the session response_id for a task and call type, if it exists.""" - session_key = self._make_session_key(task_id, call_type) - return self._session_cache_registry.get(session_key) - - def end_session(self, task_id: str, call_type: str) -> None: - """Clean up session cache for a specific call type when task ends.""" - session_key = self._make_session_key(task_id, call_type) - response_id = self._session_cache_registry.pop(session_key, None) - if response_id: - logger.info(f"[CACHE] Ended session cache {response_id} for {session_key}") - - def end_all_sessions_for_task(self, task_id: str) -> None: - """Clean up ALL session caches for a task (all call types).""" - keys_to_remove = [ - k for k in self._session_cache_registry if k.startswith(f"{task_id}:") - ] - for key in keys_to_remove: - response_id = self._session_cache_registry.pop(key, None) - if response_id: - logger.info(f"[CACHE] Ended session cache {response_id} for {key}") - - def has_session(self, task_id: str, call_type: str) -> bool: - """Check if a session cache exists for the given task and call type.""" - session_key = self._make_session_key(task_id, call_type) - return session_key in self._session_cache_registry - - -# ─────────────────────────── Gemini Cache Manager ─────────────────────────── -class GeminiCacheManager: - """Manages explicit caches for Gemini API. - - Unlike BytePlus which supports session-based caching with growing context, - Gemini uses explicit cache objects that store system prompts. Each cache - has a TTL and can be referenced in subsequent requests. - - This manager provides: - - Prefix caching per call type (reasoning, action_selection, etc.) - - Each call type's system prompt is cached separately - - Caches are keyed by hash of system prompt + call type - - Usage: - manager = GeminiCacheManager(gemini_client, model) - # First call creates the cache - result = manager.get_or_create_cache(system_prompt, user_prompt, "reasoning", ...) - # Subsequent calls with same system prompt use the cache - result = manager.get_or_create_cache(system_prompt, user_prompt, "reasoning", ...) - """ - - def __init__(self, gemini_client: "GeminiClient", model: str) -> None: - self._client = gemini_client - self._model = model - # Cache registry: "call_type:prompt_hash" -> cache_name - self._cache_registry: Dict[str, str] = {} - # Track cache creation time for TTL management - self._cache_created_at: Dict[str, float] = {} - self._config = get_cache_config() - - def _make_cache_key(self, system_prompt: str, call_type: str) -> str: - """Create a unique key for the cache based on system prompt and call type.""" - prompt_hash = hashlib.sha256(system_prompt.encode()).hexdigest()[:16] - return f"{call_type}:{prompt_hash}" - - def get_or_create_cache( - self, - system_prompt: str, - user_prompt: str, - call_type: str, - temperature: float, - max_tokens: int, - ) -> Dict[str, Any]: - """Get response using explicit cache, creating cache if needed. - - Args: - system_prompt: The system prompt to cache. - user_prompt: The user prompt for this request. - call_type: Type of LLM call (e.g., "reasoning", "action_selection"). - temperature: Sampling temperature. - max_tokens: Maximum output tokens. - - Returns: - Response dict with tokens_used, content, cached_tokens, etc. - """ - import time - - cache_key = self._make_cache_key(system_prompt, call_type) - - # Always enable JSON mode for all calls - json_mode = True - - # Check if we have an existing cache - if cache_key in self._cache_registry: - cache_name = self._cache_registry[cache_key] - # Check if cache might have expired (TTL is typically 1 hour) - created_at = self._cache_created_at.get(cache_key, 0) - if ( - time.time() - created_at < self._config.prefix_cache_ttl - 60 - ): # 60s buffer - try: - logger.debug( - f"[GEMINI CACHE] Using existing cache {cache_name} for {cache_key}" - ) - return self._client.generate_text_with_cache( - self._model, - cache_name=cache_name, - prompt=user_prompt, - temperature=temperature, - max_output_tokens=max_tokens, - json_mode=json_mode, - ) - except Exception as e: - logger.warning( - f"[GEMINI CACHE] Cache {cache_name} failed, recreating: {e}" - ) - # Cache might have expired or been deleted, remove from registry - self._cache_registry.pop(cache_key, None) - self._cache_created_at.pop(cache_key, None) - - # Create new cache - try: - logger.info(f"[GEMINI CACHE] Creating new cache for {cache_key}") - cache_result = self._client.create_cache( - self._model, - system_prompt=system_prompt, - display_name=f"agent_{call_type}_{hashlib.sha256(system_prompt.encode()).hexdigest()[:8]}", - ttl_seconds=self._config.prefix_cache_ttl, - ) - cache_name = cache_result.get("name") - if cache_name: - self._cache_registry[cache_key] = cache_name - self._cache_created_at[cache_key] = time.time() - logger.info( - f"[GEMINI CACHE] Created cache {cache_name} for {cache_key}" - ) - - # Now generate using the cache - return self._client.generate_text_with_cache( - self._model, - cache_name=cache_name, - prompt=user_prompt, - temperature=temperature, - max_output_tokens=max_tokens, - json_mode=json_mode, - ) - except Exception as e: - logger.warning( - f"[GEMINI CACHE] Failed to create cache for {cache_key}: {e}" - ) - # Fall back to non-cached generation - pass - - # Fallback: generate without cache - logger.debug( - f"[GEMINI CACHE] Falling back to non-cached generation for {cache_key}" - ) - return self._client.generate_text( - self._model, - prompt=user_prompt, - system_prompt=system_prompt, - temperature=temperature, - max_output_tokens=max_tokens, - json_mode=json_mode, - ) - - def invalidate_cache(self, system_prompt: str, call_type: str) -> None: - """Remove a cache entry and optionally delete from Gemini.""" - cache_key = self._make_cache_key(system_prompt, call_type) - cache_name = self._cache_registry.pop(cache_key, None) - self._cache_created_at.pop(cache_key, None) - if cache_name: - try: - self._client.delete_cache(cache_name) - logger.info( - f"[GEMINI CACHE] Deleted cache {cache_name} for {cache_key}" - ) - except Exception as e: - logger.warning( - f"[GEMINI CACHE] Failed to delete cache {cache_name}: {e}" - ) - - def invalidate_all_caches_for_call_type(self, call_type: str) -> None: - """Remove all caches for a specific call type.""" - keys_to_remove = [ - k for k in self._cache_registry if k.startswith(f"{call_type}:") - ] - for key in keys_to_remove: - cache_name = self._cache_registry.pop(key, None) - self._cache_created_at.pop(key, None) - if cache_name: - try: - self._client.delete_cache(cache_name) - logger.info(f"[GEMINI CACHE] Deleted cache {cache_name} for {key}") - except Exception: - pass # Best effort cleanup - - def cleanup_expired_caches(self) -> None: - """Clean up caches that may have expired.""" - import time - - current_time = time.time() - keys_to_remove = [] - for key, created_at in self._cache_created_at.items(): - if current_time - created_at >= self._config.prefix_cache_ttl: - keys_to_remove.append(key) - - for key in keys_to_remove: - cache_name = self._cache_registry.pop(key, None) - self._cache_created_at.pop(key, None) - if cache_name: - try: - self._client.delete_cache(cache_name) - except Exception: - pass # Best effort cleanup - - -class LLMInterface: - """Simple wrapper to interact with multiple Large-Language-Model back-ends. - - Supported providers - ------------------- - * ``openai`` – OpenAI Chat Completions API - * ``remote`` – Local Ollama HTTP endpoint (``/api/generate``) - * ``gemini`` – Google Generative AI (Gemini) API - * ``byteplus`` – BytePlus ModelArk Chat Completions API - * ``anthropic`` – Anthropic Claude API - """ - - _CODE_BLOCK_RE = re.compile(r"^```(?:\w+)?\s*|\s*```$", re.MULTILINE) - - def __init__( - self, - *, - provider: Optional[str] = None, - model: Optional[str] = None, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - db_interface: Optional[Any] = None, - temperature: float = 0.0, - max_tokens: int = 8000, - deferred: bool = False, - ) -> None: - self.db_interface = db_interface - self.temperature = temperature - self.max_tokens = max_tokens - self._gemini_client: GeminiClient | None = None - self._anthropic_client = None - self._initialized = False - self._deferred = deferred - - # Store for reinitialization - self._init_api_key = api_key - self._init_base_url = base_url - - ctx = ModelFactory.create( - provider=provider, - interface=InterfaceType.LLM, - model_override=model, - api_key=api_key, - base_url=base_url, - deferred=deferred, - ) - - logger.info(f"[LLM FACTORY] {ctx}") - - self.provider = ctx["provider"] - self.model = ctx["model"] - self.client = ctx["client"] - self._gemini_client = ctx["gemini_client"] - self.remote_url = ctx["remote_url"] - self._anthropic_client = ctx["anthropic_client"] - self._initialized = ctx.get("initialized", False) - - # Initialize BytePlus-specific attributes - self._byteplus_cache_manager: Optional[BytePlusCacheManager] = None - # Store system prompts for lazy session creation (instance variable) - self._session_system_prompts: Dict[str, str] = {} - # Anthropic multi-turn session message history for KV cache accumulation - self._anthropic_session_messages: Dict[str, List[dict]] = {} - - if ctx["byteplus"]: - self.api_key = ctx["byteplus"]["api_key"] - self.byteplus_base_url = ctx["byteplus"]["base_url"] - # Initialize cache manager for BytePlus (caching always enabled) - self._byteplus_cache_manager = BytePlusCacheManager( - api_key=self.api_key, - base_url=self.byteplus_base_url, - model=self.model, - ) - - # Initialize Gemini-specific attributes - self._gemini_cache_manager: Optional[GeminiCacheManager] = None - if self._gemini_client: - self._gemini_cache_manager = GeminiCacheManager( - gemini_client=self._gemini_client, - model=self.model, - ) - - @property - def is_initialized(self) -> bool: - """Check if the LLM client is properly initialized.""" - return self._initialized - - def reinitialize( - self, - provider: Optional[str] = None, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - ) -> bool: - """Reinitialize the LLM client with new settings. - - Args: - provider: Optional provider override. If None, uses current provider. - api_key: Optional API key. If None, uses stored key. - base_url: Optional base URL. If None, uses stored URL. - - Returns: - True if initialization was successful, False otherwise. - """ - from app.config import ( - get_api_key as _get_api_key, - get_base_url as _get_base_url, - get_llm_model as _get_llm_model, - ) - - target_provider = provider or self.provider - target_api_key = ( - api_key if api_key is not None else _get_api_key(target_provider) - ) - target_base_url = ( - base_url if base_url is not None else _get_base_url(target_provider) - ) - target_model = _get_llm_model() # None means use registry default - - try: - logger.info( - f"[LLM] Reinitializing with provider: {target_provider}, model: {target_model or 'registry default'}" - ) - ctx = ModelFactory.create( - provider=target_provider, - interface=InterfaceType.LLM, - model_override=target_model, - api_key=target_api_key, - base_url=target_base_url, - deferred=False, - ) - - self.provider = ctx["provider"] - self.model = ctx["model"] - self.client = ctx["client"] - self._gemini_client = ctx["gemini_client"] - self.remote_url = ctx["remote_url"] - self._anthropic_client = ctx["anthropic_client"] - self._initialized = ctx.get("initialized", False) - - if ctx["byteplus"]: - self.api_key = ctx["byteplus"]["api_key"] - self.byteplus_base_url = ctx["byteplus"]["base_url"] - # Reinitialize cache manager for BytePlus - self._byteplus_cache_manager = BytePlusCacheManager( - api_key=self.api_key, - base_url=self.byteplus_base_url, - model=self.model, - ) - # Reset session system prompts and Anthropic message history - self._session_system_prompts = {} - self._anthropic_session_messages = {} - else: - self._byteplus_cache_manager = None - self._session_system_prompts = {} - self._anthropic_session_messages = {} - - # Reinitialize Gemini cache manager - if self._gemini_client: - self._gemini_cache_manager = GeminiCacheManager( - gemini_client=self._gemini_client, - model=self.model, - ) - else: - self._gemini_cache_manager = None - - logger.info( - f"[LLM] Reinitialized successfully with provider: {self.provider}, model: {self.model}" - ) - return self._initialized - except EnvironmentError as e: - logger.warning(f"[LLM] Failed to reinitialize - missing API key: {e}") - return False - except Exception as e: - logger.error( - f"[LLM] Failed to reinitialize - unexpected error: {e}", exc_info=True - ) - return False - - # ─────────────────────────── Public helpers ──────────────────────────── - def _generate_response_sync( - self, - system_prompt: Optional[str] = None, - user_prompt: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Synchronous implementation shared by sync/async entry points.""" - if user_prompt is None: - raise ValueError("`user_prompt` cannot be None.") - - if log_response: - logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") - - # Slow mode: throttle before making the API call - from app.config import is_slow_mode_enabled - - _slow_mode_active = is_slow_mode_enabled() - if _slow_mode_active: - from agent_core.utils.token import count_tokens - from app.rate_limiter import get_rate_limiter - - estimated = count_tokens(system_prompt or "") + count_tokens(user_prompt) - get_rate_limiter().wait_if_needed(estimated) - - if self.provider == "openai": - response = self._generate_openai(system_prompt, user_prompt) - elif self.provider == "remote": - response = self._generate_ollama(system_prompt, user_prompt) - elif self.provider == "gemini": - response = self._generate_gemini(system_prompt, user_prompt) - elif self.provider == "byteplus": - response = self._generate_byteplus(system_prompt, user_prompt) - elif self.provider == "anthropic": - response = self._generate_anthropic(system_prompt, user_prompt) - else: # pragma: no cover - raise RuntimeError(f"Unknown provider {self.provider!r}") - - cleaned = re.sub(self._CODE_BLOCK_RE, "", response.get("content", "").strip()) - - tokens_used = response.get("tokens_used", 0) - _props = get_session_props() - _props.set_property( - "token_count", - _props.get_property("token_count", 0) + _billable_tokens(response), - ) - - if _slow_mode_active and tokens_used > 0: - from app.rate_limiter import get_rate_limiter - - get_rate_limiter().record_usage(tokens_used) - - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned - - @profile("llm_generate_response", OperationCategory.LLM) - def generate_response( - self, - system_prompt: Optional[str] = None, - user_prompt: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Generate a single response from the configured provider.""" - return self._generate_response_sync(system_prompt, user_prompt, log_response) - - @profile("llm_generate_response_async", OperationCategory.LLM) - async def generate_response_async( - self, - system_prompt: Optional[str] = None, - user_prompt: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Async wrapper that defers the blocking call to a worker thread.""" - return await asyncio.to_thread( - self._generate_response_sync, - system_prompt, - user_prompt, - log_response, - ) - - # ─────────────────── Session/Explicit Cache Methods ─────────────────── - - def create_session_cache( - self, task_id: str, call_type: str, system_prompt: str - ) -> Optional[str]: - """Register a session/cache for a specific call type within a task. - - Supports multiple providers: - - BytePlus: Uses session caching with Responses API - - Gemini: Uses explicit caching with per-call-type caches - - The actual cache is created lazily on the first LLM call. - This method stores the system prompt for later use. - - Should be called at task start. Each call type gets its own cache. - - Args: - task_id: Unique identifier for the task. - call_type: Type of LLM call (use LLMCallType enum values). - system_prompt: Initial system prompt for the session. - - Returns: - A placeholder ID if successful, None if caching not available. - """ - # Check if caching is supported for this provider - supports_caching = ( - (self.provider == "byteplus" and self._byteplus_cache_manager) - or (self.provider == "gemini" and self._gemini_cache_manager) - or ( - self.provider == "openai" and self.client - ) # OpenAI uses automatic caching with prompt_cache_key - or ( - self.provider == "anthropic" and self._anthropic_client - ) # Anthropic uses ephemeral caching with extended TTL - ) - - if not supports_caching: - logger.debug( - f"[SESSION] Session cache not available for provider: {self.provider}" - ) - return None - - # Store system prompt for lazy session/cache creation - session_key = f"{task_id}:{call_type}" - self._session_system_prompts[session_key] = system_prompt - logger.info( - f"[SESSION] Registered session for {session_key} (provider: {self.provider})" - ) - return session_key # Return placeholder ID - - def get_session_system_prompt(self, task_id: str, call_type: str) -> Optional[str]: - """Get the stored system prompt for a session. - - Args: - task_id: The task ID. - call_type: Type of LLM call. - - Returns: - The system prompt if registered, None otherwise. - """ - session_key = f"{task_id}:{call_type}" - return self._session_system_prompts.get(session_key) - - def end_session_cache(self, task_id: str, call_type: str) -> None: - """End a session/explicit cache for a specific call type. - - Should be called at task end to clean up resources. - - Args: - task_id: The task ID. - call_type: Type of LLM call (use LLMCallType enum values). - """ - # Clean up stored system prompt and Anthropic message history - session_key = f"{task_id}:{call_type}" - system_prompt = self._session_system_prompts.pop(session_key, None) - self._anthropic_session_messages.pop(session_key, None) - - # Clean up provider-specific caches - if self.provider == "byteplus" and self._byteplus_cache_manager: - self._byteplus_cache_manager.end_session(task_id, call_type) - elif self.provider == "gemini" and self._gemini_cache_manager and system_prompt: - # Invalidate the explicit cache for this system prompt + call_type - self._gemini_cache_manager.invalidate_cache(system_prompt, call_type) - - def end_all_session_caches(self, task_id: str) -> None: - """End ALL session/explicit caches for a task (all call types). - - Convenience method to clean up all caches when a task ends. - - Args: - task_id: The task whose sessions should be ended. - """ - # Get all system prompts for this task before removing - keys_to_remove = [ - k for k in self._session_system_prompts if k.startswith(f"{task_id}:") - ] - prompts_and_types = [] - for key in keys_to_remove: - system_prompt = self._session_system_prompts.pop(key, None) - if system_prompt: - # Extract call_type from key (format: "task_id:call_type") - call_type = key.split(":", 1)[1] if ":" in key else None - if call_type: - prompts_and_types.append((system_prompt, call_type)) - - # Clean up Anthropic multi-turn message history - anthropic_keys = [ - k for k in self._anthropic_session_messages if k.startswith(f"{task_id}:") - ] - for key in anthropic_keys: - self._anthropic_session_messages.pop(key, None) - - # Clean up provider-specific caches - if self.provider == "byteplus" and self._byteplus_cache_manager: - self._byteplus_cache_manager.end_all_sessions_for_task(task_id) - elif self.provider == "gemini" and self._gemini_cache_manager: - # Invalidate all explicit caches for this task's prompts - for system_prompt, call_type in prompts_and_types: - self._gemini_cache_manager.invalidate_cache(system_prompt, call_type) - - def has_session_cache(self, task_id: str, call_type: str) -> bool: - """Check if a session/explicit cache is available for the given task and call type. - - Returns True if: - - An actual session cache exists (created on previous calls), OR - - A session has been registered (system prompt stored for lazy creation) - - Supports: - - BytePlus: Session caching with previous_response_id - - Gemini: Explicit caching with per-call-type caches - - This allows callers to use session-based generation even on the first call, - as the session will be created lazily when needed. - """ - session_key = f"{task_id}:{call_type}" - - # Check if system prompt is registered (works for all providers) - if session_key in self._session_system_prompts: - # Also verify the provider supports caching - if self.provider == "byteplus" and self._byteplus_cache_manager: - return True - if self.provider == "gemini" and self._gemini_cache_manager: - return True - if self.provider == "openai" and self.client: - return True - if self.provider == "anthropic" and self._anthropic_client: - return True - - # Check provider-specific actual session existence - if self.provider == "byteplus" and self._byteplus_cache_manager: - return self._byteplus_cache_manager.has_session(task_id, call_type) - - return False - - def get_cache_stats(self) -> str: - """Get a summary of cache metrics for all providers. - - Returns a formatted string with cache hit rates, token savings, etc. - Useful for validating cache effectiveness. - - Example output: - ============================================================ - CACHE METRICS SUMMARY - ============================================================ - - BYTEPLUS: - prefix: - Calls: 10 (hits=8, misses=2) - Hit Rate: 80.0% - Tokens Cached: 5000 - Tokens Uncached: 1200 - Token Cache Rate: 80.6% - session: - Calls: 25 (hits=22, misses=3) - Hit Rate: 88.0% - ... - ============================================================ - """ - return get_cache_metrics().get_summary() - - def reset_cache_stats(self) -> None: - """Reset all cache metrics to zero. - - Useful for starting a new measurement period. - """ - get_cache_metrics().reset() - logger.info("[CACHE] Cache metrics reset") - - def _generate_response_with_session_sync( - self, - task_id: str, - call_type: str, - user_prompt: str, - system_prompt_for_new_session: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Generate response using session/explicit cache for the given task and call type. - - Supports multiple providers: - - BytePlus: Uses session caching with previous_response_id chaining - - Gemini: Uses explicit caching with separate caches per call_type - - Others: Falls back to standard generation - - If no session exists and system_prompt_for_new_session is provided, - creates a new session cache first. Each call type gets its own session. - - Args: - task_id: The task ID to use for session cache. - call_type: Type of LLM call (use LLMCallType enum values). - user_prompt: The user prompt to send. - system_prompt_for_new_session: System prompt to use if creating new session. - log_response: Whether to log the response. - - Returns: - The cleaned response content. - """ - if user_prompt is None: - raise ValueError("`user_prompt` cannot be None.") - - if log_response: - logger.info( - f"[LLM SESSION] task={task_id} call_type={call_type} | user={user_prompt}" - ) - - # Slow mode: throttle before making the API call - from app.config import is_slow_mode_enabled - - _slow_mode_active = is_slow_mode_enabled() - if _slow_mode_active: - from agent_core.utils.token import count_tokens - from app.rate_limiter import get_rate_limiter - - estimated = count_tokens(user_prompt) - get_rate_limiter().wait_if_needed(estimated) - - # Handle Gemini with explicit caching (per call_type) - if self.provider == "gemini" and self._gemini_cache_manager: - # Get stored system prompt or use provided one - session_key = f"{task_id}:{call_type}" - stored_system_prompt = self._session_system_prompts.get(session_key) - effective_system_prompt = ( - system_prompt_for_new_session or stored_system_prompt - ) - - if not effective_system_prompt: - raise ValueError(f"No system prompt for task {task_id}:{call_type}") - - # Use Gemini with explicit caching (call_type passed for cache keying) - response = self._generate_gemini( - effective_system_prompt, user_prompt, call_type=call_type - ) - cleaned = re.sub( - self._CODE_BLOCK_RE, "", response.get("content", "").strip() - ) - _tokens_used = response.get("tokens_used", 0) - _props = get_session_props(task_id) - _props.set_property( - "token_count", - _props.get_property("token_count", 0) + _billable_tokens(response), - ) - if _slow_mode_active and _tokens_used > 0: - from app.rate_limiter import get_rate_limiter - - get_rate_limiter().record_usage(_tokens_used) - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned - - # Handle OpenAI with call_type-based cache routing - if self.provider == "openai": - # Get stored system prompt or use provided one - session_key = f"{task_id}:{call_type}" - stored_system_prompt = self._session_system_prompts.get(session_key) - effective_system_prompt = ( - system_prompt_for_new_session or stored_system_prompt - ) - - if not effective_system_prompt: - raise ValueError(f"No system prompt for task {task_id}:{call_type}") - - # Use OpenAI with call_type for better cache routing via prompt_cache_key - response = self._generate_openai( - effective_system_prompt, user_prompt, call_type=call_type - ) - cleaned = re.sub( - self._CODE_BLOCK_RE, "", response.get("content", "").strip() - ) - _tokens_used = response.get("tokens_used", 0) - _props = get_session_props(task_id) - _props.set_property( - "token_count", - _props.get_property("token_count", 0) + _billable_tokens(response), - ) - if _slow_mode_active and _tokens_used > 0: - from app.rate_limiter import get_rate_limiter - - get_rate_limiter().record_usage(_tokens_used) - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned - - # Handle Anthropic with multi-turn KV caching - if self.provider == "anthropic" and self._anthropic_client: - session_key = f"{task_id}:{call_type}" - stored_system_prompt = self._session_system_prompts.get(session_key) - effective_system_prompt = ( - system_prompt_for_new_session or stored_system_prompt - ) - - if not effective_system_prompt: - raise ValueError(f"No system prompt for task {task_id}:{call_type}") - - # Get or initialize multi-turn message history - if session_key not in self._anthropic_session_messages: - self._anthropic_session_messages[session_key] = [] - - history = self._anthropic_session_messages[session_key] - - # Build messages: history (with cache_control on last assistant) + new user msg - messages: List[dict] = [] - - # Copy history messages (strip old cache_control, we'll re-place it) - for msg in history: - msg_copy = {"role": msg["role"]} - content = msg["content"] - if isinstance(content, list): - # Strip cache_control from content blocks - msg_copy["content"] = [ - {k: v for k, v in block.items() if k != "cache_control"} - for block in content - ] - else: - msg_copy["content"] = content - messages.append(msg_copy) - - # Place cache_control on the LAST assistant message for prefix caching - if messages: - cache_control = {"type": "ephemeral"} - if call_type: - cache_control["ttl"] = "1h" - for i in range(len(messages) - 1, -1, -1): - if messages[i]["role"] == "assistant": - content = messages[i]["content"] - if isinstance(content, str): - messages[i]["content"] = [ - { - "type": "text", - "text": content, - "cache_control": cache_control, - } - ] - elif isinstance(content, list): - # Add cache_control to the last text block - for j in range(len(content) - 1, -1, -1): - if content[j].get("type") == "text": - content[j]["cache_control"] = cache_control - break - break - - # Append the new user message - messages.append({"role": "user", "content": user_prompt}) - - logger.debug( - f"[ANTHROPIC SESSION] {session_key}: {len(history)} history msgs, " - f"sending {len(messages)} total msgs" - ) - - # Call Anthropic with the full multi-turn messages - # Note: _generate_anthropic adds JSON prefill as the last message automatically - response = self._generate_anthropic( - effective_system_prompt, - user_prompt, - call_type=call_type, - messages=messages, - ) - - # On success, accumulate user message + assistant response in history - # The response content already has '{' prepended from JSON prefill - assistant_content = response.get("content", "") - if assistant_content and "error" not in response: - history.append({"role": "user", "content": user_prompt}) - history.append({"role": "assistant", "content": assistant_content}) - - cleaned = re.sub( - self._CODE_BLOCK_RE, "", response.get("content", "").strip() - ) - _tokens_used = response.get("tokens_used", 0) - _props = get_session_props(task_id) - _props.set_property( - "token_count", - _props.get_property("token_count", 0) + _billable_tokens(response), - ) - if _slow_mode_active and _tokens_used > 0: - from app.rate_limiter import get_rate_limiter - - get_rate_limiter().record_usage(_tokens_used) - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned - - # If not BytePlus (and not Gemini/OpenAI/Anthropic which are handled above), fall back to standard - if self.provider != "byteplus" or not self._byteplus_cache_manager: - return self._generate_response_sync( - system_prompt_for_new_session, user_prompt, log_response=False - ) - - # Use SESSION cache for BytePlus - context grows with each call - # Round 1: system_prompt + static_prompt + event_1 - # Round 2: event_2 (delta only) - # Round 3: event_3 (delta only) - session_key = f"{task_id}:{call_type}" - stored_system_prompt = self._session_system_prompts.get(session_key) - effective_system_prompt = system_prompt_for_new_session or stored_system_prompt - - if not effective_system_prompt: - raise ValueError(f"No system prompt for task {task_id}:{call_type}") - - # Store system prompt for future cache recreation if not stored - if session_key not in self._session_system_prompts: - self._session_system_prompts[session_key] = effective_system_prompt - - try: - # Check if session cache exists - if self._byteplus_cache_manager.has_session(task_id, call_type): - # Session exists - send only the user_prompt (delta events) - logger.info( - f"[SESSION CACHE] Using existing session for {session_key}, sending delta" - ) - result = self._byteplus_cache_manager.chat_with_session( - task_id=task_id, - call_type=call_type, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - response = self._process_session_response( - result, task_id, call_type, is_first_call=False - ) - else: - # No session - create one with full prompt (system + user) - logger.info(f"[SESSION CACHE] Creating new session for {session_key}") - result = self._byteplus_cache_manager.create_session_cache( - task_id=task_id, - call_type=call_type, - system_prompt=effective_system_prompt, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - response = self._process_session_response( - result, task_id, call_type, is_first_call=True - ) - - except BytePlusContextOverflowError: - # Context exceeded maximum length - reset session and retry with fresh context - logger.warning( - f"[SESSION CACHE] Context overflow for {session_key}, resetting session..." - ) - - # End the overflowed session - self._byteplus_cache_manager.end_session(task_id, call_type) - - # Create a fresh session with system prompt and current user prompt - logger.info( - f"[SESSION CACHE] Creating fresh session for {session_key} after overflow" - ) - result = self._byteplus_cache_manager.create_session_cache( - task_id=task_id, - call_type=call_type, - system_prompt=effective_system_prompt, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - response = self._process_session_response( - result, task_id, call_type, is_first_call=True - ) - - except Exception as e: - logger.warning(f"[SESSION CACHE] Failed: {e}, falling back to standard") - return self._generate_response_sync( - effective_system_prompt, user_prompt, log_response=False - ) - - cleaned = re.sub(self._CODE_BLOCK_RE, "", response.get("content", "").strip()) - - _tokens_used = response.get("tokens_used", 0) - _props = get_session_props(task_id) - _props.set_property( - "token_count", - _props.get_property("token_count", 0) + _billable_tokens(response), - ) - if _slow_mode_active and _tokens_used > 0: - from app.rate_limiter import get_rate_limiter - - get_rate_limiter().record_usage(_tokens_used) - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned - - def _process_session_response( - self, - result: Dict[str, Any], - task_id: str, - call_type: str, - is_first_call: bool = False, - ) -> Dict[str, Any]: - """Process response from session cache call and record metrics. - - Args: - result: Raw response from Responses API. - task_id: The task ID. - call_type: Type of LLM call. - is_first_call: Whether this is the first call (session creation). - - Returns: - Processed response dict with 'tokens_used' and 'content'. - """ - session_key = f"{task_id}:{call_type}" - - # Parse content (Responses API format) - content = self._parse_responses_api_content(result) - - # Token usage from Responses API - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - - # Log cache info and record metrics - cached_tokens = usage.get("input_tokens_details", {}).get("cached_tokens", 0) - metrics = get_cache_metrics() - if cached_tokens and cached_tokens > 0: - logger.info( - f"[CACHE] BytePlus session cache hit: {cached_tokens}/{token_count_input} tokens cached" - ) - metrics.record_hit( - "byteplus", - "session", - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - else: - # First call in session or cache miss - metrics.record_miss("byteplus", "session", total_tokens=token_count_input) - - logger.info(f"BYTEPLUS SESSION RESPONSE for {session_key}: {result}") - - self._log_to_db( - f"[SESSION:{session_key}]", - "[session_call]", - content, - "success", - token_count_input, - token_count_output, - ) - - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens or 0, - } - - def _process_prefix_response( - self, result: Dict[str, Any], session_key: str - ) -> Dict[str, Any]: - """Process response from prefix cache call and record metrics. - - Args: - result: Raw response from Responses API. - session_key: The session key for logging. - - Returns: - Processed response dict with 'tokens_used' and 'content'. - """ - # Parse content (Responses API format) - content = self._parse_responses_api_content(result) - - # Token usage from Responses API - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - - # Log cache info and record metrics - cached_tokens = usage.get("input_tokens_details", {}).get("cached_tokens", 0) - metrics = get_cache_metrics() - if cached_tokens and cached_tokens > 0: - logger.info( - f"[CACHE] BytePlus prefix cache hit: {cached_tokens}/{token_count_input} tokens cached" - ) - metrics.record_hit( - "byteplus", - "prefix", - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - else: - # First call or cache miss - metrics.record_miss("byteplus", "prefix", total_tokens=token_count_input) - - logger.info( - f"BYTEPLUS PREFIX RESPONSE for {session_key}: input={token_count_input}, cached={cached_tokens}" - ) - - self._log_to_db( - f"[PREFIX:{session_key}]", - "[prefix_call]", - content, - "success", - token_count_input, - token_count_output, - ) - - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens or 0, - } - - def generate_response_with_session( - self, - task_id: str, - call_type: str, - user_prompt: str, - system_prompt_for_new_session: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Synchronous session-based response generation. - - Args: - task_id: The task ID to use for session cache. - call_type: Type of LLM call (use LLMCallType enum values). - user_prompt: The user prompt to send. - system_prompt_for_new_session: System prompt to use if creating new session. - log_response: Whether to log the response. - """ - return self._generate_response_with_session_sync( - task_id, call_type, user_prompt, system_prompt_for_new_session, log_response - ) - - @profile("llm_generate_response_with_session_async", OperationCategory.LLM) - async def generate_response_with_session_async( - self, - task_id: str, - call_type: str, - user_prompt: str, - system_prompt_for_new_session: Optional[str] = None, - log_response: bool = True, - ) -> str: - """Async wrapper for session-based response generation. - - Args: - task_id: The task ID to use for session cache. - call_type: Type of LLM call (use LLMCallType enum values). - user_prompt: The user prompt to send. - system_prompt_for_new_session: System prompt to use if creating new session. - log_response: Whether to log the response. - """ - return await asyncio.to_thread( - self._generate_response_with_session_sync, - task_id, - call_type, - user_prompt, - system_prompt_for_new_session, - log_response, - ) - - def _generate_byteplus_with_session( - self, task_id: str, call_type: str, user_prompt: str - ) -> Dict[str, Any]: - """Use Responses API with session caching for task/GUI calls. - - The context grows with each call as we chain responses via previous_response_id. - Each call type has its own session to avoid polluting different prompt structures. - - If context overflow is detected, the session is automatically reset and retried - with a fresh session containing only the system prompt and current user prompt. - """ - token_count_input = token_count_output = 0 - total_tokens = 0 - cached_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - session_key = f"{task_id}:{call_type}" - - try: - if not self._byteplus_cache_manager.has_session(task_id, call_type): - raise ValueError(f"No session cache found for {session_key}") - - result = self._byteplus_cache_manager.chat_with_session( - task_id=task_id, - call_type=call_type, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - logger.info(f"BYTEPLUS SESSION RESPONSE: {result}") - - # Parse response (Responses API format) - content = self._parse_responses_api_content(result) - - # Token usage from Responses API - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - - # Log cache info and record metrics - # Responses API uses input_tokens_details instead of prompt_tokens_details - cached_tokens = usage.get("input_tokens_details", {}).get( - "cached_tokens", 0 - ) - metrics = get_cache_metrics() - if cached_tokens and cached_tokens > 0: - logger.info( - f"[CACHE] BytePlus session cache hit: {cached_tokens}/{token_count_input} tokens cached" - ) - metrics.record_hit( - "byteplus", - "session", - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - else: - # First call in session or growing context - metrics.record_miss( - "byteplus", "session", total_tokens=token_count_input - ) - - status = "success" - - except BytePlusContextOverflowError: - # Context exceeded maximum length - reset session and retry with fresh context - logger.warning( - f"[BYTEPLUS] Context overflow for {session_key}, resetting session and retrying..." - ) - - # End the overflowed session - self._byteplus_cache_manager.end_session(task_id, call_type) - - # Get the stored system prompt for this session - system_prompt = self._session_system_prompts.get(session_key) - if not system_prompt: - exc_obj = ValueError( - f"Cannot reset session {session_key}: no system prompt stored" - ) - logger.error(str(exc_obj)) - else: - try: - # Create a fresh session with system prompt and current user prompt - logger.info( - f"[BYTEPLUS] Creating fresh session for {session_key} after overflow" - ) - result = self._byteplus_cache_manager.create_session_cache( - task_id=task_id, - call_type=call_type, - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - logger.info(f"BYTEPLUS SESSION RESPONSE (after reset): {result}") - - # Parse response - content = self._parse_responses_api_content(result) - - # Token usage - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - - # Record as cache miss (fresh session) - metrics = get_cache_metrics() - metrics.record_miss( - "byteplus", "session_reset", total_tokens=token_count_input - ) - - status = "success" - logger.info( - f"[BYTEPLUS] Successfully recovered from context overflow for {session_key}" - ) - - except Exception as retry_exc: - exc_obj = retry_exc - logger.error( - f"Error retrying BytePlus Session API for {session_key} after reset: {retry_exc}" - ) - - except Exception as exc: - exc_obj = exc - logger.error(f"Error calling BytePlus Session API for {session_key}: {exc}") - - self._log_to_db( - f"[SESSION:{session_key}]", # Mark as session call in logs with call_type - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens or 0, - } - - # ───────────────────── Provider‑specific private helpers ───────────────────── - @profile("llm_openai_call", OperationCategory.LLM) - def _generate_openai( - self, - system_prompt: str | None, - user_prompt: str, - call_type: Optional[str] = None, - ) -> Dict[str, Any]: - """Generate response using OpenAI with automatic prompt caching. - - OpenAI's prompt caching is automatic for prompts ≥1024 tokens: - - No code changes required to enable caching - - Cached tokens are returned in usage.prompt_tokens_details.cached_tokens - - 50% discount on cached input tokens - - Cache retention: 5-10 minutes (up to 1 hour during off-peak) - - Using prompt_cache_key influences routing for better cache hit rates - - Args: - system_prompt: The system prompt. - user_prompt: The user prompt for this request. - call_type: Optional call type for cache routing (e.g., "reasoning", "action_selection"). - When provided, generates a prompt_cache_key to improve cache hit rates - when alternating between different call types. - - Cache hits are logged when cached_tokens > 0 in the response. - """ - token_count_input = token_count_output = 0 - cached_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - config = get_cache_config() - cache_type = f"automatic_{call_type}" if call_type else "automatic" - - try: - messages: List[Dict[str, str]] = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": user_prompt}) - - # Build request kwargs - request_kwargs: Dict[str, Any] = { - "model": self.model, - "messages": messages, - "temperature": self.temperature, - "max_tokens": self.max_tokens, - } - - # Always enforce JSON output format - request_kwargs["response_format"] = {"type": "json_object"} - - # Add prompt_cache_key when call_type is provided for better cache routing - # This helps when alternating between different call types (reasoning, action_selection) - if ( - call_type - and system_prompt - and len(system_prompt) >= config.min_cache_tokens - ): - prompt_hash = hashlib.sha256(system_prompt.encode()).hexdigest()[:16] - cache_key = f"{call_type}_{prompt_hash}" - request_kwargs["extra_body"] = {"prompt_cache_key": cache_key} - logger.debug(f"[OPENAI] Using prompt_cache_key: {cache_key}") - - response = self.client.chat.completions.create(**request_kwargs) - content = response.choices[0].message.content.strip() - token_count_input = response.usage.prompt_tokens - token_count_output = response.usage.completion_tokens - - # Extract cached tokens from prompt_tokens_details (OpenAI automatic caching) - # Available for prompts ≥1024 tokens - prompt_tokens_details = getattr( - response.usage, "prompt_tokens_details", None - ) - if prompt_tokens_details: - cached_tokens = getattr(prompt_tokens_details, "cached_tokens", 0) or 0 - - # Record cache metrics - metrics = get_cache_metrics() - if cached_tokens > 0: - logger.info( - f"[CACHE] OpenAI {cache_type} cache hit: {cached_tokens}/{token_count_input} tokens from cache" - ) - metrics.record_hit( - "openai", - cache_type, - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - elif system_prompt and len(system_prompt) >= config.min_cache_tokens: - # Caching should have been attempted (prompt long enough) - # This is a miss - either first call or cache expired - metrics.record_miss( - "openai", cache_type, total_tokens=token_count_input - ) - - status = "success" - except Exception as exc: - exc_obj = exc - logger.error(f"Error calling OpenAI API: {exc}") - - total_tokens = token_count_input + token_count_output - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens, - } - - @profile("llm_ollama_call", OperationCategory.LLM) - def _generate_ollama(self, system_prompt: str | None, user_prompt: str) -> str: - token_count_input = token_count_output = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - - try: - payload = { - "model": self.model, - "system": system_prompt, - "prompt": user_prompt, - "stream": False, - "options": { - "temperature": self.temperature, - }, - } - url: str = f"{self.remote_url.rstrip('/')}/api/generate" - response = requests.post(url, json=payload, timeout=600) - response.raise_for_status() - result = response.json() - - content = result.get("response", "").strip() - total_tokens = result.get("usage", {}).get("total_tokens", 0) - token_count_input = result.get("prompt_eval_count", 0) - token_count_output = result.get("eval_count", 0) - status = "success" - except Exception as exc: - exc_obj = exc - logger.error(f"Error calling Ollama API: {exc}") - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return {"tokens_used": total_tokens or 0, "content": content or ""} - - @profile("llm_gemini_call", OperationCategory.LLM) - def _generate_gemini( - self, - system_prompt: str | None, - user_prompt: str, - call_type: Optional[str] = None, - ) -> Dict[str, Any]: - """Generate response using Gemini with explicit or implicit caching. - - When call_type is provided and system_prompt is long enough, uses explicit - caching via GeminiCacheManager. This ensures different call types (reasoning, - action_selection, etc.) get separate caches for optimal cache hit rates. - - Without call_type, falls back to Gemini's implicit caching which may have - lower hit rates when alternating between different prompt structures. - - Args: - system_prompt: The system prompt (cached when using explicit caching). - user_prompt: The user prompt for this request. - call_type: Optional call type for cache keying (e.g., "reasoning", "action_selection"). - When provided, enables explicit caching per call type. - - Returns: - Dict with tokens_used, content, cached_tokens. - """ - token_count_input = token_count_output = 0 - cached_tokens = 0 - total_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - config = get_cache_config() - cache_type = "implicit" # Default cache type for metrics - - try: - if not self._gemini_client: - raise RuntimeError("Gemini client was not initialised.") - - # Use explicit caching when: - # 1. call_type is provided - # 2. system_prompt is long enough - # 3. cache manager is available - use_explicit_cache = ( - call_type - and system_prompt - and len(system_prompt) >= config.min_cache_tokens - and self._gemini_cache_manager - ) - - if use_explicit_cache: - cache_type = f"explicit_{call_type}" - logger.debug( - f"[GEMINI] Using explicit caching for call_type: {call_type}" - ) - result = self._gemini_cache_manager.get_or_create_cache( - system_prompt=system_prompt, - user_prompt=user_prompt, - call_type=call_type, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - else: - # Fall back to implicit caching (or no caching for short prompts) - result = self._gemini_client.generate_text( - self.model, - prompt=user_prompt, - system_prompt=system_prompt, - temperature=self.temperature, - max_output_tokens=self.max_tokens, - json_mode=True, - ) - - # Extract response data - content = result.get("content", "") - total_tokens = result.get("tokens_used", 0) - token_count_input = result.get("prompt_tokens", 0) - token_count_output = result.get("completion_tokens", 0) - cached_tokens = result.get("cached_tokens", 0) - - # Record cache metrics - metrics = get_cache_metrics() - if cached_tokens > 0: - logger.info( - f"[CACHE] Gemini {cache_type} cache hit: {cached_tokens}/{token_count_input} tokens from cache" - ) - metrics.record_hit( - "gemini", - cache_type, - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - elif system_prompt and len(system_prompt) >= config.min_cache_tokens: - # Caching should have been attempted (prompt long enough) - # This is a miss - either first call or cache expired - metrics.record_miss( - "gemini", cache_type, total_tokens=token_count_input - ) - - status = "success" - except GeminiAPIError as exc: # pragma: no cover - exc_obj = exc - logger.error(f"Gemini API rejected the prompt: {exc}") - except Exception as exc: # pragma: no cover - exc_obj = exc - logger.error(f"Error calling Gemini API: {exc}") - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens, - } - - @profile("llm_byteplus_call", OperationCategory.LLM) - def _generate_byteplus( - self, system_prompt: str | None, user_prompt: str - ) -> Dict[str, Any]: - """Generate response using BytePlus with automatic prefix caching. - - Routes to prefix cache or standard API based on context. - """ - config = get_cache_config() - # Use prefix caching if: - # - System prompt is provided - # - System prompt is long enough (uses shared config) - # - Cache manager is available - if ( - system_prompt - and len(system_prompt) >= config.min_cache_tokens - and self._byteplus_cache_manager - ): - return self._generate_byteplus_with_prefix_cache(system_prompt, user_prompt) - - # Standard path (no caching) - return self._generate_byteplus_standard(system_prompt, user_prompt) - - def _generate_byteplus_with_prefix_cache( - self, system_prompt: str, user_prompt: str - ) -> Dict[str, Any]: - """Use Responses API with prefix caching. - - The system prompt is cached and reused across calls with the same content. - Only the user prompt is processed fresh each time. - Uses previous_response_id chaining for cache hits. - """ - token_count_input = token_count_output = 0 - total_tokens = 0 - cached_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - - try: - # Get response using prefix cache (creates cache on first call) - result = self._byteplus_cache_manager.get_or_create_prefix_cache( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - logger.info(f"BYTEPLUS CACHED RESPONSE: {result}") - - # Parse response (Responses API format) - content = self._parse_responses_api_content(result) - - # Token usage from Responses API - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - - # Log cache hit info if available and record metrics - # Responses API uses input_tokens_details instead of prompt_tokens_details - cached_tokens = usage.get("input_tokens_details", {}).get( - "cached_tokens", 0 - ) - metrics = get_cache_metrics() - if cached_tokens and cached_tokens > 0: - logger.info( - f"[CACHE] BytePlus prefix cache hit: {cached_tokens}/{token_count_input} tokens cached" - ) - metrics.record_hit( - "byteplus", - "prefix", - cached_tokens=cached_tokens, - total_tokens=token_count_input, - ) - else: - # First call or cache miss - metrics.record_miss( - "byteplus", "prefix", total_tokens=token_count_input - ) - - status = "success" - - except requests.HTTPError as e: - # Check if this is a cache-related error (expired, not found) - if e.response is not None and e.response.status_code in (404, 410): - logger.warning(f"[CACHE] Cache expired or not found, recreating: {e}") - # Invalidate and retry once - self._byteplus_cache_manager.invalidate_prefix_cache(system_prompt) - try: - result = self._byteplus_cache_manager.get_or_create_prefix_cache( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - content = self._parse_responses_api_content(result) - usage = result.get("usage") or {} - token_count_input = int(usage.get("input_tokens", 0)) - token_count_output = int(usage.get("output_tokens", 0)) - total_tokens = int(usage.get("total_tokens", 0)) or ( - token_count_input + token_count_output - ) - status = "success" - except Exception as retry_exc: - exc_obj = retry_exc - logger.error(f"[CACHE] Retry failed, falling back: {retry_exc}") - return self._generate_byteplus_standard(system_prompt, user_prompt) - else: - exc_obj = e - logger.error(f"Error calling BytePlus Responses API: {e}") - except Exception as exc: - exc_obj = exc - logger.error(f"Error calling BytePlus Responses API: {exc}") - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens or 0, - } - - def _parse_responses_api_content(self, result: Dict[str, Any]) -> str: - """Parse content from BytePlus Responses API response. - - The Responses API uses a different format than chat/completions: - { - "output": [ - {"type": "message", "role": "assistant", "content": [ - {"type": "text", "text": "..."} - ]} - ] - } - """ - content = "" - output = result.get("output", []) - for item in output: - if item.get("type") == "message" and item.get("role") == "assistant": - content_blocks = item.get("content", []) - for block in content_blocks: - # Handle both "text" and "output_text" types (BytePlus uses "output_text") - if block.get("type") in ("text", "output_text"): - content += block.get("text", "") - return content.strip() - - def _generate_byteplus_standard( - self, system_prompt: str | None, user_prompt: str - ) -> Dict[str, Any]: - """Standard BytePlus API call without caching (uses /chat/completions).""" - token_count_input = token_count_output = 0 - total_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - - try: - # Build OpenAI-compatible messages array - messages: List[Dict[str, str]] = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": user_prompt}) - - url = f"{self.byteplus_base_url.rstrip('/')}/chat/completions" - payload = { - "model": self.model, - "messages": messages, - # Wire through sampling + output control - "temperature": self.temperature, - "max_tokens": self.max_tokens, - # Note: response_format not supported by all BytePlus models (e.g., kimi) - # "stream": False, # default is non-streaming - } - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - - # Log the request - logger.info(f"[BYTEPLUS STANDARD REQUEST] URL: {url}") - logger.info( - f"[BYTEPLUS STANDARD REQUEST] Model: {self.model}, Temp: {self.temperature}, MaxTokens: {self.max_tokens}" - ) - logger.info(f"[BYTEPLUS STANDARD REQUEST] Messages count: {len(messages)}") - - response = requests.post(url, json=payload, headers=headers, timeout=600) - - # Log response status - logger.info(f"[BYTEPLUS STANDARD RESPONSE] Status: {response.status_code}") - - response.raise_for_status() - result = response.json() - - logger.info(f"[BYTEPLUS STANDARD RESPONSE] Body: {result}") - - # Non-streaming content location (OpenAI-compatible) - choices = result.get("choices", []) - if choices: - # choices[0].message.content is the OpenAI-compatible field - content = ( - choices[0].get("message", {}).get("content") - or choices[0].get("delta", {}).get("content", "") - or "" - ).strip() - - total_tokens = int(result.get("usage", {}).get("total_tokens", 0)) - - # Token usage (prompt/completion/total) - usage = result.get("usage") or {} - token_count_input = int(usage.get("prompt_tokens", 0)) - token_count_output = int(usage.get("completion_tokens", 0)) - status = "success" - - except Exception as exc: # pragma: no cover - exc_obj = exc - logger.error(f"Error calling BytePlus API: {exc}") - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return {"tokens_used": total_tokens or 0, "content": content or ""} - - @profile("llm_anthropic_call", OperationCategory.LLM) - def _generate_anthropic( - self, - system_prompt: str | None, - user_prompt: str, - call_type: Optional[str] = None, - messages: Optional[List[dict]] = None, - ) -> Dict[str, Any]: - """Generate response using Anthropic with prompt caching. - - Anthropic's prompt caching uses `cache_control` markers on content blocks. - When the system prompt is long enough (≥1024 tokens), we enable caching. - - For multi-turn sessions, pass pre-built `messages` with cache_control on the - last assistant message. This enables prefix caching of the entire conversation - history, not just the system prompt. - - TTL Options: - - Default (5 minutes): Free, uses "ephemeral" type - - Extended (1 hour): When call_type is provided, uses extended TTL for better - cache hit rates when alternating between different call types. - Note: Extended TTL cache writes cost 100% more, but reads are 90% cheaper. - - Args: - system_prompt: The system prompt (cached when long enough). - user_prompt: The user prompt for this request. - call_type: Optional call type (e.g., "reasoning", "action_selection"). - When provided, uses extended 1-hour TTL for better cache hit rates. - messages: Optional pre-built messages list for multi-turn sessions. - When provided, used instead of building a single-turn message. - JSON prefill is added automatically when applicable. - - Cache hits are logged when `cache_read_input_tokens` > 0 in the response. - """ - token_count_input = token_count_output = 0 - total_tokens = 0 - cached_tokens = 0 - status = "failed" - content: Optional[str] = None - exc_obj: Optional[Exception] = None - config = get_cache_config() - cache_type = f"ephemeral_{call_type}" if call_type else "ephemeral" - - try: - if not self._anthropic_client: - raise RuntimeError("Anthropic client was not initialised.") - - # Always enable JSON mode via prefilling - json_mode = True - - # Build the message list: use pre-built messages for multi-turn, or single-turn - if messages is not None: - api_messages = list(messages) # Copy to avoid mutating caller's list - else: - api_messages = [{"role": "user", "content": user_prompt}] - # For JSON mode, use prefilling to force JSON output (always last message) - if json_mode: - api_messages.append({"role": "assistant", "content": "{"}) - - # Anthropic requires max_tokens; use 16384 (Claude 4 default) to avoid truncation - message_kwargs: Dict[str, Any] = { - "model": self.model, - "max_tokens": 16384, - "messages": api_messages, - } - - if system_prompt: - # Use caching if system prompt is long enough - if len(system_prompt) >= config.min_cache_tokens: - # Format system as list of content blocks with cache_control - # Use extended 1-hour TTL when call_type is provided for better - # cache hit rates when alternating between different call types - cache_control: Dict[str, str] = {"type": "ephemeral"} - if call_type: - # Extended TTL: cache writes cost 100% more, reads 90% cheaper - # Better for alternating call types where 5-minute TTL might expire - cache_control["ttl"] = "1h" - logger.debug( - f"[ANTHROPIC] Using 1-hour TTL for call_type: {call_type}" - ) - - message_kwargs["system"] = [ - { - "type": "text", - "text": system_prompt, - "cache_control": cache_control, - } - ] - else: - # Short prompt - use simple string format (no caching) - message_kwargs["system"] = system_prompt - - # Always pass temperature for Anthropic (their default is 1.0, not 0.0) - message_kwargs["temperature"] = self.temperature - - response = self._anthropic_client.messages.create(**message_kwargs) - - # Extract content from the response - content = "" - for block in response.content: - if block.type == "text": - content += block.text - - content = content.strip() - - # If using JSON mode prefilling, prepend the '{' that was used as prefill - if json_mode: - content = "{" + content - - # Token usage from Anthropic response - token_count_input = response.usage.input_tokens - token_count_output = response.usage.output_tokens - total_tokens = token_count_input + token_count_output - - # Log cache stats if available (Anthropic returns cache info in usage) - # cache_creation_input_tokens: tokens written to cache (first call) - # cache_read_input_tokens: tokens read from cache (subsequent calls) - cache_creation = ( - getattr(response.usage, "cache_creation_input_tokens", 0) or 0 - ) - cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0 - cached_tokens = cache_creation + cache_read - - # Record metrics - metrics = get_cache_metrics() - if cache_read > 0: - logger.info( - f"[CACHE] Anthropic {cache_type} cache hit: {cache_read}/{token_count_input} tokens from cache" - ) - metrics.record_hit( - "anthropic", - cache_type, - cached_tokens=cache_read, - total_tokens=token_count_input, - ) - elif cache_creation > 0: - logger.info( - f"[CACHE] Anthropic {cache_type} cache created: {cache_creation} tokens cached" - ) - # Cache creation is a "miss" for the current call but sets up future hits - metrics.record_miss( - "anthropic", cache_type, total_tokens=token_count_input - ) - elif system_prompt and len(system_prompt) >= config.min_cache_tokens: - # Caching was attempted but no cache info returned - unexpected - metrics.record_miss( - "anthropic", cache_type, total_tokens=token_count_input - ) - - status = "success" - - except Exception as exc: # pragma: no cover - exc_obj = exc - logger.error(f"Error calling Anthropic API: {exc}") - - self._log_to_db( - system_prompt, - user_prompt, - content if content is not None else str(exc_obj), - status, - token_count_input, - token_count_output, - ) - return { - "tokens_used": total_tokens or 0, - "content": content or "", - "cached_tokens": cached_tokens, - } - - # ─────────────────── CLI helper for ad‑hoc testing ─────────────────── - def _cli(self) -> None: # pragma: no cover - """Run a quick interactive shell for manual testing.""" - logger.debug( - "Provider: {provider!r}, model: {model!r}", - provider=self.provider, - model=self.model, - ) - while True: - user_prompt = input("\nEnter prompt (or 'exit'): ").strip() - if user_prompt.lower() in {"exit", "quit"}: - break - response = self.generate_response(user_prompt=user_prompt) - logger.debug(f"AI Response:\n{response}\n") diff --git a/app/security/error_handler.py b/app/security/error_handler.py deleted file mode 100644 index 92a96dad..00000000 --- a/app/security/error_handler.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Secure Error Handling Module - -Provides safe error handling that: -- Prevents information disclosure via tracebacks -- Logs full details internally for debugging -- Returns sanitized errors to users -""" - -import logging -import traceback -import sys -from typing import Optional, Tuple - - -class SecureErrorHandler: - """Handles errors securely without exposing sensitive information.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - - @staticmethod - def sanitize_error_message(error: Exception, max_length: int = 200) -> str: - """ - Sanitize error message to prevent information disclosure. - - Args: - error: The exception to sanitize - max_length: Maximum returned message length - - Returns: - Safe, user-friendly error message - """ - error_str = str(error) - - # Remove sensitive patterns - sensitive_patterns = [ - r"/[^/\s]+\.py", # File paths - r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)", # Email addresses - r"(:\/\/[^/\s]+)", # URLs/hostnames - r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", # IP addresses - ] - - import re - - for pattern in sensitive_patterns: - error_str = re.sub(pattern, "[REDACTED]", error_str) - - # Truncate to max length - if len(error_str) > max_length: - error_str = error_str[:max_length] + "..." - - return error_str - - def handle_exception( - self, - exc: Exception, - context: str = "Unknown operation", - log_traceback: bool = True, - ) -> str: - """ - Handle exception securely. - - Args: - exc: The exception to handle - context: Description of what was being done - log_traceback: Whether to log full traceback internally - - Returns: - Safe error message for user - """ - # Log full details internally (for debugging) - if log_traceback: - self.logger.error(f"[ERROR] {context}") - self.logger.error(f"Exception: {type(exc).__name__}: {exc}") - if self.logger.isEnabledFor(logging.DEBUG): - self.logger.debug(traceback.format_exc()) - else: - self.logger.error(f"[ERROR] {context}: {type(exc).__name__}") - - # Return sanitized message to user - safe_message = self.sanitize_error_message(exc) - return safe_message - - def safe_execute( - self, func, *args, context: str = "Executing operation", **kwargs - ) -> Tuple[Optional[any], Optional[str]]: - """ - Safely execute a function with error handling. - - Args: - func: Function to execute - *args: Arguments to pass - context: Description of operation - **kwargs: Keyword arguments to pass - - Returns: - Tuple of (result, error_message) - - If successful: (result, None) - - If error: (None, error_message) - """ - try: - result = func(*args, **kwargs) - return result, None - except Exception as e: - error_msg = self.handle_exception(e, context=context) - return None, error_msg - - -def setup_secure_exception_hook(): - """ - Install a global exception hook that prevents traceback disclosure. - Call this at application startup. - """ - - def secure_excepthook(exc_type, exc_value, exc_traceback): - """Global exception handler.""" - # Log full traceback internally - logger = logging.getLogger("UNCAUGHT_EXCEPTION") - logger.error( - f"Uncaught exception: {exc_type.__name__}: {exc_value}", - exc_info=(exc_type, exc_value, exc_traceback), - ) - - # Print sanitized message to user - error_handler = SecureErrorHandler(logger) - safe_msg = error_handler.sanitize_error_message(exc_value) - - print(f"\n❌ An error occurred: {safe_msg}", file=sys.stderr) - - # Exit gracefully - sys.exit(1) - - sys.excepthook = secure_excepthook diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 28101ba7..4e41de90 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -214,9 +214,6 @@ def _subscribe_events(self) -> None: self._unsubscribers.append( bus.subscribe(UIEventType.ERROR_MESSAGE, self._handle_error_message) ) - self._unsubscribers.append( - bus.subscribe(UIEventType.LLM_FATAL_ERROR, self._handle_llm_fatal_error) - ) self._unsubscribers.append( bus.subscribe(UIEventType.INFO_MESSAGE, self._handle_info_message) ) @@ -326,27 +323,6 @@ def _handle_error_message(self, event: UIEvent) -> None: ) ) - def _handle_llm_fatal_error(self, event: UIEvent) -> None: - """Handle fatal LLM consecutive failure — show retry/change-model options.""" - from app.ui_layer.components.types import ChatMessageOption - - session_id = event.data.get("session_id") - options = [ - ChatMessageOption(label="Retry", value="llm_retry", style="primary"), - ChatMessageOption( - label="Change Model", value="llm_change_model", style="default" - ), - ] - asyncio.create_task( - self._display_chat_message( - "System", - "What would you like to do?", - "system", - session_id=session_id, - options=options, - ) - ) - def _handle_info_message(self, event: UIEvent) -> None: """Handle info message event.""" asyncio.create_task( diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index d35b49da..a2c174ea 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -2990,10 +2990,32 @@ async def _living_ui_export_handler(self, request: "web.Request") -> "web.Respon response._zip_cleanup_path = zip_path return response except (ValueError, FileNotFoundError) as e: - return web.json_response({"error": str(e)}, status=404) + from agent_core.core.errors import ErrorCategory, ErrorInfo, redact + from app.errors.web import error_json_response + + return error_json_response( + ErrorInfo( + category=ErrorCategory.NOT_FOUND, + code="LIVING_UI_EXPORT_NOT_FOUND", + title="Export not found", + message=redact(str(e)), + ), + status=404, + ) except Exception as e: logger.error(f"[LIVING_UI] Export error: {e}") - return web.json_response({"error": str(e)}, status=500) + from agent_core.core.errors import ErrorCategory, ErrorInfo, redact + from app.errors.web import error_json_response + + return error_json_response( + ErrorInfo( + category=ErrorCategory.INTERNAL, + code="LIVING_UI_EXPORT_FAILED", + title="Export failed", + message=redact(str(e)), + ), + status=500, + ) async def _living_ui_stage_handler(self, request: "web.Request") -> "web.Response": """Stage a reference file (sketch/screenshot/doc) for a NEW Living UI. @@ -3645,16 +3667,6 @@ async def _handle_option_click( m.option_selected = value break - # Navigate to model settings page - if value == "llm_change_model": - await self._broadcast( - { - "type": "navigate", - "data": {"path": "/settings"}, - } - ) - return - # Route to the controller await self._controller.handle_option_click(value, session_id) except Exception as e: diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index dcf348e4..27db82c0 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect, useLayoutEffect, KeyboardEvent, useCallback, ChangeEvent, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { Send, Paperclip, Plus, X, Loader2, File, AlertCircle, Mic, MicOff, ChevronDown, Sparkles, BookOpen, Reply } from 'lucide-react' import { useVirtualizer } from '@tanstack/react-virtual' import { useWebSocket } from '../../contexts/WebSocketContext' @@ -10,8 +11,14 @@ import { TypingIndicatorRow } from '../../pages/Chat/TypingIndicator' import { ReasoningBlock, ActionBlock, ChunkHeaderRow } from '../activity/ActivityBlocks' import { normalizeActionName } from '../activity/actionNames' import { useAppDispatch, useAppSelector } from '../../store/hooks' -import { selectPendingPrefill } from '../../store/selectors/chatInput' -import { clearPendingPrefill, setPendingPrefill } from '../../store/slices/chatInputSlice' +import { selectPendingPrefill, selectDraftText } from '../../store/selectors/chatInput' +import { + clearPendingPrefill, + setPendingPrefill, + setDraftText, + appendDraftText, + clearDraftText, +} from '../../store/slices/chatInputSlice' import { useSettingsWebSocket } from '../../pages/Settings/useSettingsWebSocket' import { DraftMascot, DRAFT_MASCOT_EXIT_MS } from '@mascot' import { @@ -144,6 +151,7 @@ const formatDateDivider = (tsMs: number): string => { } export function Chat({ sessionId, placeholder }: ChatProps) { + const navigate = useNavigate() const { connected, sendMessage, @@ -282,7 +290,15 @@ export function Chat({ sessionId, placeholder }: ChatProps) { return { displayRows: rows, tailChunk: tail as TailChunkInfo | null } }, [timeline, expandedChunks]) - const [input, setInput] = useState('') + const dispatch = useAppDispatch() + // Composer draft: persisted in Redux keyed by sessionId (not local + // component state), so it survives both switching sessions and + // navigating away to another app tab and back — Chat.tsx unmounts in + // both cases, but the store doesn't. + const input = useAppSelector(state => selectDraftText(state, sessionId)) + const setInput = useCallback((text: string) => { + dispatch(text === '' ? clearDraftText({ sessionId }) : setDraftText({ sessionId, text })) + }, [dispatch, sessionId]) const [enhancing, setEnhancing] = useState(false) // Reply-to-bubble: set from an agent bubble's hover Reply action. The // next send carries the quoted original so the event stream records @@ -291,7 +307,6 @@ export function Chat({ sessionId, placeholder }: ChatProps) { displayName: string originalContent: string } | null>(null) - const dispatch = useAppDispatch() const pendingPrefill = useAppSelector(selectPendingPrefill) const [pendingAttachments, setPendingAttachments] = useState([]) const [attachmentError, setAttachmentError] = useState(null) @@ -471,7 +486,9 @@ export function Chat({ sessionId, placeholder }: ChatProps) { useEffect(() => { if (!sessionResetPendingRef.current) return sessionResetPendingRef.current = false - setInput('') + // Composer draft is intentionally NOT reset here — it's persisted per + // session in Redux (see `input`/`setInput` above) and should still be + // there when the user switches back to this session. setReplyTarget(null) setExpandedChunks({}) setPendingAttachments([]) @@ -727,7 +744,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { ta.setSelectionRange(end, end) } }, 0) - }, [pendingPrefill, dispatch]) + }, [pendingPrefill, dispatch, setInput]) // Consume enhanced prompt from context when WS response arrives useEffect(() => { @@ -736,7 +753,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { setEnhancing(false) clearEnhancedPrompt() inputRef.current?.focus() - }, [enhancedPrompt, clearEnhancedPrompt]) + }, [enhancedPrompt, clearEnhancedPrompt, setInput]) // Reset enhancing spinner if the WebSocket disconnects mid-request useEffect(() => { @@ -750,8 +767,12 @@ export function Chat({ sessionId, placeholder }: ChatProps) { }, [input, enhancing, enhancePrompt]) const handleOptionClick = useCallback((value: string, messageId: string) => { + if (value === 'open_settings_model') { + navigate('/settings') + return + } sendOptionClick(value, messageId, sessionId) - }, [sendOptionClick, sessionId]) + }, [navigate, sendOptionClick, sessionId]) // Reply action from an agent bubble — arm the reply bar and focus the // input so the user can type straight away. @@ -793,7 +814,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { } } if (finalTranscript) { - setInput(prev => prev + (prev.endsWith(' ') || prev === '' ? '' : ' ') + finalTranscript) + dispatch(appendDraftText({ sessionId, text: finalTranscript })) if (inputRef.current) { inputRef.current.style.height = 'auto' inputRef.current.style.height = inputRef.current.scrollHeight + 'px' @@ -814,7 +835,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { recognition.start() setIsListening(true) inputRef.current?.focus() - }, [isListening, micLang]) + }, [isListening, micLang, dispatch, sessionId]) // Stop mic if component unmounts while listening useEffect(() => { diff --git a/app/ui_layer/browser/frontend/src/constants/errorCategories.ts b/app/ui_layer/browser/frontend/src/constants/errorCategories.ts new file mode 100644 index 00000000..ae7ac0d0 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/constants/errorCategories.ts @@ -0,0 +1,51 @@ +import { + KeyRound, + WifiOff, + Clock, + CreditCard, + ShieldAlert, + ServerCrash, + AlertCircle, + AlertTriangle, + type LucideIcon, +} from 'lucide-react' + +export interface ErrorCategoryStyle { + icon: LucideIcon + /** CSS custom property name (without var()) carrying this category's accent color. */ + colorVar: string + label: string +} + +// Mirrors ErrorCategory in agent_core/core/errors.py. Single source of truth +// for how a classified error (auth/rate-limit/connection/...) is presented +// across chat bubbles and toasts, instead of every surface picking its own +// icon/color independently. +export const ERROR_CATEGORY_STYLE: Record = { + auth: { icon: KeyRound, colorVar: '--color-error', label: 'Authentication' }, + credit: { icon: CreditCard, colorVar: '--color-warning', label: 'Billing' }, + rate_limit: { icon: Clock, colorVar: '--color-warning', label: 'Rate limited' }, + quota: { icon: CreditCard, colorVar: '--color-warning', label: 'Quota' }, + model: { icon: AlertCircle, colorVar: '--color-error', label: 'Model' }, + bad_request: { icon: AlertCircle, colorVar: '--color-error', label: 'Request' }, + blocked: { icon: ShieldAlert, colorVar: '--color-error', label: 'Blocked' }, + server: { icon: ServerCrash, colorVar: '--color-error', label: 'Service unavailable' }, + connection: { icon: WifiOff, colorVar: '--color-error', label: 'Connection' }, + config: { icon: KeyRound, colorVar: '--color-error', label: 'Configuration' }, + validation: { icon: AlertCircle, colorVar: '--color-error', label: 'Invalid input' }, + not_found: { icon: AlertCircle, colorVar: '--color-error', label: 'Not found' }, + permission: { icon: ShieldAlert, colorVar: '--color-error', label: 'Permission' }, + internal: { icon: AlertTriangle, colorVar: '--color-error', label: 'Internal error' }, + unknown: { icon: AlertTriangle, colorVar: '--color-error', label: 'Error' }, +} + +export const DEFAULT_ERROR_CATEGORY_STYLE: ErrorCategoryStyle = { + icon: AlertTriangle, + colorVar: '--color-error', + label: 'Error', +} + +export function getErrorCategoryStyle(category?: string | null): ErrorCategoryStyle { + if (!category) return DEFAULT_ERROR_CATEGORY_STYLE + return ERROR_CATEGORY_STYLE[category] ?? DEFAULT_ERROR_CATEGORY_STYLE +} diff --git a/app/ui_layer/browser/frontend/src/contexts/ToastContext.tsx b/app/ui_layer/browser/frontend/src/contexts/ToastContext.tsx index f4e3bba8..f38ebb32 100644 --- a/app/ui_layer/browser/frontend/src/contexts/ToastContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/ToastContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useCallback, useRef } from 'react' import { Check, X, AlertTriangle, Info } from 'lucide-react' +import { getErrorCategoryStyle } from '../constants/errorCategories' import styles from './ToastContext.module.css' type ToastType = 'success' | 'error' | 'warning' | 'info' @@ -8,10 +9,15 @@ interface Toast { id: string type: ToastType message: string + category?: string } interface ToastContextValue { - showToast: (type: ToastType, message: string) => void + /** `category` (an ErrorCategory value, e.g. "auth"/"rate_limit") is optional — + * when provided on a type='error' toast, its icon/color from + * errorCategories.ts replaces the generic error icon. Existing call sites + * that don't pass it keep today's behavior unchanged. */ + showToast: (type: ToastType, message: string, category?: string) => void } const ToastContext = createContext(null) @@ -28,9 +34,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]) const idCounter = useRef(0) - const showToast = useCallback((type: ToastType, message: string) => { + const showToast = useCallback((type: ToastType, message: string, category?: string) => { const id = `toast-${++idCounter.current}` - setToasts(prev => [...prev, { id, type, message }]) + setToasts(prev => [...prev, { id, type, message, category }]) // Auto-dismiss after 3 seconds setTimeout(() => { @@ -42,7 +48,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { setToasts(prev => prev.filter(t => t.id !== id)) }, []) - const getIcon = (type: ToastType) => { + const getIcon = (type: ToastType, category?: string) => { + if (type === 'error' && category) { + const { icon: CategoryIcon } = getErrorCategoryStyle(category) + return + } switch (type) { case 'success': return @@ -65,7 +75,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { className={`${styles.toast} ${styles[toast.type]}`} onClick={() => dismissToast(toast.id)} > - {getIcon(toast.type)} + {getIcon(toast.type, toast.category)} {toast.message} ))} diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index c4818960..45aa4f42 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -17,6 +17,7 @@ import { markOptionSelected as messagesMarkOptionSelected, transferSession as messagesTransferSession, } from '../store/slices/messagesSlice' +import { transferDraft as chatInputTransferDraft } from '../store/slices/chatInputSlice' import { selectAllMessages, selectLastMessageIdBySession, @@ -320,6 +321,9 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { // later. Dropping it here instead caused the "Working…" row to // appear before/without the user's message. dispatch(messagesTransferSession({ from: 'new', to: session.id })) + // Carry over any composer text typed after the send but before + // this reply arrived, so it isn't lost when the route switches. + dispatch(chatInputTransferDraft({ from: 'new', to: session.id })) // Transfer the optimistic busy flag from the draft to the real // session so the typing indicator survives the handoff. dispatch(setSessionBusy({ sessionId: 'new', busy: false })) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index 5fddc5b3..e854f4b1 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -3,6 +3,7 @@ import { Copy, Check, Reply } from 'lucide-react' import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton } from '../../components/ui' import type { Attachment, ChatMessage as ChatMessageType } from '../../types' import { useWebSocket } from '../../contexts/WebSocketContext' +import { getErrorCategoryStyle } from '../../constants/errorCategories' import styles from './ChatPage.module.css' interface ChatMessageProps { @@ -86,12 +87,23 @@ export const ChatMessageItem = memo(function ChatMessageItem({ } const isAgent = message.style === 'agent' + const errorStyle = message.style === 'error' ? getErrorCategoryStyle(message.errorCategory) : null + const ErrorIcon = errorStyle?.icon const bubbleContainer = (
- {message.sender} + + {ErrorIcon && ( + + )} + {message.sender} + {new Date(message.timestamp * 1000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })} @@ -108,7 +120,9 @@ export const ChatMessageItem = memo(function ChatMessageItem({
{message.options && message.options.length > 0 && (
- Please select a response to continue: + {message.requiresChoice !== false && ( + Please select a response to continue: + )} {message.options.map((opt, index) => (