feat: add Claude backend with automatic Ollama fallback (Claudification Phase 1)
Build and Push / release (release) Failing after 6s
Build and Push / build (release) Successful in 3m5s

All agents now prefer Claude API when ANTHROPIC_API_KEY is configured,
with automatic fallback to Ollama when offline or unconfigured. New
src/anthropic/ module provides model selection via get_model() factory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 07:19:29 +01:00
co-authored by Claude Opus 4.5
parent 5d23bcae79
commit 496f37a538
16 changed files with 790 additions and 486 deletions
+7 -10
View File
@@ -102,16 +102,10 @@ _biographer_agent: Optional[Agent[None, str]] = None
def _create_biographer_agent() -> Agent[None, str]:
"""Create The Biographer PydanticAI agent."""
from pydantic_ai.models.openai import OpenAIChatModel
from src.anthropic.model_selector import get_model
from src.ollama.provider import get_ollama_provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=get_ollama_provider(),
)
# Get best available model (Claude if available, else Ollama)
model = get_model()
agent: Agent[None, str] = Agent(
model=model,
@@ -131,9 +125,12 @@ def _create_biographer_agent() -> Agent[None, str]:
# Register management tools
agent.tool_plain(forget_memory)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"biographer_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
backend=model_info["backend"],
model=model_info["model"],
tool_count=6,
)
+7 -10
View File
@@ -103,16 +103,10 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
def _create_housekeeper_agent() -> Agent[None, str]:
"""Create the Housekeeper PydanticAI agent."""
from pydantic_ai.models.openai import OpenAIChatModel
from src.anthropic.model_selector import get_model
from src.ollama.provider import get_ollama_provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=get_ollama_provider(),
)
# Get best available model (Claude if available, else Ollama)
model = get_model()
agent: Agent[None, str] = Agent(
model=model,
@@ -145,9 +139,12 @@ def _create_housekeeper_agent() -> Agent[None, str]:
# Register history tools
agent.tool_plain(get_history)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"housekeeper_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
backend=model_info["backend"],
model=model_info["model"],
tool_count=13,
)
+7 -10
View File
@@ -143,16 +143,10 @@ _librarian_agent: Optional[Agent[None, str]] = None
def _create_librarian_agent() -> Agent[None, str]:
"""Create the Librarian PydanticAI agent."""
from pydantic_ai.models.openai import OpenAIChatModel
from src.anthropic.model_selector import get_model
from src.ollama.provider import get_ollama_provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=get_ollama_provider(),
)
# Get best available model (Claude if available, else Ollama)
model = get_model()
agent: Agent[None, str] = Agent(
model=model,
@@ -182,9 +176,12 @@ def _create_librarian_agent() -> Agent[None, str]:
agent.tool_plain(update_wiki_page)
agent.tool_plain(smart_create_wiki_page)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"librarian_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
backend=model_info["backend"],
model=model_info["model"],
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
)
+96 -27
View File
@@ -5,11 +5,13 @@ The Steward analyzes incoming requests, identifies relevant household
capabilities, and provides focused recommendations to Tatlock (the Butler).
This creates a two-tier architecture that prevents cognitive overload.
Uses plain text output (not JSON) for reliability with Ollama models.
Uses plain text output (not JSON) for reliability. Supports both Claude
(preferred) and Ollama (fallback) backends via direct API calls.
"""
import httpx
from typing import Optional
from src.anthropic.model_selector import is_claude_available, get_model_info
from src.core.config import config
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
@@ -105,22 +107,74 @@ class StewardAgent:
Analyzes requests with full conversation context and recommends
which household capabilities the Butler should use.
Uses plain text output for reliability with Ollama models.
Uses plain text output for reliability. Supports both Claude
(preferred) and Ollama (fallback) backends via direct API calls.
"""
def __init__(self):
"""Initialize Steward with Ollama model (same as Tatlock for VRAM efficiency)."""
"""Initialize Steward with backend selection based on availability."""
# Ollama config (fallback)
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
self.model_name = config.OLLAMA_DEFAULT_MODEL
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
# Claude config (preferred)
self.claude_model = config.ANTHROPIC_MODEL
self._anthropic_client = None
# Determine which backend to use
self._use_claude = config.PREFER_CLOUD_BACKEND and is_claude_available()
self.timeout = 30.0 # 30 second timeout for analysis
model_info = get_model_info()
logger.info(
"steward_agent_created",
ollama_host=self.ollama_host,
model=self.model_name,
backend=model_info["backend"],
model=model_info["model"],
timeout=self.timeout,
)
def _get_anthropic_client(self):
"""Get or create Anthropic client (lazy initialization)."""
if self._anthropic_client is None:
from anthropic import AsyncAnthropic
self._anthropic_client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
return self._anthropic_client
async def _call_claude(self, system_prompt: str, user_message: str) -> str:
"""Call Claude API directly for plain text generation."""
client = self._get_anthropic_client()
response = await client.messages.create(
model=self.claude_model,
max_tokens=1024,
system=system_prompt,
messages=[{"role": "user", "content": user_message}],
temperature=0.3, # Lower = more consistent
)
return response.content[0].text.strip()
async def _call_ollama(self, prompt: str) -> str:
"""Call Ollama API directly for plain text generation."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.ollama_host}/api/generate",
json={
"model": self.ollama_model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3, # Lower = more consistent
"top_p": 0.9
}
}
)
response.raise_for_status()
result = response.json()
return result["response"].strip()
async def analyze(
self,
query: str,
@@ -129,6 +183,8 @@ class StewardAgent:
"""
Analyze query and return plain text recommendation.
Uses Claude if available, falls back to Ollama.
Args:
query: User's query to analyze
conversation_history: Previous conversation turns
@@ -144,35 +200,48 @@ class StewardAgent:
history = conversation_history or []
prompt = build_steward_prompt(query, history)
logger.debug("steward_calling_ollama", query_preview=query[:100])
backend = "claude" if self._use_claude else "ollama"
logger.debug(
"steward_calling_llm",
backend=backend,
query_preview=query[:100],
)
# Call Ollama API directly (more reliable than PydanticAI for plain text)
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.ollama_host}/api/generate",
json={
"model": self.model_name,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3, # Lower = more consistent
"top_p": 0.9
}
}
)
response.raise_for_status()
result = response.json()
analysis_text = result["response"].strip()
try:
if self._use_claude:
# For Claude, split into system + user message
# The prompt contains both, but Claude prefers explicit system
analysis_text = await self._call_claude(
system_prompt="You are the Steward of the household, advising the Butler (Tatlock) on which capabilities to use. Be concise and specific.",
user_message=prompt,
)
else:
analysis_text = await self._call_ollama(prompt)
logger.debug(
"steward_analysis_received",
text_preview=analysis_text[:150]
backend=backend,
text_preview=analysis_text[:150],
)
return analysis_text
except Exception as e:
# If Claude fails, try Ollama as fallback
if self._use_claude:
logger.warning(
"steward_claude_fallback",
error=str(e),
)
analysis_text = await self._call_ollama(prompt)
logger.debug(
"steward_analysis_received",
backend="ollama_fallback",
text_preview=analysis_text[:150],
)
return analysis_text
raise
# Global Steward instance
_steward_agent = None
+22 -62
View File
@@ -138,10 +138,7 @@ class TatlockAgent(AgentInterface):
"""
def __init__(self):
"""Initialize Tatlock configuration (lazy agent creation)."""
# Store Ollama configuration
self.ollama_host = str(config.OLLAMA_HOST)
self.model_name = config.OLLAMA_DEFAULT_MODEL
"""Initialize Tatlock (lazy agent creation)."""
self._agent = None # Lazy initialization
def _ensure_agent(self):
@@ -149,30 +146,21 @@ class TatlockAgent(AgentInterface):
if self._agent is not None:
return
from src.anthropic.model_selector import get_model, get_model_info
model_info = get_model_info()
logger.info(
"tatlock_agent_initializing",
ollama_host=self.ollama_host,
model=self.model_name,
backend=model_info["backend"],
model=model_info["model"],
)
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from src.ollama.provider import get_ollama_provider
# Get best available model (Claude if available, else Ollama)
model = get_model()
# PydanticAI expects Ollama base URL to end with /v1
# Remove trailing slash from ollama_host if present
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
# Create Ollama model with provider
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=get_ollama_provider()
)
# Create PydanticAI agent with Ollama model
# Create PydanticAI agent
self._agent = Agent(
ollama_model,
model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
)
@@ -461,8 +449,7 @@ class TatlockAgent(AgentInterface):
... tool_tracker=tracker,
... )
"""
from pydantic_ai.models.openai import OpenAIChatModel
from src.ollama.provider import get_ollama_provider
from src.anthropic.model_selector import get_model
logger.info(
"tatlock_run_with_scoped_tools",
@@ -473,18 +460,12 @@ class TatlockAgent(AgentInterface):
# Create a fresh agent instance with scoped tools only
# This ensures Tatlock can ONLY use tools recommended by the Steward
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=get_ollama_provider()
)
model = get_model()
# Create agent with scoped tools
# Tools from household registry are already PydanticAI Tool objects
scoped_agent = Agent(
ollama_model,
model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
tools=scoped_tools, # Pass tools directly to Agent constructor
)
@@ -555,8 +536,7 @@ class TatlockAgent(AgentInterface):
Yields:
Text chunks from the streaming response
"""
from pydantic_ai.models.openai import OpenAIChatModel
from src.ollama.provider import get_ollama_provider
from src.anthropic.model_selector import get_model
logger.info(
"tatlock_run_with_scoped_tools_stream",
@@ -566,17 +546,11 @@ class TatlockAgent(AgentInterface):
)
# Create a fresh agent instance with scoped tools only
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=get_ollama_provider()
)
model = get_model()
# Create agent with scoped tools
scoped_agent = Agent(
ollama_model,
model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
tools=scoped_tools,
)
@@ -650,8 +624,6 @@ class TatlockAgent(AgentInterface):
- tool_outputs: Dict mapping tool names to their outputs
- raw_output: The agent's raw text output
"""
from pydantic_ai.models.openai import OpenAIChatModel
from src.ollama.provider import get_ollama_provider
from pydantic_ai.settings import ModelSettings
from pydantic_ai.messages import (
ModelRequest,
@@ -661,6 +633,7 @@ class TatlockAgent(AgentInterface):
ToolCallPart,
ToolReturnPart,
)
from src.anthropic.model_selector import get_model
logger.info(
"tatlock_orchestrate_tool_calls",
@@ -680,17 +653,11 @@ class TatlockAgent(AgentInterface):
)
# Create a fresh agent instance with scoped tools only
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=get_ollama_provider()
)
model = get_model()
# Create agent with scoped tools
scoped_agent = Agent(
ollama_model,
model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
tools=scoped_tools,
)
@@ -799,9 +766,8 @@ class TatlockAgent(AgentInterface):
Returns:
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.models.openai import OpenAIChatModel
from src.ollama.provider import get_ollama_provider
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
from src.anthropic.model_selector import get_model
logger.info(
"tatlock_synthesize_from_results",
@@ -848,17 +814,11 @@ class TatlockAgent(AgentInterface):
synthesis_prompt = "\n".join(synthesis_parts)
# Create synthesis agent (no tools needed)
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=get_ollama_provider()
)
model = get_model()
# Synthesis agent uses butler prompt but no tools
synthesis_agent = Agent(
ollama_model,
model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
# No tools for synthesis phase
)