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:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user