feat: add The Housekeeper agent for home automation

Implements The Housekeeper, a new expert agent for home automation
following the Librarian pattern. Communicates with core-api service
which wraps Home Assistant REST API.

New agent features:
- CoreAPIClient with 13 home automation methods
- 13 tools: list_areas, list_devices, get_device_state, turn_on,
  turn_off, toggle, list_scenes, activate_scene, list_scripts,
  run_script, list_automations, toggle_automation, get_history
- PydanticAI agent with butler-friendly system prompt
- HouseholdCapability registration for Steward coordination
- delegate_to_housekeeper() wrapper for orchestration

Also includes:
- Dev port changed from 8123 to 8777 (avoids Home Assistant conflict)
- Config: CORE_API_HOST, CORE_API_KEY, CORE_API_TIMEOUT
- 44 unit tests for client and capability

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 10:24:35 +01:00
co-authored by Claude Opus 4.5
parent 64cad4500a
commit a1b8fe46e8
20 changed files with 2388 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for The Housekeeper agent."""
+140
View File
@@ -0,0 +1,140 @@
"""
Tests for Housekeeper capability registration.
"""
import pytest
from unittest.mock import MagicMock, patch
from src.agents.housekeeper.capability import (
HOUSEKEEPER_CAPABILITY,
get_housekeeper_capability,
register_housekeeper,
unregister_housekeeper,
)
from src.core.household_registry import HouseholdCapability
@pytest.mark.unit
class TestHousekeeperCapability:
"""Tests for the Housekeeper capability definition."""
def test_capability_is_household_capability(self):
"""Test capability is correct type."""
assert isinstance(HOUSEKEEPER_CAPABILITY, HouseholdCapability)
def test_capability_name(self):
"""Test capability has correct name."""
assert HOUSEKEEPER_CAPABILITY.name == "housekeeper"
def test_capability_role(self):
"""Test capability has correct role."""
assert HOUSEKEEPER_CAPABILITY.role == "The Housekeeper"
def test_capability_category(self):
"""Test capability is in automation category."""
assert HOUSEKEEPER_CAPABILITY.category == "automation"
def test_capability_domains(self):
"""Test capability covers expected domains."""
domains = HOUSEKEEPER_CAPABILITY.domains
assert "lights" in domains
assert "switches" in domains
assert "automation" in domains
assert "home" in domains
assert "scene" in domains
assert "turn on" in domains
assert "turn off" in domains
def test_capability_requires_network(self):
"""Test capability requires network access."""
assert HOUSEKEEPER_CAPABILITY.requires_network is True
def test_capability_cost_is_low(self):
"""Test capability is low cost (local API calls)."""
assert HOUSEKEEPER_CAPABILITY.cost == "low"
def test_get_housekeeper_capability(self):
"""Test getter returns same capability."""
cap = get_housekeeper_capability()
assert cap is HOUSEKEEPER_CAPABILITY
@pytest.mark.unit
class TestHousekeeperRegistration:
"""Tests for Housekeeper registration functions."""
def test_register_housekeeper(self):
"""Test registering housekeeper with registry."""
mock_registry = MagicMock()
mock_registry.__contains__ = MagicMock(return_value=False)
with patch(
"src.agents.housekeeper.capability.get_household_registry",
return_value=mock_registry,
):
with patch(
"src.agents.housekeeper.capability.get_housekeeper_agent"
) as mock_get_agent:
mock_agent = MagicMock()
mock_get_agent.return_value = mock_agent
register_housekeeper()
mock_registry.register.assert_called_once()
call_kwargs = mock_registry.register.call_args[1]
assert call_kwargs["name"] == "housekeeper"
assert call_kwargs["capability"] is HOUSEKEEPER_CAPABILITY
assert call_kwargs["agent"] is mock_agent
def test_register_housekeeper_already_registered(self):
"""Test registering when already registered does nothing."""
mock_registry = MagicMock()
mock_registry.__contains__ = MagicMock(return_value=True)
with patch(
"src.agents.housekeeper.capability.get_household_registry",
return_value=mock_registry,
):
register_housekeeper()
# Should not call register since already registered
mock_registry.register.assert_not_called()
def test_unregister_housekeeper(self):
"""Test unregistering housekeeper from registry."""
mock_registry = MagicMock()
with patch(
"src.agents.housekeeper.capability.get_household_registry",
return_value=mock_registry,
):
unregister_housekeeper()
mock_registry.unregister.assert_called_once_with("housekeeper")
@pytest.mark.unit
class TestCapabilityDescription:
"""Tests for capability description."""
def test_description_mentions_device_control(self):
"""Test description mentions device control capabilities."""
desc = HOUSEKEEPER_CAPABILITY.description.lower()
assert "turn on" in desc
# Description uses "ON/OFF" format
assert "off" in desc
def test_description_mentions_scenes(self):
"""Test description mentions scene capability."""
assert "scene" in HOUSEKEEPER_CAPABILITY.description.lower()
def test_description_mentions_scripts(self):
"""Test description mentions script capability."""
assert "script" in HOUSEKEEPER_CAPABILITY.description.lower()
def test_description_mentions_automations(self):
"""Test description mentions automation management."""
assert "automation" in HOUSEKEEPER_CAPABILITY.description.lower()
+557
View File
@@ -0,0 +1,557 @@
"""
Tests for the Core-API HTTP client.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
import httpx
from src.agents.housekeeper.client import (
Area,
Automation,
ControlResult,
CoreAPIClient,
Device,
DeviceState,
HistoryEntry,
Scene,
Script,
)
@pytest.fixture
def mock_httpx_client():
"""Create a mock httpx client."""
return AsyncMock(spec=httpx.AsyncClient)
@pytest.fixture
def client_with_mock(mock_httpx_client):
"""Create a CoreAPIClient with mocked httpx client."""
client = CoreAPIClient(
base_url="http://test:8090",
api_key="test-key",
)
client._client = mock_httpx_client
return client
@pytest.mark.unit
class TestCoreAPIClientInit:
"""Tests for client initialization."""
def test_default_initialization(self):
"""Test client initializes with defaults from config."""
client = CoreAPIClient()
assert client.base_url is not None
assert client.timeout == 30
assert client._client is None
def test_custom_initialization(self):
"""Test client with custom parameters."""
client = CoreAPIClient(
base_url="http://custom:9000",
api_key="my-api-key",
timeout=60,
)
assert client.base_url == "http://custom:9000"
assert client.api_key == "my-api-key"
assert client.timeout == 60
def test_ensure_client_not_initialized(self):
"""Test _ensure_client raises when not in context."""
client = CoreAPIClient()
with pytest.raises(RuntimeError) as exc_info:
client._ensure_client()
assert "not initialized" in str(exc_info.value)
@pytest.mark.unit
class TestContextManager:
"""Tests for async context manager."""
@pytest.mark.asyncio
async def test_context_manager_creates_client(self):
"""Test context manager creates httpx client."""
async with CoreAPIClient(
base_url="http://test:8090",
api_key="test-key",
) as client:
assert client._client is not None
@pytest.mark.asyncio
async def test_context_manager_closes_client(self):
"""Test context manager closes client on exit."""
client = CoreAPIClient(base_url="http://test:8090")
async with client:
assert client._client is not None
# After exit, client should be None
assert client._client is None
@pytest.mark.unit
class TestDeviceDiscovery:
"""Tests for device discovery methods."""
@pytest.mark.asyncio
async def test_list_devices(self, client_with_mock, mock_httpx_client):
"""Test listing devices."""
mock_response = MagicMock()
mock_response.json.return_value = {
"devices": [
{
"entity_id": "light.living_room",
"name": "Living Room Light",
"state": "on",
"domain": "light",
"area": "living_room",
"attributes": {"brightness": 255},
},
{
"entity_id": "switch.coffee_maker",
"name": "Coffee Maker",
"state": "off",
"domain": "switch",
"area": "kitchen",
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
devices = await client_with_mock.list_devices()
assert len(devices) == 2
assert isinstance(devices[0], Device)
assert devices[0].entity_id == "light.living_room"
assert devices[0].state == "on"
assert devices[0].domain == "light"
@pytest.mark.asyncio
async def test_list_areas(self, client_with_mock, mock_httpx_client):
"""Test listing areas."""
mock_response = MagicMock()
mock_response.json.return_value = {
"areas": [
{
"area_id": "living_room",
"name": "Living Room",
"device_count": 5,
},
{
"area_id": "bedroom",
"name": "Bedroom",
"device_count": 3,
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
areas = await client_with_mock.list_areas()
assert len(areas) == 2
assert isinstance(areas[0], Area)
assert areas[0].area_id == "living_room"
assert areas[0].name == "Living Room"
assert areas[0].device_count == 5
@pytest.mark.asyncio
async def test_list_devices_with_filter(self, client_with_mock, mock_httpx_client):
"""Test listing devices with domain filter."""
mock_response = MagicMock()
mock_response.json.return_value = {
"devices": [
{
"entity_id": "light.bedroom",
"name": "Bedroom Light",
"state": "off",
"domain": "light",
}
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
devices = await client_with_mock.list_devices(domain="light")
assert len(devices) == 1
mock_httpx_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_device_state(self, client_with_mock, mock_httpx_client):
"""Test getting device state."""
mock_response = MagicMock()
mock_response.json.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"brightness": 200,
"color_temp": 370,
},
"last_changed": "2024-01-15T10:30:00Z",
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
state = await client_with_mock.get_device_state("light.living_room")
assert isinstance(state, DeviceState)
assert state.entity_id == "light.living_room"
assert state.state == "on"
assert state.attributes["brightness"] == 200
@pytest.mark.unit
class TestDeviceControl:
"""Tests for device control methods."""
@pytest.mark.asyncio
async def test_turn_on(self, client_with_mock, mock_httpx_client):
"""Test turning on a device."""
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"message": "Turned on",
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.turn_on("light.living_room")
assert isinstance(result, ControlResult)
assert result.success is True
assert result.entity_id == "light.living_room"
assert result.action == "turn_on"
@pytest.mark.asyncio
async def test_turn_on_with_brightness(self, client_with_mock, mock_httpx_client):
"""Test turning on with brightness."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.turn_on(
"light.bedroom",
brightness=128,
)
assert result.success is True
# Check that brightness was in the payload
call_kwargs = mock_httpx_client.post.call_args[1]
assert call_kwargs["json"]["brightness"] == 128
@pytest.mark.asyncio
async def test_turn_off(self, client_with_mock, mock_httpx_client):
"""Test turning off a device."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.turn_off("switch.coffee_maker")
assert result.success is True
assert result.action == "turn_off"
@pytest.mark.asyncio
async def test_toggle(self, client_with_mock, mock_httpx_client):
"""Test toggling a device."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.toggle("light.hallway")
assert result.success is True
assert result.action == "toggle"
@pytest.mark.unit
class TestScenes:
"""Tests for scene methods."""
@pytest.mark.asyncio
async def test_list_scenes(self, client_with_mock, mock_httpx_client):
"""Test listing scenes."""
mock_response = MagicMock()
mock_response.json.return_value = {
"scenes": [
{
"entity_id": "scene.movie_night",
"name": "movie_night",
"friendly_name": "Movie Night",
},
{
"entity_id": "scene.good_morning",
"name": "good_morning",
"friendly_name": "Good Morning",
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
scenes = await client_with_mock.list_scenes()
assert len(scenes) == 2
assert isinstance(scenes[0], Scene)
assert scenes[0].entity_id == "scene.movie_night"
@pytest.mark.asyncio
async def test_activate_scene(self, client_with_mock, mock_httpx_client):
"""Test activating a scene."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.activate_scene("scene.movie_night")
assert result.success is True
assert result.action == "activate"
@pytest.mark.unit
class TestScripts:
"""Tests for script methods."""
@pytest.mark.asyncio
async def test_list_scripts(self, client_with_mock, mock_httpx_client):
"""Test listing scripts."""
mock_response = MagicMock()
mock_response.json.return_value = {
"scripts": [
{
"entity_id": "script.good_morning",
"name": "Good Morning Routine",
"description": "Morning automation",
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
scripts = await client_with_mock.list_scripts()
assert len(scripts) == 1
assert isinstance(scripts[0], Script)
assert scripts[0].name == "Good Morning Routine"
@pytest.mark.asyncio
async def test_run_script(self, client_with_mock, mock_httpx_client):
"""Test running a script."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.run_script("script.good_morning")
assert result.success is True
assert result.action == "run"
@pytest.mark.unit
class TestAutomations:
"""Tests for automation methods."""
@pytest.mark.asyncio
async def test_list_automations(self, client_with_mock, mock_httpx_client):
"""Test listing automations."""
mock_response = MagicMock()
mock_response.json.return_value = {
"automations": [
{
"entity_id": "automation.morning_lights",
"name": "Morning Lights",
"state": "on",
},
{
"entity_id": "automation.vacation_mode",
"name": "Vacation Mode",
"state": "off",
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
automations = await client_with_mock.list_automations()
assert len(automations) == 2
assert isinstance(automations[0], Automation)
assert automations[0].state == "on"
@pytest.mark.asyncio
async def test_toggle_automation_enable(self, client_with_mock, mock_httpx_client):
"""Test enabling an automation."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.toggle_automation(
"automation.vacation_mode",
enable=True,
)
assert result.success is True
assert result.action == "enable"
@pytest.mark.asyncio
async def test_toggle_automation_disable(self, client_with_mock, mock_httpx_client):
"""Test disabling an automation."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
result = await client_with_mock.toggle_automation(
"automation.morning_lights",
enable=False,
)
assert result.action == "disable"
@pytest.mark.unit
class TestHistory:
"""Tests for history methods."""
@pytest.mark.asyncio
async def test_get_history(self, client_with_mock, mock_httpx_client):
"""Test getting device history."""
mock_response = MagicMock()
mock_response.json.return_value = {
"history": [
{
"state": "on",
"timestamp": "2024-01-15T08:00:00Z",
"attributes": {"brightness": 255},
},
{
"state": "off",
"timestamp": "2024-01-15T10:30:00Z",
"attributes": {},
},
]
}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
history = await client_with_mock.get_history("light.living_room")
assert len(history) == 2
assert isinstance(history[0], HistoryEntry)
assert history[0].state == "on"
assert history[1].state == "off"
@pytest.mark.unit
class TestHealthCheck:
"""Tests for health check."""
@pytest.mark.asyncio
async def test_health_check_healthy(self, client_with_mock, mock_httpx_client):
"""Test health check returns true when healthy."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_httpx_client.get.return_value = mock_response
result = await client_with_mock.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client):
"""Test health check returns false on error."""
mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused")
result = await client_with_mock.health_check()
assert result is False
@pytest.mark.unit
class TestResponseModels:
"""Tests for response model validation."""
def test_device_model(self):
"""Test Device model."""
device = Device(
entity_id="light.test",
name="Test Light",
state="on",
domain="light",
area="bedroom",
attributes={"brightness": 255},
)
assert device.entity_id == "light.test"
assert device.state == "on"
assert device.attributes["brightness"] == 255
def test_device_model_optional_fields(self):
"""Test Device with minimal fields."""
device = Device(
entity_id="switch.test",
name="Test Switch",
state="off",
domain="switch",
)
assert device.area is None
assert device.attributes == {}
def test_area_model(self):
"""Test Area model."""
area = Area(
area_id="living_room",
name="Living Room",
device_count=5,
)
assert area.area_id == "living_room"
assert area.name == "Living Room"
assert area.device_count == 5
def test_area_model_defaults(self):
"""Test Area with default device_count."""
area = Area(
area_id="bedroom",
name="Bedroom",
)
assert area.device_count == 0
def test_control_result_model(self):
"""Test ControlResult model."""
result = ControlResult(
success=True,
entity_id="light.test",
action="turn_on",
message="Success",
)
assert result.success is True
assert result.action == "turn_on"
def test_history_entry_model(self):
"""Test HistoryEntry model."""
entry = HistoryEntry(
state="on",
timestamp="2024-01-15T10:00:00Z",
attributes={"brightness": 200},
)
assert entry.state == "on"
assert entry.attributes["brightness"] == 200
+2 -2
View File
@@ -4,7 +4,7 @@ These tests make real HTTP requests to the running Tatlock API server to verify
## Prerequisites
1. **Server must be running** on `http://localhost:8123` (use `./wakeup.sh`)
1. **Server must be running** on `http://localhost:8777` (use `./wakeup.sh`)
2. **Ollama must be running** with `mistral-nemo:latest` model
3. **Redis must be running** (for benchmarking)
4. **Qdrant must be running** on `http://localhost:6333` (for memory tests)
@@ -133,7 +133,7 @@ memory = await qdrant.find_memory_by_key("memories_llm_tester", "favorite_color"
Make sure the server is running:
```bash
./wakeup.sh
curl http://localhost:8123/health # Should return 200
curl http://localhost:8777/health # Should return 200
```
### Tests timeout
+2 -2
View File
@@ -12,8 +12,8 @@ import httpx
import asyncio
from typing import AsyncGenerator
# Test server base URL (assumes server is running on localhost:8123 via ./wakeup.sh)
BASE_URL = "http://localhost:8123"
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
BASE_URL = "http://localhost:8777"
API_TIMEOUT = 120.0 # 120 second timeout for LLM calls
+2 -2
View File
@@ -11,7 +11,7 @@ These tests hit the actual running server and verify data persistence.
They use the `llm_tester` user for isolation from production data.
Requirements:
- Server running on localhost:8123 (use ./wakeup.sh)
- Server running on localhost:8777 (use ./wakeup.sh)
- Qdrant running on localhost:6333
- Ollama running with mistral-nemo model
@@ -27,7 +27,7 @@ from dataclasses import dataclass
# Test configuration
BASE_URL = "http://localhost:8123"
BASE_URL = "http://localhost:8777"
QDRANT_URL = "http://localhost:6333"
API_TIMEOUT = 120.0 # LLM calls can be slow
TEST_USER = "llm_tester"