Skip to content
Open
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
106 changes: 79 additions & 27 deletions src/crucible/agents/smolagents_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def __init__(
system_prompt: str | None = None,
) -> None:
_import_smolagents() # lazy: raises with install hint if missing
from smolagents import LiteLLMModel, ToolCallingAgent
from smolagents import ToolCallingAgent

from crucible.agents._smolagents_tools import build_default_tools

Expand All @@ -119,31 +119,17 @@ def __init__(
self._system_prompt = system_prompt
self._backend_version = _resolve_backend_version()

# API key: read env var name from config; LiteLLM reads VALUE
# at request time. Never store the value on this object.
self._api_key_env = config.api_key_env

# Surface a clear error early if the env is missing — but don't
# block construction (POC patterns may set it later).
if not os.environ.get(self._api_key_env):
logger.warning(
"smolagents backend: env var %s is not set — model calls "
"will fail at request time unless set before generate_edit().",
self._api_key_env,
)

# Provider/model are forwarded verbatim to LiteLLM. The provider
# prefix tells LiteLLM which backend to route to (anthropic/,
# openai/, openrouter/, etc.).
model_id = (
config.model
if "/" in config.model
else f"{config.provider}/{config.model}"
)
self._model = LiteLLMModel(
model_id=model_id,
api_key=None, # let LiteLLM read from env at request time
)
# M3 PR 19: model dispatch by `provider`. Most values go through
# LiteLLM with API key. The special "claude-subscription" value
# uses claude_agent_sdk + OAuth from `~/.claude/credentials.json`
# (NO API key needed). See `smolagents_claude_sdk_model.py` for
# the ACL-preservation invariant (SDK forced single-turn,
# allowed_tools=[]).
self._api_key_env = config.api_key_env # informational only
if config.provider == "claude-subscription":
self._model = self._build_claude_subscription_model(config)
else:
self._model = self._build_litellm_model(config)

self._tools = build_default_tools(policy=policy, workspace=workspace)
self._agent = ToolCallingAgent(
Expand Down Expand Up @@ -188,7 +174,11 @@ def generate_edit(self, prompt: str, workspace: Path) -> AgentResult:
except Exception as exc:
description = ""
agent_output = f"smolagents agent error: {exc}"
error_type = _classify_error(str(exc))
# M3 PR 19 R2 fix #1: type-based classification first so
# ClaudeAgentSDKAuthError isn't dependent on a string-match
# coincidence. Generic exceptions still go through
# `_classify_error`'s pattern-match for back-compat.
error_type = _classify_error_typed(exc)
logger.warning("smolagents backend: agent error: %s", exc)

duration = time.monotonic() - t0
Expand Down Expand Up @@ -241,6 +231,44 @@ def _compose_prompt(self, user_prompt: str) -> str:
return user_prompt
return f"{self._system_prompt}\n\n---\n\n{user_prompt}"

def _build_litellm_model(self, config: "SmolagentsConfig"):
"""Build the LiteLLM-driven model (default API-key path)."""
from smolagents import LiteLLMModel

if not os.environ.get(config.api_key_env):
logger.warning(
"smolagents backend: env var %s is not set — model calls "
"will fail at request time unless set before generate_edit().",
config.api_key_env,
)
# Provider/model are forwarded verbatim to LiteLLM. The provider
# prefix tells LiteLLM which backend to route to (anthropic/,
# openai/, openrouter/, etc.).
model_id = (
config.model
if "/" in config.model
else f"{config.provider}/{config.model}"
)
return LiteLLMModel(
model_id=model_id,
api_key=None, # let LiteLLM read from env at request time
)

def _build_claude_subscription_model(self, config: "SmolagentsConfig"):
"""Build the claude_agent_sdk-driven model (M3 PR 19, OAuth path).

Uses `claude_agent_sdk` to read OAuth credentials from
`~/.claude/credentials.json` (no API key required). The SDK
is configured as a single-turn text generator (allowed_tools=[],
max_turns=1) so smolagents' CheatResistancePolicy boundary is
the only one that fires — see `smolagents_claude_sdk_model.py`
for the ACL invariant.
"""
from crucible.agents.smolagents_claude_sdk_model import (
ClaudeAgentSDKModel,
)
return ClaudeAgentSDKModel(model=config.model)


# ---------------------------------------------------------------------------
# Helpers
Expand All @@ -261,6 +289,30 @@ def _classify_error(msg: str) -> AgentErrorType:
return AgentErrorType.UNKNOWN


def _classify_error_typed(exc: Exception) -> AgentErrorType:
"""Type-aware classification — M3 PR 19 R2 fix #1.

Reviewer round 2 caught that `ClaudeAgentSDKAuthError` mapped to
`AgentErrorType.AUTH` only by string-match coincidence (the error
message happened to contain "api key"). If the message is reworded,
classification silently breaks.

Fix: pre-check known typed errors before falling through to
pattern match. Generic exceptions still go through `_classify_error`
for back-compat with non-Crucible-typed errors.
"""
# Lazy import to avoid cycles + only load if smolagents-CC path is used
try:
from crucible.agents.smolagents_claude_sdk_model import (
ClaudeAgentSDKAuthError,
)
if isinstance(exc, ClaudeAgentSDKAuthError):
return AgentErrorType.AUTH
except ImportError:
pass
return _classify_error(str(exc))


def _snapshot_mtimes(workspace: Path) -> dict[Path, float]:
"""Take a {abs_path: mtime} snapshot of all files in workspace."""
snapshot: dict[Path, float] = {}
Expand Down
Loading