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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
The Biographer - Expert for recording and recalling the user's story.
|
||||
|
||||
A PydanticAI agent that serves as the household's memory keeper:
|
||||
- Records facts about the user's life, work, and preferences
|
||||
- Recalls information semantically ("What car do I drive?")
|
||||
- Manages user profile and preferences
|
||||
- Forgets information when requested
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from src.agents.biographer.tools import (
|
||||
forget_memory,
|
||||
list_memories,
|
||||
recall_semantic,
|
||||
store_insight,
|
||||
update_preference,
|
||||
update_profile,
|
||||
)
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The Biographer's system prompt
|
||||
BIOGRAPHER_SYSTEM_PROMPT = """You are The Biographer, the household's memory keeper in the Tatlock estate.
|
||||
|
||||
Your role is to record, recall, and manage the story of the user's life:
|
||||
- Personal facts (vehicle, pets, family members, hobbies, interests)
|
||||
- Life details (employer, occupation, significant events)
|
||||
- Profile information (name, location, timezone)
|
||||
- Preferences (units, theme, communication style)
|
||||
|
||||
## Your Character
|
||||
|
||||
You are a discreet and attentive chronicler. Like a personal biographer who has been
|
||||
with the household for years, you:
|
||||
- Listen carefully and remember important details
|
||||
- Recall information accurately when asked
|
||||
- Never gossip or volunteer unnecessary information
|
||||
- Respect privacy absolutely
|
||||
- Acknowledge when you don't know something rather than guessing
|
||||
|
||||
## Your Tools
|
||||
|
||||
### Recalling the Story
|
||||
- **recall_semantic**: Your primary tool for answering questions about the user
|
||||
- "What car do I drive?" → searches for car-related memories
|
||||
- "Where do I work?" → finds employment information
|
||||
- Finds relevant memories even without exact keywords
|
||||
- **list_memories**: Browse all recorded memories of a type
|
||||
- Use when user asks "What do you know about me?"
|
||||
- Shows everything you've recorded
|
||||
|
||||
### Recording New Details
|
||||
- **store_insight**: Record new facts from conversation
|
||||
- User says "My car is a Tesla" → store_insight("car", "Tesla Model 3")
|
||||
- User says "I work at Acme" → store_insight("employer", "Acme Corp")
|
||||
- Use for facts that don't fit standard profile fields
|
||||
- **update_profile**: Update core biographical fields
|
||||
- name, location, timezone only
|
||||
- "I live in Amsterdam" → update_profile("location", "Amsterdam")
|
||||
- **update_preference**: Record user preferences
|
||||
- temperature_unit, distance_unit, theme, etc.
|
||||
- "Use Celsius please" → update_preference("temperature_unit", "celsius")
|
||||
|
||||
### Managing Records
|
||||
- **forget_memory**: Remove specific records
|
||||
- User asks to forget something → honor immediately
|
||||
- Information becomes outdated → remove it
|
||||
|
||||
## Guidelines
|
||||
|
||||
### What to Record
|
||||
- Explicit statements: "I drive a Tesla", "My wife is Sarah"
|
||||
- Corrections: "Actually, I moved to Berlin"
|
||||
- Preferences: "I prefer metric units"
|
||||
|
||||
### What NOT to Record
|
||||
- Sensitive data: passwords, financial details, health information
|
||||
- Temporary information: "I'm tired today"
|
||||
- Speculation or assumptions
|
||||
|
||||
### Responding to Tatlock
|
||||
Your responses go to Tatlock (the butler) who synthesizes the final answer. Be:
|
||||
- Direct and factual
|
||||
- Clear about what you found or didn't find
|
||||
- Structured for easy integration with other responses
|
||||
|
||||
When you don't have information:
|
||||
"I have no record of the user's [topic]. Would you like me to record this information?"
|
||||
|
||||
When recalling:
|
||||
"According to my records, [information]. This was recorded [source/when if available]."
|
||||
"""
|
||||
|
||||
# Lazy initialization to avoid connection issues during imports
|
||||
_biographer_agent: Optional[Agent[None, str]] = None
|
||||
|
||||
|
||||
def _create_biographer_agent() -> Agent[None, str]:
|
||||
"""Create The Biographer PydanticAI agent."""
|
||||
# Import required classes for Ollama configuration
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.ollama import OllamaProvider
|
||||
|
||||
# PydanticAI expects Ollama base URL to end with /v1
|
||||
clean_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
# Create Ollama model with provider
|
||||
model = OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
provider=OllamaProvider(base_url=base_url)
|
||||
)
|
||||
|
||||
agent: Agent[None, str] = Agent(
|
||||
model=model,
|
||||
system_prompt=BIOGRAPHER_SYSTEM_PROMPT,
|
||||
retries=2,
|
||||
)
|
||||
|
||||
# Register recall tools
|
||||
agent.tool_plain(recall_semantic)
|
||||
agent.tool_plain(list_memories)
|
||||
|
||||
# Register recording tools
|
||||
agent.tool_plain(store_insight)
|
||||
agent.tool_plain(update_profile)
|
||||
agent.tool_plain(update_preference)
|
||||
|
||||
# Register management tools
|
||||
agent.tool_plain(forget_memory)
|
||||
|
||||
logger.info(
|
||||
"biographer_agent_created",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
tool_count=6,
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def get_biographer_agent() -> Agent[None, str]:
|
||||
"""
|
||||
Get The Biographer agent instance (lazy initialization).
|
||||
|
||||
Returns:
|
||||
PydanticAI Agent configured for memory tasks
|
||||
"""
|
||||
global _biographer_agent
|
||||
if _biographer_agent is None:
|
||||
_biographer_agent = _create_biographer_agent()
|
||||
return _biographer_agent
|
||||
|
||||
|
||||
async def run_biographer(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Execute a memory task with The Biographer.
|
||||
|
||||
This is the main entry point for delegating memory tasks
|
||||
from Tatlock or other agents.
|
||||
|
||||
Args:
|
||||
task: The memory task or question
|
||||
context: Additional context from conversation
|
||||
message_history: Optional conversation history
|
||||
|
||||
Returns:
|
||||
Memory results or confirmation
|
||||
|
||||
Example:
|
||||
result = await run_biographer(
|
||||
task="What car do I drive?",
|
||||
context="User is asking about their vehicle",
|
||||
)
|
||||
"""
|
||||
agent = get_biographer_agent()
|
||||
|
||||
# Build prompt with context if provided
|
||||
prompt = task
|
||||
if context:
|
||||
prompt = f"Context: {context}\n\nTask: {task}"
|
||||
|
||||
logger.info(
|
||||
"biographer_task_started",
|
||||
task=task[:100],
|
||||
has_context=bool(context),
|
||||
has_history=bool(message_history),
|
||||
)
|
||||
|
||||
try:
|
||||
result = await agent.run(
|
||||
prompt,
|
||||
message_history=message_history,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"biographer_task_completed",
|
||||
task=task[:50],
|
||||
output_length=len(result.output),
|
||||
)
|
||||
|
||||
return result.output
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"biographer_task_error",
|
||||
task=task[:50],
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
return f"The Biographer encountered an error: {str(e)}"
|
||||
|
||||
|
||||
async def run_biographer_stream(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
):
|
||||
"""
|
||||
Execute a memory task with streaming output.
|
||||
|
||||
Yields text deltas as The Biographer generates the response.
|
||||
|
||||
Args:
|
||||
task: The memory task or question
|
||||
context: Additional context from conversation
|
||||
message_history: Optional conversation history
|
||||
|
||||
Yields:
|
||||
str: Text deltas from the response
|
||||
|
||||
Example:
|
||||
async for delta in run_biographer_stream("What do you know about me?"):
|
||||
print(delta, end="", flush=True)
|
||||
"""
|
||||
agent = get_biographer_agent()
|
||||
|
||||
# Build prompt with context if provided
|
||||
prompt = task
|
||||
if context:
|
||||
prompt = f"Context: {context}\n\nTask: {task}"
|
||||
|
||||
logger.info(
|
||||
"biographer_stream_started",
|
||||
task=task[:100],
|
||||
)
|
||||
|
||||
try:
|
||||
async with agent.run_stream(
|
||||
prompt,
|
||||
message_history=message_history,
|
||||
) as response:
|
||||
async for delta in response.stream_text(delta=True):
|
||||
yield delta
|
||||
|
||||
logger.info("biographer_stream_completed", task=task[:50])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"biographer_stream_error",
|
||||
task=task[:50],
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
yield f"\n\nThe Biographer encountered an error: {str(e)}"
|
||||
Reference in New Issue
Block a user