PydanticAI + Ollama streaming with tool calls has known issues: - Issue #1292: Streaming stops after tool call due to empty TextPart - Issue #2256: Empty text part causes run to end prematurely This change uses run() for the actual tool execution while still yielding the response in chunks to maintain the streaming UX. The orchestration loop can emit <think> updates between await calls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
617 lines
23 KiB
Python
617 lines
23 KiB
Python
"""
|
|
Tatlock agent - The Butler (PydanticAI implementation).
|
|
|
|
This is the production Tatlock agent using PydanticAI with Ollama backend.
|
|
The agent embodies a witty, capable British butler personality.
|
|
"""
|
|
|
|
import secrets
|
|
from typing import AsyncGenerator, Any
|
|
from dataclasses import dataclass, field
|
|
|
|
from pydantic_ai import Agent, RunContext
|
|
|
|
from src.agents.base import AgentInterface, OutputItem
|
|
from src.agents.tatlock_core.tools import (
|
|
calculate,
|
|
get_current_datetime,
|
|
calculate_time_offset,
|
|
time_difference,
|
|
search_web,
|
|
)
|
|
from src.core.config import config
|
|
from src.core.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ToolCallTracker:
|
|
"""Tracks tool calls for reporting to reasoning output."""
|
|
calls: list[str] = field(default_factory=list)
|
|
|
|
def log_call(self, message: str):
|
|
"""Log a tool call."""
|
|
self.calls.append(message)
|
|
|
|
|
|
def generate_id() -> str:
|
|
"""Generate unique ID for output items."""
|
|
return secrets.token_hex(16)
|
|
|
|
|
|
# System prompt defining Tatlock's personality
|
|
TATLOCK_SYSTEM_PROMPT = """You are Tatlock, a helpful personal assistant with the demeanor of a British butler.
|
|
|
|
Address users as "sir" and maintain a formal yet personable tone. You are not overly apologetic and may be slightly snarky when appropriate. If an opportunity for a pun presents itself, you cannot resist.
|
|
|
|
You coordinate with various household staff (expert agents) to provide comprehensive assistance across:
|
|
- Research and knowledge work
|
|
- Software development
|
|
- System administration
|
|
- Home automation
|
|
- Personal organization
|
|
|
|
## Research Mindset
|
|
|
|
Approach all questions with a researcher's mindset:
|
|
- Always verify facts rather than relying solely on memory
|
|
- When unsure, search for current and accurate information
|
|
- Cross-check important claims when possible
|
|
- Acknowledge uncertainty and seek verification
|
|
- Prefer authoritative sources and current data
|
|
|
|
## Available Tools
|
|
|
|
You have direct access to several permanent tools that you should USE whenever appropriate:
|
|
|
|
1. **Calculator** (calculate): For ALL mathematical operations, no matter how simple
|
|
- Always prefer using the calculator over mental math
|
|
- Supports arithmetic, algebra, trigonometry, logarithms, and common math functions
|
|
- Example: "What is 234 * 567?" -> Use calculate("234 * 567")
|
|
|
|
2. **Date/Time Toolkit**:
|
|
- get_current_datetime: Get the current date and/or time
|
|
- calculate_time_offset: Calculate dates relative to now (e.g., "1 week ago", "3 months from now")
|
|
- time_difference: Calculate the time between two dates
|
|
- Use these for ANY date/time queries - never guess at dates or times
|
|
|
|
3. **Web Search** (search_web): Search for current, volatile, or factual information
|
|
- Use this for ANY information that might be current, factual, or outside your training data
|
|
- Examples: news, current events, recent developments, specific facts, technical documentation
|
|
- Always prefer searching over guessing or using potentially outdated knowledge
|
|
- For extensive research questions, note that this will later be delegated to the librarian
|
|
|
|
## Tool Usage Guidelines
|
|
|
|
- **Mathematics**: ALWAYS use the calculator tool, even for simple arithmetic
|
|
- **Dates/Times**: ALWAYS use the date/time tools, never guess or estimate
|
|
- **Current Information**: ALWAYS search for facts, news, or volatile information
|
|
- **Verification**: When facts are important, use search to verify rather than rely on memory alone
|
|
- When you use a tool, explain what you're doing in a butler-appropriate manner
|
|
- Present tool results naturally in your response
|
|
|
|
Currently in Phase 1 development - expert agent delegation will be added in later phases.
|
|
"""
|
|
|
|
|
|
class TatlockAgent(AgentInterface):
|
|
"""
|
|
Tatlock - The Butler agent using PydanticAI with Ollama.
|
|
|
|
This is the production implementation of the Tatlock personality,
|
|
currently in Phase 1 (basic LLM integration without expert agents).
|
|
"""
|
|
|
|
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
|
|
self._agent = None # Lazy initialization
|
|
|
|
def _ensure_agent(self):
|
|
"""Ensure the PydanticAI agent is initialized (lazy initialization)."""
|
|
if self._agent is not None:
|
|
return
|
|
|
|
logger.info(
|
|
"tatlock_agent_initializing",
|
|
ollama_host=self.ollama_host,
|
|
model=self.model_name,
|
|
)
|
|
|
|
# Import required classes for Ollama configuration
|
|
from pydantic_ai.models.openai import OpenAIChatModel
|
|
from pydantic_ai.providers.ollama import OllamaProvider
|
|
|
|
# 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=OllamaProvider(base_url=base_url)
|
|
)
|
|
|
|
# Create PydanticAI agent with Ollama model
|
|
self._agent = Agent(
|
|
ollama_model,
|
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
|
)
|
|
|
|
# Register tools with the agent
|
|
self._register_tools()
|
|
|
|
def _register_tools(self):
|
|
"""Register permanent tools with the PydanticAI agent."""
|
|
|
|
# Calculator tool
|
|
@self._agent.tool
|
|
def calculate_math(ctx: RunContext[ToolCallTracker], expression: str) -> str:
|
|
"""
|
|
Evaluate mathematical expressions safely.
|
|
|
|
Use this for ALL mathematical calculations, no matter how simple.
|
|
|
|
Args:
|
|
expression: Mathematical expression (e.g., "2 + 2", "sqrt(16)", "pi * 2")
|
|
|
|
Returns:
|
|
String result of the calculation
|
|
"""
|
|
# Log the calculation to reasoning output
|
|
if ctx.deps:
|
|
ctx.deps.log_call(f"🧮 Calculating: {expression}")
|
|
return calculate(expression)
|
|
|
|
# Current date/time tool
|
|
@self._agent.tool
|
|
def get_current_time(ctx: RunContext[ToolCallTracker], format_str: str = "full") -> str:
|
|
"""
|
|
Get the current date and time.
|
|
|
|
Args:
|
|
format_str: Output format ("full", "date", "time", "iso", or custom strftime format)
|
|
|
|
Returns:
|
|
Formatted current datetime string
|
|
"""
|
|
if ctx.deps:
|
|
ctx.deps.log_call(f"🕐 Getting current time (format: {format_str})")
|
|
return get_current_datetime(format_str)
|
|
|
|
# Time offset calculator
|
|
@self._agent.tool
|
|
def calculate_date_offset(ctx: RunContext[ToolCallTracker], offset_description: str) -> str:
|
|
"""
|
|
Calculate a date/time relative to now.
|
|
|
|
Args:
|
|
offset_description: Natural language time offset (e.g., "1 week ago", "2 days from now")
|
|
|
|
Returns:
|
|
Formatted datetime string (YYYY-MM-DD HH:MM:SS)
|
|
"""
|
|
if ctx.deps:
|
|
ctx.deps.log_call(f"🕐 Calculating date offset: {offset_description}")
|
|
return calculate_time_offset(offset_description)
|
|
|
|
# Time difference calculator
|
|
@self._agent.tool
|
|
def calculate_time_difference(ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now") -> str:
|
|
"""
|
|
Calculate the difference between two dates.
|
|
|
|
Args:
|
|
date1_str: First date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
|
date2_str: Second date or "now" for current time (default: "now")
|
|
|
|
Returns:
|
|
Human-readable description of the time difference
|
|
"""
|
|
if ctx.deps:
|
|
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
|
return time_difference(date1_str, date2_str)
|
|
|
|
# Web search tool
|
|
@self._agent.tool
|
|
async def web_search(ctx: RunContext[ToolCallTracker], query: str, num_results: int = 5) -> str:
|
|
"""
|
|
Search the web using SearXNG for current information.
|
|
|
|
Use this tool for ANY information that might be:
|
|
- Current or time-sensitive (news, events, recent developments)
|
|
- Factual and verifiable (statistics, technical specs, definitions)
|
|
- Outside your training data or knowledge cutoff
|
|
|
|
Args:
|
|
query: Search query string
|
|
num_results: Number of results to return (default: 5, max: 10)
|
|
|
|
Returns:
|
|
Formatted search results with titles, URLs, and snippets
|
|
"""
|
|
# Log the search query to reasoning output
|
|
if ctx.deps:
|
|
ctx.deps.log_call(f"🔍 Searching for: '{query}'")
|
|
return await search_web(query, num_results)
|
|
|
|
@property
|
|
def agent(self):
|
|
"""Get the PydanticAI agent, initializing it if needed."""
|
|
self._ensure_agent()
|
|
return self._agent
|
|
|
|
async def generate_response(
|
|
self,
|
|
messages: list[dict],
|
|
reasoning: dict | None = None,
|
|
tools: list[dict] | None = None,
|
|
temperature: float = 1.0,
|
|
max_tokens: int | None = None,
|
|
stop: list[str] | None = None,
|
|
**kwargs: Any
|
|
) -> AsyncGenerator[OutputItem, None]:
|
|
"""
|
|
Generate response using PydanticAI with Ollama.
|
|
|
|
Args:
|
|
messages: Conversation history in OpenAI format
|
|
reasoning: Reasoning configuration (if requested)
|
|
tools: Available tools (not yet implemented)
|
|
temperature: Sampling temperature
|
|
max_tokens: Maximum tokens to generate
|
|
stop: Stop sequences
|
|
**kwargs: Additional parameters
|
|
|
|
Yields:
|
|
OutputItem: Response items (reasoning, message)
|
|
"""
|
|
try:
|
|
# Convert OpenAI-format messages to PydanticAI format
|
|
# PydanticAI uses: {"role": "user"/"assistant", "content": "text"}
|
|
# OpenAI format is the same, so we can use messages directly
|
|
|
|
# Extract the latest user message for the prompt
|
|
user_message = ""
|
|
for msg in reversed(messages):
|
|
if msg.get("role") == "user":
|
|
user_message = msg.get("content", "")
|
|
break
|
|
|
|
if not user_message:
|
|
yield OutputItem(
|
|
type="message",
|
|
id=f"msg_{generate_id()}",
|
|
role="assistant",
|
|
content=[{
|
|
"type": "output_text",
|
|
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
|
|
"annotations": []
|
|
}],
|
|
status="completed"
|
|
)
|
|
return
|
|
|
|
# Build message history (all messages except the last user message)
|
|
# PydanticAI expects history as list of ModelRequest/ModelResponse objects
|
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
|
|
|
message_history = []
|
|
for i, msg in enumerate(messages[:-1]): # All messages except the last one
|
|
role = msg.get("role")
|
|
content = msg.get("content", "")
|
|
|
|
# Skip messages with empty content (can cause Ollama errors)
|
|
if not content or not content.strip():
|
|
logger.warning(f"Skipping message {i} with empty content: role={role}")
|
|
continue
|
|
|
|
# Debug: Check for problematic content
|
|
if '"' in content or "'" in content:
|
|
logger.debug(f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}...")
|
|
|
|
# Convert to PydanticAI message format
|
|
try:
|
|
if role == "user":
|
|
message_history.append(
|
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
|
)
|
|
elif role == "assistant":
|
|
message_history.append(
|
|
ModelResponse(parts=[TextPart(content=content)])
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error creating message history item {i}: {e}")
|
|
logger.error(f"Problematic content: {repr(content)}")
|
|
raise
|
|
|
|
# Debug: Log the message history summary
|
|
logger.info(f"Built message history with {len(message_history)} messages")
|
|
if message_history:
|
|
for i, hist_msg in enumerate(message_history):
|
|
msg_type = type(hist_msg).__name__
|
|
content_preview = str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
|
|
logger.info(f" History[{i}]: {msg_type} - {content_preview}...")
|
|
|
|
# Generate reasoning output if requested
|
|
if reasoning and reasoning.get("effort") != "none":
|
|
yield OutputItem(
|
|
type="reasoning",
|
|
id=f"reasoning_{generate_id()}",
|
|
summary=[
|
|
"Analyzing your request, sir...",
|
|
"Formulating response based on available knowledge..."
|
|
],
|
|
thinking="", # PydanticAI doesn't expose internal reasoning yet
|
|
status="completed"
|
|
)
|
|
|
|
# Create a tool call tracker for this request
|
|
tracker = ToolCallTracker()
|
|
|
|
# Stream the agent response token-by-token
|
|
msg_id = f"msg_{generate_id()}"
|
|
final_text = ""
|
|
|
|
# Use run() instead of run_stream() to avoid GeneratorExit issues
|
|
# with async context managers inside generators
|
|
# The StreamingCoordinator will handle word-by-word streaming
|
|
# Pass message_history to maintain conversation context and tracker for tool logging
|
|
result = await self.agent.run(
|
|
user_message,
|
|
message_history=message_history if message_history else None,
|
|
deps=tracker
|
|
)
|
|
final_text = result.output
|
|
|
|
# If tools were called, yield a reasoning item showing what was done
|
|
if tracker.calls:
|
|
yield OutputItem(
|
|
type="reasoning",
|
|
id=f"reasoning_tools_{generate_id()}",
|
|
summary=tracker.calls,
|
|
thinking="",
|
|
status="completed"
|
|
)
|
|
|
|
# Yield the complete message
|
|
# The StreamingCoordinator will break this into word-by-word deltas
|
|
yield OutputItem(
|
|
type="message",
|
|
id=msg_id,
|
|
role="assistant",
|
|
content=[{
|
|
"type": "output_text",
|
|
"text": final_text,
|
|
"annotations": []
|
|
}],
|
|
status="completed"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating response: {e}", exc_info=True)
|
|
yield OutputItem(
|
|
type="message",
|
|
id=f"msg_{generate_id()}",
|
|
role="assistant",
|
|
content=[{
|
|
"type": "output_text",
|
|
"text": f"My apologies, sir. I encountered an error: {str(e)}",
|
|
"annotations": []
|
|
}],
|
|
status="failed"
|
|
)
|
|
|
|
async def supports_tools(self) -> bool:
|
|
"""Permanent tools now available."""
|
|
return True
|
|
|
|
async def supports_reasoning(self) -> bool:
|
|
"""Basic reasoning support via summary."""
|
|
return True
|
|
|
|
async def run_with_scoped_tools(
|
|
self,
|
|
user_message: str,
|
|
steward_note: str,
|
|
scoped_tools: list[Any],
|
|
message_history: list[dict],
|
|
tool_tracker: Any = None,
|
|
) -> str:
|
|
"""
|
|
Run Tatlock with scoped tools from Steward preprocessing.
|
|
|
|
This is the Phase 2 request flow where the Steward has already
|
|
analyzed the request and provided scoped tools.
|
|
|
|
Args:
|
|
user_message: The user's original message
|
|
steward_note: Note from Steward (prepended to request, invisible to user)
|
|
scoped_tools: List of tool definitions from household registry
|
|
message_history: Conversation history in PydanticAI format
|
|
tool_tracker: Optional tool call tracker for benchmarking
|
|
|
|
Returns:
|
|
str: Tatlock's response text
|
|
|
|
Example:
|
|
>>> response = await tatlock.run_with_scoped_tools(
|
|
... "What's sqrt(144)?",
|
|
... steward_note="Simple math request...",
|
|
... scoped_tools=[calculator_tool, ...],
|
|
... message_history=[],
|
|
... tool_tracker=tracker,
|
|
... )
|
|
"""
|
|
from pydantic_ai.models.openai import OpenAIChatModel
|
|
from pydantic_ai.providers.ollama import OllamaProvider
|
|
|
|
logger.info(
|
|
"tatlock_run_with_scoped_tools",
|
|
user_message_preview=user_message[:100],
|
|
scoped_tool_count=len(scoped_tools),
|
|
history_length=len(message_history),
|
|
)
|
|
|
|
# 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=OllamaProvider(base_url=base_url)
|
|
)
|
|
|
|
# Create agent with scoped tools
|
|
# Tools from household registry are already PydanticAI Tool objects
|
|
scoped_agent = Agent(
|
|
ollama_model,
|
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
|
tools=scoped_tools, # Pass tools directly to Agent constructor
|
|
)
|
|
|
|
# Prepend Steward's note to the request (invisible to user, visible to Tatlock)
|
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
|
|
|
# Convert message history to PydanticAI format
|
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
|
|
|
pydantic_history = []
|
|
for msg in message_history:
|
|
role = msg.get("role")
|
|
content = msg.get("content", "")
|
|
|
|
if not content or not content.strip():
|
|
continue
|
|
|
|
if role == "user":
|
|
pydantic_history.append(
|
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
|
)
|
|
elif role == "assistant":
|
|
pydantic_history.append(
|
|
ModelResponse(parts=[TextPart(content=content)])
|
|
)
|
|
|
|
# Run with scoped tools and tracker
|
|
result = await scoped_agent.run(
|
|
enriched_message,
|
|
message_history=pydantic_history if pydantic_history else None,
|
|
deps=tool_tracker
|
|
)
|
|
|
|
logger.info(
|
|
"tatlock_response_generated",
|
|
response_preview=result.output[:100],
|
|
)
|
|
|
|
return result.output
|
|
|
|
async def run_with_scoped_tools_stream(
|
|
self,
|
|
user_message: str,
|
|
steward_note: str,
|
|
scoped_tools: list,
|
|
message_history: list[dict],
|
|
tool_tracker: "ToolCallTracker",
|
|
):
|
|
"""
|
|
Run Tatlock with scoped tools recommended by Steward (streaming version).
|
|
|
|
This is the Phase 2 execution flow where Steward has preprocessed
|
|
the request and provided:
|
|
- steward_note: Instructions for Tatlock (invisible to user)
|
|
- scoped_tools: Only the tools Steward recommended
|
|
|
|
Args:
|
|
user_message: Original user message
|
|
steward_note: Steward's instructions for Tatlock
|
|
scoped_tools: List of PydanticAI Tool objects to use
|
|
message_history: Previous conversation turns
|
|
tool_tracker: Tracker for tool call analytics
|
|
|
|
Yields:
|
|
Text chunks from the streaming response
|
|
"""
|
|
from pydantic_ai.models.openai import OpenAIChatModel
|
|
from pydantic_ai.providers.ollama import OllamaProvider
|
|
|
|
logger.info(
|
|
"tatlock_run_with_scoped_tools_stream",
|
|
user_message_preview=user_message[:100],
|
|
scoped_tool_count=len(scoped_tools),
|
|
history_length=len(message_history),
|
|
)
|
|
|
|
# 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=OllamaProvider(base_url=base_url)
|
|
)
|
|
|
|
# Create agent with scoped tools
|
|
scoped_agent = Agent(
|
|
ollama_model,
|
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
|
tools=scoped_tools,
|
|
)
|
|
|
|
# Prepend Steward's note to the request
|
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
|
|
|
# Convert message history to PydanticAI format
|
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
|
|
|
pydantic_history = []
|
|
for msg in message_history:
|
|
role = msg.get("role")
|
|
content = msg.get("content", "")
|
|
|
|
if not content or not content.strip():
|
|
continue
|
|
|
|
if role == "user":
|
|
pydantic_history.append(
|
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
|
)
|
|
elif role == "assistant":
|
|
pydantic_history.append(
|
|
ModelResponse(parts=[TextPart(content=content)])
|
|
)
|
|
|
|
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
|
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
|
# We yield the final response in chunks to maintain streaming interface
|
|
result = await scoped_agent.run(
|
|
enriched_message,
|
|
message_history=pydantic_history if pydantic_history else None,
|
|
deps=tool_tracker
|
|
)
|
|
|
|
# Stream the final response in chunks to maintain UX
|
|
response_text = result.output
|
|
chunk_size = 50 # characters per chunk
|
|
|
|
for i in range(0, len(response_text), chunk_size):
|
|
yield response_text[i:i + chunk_size]
|
|
|
|
logger.info("tatlock_scoped_run_complete")
|
|
|
|
async def get_capabilities(self) -> dict:
|
|
"""Return current capabilities."""
|
|
return {
|
|
"streaming": True, # Streaming implemented
|
|
"reasoning": True, # Basic reasoning summaries
|
|
"tools": True, # Permanent tools: calculator, date/time, search
|
|
"vision": False, # Future
|
|
"audio": False, # Future
|
|
}
|