feat: implement Phase 2 two-tier architecture with Steward

Add comprehensive two-tier architecture where Steward analyzes requests
and Tatlock executes with scoped tools. Includes full infrastructure for
request preprocessing, tool tracking, benchmarking, and streaming.

**Added:**
- Steward agent for request analysis and capability recommendation
- Household Registry for centralized capability management
- Request preprocessing pipeline (Steward → Tatlock flow)
- Tool usage tracking and benchmarking system
- Streaming transparency (Steward reasoning visible in streams)
- Structured logging with operation timing
- Redis benchmark storage with 30-day expiry
- Benchmark analysis CLI tools

**Infrastructure:**
- src/agents/steward/ - Steward agent implementation
- src/agents/tatlock_core/ - Tatlock capability domain
- src/core/preprocessing.py - Request preprocessing pipeline
- src/core/tool_tracking.py - Tool call tracking
- src/core/benchmarks.py - Benchmark recording system
- src/core/household_registry.py - Capability registry
- src/core/startup.py - Application startup coordination
- src/core/logging_config.py - Structured logging setup

**Integration:**
- Responses API uses Steward for Tatlock requests
- Chat Completions wraps Responses API for OpenAI compatibility
- Streaming coordinator supports Steward + Tatlock flow
- Tool scoping per request based on Steward recommendations

**Testing:**
- Integration tests for Steward-Tatlock flow
- Benchmark and registry unit tests
- Steward streaming tests

See PHASE2_PLAN.md and PHASE2_COMPLETE.md for detailed documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 15:39:20 +01:00
co-authored by Claude Sonnet 4.5
parent 2577730546
commit 6eed5f4d13
34 changed files with 6362 additions and 133 deletions
+18
View File
@@ -0,0 +1,18 @@
"""
Steward agent package.
The Steward analyzes incoming requests and recommends relevant household
capabilities, creating a two-tier architecture with the Butler.
"""
from .agent import StewardAgent, get_steward_agent
from .schemas import ConversationContext, StewardRecommendation
from .service import analyze_request, format_steward_note
__all__ = [
"StewardAgent",
"get_steward_agent",
"ConversationContext",
"StewardRecommendation",
"analyze_request",
"format_steward_note",
]
+165
View File
@@ -0,0 +1,165 @@
"""
Steward agent - First-tier request analyzer.
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.
"""
import httpx
from typing import Optional
from src.core.config import config
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# System prompt for plain text recommendations
def build_steward_prompt(query: str, conversation_history: list[dict]) -> str:
"""Build the steward's analysis prompt with query and conversation history."""
# Get available capabilities from registry
registry = get_household_registry()
capabilities = registry.get_all_capabilities()
cap_list = []
for cap in capabilities:
cap_list.append(
f"{cap.name} - {cap.description} (domains: {', '.join(cap.domains)})"
)
capabilities_text = "\n".join(cap_list)
# Format conversation history if present
history_text = ""
if conversation_history:
history_lines = []
for i, msg in enumerate(conversation_history):
role = msg.get("role", "unknown")
content = msg.get("content", "")[:100] # Truncate long messages
history_lines.append(f"{i}. {role}: {content}")
history_text = "\n\nCONVERSATION HISTORY:\n" + "\n".join(history_lines)
return f"""You are the Steward of the household, advising the Butler (Tatlock) on which capabilities to use.
AVAILABLE HOUSEHOLD CAPABILITIES:
{capabilities_text}
YOUR TASK:
Analyze the user's query and recommend which capabilities are needed.
{history_text}
USER QUERY: {query}
GUIDELINES:
- Be conservative - only recommend truly necessary capabilities
- Simple greetings/chat → no capabilities needed (conversational response only)
- Math/calculations → tatlock_core
- Web searches → tatlock_core
- Time/date queries → tatlock_core
- If conversation history is relevant, note which previous turns matter
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
- If capabilities are missing, mention what would be needed
RESPOND WITH 2-3 SENTENCES:
1. Which capabilities (if any) are needed and why
2. Complexity assessment (simple/moderate/complex)
3. Any conversation context or missing capabilities
Use capability names in your response (e.g., "tatlock_core for calculations").
Plain text only - no JSON, no special formatting."""
class StewardAgent:
"""
The Steward - Request analyzer and capability coordinator.
Analyzes requests with full conversation context and recommends
which household capabilities the Butler should use.
Uses plain text output for reliability with Ollama models.
"""
def __init__(self):
"""Initialize Steward with Ollama model (same as Tatlock for VRAM efficiency)."""
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
self.model_name = config.OLLAMA_DEFAULT_MODEL
self.timeout = 30.0 # 30 second timeout for analysis
logger.info(
"steward_agent_created",
ollama_host=self.ollama_host,
model=self.model_name,
timeout=self.timeout,
)
async def analyze(
self,
query: str,
conversation_history: Optional[list[dict]] = None
) -> str:
"""
Analyze query and return plain text recommendation.
Args:
query: User's query to analyze
conversation_history: Previous conversation turns
Returns:
Plain text analysis from Steward
Example:
>>> text = await steward.analyze("What's 2 + 2?")
>>> print(text)
"This requires tatlock_core for mathematical calculations. Complexity: simple."
"""
history = conversation_history or []
prompt = build_steward_prompt(query, history)
logger.debug("steward_calling_ollama", 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()
logger.debug(
"steward_analysis_received",
text_preview=analysis_text[:150]
)
return analysis_text
# Global Steward instance
_steward_agent = None
def get_steward_agent() -> StewardAgent:
"""
Get the global Steward agent instance.
Returns:
StewardAgent instance
"""
global _steward_agent
if _steward_agent is None:
_steward_agent = StewardAgent()
return _steward_agent
+93
View File
@@ -0,0 +1,93 @@
"""
Steward agent schemas.
Defines the structured output models for Steward's request analysis
and capability recommendations.
"""
from typing import Literal, Optional
from pydantic import BaseModel, Field
class ConversationContext(BaseModel):
"""
Contextual information extracted from conversation history.
The Steward analyzes the full conversation to identify references
to previous topics, helping the Butler maintain context.
"""
has_previous_context: bool = Field(
description="Whether the current request references previous conversation turns"
)
relevant_turns: list[int] = Field(
default_factory=list,
description="0-indexed turn numbers that are relevant to the current request"
)
context_summary: str = Field(
default="",
description="Brief summary of relevant context for the Butler"
)
class StewardRecommendation(BaseModel):
"""
Structured recommendation from Steward's request analysis.
This is the output format for the Steward agent, providing:
- Which household capabilities are needed
- Why those capabilities were chosen
- Complexity assessment
- Conversation context
- Missing capabilities (if any)
"""
recommended_capabilities: list[str] = Field(
description="List of household member names to include (e.g., ['tatlock_core'])"
)
reasoning: str = Field(
description="Explanation of why these capabilities were recommended"
)
estimated_complexity: Literal["simple", "moderate", "complex"] = Field(
description="Complexity assessment: simple (1 tool), moderate (2-3 tools), complex (multiple tools/steps)"
)
conversation_context: ConversationContext = Field(
description="Contextual information from conversation history"
)
missing_capabilities: Optional[str] = Field(
default=None,
description="Description of capabilities that would be helpful but aren't available"
)
def format_for_butler(self) -> str:
"""
Format recommendation as a note for the Butler.
Returns:
Formatted string suitable for prepending to user request
"""
lines = []
# Header
lines.append("📋 Steward's Analysis")
lines.append("=" * 40)
# Complexity
lines.append(f"Complexity: {self.estimated_complexity.upper()}")
# Recommended capabilities
if self.recommended_capabilities:
caps = ", ".join(self.recommended_capabilities)
lines.append(f"Recommended tools: {caps}")
else:
lines.append("Recommended tools: None (conversational response)")
# Context summary
if self.conversation_context.has_previous_context:
lines.append(f"Context: {self.conversation_context.context_summary}")
# Missing capabilities warning
if self.missing_capabilities:
lines.append(f"⚠️ Missing: {self.missing_capabilities}")
lines.append("=" * 40)
return "\n".join(lines)
+282
View File
@@ -0,0 +1,282 @@
"""
Steward service layer.
Provides high-level interface for request analysis with logging,
benchmarking, and error handling.
Parses plain text recommendations into structured data.
"""
import re
from typing import Optional
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger, log_operation
from .agent import get_steward_agent
from .schemas import ConversationContext, StewardRecommendation
logger = get_logger(__name__)
def _extract_capabilities(text: str) -> list[str]:
"""
Extract capability names from Steward's text response.
Uses keyword matching to find mentioned capabilities.
Args:
text: Steward's plain text analysis
Returns:
List of capability names (e.g., ['tatlock_core'])
"""
text_lower = text.lower()
registry = get_household_registry()
capabilities = registry.get_all_capabilities()
found_caps = []
for cap in capabilities:
# Check if capability name is mentioned
if cap.name.lower() in text_lower:
found_caps.append(cap.name)
continue
# Check if any domains are mentioned
for domain in cap.domains:
if domain.lower() in text_lower:
found_caps.append(cap.name)
break
return found_caps
def _extract_complexity(text: str) -> str:
"""
Extract complexity assessment from text.
Args:
text: Steward's plain text analysis
Returns:
One of: "simple", "moderate", "complex"
"""
text_lower = text.lower()
if "complex" in text_lower:
return "complex"
elif "moderate" in text_lower:
return "moderate"
else:
return "simple" # Default to simple
def _extract_conversation_context(
text: str,
conversation_history: list[dict]
) -> ConversationContext:
"""
Extract conversation context analysis from text.
Args:
text: Steward's plain text analysis
conversation_history: Previous conversation turns
Returns:
ConversationContext with relevant turn analysis
"""
text_lower = text.lower()
# Check if conversation history is referenced
has_context = bool(conversation_history) and any([
"previous" in text_lower,
"earlier" in text_lower,
"context" in text_lower,
"turn" in text_lower,
"history" in text_lower,
])
# Extract turn numbers if mentioned (e.g., "turn 0", "turn 1")
relevant_turns = []
turn_pattern = r"turn\s+(\d+)"
matches = re.findall(turn_pattern, text_lower)
relevant_turns = [int(m) for m in matches]
# Create summary from relevant portion of text
context_summary = ""
if has_context:
# Extract sentence(s) mentioning context
sentences = text.split('.')
context_sentences = [s for s in sentences if any(
word in s.lower() for word in ["previous", "earlier", "context", "history"]
)]
if context_sentences:
context_summary = context_sentences[0].strip()
return ConversationContext(
has_previous_context=has_context,
relevant_turns=relevant_turns,
context_summary=context_summary
)
def _extract_missing_capabilities(text: str) -> Optional[str]:
"""
Extract missing capability notes from text.
Args:
text: Steward's plain text analysis
Returns:
Description of missing capabilities, or None
"""
text_lower = text.lower()
# Look for indicators of missing capabilities
if any(word in text_lower for word in [
"missing", "unavailable", "not available", "don't have", "doesn't have"
]):
# Find the sentence mentioning missing capabilities
sentences = text.split('.')
for sentence in sentences:
if any(word in sentence.lower() for word in [
"missing", "unavailable", "not available"
]):
return sentence.strip()
return None
async def analyze_request(
user_request: str,
conversation_history: list[dict],
conversation_id: Optional[str] = None,
) -> StewardRecommendation:
"""
Analyze user request with full conversation context.
This is the main entry point for Steward analysis. It:
1. Calls the Steward agent with full conversation history
2. Logs the operation with timing
3. Records performance benchmarks to Redis
4. Returns structured recommendations
Args:
user_request: The current user message to analyze
conversation_history: Full conversation history (all previous turns)
conversation_id: Optional conversation ID for tracking
Returns:
StewardRecommendation with capability recommendations and context analysis
Example:
>>> recommendation = await analyze_request(
... "What's sqrt(144)?",
... conversation_history=[],
... )
>>> print(recommendation.recommended_capabilities)
['tatlock_core']
"""
async with log_operation(
"steward_analysis",
{
"request_preview": user_request[:100],
"conversation_id": conversation_id,
"history_length": len(conversation_history),
}
) as log_ctx:
try:
# Get Steward agent
steward = get_steward_agent()
logger.debug(
"steward_analyzing_request",
request=user_request,
history_turns=len(conversation_history),
)
# Get plain text analysis from Steward
analysis_text = await steward.analyze(
user_request,
conversation_history=conversation_history
)
# Parse plain text into structured recommendation
capabilities = _extract_capabilities(analysis_text)
complexity = _extract_complexity(analysis_text)
context = _extract_conversation_context(analysis_text, conversation_history)
missing = _extract_missing_capabilities(analysis_text)
recommendation = StewardRecommendation(
recommended_capabilities=capabilities,
reasoning=analysis_text,
estimated_complexity=complexity,
conversation_context=context,
missing_capabilities=missing
)
# Update log context with results
log_ctx["recommendation_count"] = len(recommendation.recommended_capabilities)
log_ctx["complexity"] = recommendation.estimated_complexity
log_ctx["has_context"] = recommendation.conversation_context.has_previous_context
log_ctx["missing_capabilities"] = recommendation.missing_capabilities is not None
logger.info(
"steward_analysis_complete",
recommended=recommendation.recommended_capabilities,
complexity=recommendation.estimated_complexity,
reasoning=analysis_text[:200], # First 200 chars
)
# Record performance benchmark
if log_ctx.get("duration_seconds"):
benchmark = PerformanceBenchmark(
operation="steward_analysis",
duration_seconds=log_ctx["duration_seconds"],
success=True,
recommendation_count=len(recommendation.recommended_capabilities),
confidence=None, # Could add confidence scoring in future
conversation_id=conversation_id,
metadata={
"complexity": recommendation.estimated_complexity,
"has_context": recommendation.conversation_context.has_previous_context,
"missing_capabilities": recommendation.missing_capabilities is not None,
},
)
await get_benchmark_store().record(benchmark)
return recommendation
except Exception as e:
logger.error(
"steward_analysis_failed",
error=str(e),
error_type=type(e).__name__,
exc_info=True,
)
raise
async def format_steward_note(recommendation: StewardRecommendation) -> str:
"""
Format Steward's recommendation as a note for the Butler.
This creates a structured message that will be prepended to the user's
request when sent to Tatlock, providing context and guidance.
Args:
recommendation: Steward's analysis and recommendations
Returns:
Formatted note string for the Butler
Example:
>>> note = await format_steward_note(recommendation)
>>> print(note)
📋 Steward's Analysis
========================================
Complexity: SIMPLE
Recommended tools: tatlock_core
========================================
"""
return recommendation.format_for_butler()
+30
View File
@@ -0,0 +1,30 @@
"""
Tatlock's core tools package.
Provides calculator, date/time, and web search capabilities.
Organized as a household member with toolset and capability registration.
"""
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
from .toolset import get_core_tools, tatlock_core_tools
from .tools import (
calculate,
calculate_time_offset,
get_current_datetime,
search_web,
time_difference,
)
__all__ = [
# Tools
"calculate",
"get_current_datetime",
"calculate_time_offset",
"time_difference",
"search_web",
# Toolset
"tatlock_core_tools",
"get_core_tools",
# Capability
"TATLOCK_CORE_CAPABILITY",
"get_capability",
]
+28
View File
@@ -0,0 +1,28 @@
"""
Household capability definition for Tatlock's core tools.
Provides the executive summary that the Steward and Butler see
for coordinating household capabilities.
"""
from src.core.household_registry import HouseholdCapability
TATLOCK_CORE_CAPABILITY = HouseholdCapability(
name="tatlock_core",
role="Butler's Core Tools",
category="core",
description="Essential tools for computation, date/time operations, and web searches",
domains=["computation", "datetime", "information", "research"],
cost="low",
requires_network=True, # For web search
)
def get_capability() -> HouseholdCapability:
"""
Get the capability summary for Tatlock's core tools.
Returns:
HouseholdCapability executive summary
"""
return TATLOCK_CORE_CAPABILITY
+351
View File
@@ -0,0 +1,351 @@
"""
Tatlock's core permanent tools.
These tools are always available to the butler agent:
- Calculator: For all mathematical operations
- Date/Time toolkit: For current time and time calculations
- SearXNG search: For searching the web for current information
"""
import math
import re
from datetime import datetime, timedelta
import httpx
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# ============================================================================
# Calculator Tool
# ============================================================================
def calculate(expression: str) -> str:
"""
Safely evaluate mathematical expressions.
Supports:
- Basic arithmetic: +, -, *, /, //, %, **
- Parentheses for grouping
- Common math functions: sqrt, sin, cos, tan, log, exp, etc.
- Constants: pi, e
Args:
expression: Mathematical expression to evaluate (e.g., "2 + 2", "sqrt(16)", "pi * 2")
Returns:
String result of the calculation or error message
Examples:
calculate("2 + 2") -> "4"
calculate("sqrt(16) + 10") -> "14.0"
calculate("pi * 2") -> "6.283185307179586"
"""
try:
# Clean the expression
expression = expression.strip()
# Create safe namespace with math functions
safe_dict = {
# Basic math functions
'sqrt': math.sqrt,
'pow': math.pow,
'abs': abs,
'round': round,
# Trigonometric
'sin': math.sin,
'cos': math.cos,
'tan': math.tan,
'asin': math.asin,
'acos': math.acos,
'atan': math.atan,
# Logarithmic
'log': math.log,
'log10': math.log10,
'log2': math.log2,
'exp': math.exp,
# Other
'ceil': math.ceil,
'floor': math.floor,
'factorial': math.factorial,
# Constants
'pi': math.pi,
'e': math.e,
}
# Evaluate the expression safely
result = eval(expression, {"__builtins__": {}}, safe_dict)
# Format result nicely
if isinstance(result, float):
# Remove unnecessary decimal places
if result.is_integer():
return str(int(result))
return str(round(result, 10))
return str(result)
except ZeroDivisionError:
return "Error: Division by zero"
except Exception as e:
return f"Error calculating '{expression}': {str(e)}"
# ============================================================================
# Date/Time Toolkit
# ============================================================================
def get_current_datetime(format_str: str = "full") -> str:
"""
Get the current date and time.
Args:
format_str: Output format
- "full": Full datetime with timezone (default)
- "date": Just the date (YYYY-MM-DD)
- "time": Just the time (HH:MM:SS)
- "iso": ISO 8601 format
- Custom strftime format string
Returns:
Formatted current datetime string
Examples:
get_current_datetime("full") -> "2024-01-15 14:30:45"
get_current_datetime("date") -> "2024-01-15"
get_current_datetime("time") -> "14:30:45"
"""
now = datetime.now()
if format_str == "full":
return now.strftime("%Y-%m-%d %H:%M:%S")
elif format_str == "date":
return now.strftime("%Y-%m-%d")
elif format_str == "time":
return now.strftime("%H:%M:%S")
elif format_str == "iso":
return now.isoformat()
else:
# Custom format
try:
return now.strftime(format_str)
except Exception as e:
return f"Error formatting date: {str(e)}"
def calculate_time_offset(offset_description: str) -> str:
"""
Calculate a date/time relative to now.
Args:
offset_description: Natural language description of time offset
Examples: "1 week ago", "2 days from now", "3 months ago",
"1 year from now", "5 hours ago"
Returns:
Formatted datetime string (YYYY-MM-DD HH:MM:SS) or error message
Examples:
calculate_time_offset("1 week ago") -> "2024-01-08 14:30:45"
calculate_time_offset("2 days from now") -> "2024-01-17 14:30:45"
calculate_time_offset("3 months ago") -> "2023-10-15 14:30:45"
"""
try:
now = datetime.now()
# Parse the offset description
# Pattern: "N unit(s) ago/from now"
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
match = re.match(pattern, offset_description.lower().strip())
if not match:
return f"Error: Cannot parse '{offset_description}'. Use format like '1 week ago' or '2 days from now'"
amount = int(match.group(1))
unit = match.group(2)
direction = match.group(3)
# Calculate the offset
if direction == "ago":
amount = -amount
if unit == "second":
target = now + timedelta(seconds=amount)
elif unit == "minute":
target = now + timedelta(minutes=amount)
elif unit == "hour":
target = now + timedelta(hours=amount)
elif unit == "day":
target = now + timedelta(days=amount)
elif unit == "week":
target = now + timedelta(weeks=amount)
elif unit == "month":
# Approximate month as 30 days
target = now + timedelta(days=amount * 30)
elif unit == "year":
# Approximate year as 365 days
target = now + timedelta(days=amount * 365)
else:
return f"Error: Unknown time unit '{unit}'"
return target.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
return f"Error calculating time offset: {str(e)}"
def time_difference(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
Examples:
time_difference("2024-01-01", "now") -> "14 days, 14 hours"
time_difference("2024-01-01", "2024-01-15") -> "14 days"
"""
try:
# Parse date1
if len(date1_str) == 10: # YYYY-MM-DD
date1 = datetime.strptime(date1_str, "%Y-%m-%d")
else:
date1 = datetime.strptime(date1_str, "%Y-%m-%d %H:%M:%S")
# Parse date2
if date2_str.lower() == "now":
date2 = datetime.now()
elif len(date2_str) == 10:
date2 = datetime.strptime(date2_str, "%Y-%m-%d")
else:
date2 = datetime.strptime(date2_str, "%Y-%m-%d %H:%M:%S")
# Calculate difference
diff = abs(date2 - date1)
# Format human-readable
days = diff.days
seconds = diff.seconds
hours = seconds // 3600
minutes = (seconds % 3600) // 60
parts = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes > 0 and days == 0: # Only show minutes if less than a day
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if not parts:
return "Less than a minute"
return ", ".join(parts)
except Exception as e:
return f"Error calculating time difference: {str(e)}"
# ============================================================================
# SearXNG Search Tool
# ============================================================================
async def search_web(query: str, num_results: int = 5) -> str:
"""
Search the web using SearXNG.
Args:
query: Search query string
num_results: Number of results to return (default: 5, max: 10)
Returns:
Formatted search results as a string with titles, URLs, and snippets
Examples:
search_web("Python async programming") -> "1. Title: ...\n URL: ...\n ..."
"""
try:
# Limit results
num_results = min(num_results, 10)
# Get SearXNG host with fallback logic
searxng_host = str(config.SEARXNG_HOST)
# Try production host first, fall back to localhost in development
hosts_to_try = [searxng_host]
if config.ENVIRONMENT.value == "development" and "localhost" not in searxng_host:
# Add localhost fallback for development
hosts_to_try.append("http://localhost:8087")
last_error = None
for host in hosts_to_try:
try:
logger.debug("searxng_search_attempt", host=host, query=query)
async with httpx.AsyncClient(timeout=config.SEARXNG_TIMEOUT) as client:
response = await client.get(
f"{host}/search",
params={
"q": query,
"format": "json",
"pageno": 1,
}
)
if response.status_code == 200:
data = response.json()
results = data.get("results", [])
if not results:
return f"No results found for '{query}'"
# Format results
formatted_results = []
for i, result in enumerate(results[:num_results], 1):
title = result.get("title", "No title")
url = result.get("url", "")
content = result.get("content", "No description available")
formatted_results.append(
f"{i}. {title}\n"
f" URL: {url}\n"
f" {content}\n"
)
logger.info(
"searxng_search_success",
host=host,
query=query,
result_count=len(results),
)
return "\n".join(formatted_results)
else:
last_error = f"SearXNG returned status {response.status_code}"
except httpx.ConnectError:
last_error = f"Cannot connect to SearXNG at {host}"
logger.warning("searxng_connection_failed", host=host)
continue
except Exception as e:
last_error = str(e)
logger.warning("searxng_error", host=host, error=str(e))
continue
# All hosts failed
logger.error("searxng_all_hosts_failed", error=last_error)
return f"Error searching: {last_error}. Please check that SearXNG is running."
except Exception as e:
logger.error("searxng_unexpected_error", error=str(e), exc_info=True)
return f"Error searching: {str(e)}"
+88
View File
@@ -0,0 +1,88 @@
"""
PydanticAI toolset for Tatlock's core tools.
Converts the core tool functions into PydanticAI tool definitions
that can be registered with agents and the household registry.
"""
from pydantic_ai.tools import Tool
from . import tools
# Create tool definitions for PydanticAI
calculator_tool = Tool(
function=tools.calculate,
name="calculate",
description=(
"Safely evaluate mathematical expressions. "
"Supports basic arithmetic (+, -, *, /, %, **), "
"functions (sqrt, sin, cos, log, exp, etc.), "
"and constants (pi, e). "
"Use this for ALL mathematical calculations."
),
)
current_datetime_tool = Tool(
function=tools.get_current_datetime,
name="get_current_datetime",
description=(
"Get the current date and time. "
"Supports various formats: 'full' (datetime), 'date' (YYYY-MM-DD), "
"'time' (HH:MM:SS), 'iso' (ISO 8601), or custom strftime format. "
"Use this instead of guessing the current date/time."
),
)
time_offset_tool = Tool(
function=tools.calculate_time_offset,
name="calculate_time_offset",
description=(
"Calculate a date/time relative to now. "
"Accepts natural language like '1 week ago', '2 days from now', "
"'3 months ago', etc. "
"Use this for calculating past or future dates."
),
)
time_difference_tool = Tool(
function=tools.time_difference,
name="time_difference",
description=(
"Calculate the difference between two dates. "
"Accepts dates in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format. "
"Second date can be 'now'. "
"Returns human-readable difference (e.g., '5 days, 3 hours')."
),
)
web_search_tool = Tool(
function=tools.search_web,
name="search_web",
description=(
"Search the web using SearXNG for current information. "
"Use this to find recent events, current data, or verify facts. "
"Returns formatted results with titles, URLs, and snippets. "
"Useful for information that may have changed since training data."
),
takes_ctx=False,
)
# Combined toolset of all core tools
tatlock_core_tools = [
calculator_tool,
current_datetime_tool,
time_offset_tool,
time_difference_tool,
web_search_tool,
]
def get_core_tools():
"""
Get list of Tatlock's core tool definitions.
Returns:
List of PydanticAI Tool objects
"""
return tatlock_core_tools
+95 -91
View File
@@ -9,7 +9,6 @@ import time
import uuid
from typing import AsyncGenerator
from src.agents.registry import ModelRegistry
from src.chat import constants
from src.chat.schemas import (
ChatCompletionChunk,
@@ -21,6 +20,8 @@ from src.chat.schemas import (
ChatCompletionUsage,
ChatMessage,
)
from src.responses.schemas import ResponseRequest
from src.responses.service import create_response, create_response_with_steward
async def create_chat_completion(
@@ -41,49 +42,45 @@ async def create_chat_completion(
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created_at = int(time.time())
# Strip pipeline prefix if present
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
# Get agent and generate response
agent = ModelRegistry.get_agent(model_id)
# Convert Chat messages to Responses format
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
# Collect output items from agent (with reasoning enabled)
output_items = []
async for item in agent.generate_response(
messages=input_messages,
response_request = ResponseRequest(
model=request.model,
input=input_messages,
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
temperature=request.temperature or 1.0,
max_tokens=request.max_tokens,
max_output_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
):
output_items.append(item)
)
# Build content with <think> tags
# Call Responses API (will use Steward for Tatlock)
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
use_steward = model_id.lower() == "tatlock"
if use_steward:
response = await create_response_with_steward(response_request)
else:
response = await create_response(response_request)
# Convert Responses API output to Chat format
content_parts = []
# Add reasoning as <think> blocks
for item in output_items:
for item in response.output:
if item.type == "reasoning":
reasoning_text = "\n".join(item.data.get("summary", []))
reasoning_text = "\n".join(item.summary)
content_parts.append(f"<think>\n{reasoning_text}\n</think>\n\n")
elif item.type == "message":
content_parts.append(item.data["content"][0]["text"])
content_parts.append(item.content[0].text)
content = "".join(content_parts)
# Calculate token usage (approximate)
prompt_text = " ".join(m.content for m in request.messages)
prompt_tokens = len(prompt_text) // 4
completion_tokens = len(content) // 4
return ChatCompletionResponse(
id=completion_id,
object=constants.CHAT_COMPLETION_OBJECT,
@@ -100,9 +97,9 @@ async def create_chat_completion(
)
],
usage=ChatCompletionUsage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
total_tokens=response.usage.total_tokens,
),
)
@@ -121,23 +118,34 @@ async def create_chat_completion_stream(
Yields:
Chat completion chunks with reasoning as <think> tags
"""
from src.responses.streaming import StreamingCoordinator, StreamEventType
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created_at = int(time.time())
# Strip pipeline prefix if present
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
# Get agent
agent = ModelRegistry.get_agent(model_id)
# Convert Chat messages to Responses format
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
response_request = ResponseRequest(
model=request.model,
input=input_messages,
reasoning={"effort": "medium", "summary": "auto"},
temperature=request.temperature or 1.0,
max_output_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
stream=True,
)
# Determine if we should use Steward
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
use_steward = model_id.lower() == "tatlock"
# First chunk with role
yield ChatCompletionChunk(
id=completion_id,
@@ -153,17 +161,18 @@ async def create_chat_completion_stream(
],
)
# Stream from agent with reasoning enabled
# Stream from Responses API
coordinator = StreamingCoordinator()
in_reasoning = False
async for item in agent.generate_response(
messages=input_messages,
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
temperature=request.temperature or 1.0,
max_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
):
if item.type == "reasoning":
# Start <think> block
if use_steward:
stream_generator = coordinator.stream_response_with_steward(response_request)
else:
stream_generator = coordinator.stream_response(response_request)
async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed
if not in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
@@ -180,24 +189,7 @@ async def create_chat_completion_stream(
)
in_reasoning = True
# Stream reasoning summary steps
for step in item.data.get("summary", []):
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=f"{step}\n"),
finish_reason=None,
)
],
)
await asyncio.sleep(0.05) # Simulate typing
# Close <think> block
# Stream reasoning delta
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -206,20 +198,15 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
delta=ChatCompletionChunkDelta(content=event.delta),
finish_reason=None,
)
],
)
in_reasoning = False
elif item.type == "message":
# Stream message content in chunks (preserves newlines, markdown, etc.)
text = item.data["content"][0]["text"]
chunk_size = 50 # characters per chunk
for i in range(0, len(text), chunk_size):
chunk = text[i:i+chunk_size]
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block
if in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -228,24 +215,41 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=chunk),
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
finish_reason=None,
)
],
)
await asyncio.sleep(0.02) # Faster since chunks are larger
in_reasoning = False
# Final chunk with finish_reason
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(),
finish_reason=constants.FINISH_REASON_STOP,
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=event.delta),
finish_reason=None,
)
],
)
elif event.event == StreamEventType.RESPONSE_DONE:
# Final chunk with finish_reason
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(),
finish_reason=constants.FINISH_REASON_STOP,
)
],
)
],
)
+337
View File
@@ -0,0 +1,337 @@
"""
Performance benchmark storage using Redis.
Tracks operation timing, tool usage, and recommendation accuracy across sessions.
Provides time-series data for performance analysis and optimization.
"""
import json
from datetime import datetime, timezone
from typing import Any, Literal, Optional
import redis.asyncio as redis
from pydantic import BaseModel, Field
from .config import config
from .logging_config import get_logger
logger = get_logger(__name__)
class PerformanceBenchmark(BaseModel):
"""
Performance benchmark record.
Stores timing and metadata for operations like Steward analysis,
tool calls, and agent execution.
"""
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
operation: str # "steward_analysis", "tool_call", "tatlock_execution"
duration_seconds: float
success: bool
# Steward-specific fields
recommendation_count: Optional[int] = None
confidence: Optional[float] = None
# Tool-specific fields
tool_name: Optional[str] = None
was_recommended: Optional[bool] = None
was_actually_used: Optional[bool] = None
# Context
conversation_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
def to_redis_dict(self) -> dict[str, Any]:
"""Convert to dict suitable for Redis storage."""
data = self.model_dump()
data["timestamp"] = self.timestamp.isoformat()
data["metadata"] = json.dumps(self.metadata)
return data
@classmethod
def from_redis_dict(cls, data: dict[str, Any]) -> "PerformanceBenchmark":
"""Reconstruct from Redis dict."""
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
data["metadata"] = json.loads(data.get("metadata", "{}"))
return cls(**data)
class BenchmarkStore:
"""
Redis-backed benchmark storage with automatic expiry.
Stores performance metrics in time-series format with 30-day retention.
Provides querying capabilities for analysis and reporting.
"""
def __init__(self, redis_client: Optional[redis.Redis] = None):
"""
Initialize benchmark store.
Args:
redis_client: Optional Redis client. If None, creates from config.
"""
self._client = redis_client
self._ttl_days = 30 # 30-day retention
async def _get_client(self) -> redis.Redis:
"""Get or create Redis client."""
if self._client is None:
self._client = redis.from_url(
config.redis_url,
encoding="utf-8",
decode_responses=True,
socket_timeout=config.REDIS_TIMEOUT,
socket_connect_timeout=config.REDIS_TIMEOUT,
)
return self._client
async def record(self, benchmark: PerformanceBenchmark) -> None:
"""
Record a performance benchmark.
Args:
benchmark: Performance benchmark to record
Example:
>>> await store.record(PerformanceBenchmark(
... operation="steward_analysis",
... duration_seconds=1.23,
... success=True,
... recommendation_count=3,
... ))
"""
if not config.ENABLE_BENCHMARKS:
return
try:
client = await self._get_client()
# Generate key: benchmark:{operation}:{timestamp_ms}
timestamp_ms = int(benchmark.timestamp.timestamp() * 1000)
key = f"benchmark:{benchmark.operation}:{timestamp_ms}"
# Store as hash
await client.hset(key, mapping=benchmark.to_redis_dict())
# Set expiry
await client.expire(key, self._ttl_days * 24 * 60 * 60)
# Add to sorted set for time-based queries
index_key = f"benchmark_index:{benchmark.operation}"
await client.zadd(index_key, {key: timestamp_ms})
await client.expire(index_key, self._ttl_days * 24 * 60 * 60)
logger.debug(
"benchmark_recorded",
operation=benchmark.operation,
duration=benchmark.duration_seconds,
success=benchmark.success,
)
except Exception as e:
logger.warning(
"benchmark_recording_failed",
error=str(e),
operation=benchmark.operation,
)
# Don't fail the request if benchmarking fails
async def query(
self,
operation: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
limit: int = 100,
) -> list[PerformanceBenchmark]:
"""
Query benchmarks by operation and time range.
Args:
operation: Operation name to filter by
start_time: Start of time range (inclusive)
end_time: End of time range (inclusive)
limit: Maximum number of results
Returns:
List of benchmarks matching the query
Example:
>>> from datetime import timedelta
>>> now = datetime.now(timezone.utc)
>>> yesterday = now - timedelta(days=1)
>>> benchmarks = await store.query(
... "steward_analysis",
... start_time=yesterday,
... limit=50
... )
"""
if not config.ENABLE_BENCHMARKS:
return []
try:
client = await self._get_client()
index_key = f"benchmark_index:{operation}"
# Convert time range to timestamps
min_score = (
int(start_time.timestamp() * 1000)
if start_time
else "-inf"
)
max_score = (
int(end_time.timestamp() * 1000)
if end_time
else "+inf"
)
# Query sorted set
keys = await client.zrevrangebyscore(
index_key,
max_score,
min_score,
start=0,
num=limit,
)
# Fetch benchmark data
benchmarks = []
for key in keys:
data = await client.hgetall(key)
if data:
benchmarks.append(PerformanceBenchmark.from_redis_dict(data))
return benchmarks
except Exception as e:
logger.error(
"benchmark_query_failed",
error=str(e),
operation=operation,
)
return []
async def get_statistics(
self,
operation: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
) -> dict[str, Any]:
"""
Get aggregate statistics for an operation.
Args:
operation: Operation name
start_time: Start of time range
end_time: End of time range
Returns:
Dictionary with statistics (count, avg_duration, success_rate, etc.)
Example:
>>> stats = await store.get_statistics("steward_analysis")
>>> print(f"Average duration: {stats['avg_duration']}s")
>>> print(f"Success rate: {stats['success_rate']}%")
"""
benchmarks = await self.query(operation, start_time, end_time, limit=1000)
if not benchmarks:
return {
"count": 0,
"avg_duration": 0.0,
"min_duration": 0.0,
"max_duration": 0.0,
"success_rate": 0.0,
}
durations = [b.duration_seconds for b in benchmarks]
successes = sum(1 for b in benchmarks if b.success)
return {
"count": len(benchmarks),
"avg_duration": sum(durations) / len(durations),
"min_duration": min(durations),
"max_duration": max(durations),
"success_rate": (successes / len(benchmarks)) * 100,
"total_successes": successes,
"total_failures": len(benchmarks) - successes,
}
async def get_tool_accuracy(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
) -> dict[str, Any]:
"""
Analyze tool recommendation accuracy.
Compares recommended tools vs actually used tools to measure
Steward's recommendation precision.
Args:
start_time: Start of time range
end_time: End of time range
Returns:
Dictionary with accuracy metrics
Example:
>>> accuracy = await store.get_tool_accuracy()
>>> print(f"Precision: {accuracy['precision']}%")
"""
tool_calls = await self.query("tool_call", start_time, end_time, limit=1000)
if not tool_calls:
return {
"total_calls": 0,
"recommended_and_used": 0,
"recommended_not_used": 0,
"not_recommended_but_used": 0,
"precision": 0.0,
}
recommended_and_used = sum(
1 for b in tool_calls
if b.was_recommended and b.was_actually_used
)
not_recommended_but_used = sum(
1 for b in tool_calls
if not b.was_recommended and b.was_actually_used
)
total_used = sum(1 for b in tool_calls if b.was_actually_used)
precision = (
(recommended_and_used / total_used * 100) if total_used > 0 else 0.0
)
return {
"total_calls": len(tool_calls),
"total_used": total_used,
"recommended_and_used": recommended_and_used,
"not_recommended_but_used": not_recommended_but_used,
"precision": precision,
}
async def close(self) -> None:
"""Close Redis connection."""
if self._client:
await self._client.aclose()
self._client = None
# Global benchmark store instance
_benchmark_store: Optional[BenchmarkStore] = None
def get_benchmark_store() -> BenchmarkStore:
"""
Get global benchmark store instance.
Returns:
BenchmarkStore instance
"""
global _benchmark_store
if _benchmark_store is None:
_benchmark_store = BenchmarkStore()
return _benchmark_store
+35 -1
View File
@@ -69,9 +69,28 @@ class Config(BaseSettings):
description="SearXNG request timeout in seconds"
)
# Redis Configuration
REDIS_HOST: str = Field(
default="localhost",
description="Redis server host"
)
REDIS_PORT: int = Field(
default=6379,
description="Redis server port"
)
REDIS_DB: int = Field(
default=1,
description="Redis database number"
)
REDIS_TIMEOUT: int = Field(
default=5,
description="Redis connection timeout in seconds"
)
# Logging
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
# CORS
CORS_ORIGINS: list[str] = Field(
default=["*"],
@@ -81,6 +100,21 @@ class Config(BaseSettings):
CORS_ALLOW_METHODS: list[str] = ["*"]
CORS_ALLOW_HEADERS: list[str] = ["*"]
@property
def redis_url(self) -> str:
"""Construct Redis connection URL."""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def log_format(self) -> str:
"""
Determine log format based on environment.
- production: JSON format for machine parsing
- development/testing: Console format for human readability
"""
return "json" if self.ENVIRONMENT == Environment.PRODUCTION else "console"
@lru_cache
def get_config() -> Config:
+268
View File
@@ -0,0 +1,268 @@
"""
Household registry for managing agent capabilities and toolsets.
Provides centralized registry of household members (agents) with their
capabilities and tools. Supports two-tier abstraction: executive summaries
for coordination and full toolsets for execution.
"""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict
from pydantic_ai import Agent
from .logging_config import get_logger
logger = get_logger(__name__)
class HouseholdCapability(BaseModel):
"""
Executive summary of a household member's capabilities.
This is what the Steward and Butler see for coordination.
High-level description without implementation details.
"""
name: str # Unique identifier: "tatlock_core", "librarian", "developer"
role: str # Display name: "Butler's Core Tools", "The Librarian"
category: str # "core", "research", "technical", "automation"
description: str # One-sentence description of capabilities
domains: list[str] # Capability domains: ["computation", "information", "datetime"]
cost: str # "low", "medium", "high" - resource cost estimate
requires_network: bool # Whether network access is needed
class HouseholdMember(BaseModel):
"""
Full specification of a household member.
Contains both the executive summary (for coordination) and
implementation details (tools/agent).
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
capability: HouseholdCapability
tools: list[Any] # PydanticAI tool definitions (any type since Tool is a dataclass)
agent: Optional[Any] = None # For expert agents (Phase 4)
class HouseholdRegistry:
"""
Registry of household capabilities and implementations.
Manages household members and their tools. Provides:
1. Executive summaries for Steward/Butler coordination
2. Full toolsets for scoped execution
3. Agent delegation (Phase 4)
"""
def __init__(self):
"""Initialize empty registry."""
self._members: dict[str, HouseholdMember] = {}
logger.info("household_registry_initialized")
def register(
self,
name: str,
capability: HouseholdCapability,
tools: list[Any],
agent: Optional[Any] = None,
) -> None:
"""
Register a household member.
Args:
name: Unique identifier (must match capability.name)
capability: Executive summary
tools: PydanticAI tool definitions
agent: Optional expert agent for delegation
Raises:
ValueError: If name doesn't match capability.name
Example:
>>> registry.register(
... name="tatlock_core",
... capability=HouseholdCapability(
... name="tatlock_core",
... role="Butler's Core Tools",
... category="core",
... description="Basic computation, time, and information tools",
... domains=["computation", "datetime", "information"],
... cost="low",
... requires_network=True,
... ),
... tools=[calculator_tool, datetime_tool, search_tool],
... )
"""
if name != capability.name:
raise ValueError(
f"Name mismatch: '{name}' != '{capability.name}'"
)
self._members[name] = HouseholdMember(
capability=capability,
tools=tools,
agent=agent,
)
logger.info(
"household_member_registered",
name=name,
role=capability.role,
domains=capability.domains,
tool_count=len(tools),
has_agent=agent is not None,
)
def unregister(self, name: str) -> None:
"""
Unregister a household member.
Args:
name: Member name to remove
Example:
>>> registry.unregister("tatlock_core")
"""
if name in self._members:
member = self._members.pop(name)
logger.info(
"household_member_unregistered",
name=name,
role=member.capability.role,
)
def get_member(self, name: str) -> Optional[HouseholdMember]:
"""
Get full household member specification.
Args:
name: Member name
Returns:
HouseholdMember if found, None otherwise
"""
return self._members.get(name)
def get_all_capabilities(self) -> list[HouseholdCapability]:
"""
Get executive summaries of all household members.
This is what the Steward sees when analyzing requests.
Returns high-level capabilities without implementation details.
Returns:
List of capability summaries
Example:
>>> capabilities = registry.get_all_capabilities()
>>> for cap in capabilities:
... print(f"{cap.role}: {cap.description}")
"""
return [member.capability for member in self._members.values()]
def get_scoped_tools(self, names: list[str]) -> list[Any]:
"""
Get combined tools from specified household members.
Creates a scoped toolset containing only tools from
the requested members. Used to give Tatlock only the
tools recommended by the Steward.
Args:
names: List of member names to include
Returns:
Combined list of tool definitions
Example:
>>> # Steward recommends only tatlock_core
>>> tools = registry.get_scoped_tools(["tatlock_core"])
>>> # Tatlock now has only core tools, not all household tools
"""
tools = []
for name in names:
member = self._members.get(name)
if member:
tools.extend(member.tools)
else:
logger.warning(
"household_member_not_found",
requested_name=name,
available_names=list(self._members.keys()),
)
logger.debug(
"scoped_tools_created",
requested_members=names,
total_tools=len(tools),
)
return tools
def list_members(self) -> list[str]:
"""
List all registered member names.
Returns:
List of member names
"""
return list(self._members.keys())
def get_members_by_domain(self, domain: str) -> list[HouseholdCapability]:
"""
Get capabilities that support a specific domain.
Args:
domain: Domain to filter by (e.g., "computation", "research")
Returns:
List of capabilities supporting the domain
Example:
>>> # Find all members that can do research
>>> research_caps = registry.get_members_by_domain("research")
"""
return [
member.capability
for member in self._members.values()
if domain in member.capability.domains
]
def get_members_by_category(self, category: str) -> list[HouseholdCapability]:
"""
Get capabilities by category.
Args:
category: Category to filter by (e.g., "core", "research", "technical")
Returns:
List of capabilities in the category
"""
return [
member.capability
for member in self._members.values()
if member.capability.category == category
]
def __len__(self) -> int:
"""Get number of registered members."""
return len(self._members)
def __contains__(self, name: str) -> bool:
"""Check if member is registered."""
return name in self._members
# Global registry instance
household_registry = HouseholdRegistry()
def get_household_registry() -> HouseholdRegistry:
"""
Get global household registry instance.
Returns:
HouseholdRegistry instance
"""
return household_registry
+252
View File
@@ -0,0 +1,252 @@
"""
Structured logging configuration using structlog.
Deeply integrates with FastAPI/uvicorn's built-in logging to provide
seamless structured logs across the entire application stack.
"""
import logging
import logging.config
import sys
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, AsyncIterator
import structlog
from structlog.types import EventDict, Processor
from .config import config
def add_timestamp(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
"""Add ISO 8601 timestamp to log entries."""
event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
return event_dict
def add_log_level(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
"""Add log level to event dict."""
event_dict["level"] = method_name.upper()
return event_dict
def extract_from_record(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
"""
Extract extra fields from logging.LogRecord for standard library integration.
This allows standard Python logging calls to include structured data:
logger.info("request received", extra={"user_id": "123", "path": "/api"})
"""
record = event_dict.get("_record")
if record is not None:
# Extract custom fields from record
for key, value in record.__dict__.items():
if key not in {
"name", "msg", "args", "created", "filename", "funcName",
"levelname", "levelno", "lineno", "module", "msecs",
"message", "pathname", "process", "processName", "relativeCreated",
"thread", "threadName", "exc_info", "exc_text", "stack_info",
"taskName"
}:
event_dict[key] = value
return event_dict
def configure_logging() -> None:
"""
Configure structured logging with deep FastAPI/uvicorn integration.
- Replaces all Python logging with structlog
- FastAPI, uvicorn, and app logs all use same format
- JSON format for production, pretty console for development
- Preserves log levels and exception handling
"""
# Determine processors based on log format
shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_logger_name,
add_log_level,
add_timestamp,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
extract_from_record,
]
if config.log_format == "json":
# JSON format for production
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
foreign_pre_chain=shared_processors,
)
else:
# Console format for development
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.dev.ConsoleRenderer(colors=True),
],
foreign_pre_chain=shared_processors,
)
# Configure Python's logging to use structlog
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
# Set up root logger
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(logging.getLevelName(config.LOG_LEVEL))
# Configure specific loggers
for logger_name in [
"uvicorn",
"uvicorn.access",
"uvicorn.error",
"fastapi",
"tatlock",
]:
logger = logging.getLogger(logger_name)
logger.handlers.clear()
logger.propagate = True
logger.setLevel(logging.getLevelName(config.LOG_LEVEL))
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
"""
Get a structured logger instance.
Works seamlessly with both structlog and standard logging calls:
- logger.info("message", key="value") - structlog style
- logger.info("message", extra={"key": "value"}) - standard logging style
Args:
name: Logger name (typically __name__)
Returns:
Configured structlog BoundLogger
Example:
>>> logger = get_logger(__name__)
>>> logger.info("user_request", user_id="123", action="search")
>>> logger.info("standard log", extra={"request_id": "abc"})
"""
return structlog.get_logger(name)
@asynccontextmanager
async def log_operation(
operation: str,
initial_context: dict[str, Any] | None = None,
logger_name: str = "tatlock.operations"
) -> AsyncIterator[dict[str, Any]]:
"""
Context manager for automatic operation timing and logging.
Args:
operation: Operation name (e.g., "steward_analysis", "tool_call")
initial_context: Initial metadata to log
logger_name: Logger name for this operation
Yields:
Context dict that can be updated during operation
Example:
>>> async with log_operation("steward_analysis", {"user_id": "123"}) as ctx:
... # Do work
... ctx["recommendation_count"] = 3
... # Automatically logs duration and context on exit
"""
logger = get_logger(logger_name)
context = initial_context or {}
context["operation"] = operation
start_time = datetime.now(timezone.utc)
logger.info("operation_started", **context)
try:
yield context
# Success case
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
context["duration_seconds"] = duration
context["success"] = True
logger.info("operation_completed", **context)
except Exception as e:
# Error case
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
context["duration_seconds"] = duration
context["success"] = False
context["error"] = str(e)
context["error_type"] = type(e).__name__
logger.error("operation_failed", **context, exc_info=True)
raise
def get_uvicorn_log_config() -> dict[str, Any]:
"""
Get uvicorn logging configuration that integrates with structlog.
Use this when starting uvicorn:
uvicorn.run(app, log_config=get_uvicorn_log_config())
Returns:
Uvicorn-compatible logging configuration dict
"""
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer() if config.log_format == "json"
else structlog.dev.ConsoleRenderer(colors=True),
],
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": config.LOG_LEVEL},
"uvicorn.error": {"handlers": ["default"], "level": config.LOG_LEVEL},
"uvicorn.access": {"handlers": ["default"], "level": config.LOG_LEVEL},
},
}
# Initialize logging on module import
configure_logging()
+105
View File
@@ -0,0 +1,105 @@
"""
Request preprocessing pipeline.
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
"""
from dataclasses import dataclass
from typing import Any, Optional
from src.agents.steward import analyze_request, format_steward_note
from src.agents.steward.schemas import StewardRecommendation
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@dataclass
class EnrichedRequest:
"""
Request enriched with Steward's analysis.
Attributes:
original_request: The user's original message
steward_note: Formatted note for Tatlock (includes context analysis)
scoped_tools: List of tools from recommended capabilities
recommendation: Full Steward recommendation
steward_reasoning: Plain text reasoning for streaming to user
"""
original_request: str
steward_note: str
scoped_tools: list[Any] # PydanticAI tool definitions
recommendation: StewardRecommendation
steward_reasoning: str
async def preprocess_request(
user_request: str,
conversation_history: list[dict],
conversation_id: Optional[str] = None,
) -> EnrichedRequest:
"""
Analyze request via Steward and prepare scoped context for Tatlock.
This is the main preprocessing pipeline that:
1. Calls Steward with full conversation history
2. Gets capability recommendations
3. Creates scoped toolset from recommended capabilities
4. Formats a note for Tatlock with context analysis
Args:
user_request: Current user message to analyze
conversation_history: Full conversation history (all previous turns)
conversation_id: Optional conversation ID for tracking
Returns:
EnrichedRequest with scoped tools and Steward analysis
Example:
>>> enriched = await preprocess_request(
... "What's sqrt(144)?",
... conversation_history=[],
... )
>>> print(enriched.recommendation.recommended_capabilities)
['tatlock_core']
>>> print(len(enriched.scoped_tools))
5 # All tatlock_core tools
"""
logger.info(
"preprocessing_request",
request_preview=user_request[:100],
history_length=len(conversation_history),
conversation_id=conversation_id,
)
# Call Steward with full conversation history
recommendation = await analyze_request(
user_request,
conversation_history=conversation_history,
conversation_id=conversation_id,
)
# Format note for Tatlock (includes conversation context)
steward_note = await format_steward_note(recommendation)
# Get scoped tools from household registry
registry = get_household_registry()
scoped_tools = registry.get_scoped_tools(
recommendation.recommended_capabilities
)
logger.info(
"preprocessing_complete",
recommended_capabilities=recommendation.recommended_capabilities,
tool_count=len(scoped_tools),
complexity=recommendation.estimated_complexity,
has_context=recommendation.conversation_context.has_previous_context,
)
return EnrichedRequest(
original_request=user_request,
steward_note=steward_note,
scoped_tools=scoped_tools,
recommendation=recommendation,
steward_reasoning=recommendation.reasoning,
)
+70
View File
@@ -0,0 +1,70 @@
"""
Application startup module.
Handles initialization of household registry and other startup tasks.
This module should be called during application startup to register
all household members.
"""
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
logger = get_logger(__name__)
def register_household_members():
"""
Register all household members with the registry.
This function should be called during application startup to make
household capabilities available to the Steward.
Currently registers:
- tatlock_core: Butler's core tools (calculator, datetime, web search)
Future phases will add:
- librarian: Research and knowledge management
- developer: Software development assistance
- etc.
"""
registry = get_household_registry()
logger.info("household_registration_starting")
# Register Tatlock's core tools
registry.register(
name="tatlock_core",
capability=TATLOCK_CORE_CAPABILITY,
tools=tatlock_core_tools,
agent=None, # No expert agent for core tools
)
logger.info(
"household_member_registered",
name="tatlock_core",
tool_count=len(tatlock_core_tools),
)
logger.info(
"household_registration_complete",
total_members=len(registry),
)
def initialize_application():
"""
Initialize the application.
Performs all startup tasks:
1. Register household members
2. (Future) Initialize connections
3. (Future) Load configuration
This should be called once during application startup.
"""
logger.info("application_initialization_starting")
# Register household members
register_household_members()
logger.info("application_initialization_complete")
+164
View File
@@ -0,0 +1,164 @@
"""
Tool call tracking and benchmarking.
Tracks which tools are recommended by the Steward versus which tools
are actually used by Tatlock, recording benchmarks for analysis.
"""
from datetime import datetime, timezone
from typing import Optional
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
from src.core.logging_config import get_logger
logger = get_logger(__name__)
class ToolCallTracker:
"""
Tracks tool calls for benchmarking and accuracy analysis.
Compares Steward's recommendations with Tatlock's actual tool usage
to measure recommendation accuracy.
"""
def __init__(
self,
recommended_capabilities: list[str],
conversation_id: Optional[str] = None
):
"""
Initialize tool call tracker.
Args:
recommended_capabilities: List of capability names recommended by Steward
conversation_id: Optional conversation ID for tracking
"""
self.recommended_capabilities = set(recommended_capabilities)
self.actual_calls: dict[str, list[float]] = {} # tool_name -> [durations]
self.conversation_id = conversation_id
logger.debug(
"tool_tracker_initialized",
recommended=list(self.recommended_capabilities),
conversation_id=conversation_id,
)
async def track_call(self, tool_name: str, duration: float):
"""
Record a tool call with timing.
Args:
tool_name: Name of the tool that was called
duration: Duration of the call in seconds
"""
# Record the call
if tool_name not in self.actual_calls:
self.actual_calls[tool_name] = []
self.actual_calls[tool_name].append(duration)
# Check if tool was recommended
was_recommended = tool_name in self.recommended_capabilities
if not was_recommended:
logger.warning(
"tool_call_not_recommended",
tool_name=tool_name,
duration=duration,
recommended=list(self.recommended_capabilities),
)
# Record benchmark to Redis
benchmark = PerformanceBenchmark(
timestamp=datetime.now(timezone.utc),
operation="tool_call",
duration_seconds=duration,
success=True, # If we got here, the call succeeded
tool_name=tool_name,
was_recommended=was_recommended,
was_actually_used=True,
conversation_id=self.conversation_id,
metadata={
"recommended_capabilities": list(self.recommended_capabilities),
},
)
await get_benchmark_store().record(benchmark)
logger.debug(
"tool_call_tracked",
tool_name=tool_name,
duration=duration,
was_recommended=was_recommended,
)
async def finalize(self):
"""
Finalize tracking and log unused recommended tools.
Called after Tatlock completes its response to identify
tools that were recommended but never used.
"""
# Find tools that were recommended but not used
unused_tools = self.recommended_capabilities - set(self.actual_calls.keys())
if unused_tools:
logger.info(
"recommended_tools_unused",
unused=list(unused_tools),
used=list(self.actual_calls.keys()),
conversation_id=self.conversation_id,
)
# Record benchmarks for unused recommendations
for tool_name in unused_tools:
benchmark = PerformanceBenchmark(
timestamp=datetime.now(timezone.utc),
operation="tool_call",
duration_seconds=0.0, # Not used
success=True,
tool_name=tool_name,
was_recommended=True,
was_actually_used=False,
conversation_id=self.conversation_id,
metadata={
"recommended_capabilities": list(self.recommended_capabilities),
"reason": "recommended_but_unused",
},
)
await get_benchmark_store().record(benchmark)
# Log summary
total_calls = sum(len(durations) for durations in self.actual_calls.values())
logger.info(
"tool_tracking_finalized",
total_calls=total_calls,
unique_tools_used=len(self.actual_calls),
recommended_count=len(self.recommended_capabilities),
unused_count=len(unused_tools),
)
def get_summary(self) -> dict:
"""
Get tracking summary for debugging.
Returns:
Dict with tracking statistics
"""
total_calls = sum(len(durations) for durations in self.actual_calls.values())
unused = self.recommended_capabilities - set(self.actual_calls.keys())
return {
"recommended_capabilities": list(self.recommended_capabilities),
"tools_used": list(self.actual_calls.keys()),
"tools_unused": list(unused),
"total_calls": total_calls,
"accuracy": {
"recommended_and_used": len(
self.recommended_capabilities & set(self.actual_calls.keys())
),
"recommended_but_unused": len(unused),
"not_recommended_but_used": len(
set(self.actual_calls.keys()) - self.recommended_capabilities
),
},
}
+43 -24
View File
@@ -9,7 +9,6 @@ Main responsibilities:
- Router registration
- Lifecycle management
"""
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -21,35 +20,42 @@ from fastapi.responses import JSONResponse
from src.chat.router import router as chat_router
from src.core.config import config
from src.core.exceptions import AppException
from src.core.logging_config import get_logger
from src.core.router import router as core_router
from src.core.startup import initialize_application
from src.models.router import router as models_router
from src.responses.router import router as responses_router
# Configure logging
logging.basicConfig(
level=config.LOG_LEVEL,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Get structured logger
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
Application lifespan manager.
Handles startup and shutdown logic.
"""
# Startup
logger.info(f"Starting {config.APP_NAME} v{config.APP_VERSION}")
logger.info(f"Environment: {config.ENVIRONMENT.value}")
logger.info(f"Ollama host: {config.OLLAMA_HOST}")
logger.info(f"Default model: {config.OLLAMA_DEFAULT_MODEL}")
logger.info(
"application_starting",
app_name=config.APP_NAME,
version=config.APP_VERSION,
environment=config.ENVIRONMENT.value,
ollama_host=str(config.OLLAMA_HOST),
ollama_model=config.OLLAMA_DEFAULT_MODEL,
redis_url=config.redis_url,
log_format=config.log_format,
)
# Initialize application (register household members, etc.)
initialize_application()
yield
# Shutdown
logger.info("Shutting down application")
logger.info("application_shutdown")
def create_application() -> FastAPI:
@@ -102,10 +108,14 @@ def register_exception_handlers(application: FastAPI) -> None:
) -> JSONResponse:
"""Handle custom application exceptions."""
logger.error(
f"Application error: {exc.message}",
extra={"details": exc.details}
"application_exception",
error_message=exc.message,
error_type=exc.__class__.__name__,
status_code=exc.status_code,
details=exc.details,
path=request.url.path,
)
return JSONResponse(
status_code=exc.status_code,
content={
@@ -116,15 +126,19 @@ def register_exception_handlers(application: FastAPI) -> None:
}
},
)
@application.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""Handle Pydantic validation errors."""
logger.error(f"Validation error: {exc.errors()}")
logger.error(
"validation_error",
errors=exc.errors(),
path=request.url.path,
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
@@ -135,15 +149,20 @@ def register_exception_handlers(application: FastAPI) -> None:
}
},
)
@application.exception_handler(Exception)
async def general_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""Handle unexpected exceptions."""
logger.exception("Unexpected error")
logger.exception(
"unexpected_error",
error_type=type(exc).__name__,
error_message=str(exc),
path=request.url.path,
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
+26 -4
View File
@@ -95,13 +95,35 @@ async def create_response(
logger.info(f"Response request for model: {request.model}")
try:
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
use_steward = model_id.lower() == "tatlock"
if request.stream:
logger.info("Streaming response requested")
return EventSourceResponse(
service.create_response_stream(request)
)
if use_steward:
logger.info("Streaming with Steward preprocessing for Tatlock request")
# Use Steward + Tatlock streaming (Milestone 3.5)
from src.responses.streaming import StreamingCoordinator
coordinator = StreamingCoordinator()
return EventSourceResponse(
coordinator.stream_response_with_steward(request)
)
else:
# Regular streaming for non-Tatlock models
return EventSourceResponse(
service.create_response_stream(request)
)
return await service.create_response(request)
# Use appropriate service method
if use_steward:
logger.info("Using Steward preprocessing for Tatlock request")
return await service.create_response_with_steward(request)
else:
return await service.create_response(request)
except ModelNotFoundError as e:
logger.error(f"Model not found: {e}")
+139 -13
View File
@@ -3,6 +3,7 @@ Response service for creating responses.
Handles both streaming and non-streaming response generation.
Tracks conversation history for analytics and future vector memory.
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
"""
import time
@@ -22,6 +23,11 @@ from src.responses.schemas import (
from src.responses.streaming import StreamingCoordinator
from src.responses.history import ConversationHistory
from src.responses.context import ContextWindow
from src.core.preprocessing import preprocess_request
from src.core.tool_tracking import ToolCallTracker
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# Global conversation history tracker
# In production, this would be backed by a database or Redis
@@ -59,8 +65,18 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
reasoning_tokens = 0
for item in output_items:
if hasattr(item, 'type'):
# Agent OutputItem objects
# Check if it's a schema object (has summary/content attributes directly)
if isinstance(item, ReasoningOutputItem):
reasoning_text = " ".join(item.summary)
reasoning_tokens += len(reasoning_text) // 4
elif isinstance(item, MessageOutputItem):
message_text = item.content[0].text
output_tokens += len(message_text) // 4
elif isinstance(item, FunctionCallOutputItem):
func_text = item.arguments
output_tokens += len(func_text) // 4
elif hasattr(item, 'type'):
# Agent OutputItem objects (backward compatibility)
if item.type == "reasoning":
reasoning_text = " ".join(item.data.get("summary", []))
reasoning_tokens += len(reasoning_text) // 4
@@ -70,17 +86,6 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
elif item.type == "function_call":
func_text = item.data["arguments"]
output_tokens += len(func_text) // 4
else:
# Schema OutputItem objects
if isinstance(item, ReasoningOutputItem):
reasoning_text = " ".join(item.summary)
reasoning_tokens += len(reasoning_text) // 4
elif isinstance(item, MessageOutputItem):
message_text = item.content[0].text
output_tokens += len(message_text) // 4
elif isinstance(item, FunctionCallOutputItem):
func_text = item.arguments
output_tokens += len(func_text) // 4
total_tokens = input_tokens + output_tokens + reasoning_tokens
@@ -157,6 +162,127 @@ async def create_response(request: ResponseRequest) -> Response:
return response
async def create_response_with_steward(request: ResponseRequest) -> Response:
"""
Create response using Steward preprocessing (Phase 2 flow).
This is the two-tier architecture where:
1. Steward analyzes the request and recommends capabilities
2. Tatlock runs with scoped tools based on recommendations
3. Tool usage is tracked for benchmarking
Args:
request: Response request
Returns:
Response: Complete response object with Steward analysis included
Example:
request = ResponseRequest(
model="tatlock",
input=[{"role": "user", "content": "What's sqrt(144)?"}],
metadata={"conversation_id": "conv_abc123"}
)
response = await create_response_with_steward(request)
"""
# Get or generate conversation ID
conversation_id = await _conversation_history.get_conversation_id(request)
# Extract user message and conversation history
user_message = ""
for msg in reversed(request.input):
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Conversation history is all messages except the current one
conversation_history = request.input[:-1] if len(request.input) > 1 else []
logger.info(
"creating_response_with_steward",
user_message_preview=user_message[:100],
history_length=len(conversation_history),
conversation_id=conversation_id,
)
# Phase 1: Steward preprocessing
enriched = await preprocess_request(
user_message,
conversation_history=conversation_history,
conversation_id=conversation_id,
)
# Phase 2: Initialize tool tracker
tracker = ToolCallTracker(
recommended_capabilities=enriched.recommendation.recommended_capabilities,
conversation_id=conversation_id,
)
# Phase 3: Run Tatlock with scoped tools
from src.agents.tatlock import TatlockAgent
tatlock = TatlockAgent()
tatlock_response = await tatlock.run_with_scoped_tools(
user_message=user_message,
steward_note=enriched.steward_note,
scoped_tools=enriched.scoped_tools,
message_history=conversation_history,
tool_tracker=tracker,
)
# Phase 4: Finalize tool tracking
await tracker.finalize()
# Build response output items
output_items = []
# Add Steward reasoning as a reasoning output item
output_items.append(ReasoningOutputItem(
id=f"reasoning_{generate_id()}",
summary=[
"🎩 Steward's Analysis:",
enriched.steward_reasoning,
],
status="completed"
))
# Add Tatlock's message
output_items.append(MessageOutputItem(
id=f"msg_{generate_id()}",
role="assistant",
content=[OutputTextContent(
type="output_text",
text=tatlock_response,
annotations=[]
)],
status="completed"
))
# Calculate usage (approximate)
usage = _calculate_usage(request.input, output_items)
response = Response(
id=f"resp_{generate_id()}",
created_at=int(time.time()),
model=request.model,
status="completed",
output=output_items,
usage=usage
)
# Track conversation history
await _conversation_history.add_response(conversation_id, response)
logger.info(
"response_with_steward_complete",
response_id=response.id,
recommended_capabilities=enriched.recommendation.recommended_capabilities,
tool_summary=tracker.get_summary(),
)
return response
async def create_response_stream(
request: ResponseRequest
) -> AsyncGenerator[dict, None]: