Files
tatlock/src/agents/steward/service.py
T
jpmschweitzerandClaude Opus 4.5 7426dd1ac3 feat: add Phase F.2 - The Biographer (memory agent)
Add The Biographer household member for user memory management:

Memory Service (direct access layer):
- src/core/memory_service.py for fast, LLM-free lookups
- Profile, preference, and fact management
- Session context with Redis caching
- Steward integration via prefetch_context()

The Biographer Agent:
- src/agents/biographer/ package with PydanticAI agent
- Discreet chronicler personality for privacy
- Tools: recall_semantic, list_memories, store_insight,
  update_profile, update_preference, forget_memory
- Registered with Household Registry on startup

Steward Integration:
- Memory context pre-fetch during analysis
- Profile/preferences included in Butler note
- Keyword-based context determination

Also includes:
- delegate_to_biographer() wrapper
- 34 new tests (capability + memory service)
- Version bump to 1.2.0

Documentation cleanup:
- Removed obsolete PHASE2_COMPLETE.md, PHASE2_PLAN.md
- Removed docs/library-desk-requirements.md
- Moved ORCHESTRATION_SCENARIOS.md to project root

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 19:20:18 +01:00

354 lines
11 KiB
Python

"""
Steward service layer.
Provides high-level interface for request analysis with logging,
benchmarking, and error handling.
Parses plain text recommendations into structured data.
Includes memory pre-fetch for user context injection.
"""
import re
from typing import Any, 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 src.core.memory_service import memory_service
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 _prefetch_memory_context(user_request: str) -> dict[str, Any]:
"""
Pre-fetch user context that might be needed for this request.
This is the "direct access" layer - fast lookups without LLM overhead.
Uses simple keyword matching to determine what context to fetch.
Args:
user_request: The user's request text
Returns:
Dict with profile and/or preferences data
Example:
>>> ctx = await _prefetch_memory_context("What's the weather?")
>>> ctx
{"profile": {"location": "Amsterdam"}}
"""
request_lower = user_request.lower()
# Determine what context might be needed based on keywords
profile_keys = []
# Location-related queries
if any(word in request_lower for word in [
"weather", "temperature", "forecast", "nearby", "local",
"directions", "distance", "map", "here"
]):
profile_keys.append("location")
# Time-related queries
if any(word in request_lower for word in [
"time", "schedule", "meeting", "appointment", "reminder",
"alarm", "when", "today", "tomorrow"
]):
profile_keys.append("timezone")
# Personal queries
if any(word in request_lower for word in [
"my name", "who am i", "about me"
]):
profile_keys.append("name")
# Always fetch preferences if they might affect response format
include_preferences = any(word in request_lower for word in [
"temperature", "weather", "convert", "unit", "format",
"celsius", "fahrenheit", "metric", "imperial"
])
try:
return await memory_service.prefetch_context(
include_profile=bool(profile_keys),
include_preferences=include_preferences,
profile_keys=profile_keys if profile_keys else None,
)
except Exception as e:
logger.warning(
"steward_prefetch_memory_failed",
error=str(e),
)
return {}
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:
# Pre-fetch user context from memory (fast, no LLM)
memory_context = await _prefetch_memory_context(user_request)
log_ctx["memory_context_keys"] = list(memory_context.keys())
# Get Steward agent
steward = get_steward_agent()
logger.debug(
"steward_analyzing_request",
request=user_request,
history_turns=len(conversation_history),
memory_context=bool(memory_context),
)
# 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,
memory_context=memory_context,
)
# 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()