Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
38 changes: 27 additions & 11 deletions agent_core/core/embedding_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]]:
Expand All @@ -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]]:
Expand All @@ -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
Expand All @@ -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]]:
Expand All @@ -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
159 changes: 159 additions & 0 deletions agent_core/core/errors.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions agent_core/core/impl/action/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
29 changes: 20 additions & 9 deletions agent_core/core/impl/image_gen/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand Down
Loading