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 @@
|
||||
"""Tests for The Biographer agent."""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Tests for Biographer capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.agents.biographer.capability import (
|
||||
BIOGRAPHER_CAPABILITY,
|
||||
get_biographer_capability,
|
||||
register_biographer,
|
||||
unregister_biographer,
|
||||
)
|
||||
from src.core.household_registry import HouseholdCapability
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBiographerCapability:
|
||||
"""Tests for the Biographer capability definition."""
|
||||
|
||||
def test_capability_is_household_capability(self):
|
||||
"""Test capability is correct type."""
|
||||
assert isinstance(BIOGRAPHER_CAPABILITY, HouseholdCapability)
|
||||
|
||||
def test_capability_name(self):
|
||||
"""Test capability has correct name."""
|
||||
assert BIOGRAPHER_CAPABILITY.name == "biographer"
|
||||
|
||||
def test_capability_role(self):
|
||||
"""Test capability has correct role."""
|
||||
assert BIOGRAPHER_CAPABILITY.role == "The Biographer"
|
||||
|
||||
def test_capability_category(self):
|
||||
"""Test capability is in context category."""
|
||||
assert BIOGRAPHER_CAPABILITY.category == "context"
|
||||
|
||||
def test_capability_domains(self):
|
||||
"""Test capability covers expected domains."""
|
||||
domains = BIOGRAPHER_CAPABILITY.domains
|
||||
|
||||
assert "remember" in domains
|
||||
assert "recall" in domains
|
||||
assert "forget" in domains
|
||||
assert "memory" in domains
|
||||
assert "preferences" in domains
|
||||
assert "profile" in domains
|
||||
|
||||
def test_capability_does_not_require_network(self):
|
||||
"""Test capability does not require network access."""
|
||||
assert BIOGRAPHER_CAPABILITY.requires_network is False
|
||||
|
||||
def test_capability_low_cost(self):
|
||||
"""Test capability has low cost (vector search, minimal LLM)."""
|
||||
assert BIOGRAPHER_CAPABILITY.cost == "low"
|
||||
|
||||
def test_get_biographer_capability(self):
|
||||
"""Test getter returns same capability."""
|
||||
cap = get_biographer_capability()
|
||||
|
||||
assert cap is BIOGRAPHER_CAPABILITY
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBiographerRegistration:
|
||||
"""Tests for Biographer registration functions."""
|
||||
|
||||
def test_register_biographer(self):
|
||||
"""Test registering biographer with registry."""
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.__contains__ = MagicMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_biographer_agent"
|
||||
) as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
register_biographer()
|
||||
|
||||
mock_registry.register.assert_called_once()
|
||||
call_kwargs = mock_registry.register.call_args[1]
|
||||
|
||||
assert call_kwargs["name"] == "biographer"
|
||||
assert call_kwargs["capability"] is BIOGRAPHER_CAPABILITY
|
||||
assert call_kwargs["agent"] is mock_agent
|
||||
|
||||
def test_register_biographer_already_registered(self):
|
||||
"""Test registering when already registered does nothing."""
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.__contains__ = MagicMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
register_biographer()
|
||||
|
||||
# Should not call register since already registered
|
||||
mock_registry.register.assert_not_called()
|
||||
|
||||
def test_unregister_biographer(self):
|
||||
"""Test unregistering biographer from registry."""
|
||||
mock_registry = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
unregister_biographer()
|
||||
|
||||
mock_registry.unregister.assert_called_once_with("biographer")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCapabilityDescription:
|
||||
"""Tests for capability description."""
|
||||
|
||||
def test_description_mentions_recall(self):
|
||||
"""Test description mentions recall capabilities."""
|
||||
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||
assert "recall" in desc
|
||||
|
||||
def test_description_mentions_record(self):
|
||||
"""Test description mentions recording capability."""
|
||||
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||
assert "record" in desc
|
||||
|
||||
def test_description_mentions_forget(self):
|
||||
"""Test description mentions forget capability."""
|
||||
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||
assert "forget" in desc
|
||||
|
||||
def test_description_mentions_profile(self):
|
||||
"""Test description mentions profile updates."""
|
||||
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||
assert "profile" in desc
|
||||
|
||||
def test_description_mentions_preferences(self):
|
||||
"""Test description mentions preferences."""
|
||||
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||
assert "preferences" in desc
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Tests for the memory service (direct access layer).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
from src.core.memory_service import (
|
||||
MemoryService,
|
||||
MemoryType,
|
||||
MemoryRecord,
|
||||
memory_service,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryType:
|
||||
"""Tests for MemoryType enum."""
|
||||
|
||||
def test_user_profile_type(self):
|
||||
"""Test user_profile type exists."""
|
||||
assert MemoryType.USER_PROFILE.value == "user_profile"
|
||||
|
||||
def test_preference_type(self):
|
||||
"""Test preference type exists."""
|
||||
assert MemoryType.PREFERENCE.value == "preference"
|
||||
|
||||
def test_learned_fact_type(self):
|
||||
"""Test learned_fact type exists."""
|
||||
assert MemoryType.LEARNED_FACT.value == "learned_fact"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryRecord:
|
||||
"""Tests for MemoryRecord model."""
|
||||
|
||||
def test_create_minimal_record(self):
|
||||
"""Test creating record with minimal fields."""
|
||||
record = MemoryRecord(
|
||||
id="test_1",
|
||||
type=MemoryType.USER_PROFILE,
|
||||
key="location",
|
||||
value="Amsterdam",
|
||||
)
|
||||
|
||||
assert record.id == "test_1"
|
||||
assert record.type == MemoryType.USER_PROFILE
|
||||
assert record.key == "location"
|
||||
assert record.value == "Amsterdam"
|
||||
assert record.importance == 0.5 # Default
|
||||
assert record.source == "explicit" # Default
|
||||
|
||||
def test_create_full_record(self):
|
||||
"""Test creating record with all fields."""
|
||||
record = MemoryRecord(
|
||||
id="test_2",
|
||||
type=MemoryType.LEARNED_FACT,
|
||||
key="car",
|
||||
value="Tesla Model 3",
|
||||
keywords=["car", "vehicle", "tesla"],
|
||||
importance=0.8,
|
||||
source="conversation",
|
||||
)
|
||||
|
||||
assert record.keywords == ["car", "vehicle", "tesla"]
|
||||
assert record.importance == 0.8
|
||||
assert record.source == "conversation"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryServiceInit:
|
||||
"""Tests for MemoryService initialization."""
|
||||
|
||||
def test_service_has_lazy_clients(self):
|
||||
"""Test service initializes with lazy client loading."""
|
||||
service = MemoryService()
|
||||
|
||||
assert service._qdrant is None
|
||||
assert service._embedding is None
|
||||
assert service._cache is None
|
||||
|
||||
def test_global_instance_exists(self):
|
||||
"""Test global memory_service instance exists."""
|
||||
assert memory_service is not None
|
||||
assert isinstance(memory_service, MemoryService)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryServiceProfileMethods:
|
||||
"""Tests for profile-related methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_profile_uses_context(self):
|
||||
"""Test get_profile uses request context for user."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = "Amsterdam"
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.get_profile("location")
|
||||
|
||||
mock_get.assert_called_once_with("testuser", MemoryType.USER_PROFILE, "location")
|
||||
assert result == "Amsterdam"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_profile_explicit_user(self):
|
||||
"""Test get_profile with explicit user parameter."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = "Berlin"
|
||||
|
||||
result = await service.get_profile("location", user="otheruser")
|
||||
|
||||
mock_get.assert_called_once_with("otheruser", MemoryType.USER_PROFILE, "location")
|
||||
assert result == "Berlin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_profile_high_importance(self):
|
||||
"""Test set_profile uses high importance (0.9)."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||
mock_set.return_value = True
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.set_profile("timezone", "Europe/Amsterdam")
|
||||
|
||||
call_kwargs = mock_set.call_args[1]
|
||||
assert call_kwargs["importance"] == 0.9
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryServicePreferenceMethods:
|
||||
"""Tests for preference-related methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_preference(self):
|
||||
"""Test get_preference retrieves correctly."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = "celsius"
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.get_preference("temperature_unit")
|
||||
|
||||
mock_get.assert_called_once_with("testuser", MemoryType.PREFERENCE, "temperature_unit")
|
||||
assert result == "celsius"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_preference_medium_importance(self):
|
||||
"""Test set_preference uses medium importance (0.7)."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||
mock_set.return_value = True
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.set_preference("theme", "dark")
|
||||
|
||||
call_kwargs = mock_set.call_args[1]
|
||||
assert call_kwargs["importance"] == 0.7
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryServiceFactMethods:
|
||||
"""Tests for fact-related methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_fact_default_importance(self):
|
||||
"""Test store_fact uses default importance (0.5)."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||
mock_set.return_value = True
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.store_fact("car", "Tesla Model 3")
|
||||
|
||||
call_kwargs = mock_set.call_args[1]
|
||||
assert call_kwargs["importance"] == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_fact_custom_importance(self):
|
||||
"""Test store_fact with custom importance."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||
mock_set.return_value = True
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.store_fact(
|
||||
"employer",
|
||||
"Acme Corp",
|
||||
importance=0.8,
|
||||
)
|
||||
|
||||
call_kwargs = mock_set.call_args[1]
|
||||
assert call_kwargs["importance"] == 0.8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_fact(self):
|
||||
"""Test get_fact retrieves correctly."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = "Tesla Model 3"
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.get_fact("car")
|
||||
|
||||
mock_get.assert_called_once_with("testuser", MemoryType.LEARNED_FACT, "car")
|
||||
assert result == "Tesla Model 3"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryServicePrefetch:
|
||||
"""Tests for prefetch_context method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefetch_default_keys(self):
|
||||
"""Test prefetch with default profile keys."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||
mock_profile.side_effect = [
|
||||
"Amsterdam", # location
|
||||
"Europe/Amsterdam", # timezone
|
||||
"John", # name
|
||||
]
|
||||
mock_prefs.return_value = {"temperature_unit": "celsius"}
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.prefetch_context()
|
||||
|
||||
assert result["profile"]["location"] == "Amsterdam"
|
||||
assert result["profile"]["timezone"] == "Europe/Amsterdam"
|
||||
assert result["profile"]["name"] == "John"
|
||||
assert result["preferences"]["temperature_unit"] == "celsius"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefetch_specific_keys(self):
|
||||
"""Test prefetch with specific profile keys."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||
mock_profile.return_value = "Amsterdam"
|
||||
mock_prefs.return_value = {}
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.prefetch_context(
|
||||
profile_keys=["location"],
|
||||
include_preferences=False,
|
||||
)
|
||||
|
||||
# Should only fetch location
|
||||
mock_profile.assert_called_once()
|
||||
mock_prefs.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefetch_no_profile(self):
|
||||
"""Test prefetch without profile data."""
|
||||
service = MemoryService()
|
||||
|
||||
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||
mock_prefs.return_value = {"theme": "dark"}
|
||||
|
||||
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||
result = await service.prefetch_context(include_profile=False)
|
||||
|
||||
mock_profile.assert_not_called()
|
||||
assert "profile" not in result
|
||||
assert result["preferences"]["theme"] == "dark"
|
||||
Reference in New Issue
Block a user