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
+78 -1
View File
@@ -224,6 +224,83 @@ async def delegate_to_biographer(
)
async def delegate_to_housekeeper(
task: str,
context: str = "",
) -> DelegationResult:
"""
Delegate a home automation task to The Housekeeper.
The Housekeeper handles:
- Device control (turn on/off, toggle, brightness, color)
- Scene activation (movie night, good morning, etc.)
- Script execution (automation sequences)
- Automation management (enable/disable rules)
- Device discovery (list devices by area/type)
- State queries (get current state, history)
Args:
task: Clear description of what needs to be done.
Include the action verb (turn on, activate, list, etc.)
Example: "Turn on the living room lights"
Example: "Activate the movie night scene"
Example: "What devices are in the bedroom?"
context: Additional context from the user's request or
conversation history
Returns:
DelegationResult with The Housekeeper's response
Example:
>>> result = await delegate_to_housekeeper(
... task="Turn on the bedroom lights at 50% brightness",
... context="User is getting ready for bed",
... )
>>> if result.success:
... print(result.output)
"""
from src.agents.housekeeper.agent import run_housekeeper
logger.info(
"delegation_to_housekeeper_started",
task=task[:100],
has_context=bool(context),
)
try:
# Use run() not run_stream() - avoids Ollama bug
output = await run_housekeeper(task=task, context=context)
logger.info(
"delegation_to_housekeeper_completed",
task=task[:50],
output_length=len(output),
)
return DelegationResult(
expert_name="housekeeper",
task=task,
success=True,
output=output,
)
except Exception as e:
logger.error(
"delegation_to_housekeeper_error",
task=task[:50],
error=str(e),
exc_info=True,
)
return DelegationResult(
expert_name="housekeeper",
task=task,
success=False,
output="",
error=str(e),
)
# Future expert delegation wrappers will be added here:
# - delegate_to_home_automation(task, context) -> DelegationResult
# - delegate_to_developer(task, context) -> DelegationResult
# - delegate_to_secretary(task, context) -> DelegationResult
+24
View File
@@ -0,0 +1,24 @@
"""
The Housekeeper - Home Automation Agent.
Provides home automation capabilities through the core-api service,
which wraps the Home Assistant REST API into LLM-friendly endpoints.
"""
from src.agents.housekeeper.agent import run_housekeeper, run_housekeeper_stream
from src.agents.housekeeper.capability import (
HOUSEKEEPER_CAPABILITY,
register_housekeeper,
)
from src.agents.housekeeper.client import CoreAPIClient, get_core_api_client
__all__ = [
# Agent entry points
"run_housekeeper",
"run_housekeeper_stream",
# Capability
"HOUSEKEEPER_CAPABILITY",
"register_housekeeper",
# Client
"CoreAPIClient",
"get_core_api_client",
]
+297
View File
@@ -0,0 +1,297 @@
"""
The Housekeeper - Expert agent for home automation.
A PydanticAI agent that provides home automation capabilities through
the core-api service, which wraps Home Assistant REST API, offering:
- Device discovery and control
- Scene activation
- Script execution
- Automation management
"""
from typing import Any, Optional
from pydantic_ai import Agent
from src.agents.housekeeper.tools import (
activate_scene,
get_device_state,
get_history,
list_areas,
list_automations,
list_devices,
list_scenes,
list_scripts,
run_script,
toggle,
toggle_automation,
turn_off,
turn_on,
)
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# Housekeeper system prompt
HOUSEKEEPER_SYSTEM_PROMPT = """You are The Housekeeper, an expert home automation assistant in the Tatlock household.
Your role is to help users control and monitor their smart home through Home Assistant:
- Lights, switches, and other devices
- Scenes (pre-configured device states)
- Scripts (automation sequences)
- Automations (event-triggered rules)
## Your Personality
- Efficient and practical
- Safety-conscious (confirm destructive actions)
- Proactive in suggesting optimizations
- Clear about what actions you're taking
## Your Tools
### Discovery Tools
- **list_areas**: See all rooms/areas configured in Home Assistant
- **list_devices**: Find devices by type (domain) or location (area)
- **get_device_state**: Check a device's current state and attributes
### Control Tools
- **turn_on**: Turn on lights, switches, etc. (supports brightness/color for lights)
- **turn_off**: Turn off devices
- **toggle**: Flip a device's state
### Scene Tools
- **list_scenes**: See available scene presets
- **activate_scene**: Activate a scene (e.g., "movie night", "good morning")
### Script Tools
- **list_scripts**: See available automation scripts
- **run_script**: Execute a script
### Automation Tools
- **list_automations**: See all automations and their status
- **toggle_automation**: Enable or disable an automation
### History Tools
- **get_history**: Check a device's state history
## Best Practices
1. **Device Discovery First**: If the user asks about devices without being specific,
use list_devices to find what's available before acting.
2. **Confirm State After Actions**: After turning something on/off, you can verify
with get_device_state if needed.
3. **Use Entity IDs**: Devices are identified by entity_id (e.g., light.living_room).
Always use the exact entity_id from list_devices.
4. **Area-Aware**: When users say "living room lights", filter by area="living_room".
5. **Safety**: For actions affecting multiple devices or automations, summarize
what you're about to do.
## Common Patterns
- "Turn on the lights" → list_devices(domain="light"), then turn_on each
- "What's on?" → list_devices() and filter for state="on"
- "Movie time" → Either activate_scene("scene.movie_night") or run_script if available
- "Dim the bedroom" → turn_on("light.bedroom", brightness=64)
## Response Format
Your responses are returned to Tatlock (the butler) who will synthesize them into
a final answer for the user. Keep this in mind:
- Lead with confirmation of what you did or found
- Be specific about which devices were affected
- Include relevant state information
- Note any issues or failures
- Be concise - Tatlock will format the final response
"""
# Lazy initialization to avoid connection issues during imports
_housekeeper_agent: Optional[Agent[None, str]] = None
def _create_housekeeper_agent() -> Agent[None, str]:
"""Create the Housekeeper 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=HOUSEKEEPER_SYSTEM_PROMPT,
retries=2,
)
# Register discovery tools
agent.tool_plain(list_areas)
agent.tool_plain(list_devices)
agent.tool_plain(get_device_state)
# Register control tools
agent.tool_plain(turn_on)
agent.tool_plain(turn_off)
agent.tool_plain(toggle)
# Register scene tools
agent.tool_plain(list_scenes)
agent.tool_plain(activate_scene)
# Register script tools
agent.tool_plain(list_scripts)
agent.tool_plain(run_script)
# Register automation tools
agent.tool_plain(list_automations)
agent.tool_plain(toggle_automation)
# Register history tools
agent.tool_plain(get_history)
logger.info(
"housekeeper_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
tool_count=13,
)
return agent
def get_housekeeper_agent() -> Agent[None, str]:
"""
Get the Housekeeper agent instance (lazy initialization).
Returns:
PydanticAI Agent configured for home automation tasks
"""
global _housekeeper_agent
if _housekeeper_agent is None:
_housekeeper_agent = _create_housekeeper_agent()
return _housekeeper_agent
async def run_housekeeper(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
) -> str:
"""
Execute a home automation task with The Housekeeper.
This is the main entry point for delegating home automation tasks
to The Housekeeper from Tatlock or other agents.
Args:
task: The home automation task or request
context: Additional context from conversation
message_history: Optional conversation history
Returns:
Results and confirmation of actions
Example:
result = await run_housekeeper(
task="Turn on the living room lights",
context="It's evening",
)
"""
agent = get_housekeeper_agent()
# Build prompt with context if provided
prompt = task
if context:
prompt = f"Context: {context}\n\nTask: {task}"
logger.info(
"housekeeper_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(
"housekeeper_task_completed",
task=task[:50],
output_length=len(result.output),
)
return result.output
except Exception as e:
logger.error(
"housekeeper_task_error",
task=task[:50],
error=str(e),
exc_info=True,
)
return f"The Housekeeper encountered an error: {str(e)}"
async def run_housekeeper_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
):
"""
Execute a home automation task with streaming output.
Yields text deltas as The Housekeeper generates the response.
Args:
task: The home automation task or request
context: Additional context from conversation
message_history: Optional conversation history
Yields:
str: Text deltas from the response
Example:
async for delta in run_housekeeper_stream("Turn on the lights"):
print(delta, end="", flush=True)
"""
agent = get_housekeeper_agent()
# Build prompt with context if provided
prompt = task
if context:
prompt = f"Context: {context}\n\nTask: {task}"
logger.info(
"housekeeper_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("housekeeper_stream_completed", task=task[:50])
except Exception as e:
logger.error(
"housekeeper_stream_error",
task=task[:50],
error=str(e),
exc_info=True,
)
yield f"\n\nThe Housekeeper encountered an error: {str(e)}"
+90
View File
@@ -0,0 +1,90 @@
"""
Housekeeper capability registration for the Household Registry.
Defines The Housekeeper's capabilities and registers it as a
household member for coordination by the Steward and Tatlock.
"""
from src.agents.housekeeper.agent import get_housekeeper_agent
from src.agents.housekeeper.tools import HOUSEKEEPER_TOOLS
from src.core.household_registry import (
HouseholdCapability,
get_household_registry,
)
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# The Housekeeper's capability summary for Steward coordination
HOUSEKEEPER_CAPABILITY = HouseholdCapability(
name="housekeeper",
role="The Housekeeper",
category="automation",
description=(
"Home automation control: TURN ON/OFF devices, ACTIVATE scenes, "
"RUN scripts, LIST devices, MANAGE automations. Controls lights, "
"switches, climate, and other smart home devices via Home Assistant."
),
domains=[
"lights",
"switches",
"automation",
"home",
"smart home",
"scene",
"script",
"device",
"turn on",
"turn off",
"temperature",
"climate",
"fan",
"cover",
"blinds",
],
cost="low", # Fast local API calls to core-api
requires_network=True, # Needs core-api access
)
def get_housekeeper_capability() -> HouseholdCapability:
"""Get The Housekeeper's capability definition."""
return HOUSEKEEPER_CAPABILITY
def register_housekeeper() -> None:
"""
Register The Housekeeper with the Household Registry.
This makes The Housekeeper available for:
- Steward recommendations (via capability summary)
- Tatlock delegation (via agent reference)
- Tool scoping (via tool list)
"""
registry = get_household_registry()
# Check if already registered
if "housekeeper" in registry:
logger.debug("housekeeper_already_registered")
return
registry.register(
name="housekeeper",
capability=HOUSEKEEPER_CAPABILITY,
tools=HOUSEKEEPER_TOOLS,
agent=get_housekeeper_agent(),
)
logger.info(
"housekeeper_registered",
role=HOUSEKEEPER_CAPABILITY.role,
domains=HOUSEKEEPER_CAPABILITY.domains,
tool_count=len(HOUSEKEEPER_TOOLS),
)
def unregister_housekeeper() -> None:
"""Unregister The Housekeeper from the Household Registry."""
registry = get_household_registry()
registry.unregister("housekeeper")
logger.info("housekeeper_unregistered")
+555
View File
@@ -0,0 +1,555 @@
"""
HTTP client for the Core-API service.
Provides async methods for home automation operations via Home Assistant.
Core-API is a separate service that wraps the Home Assistant REST API
into LLM-friendly endpoints.
"""
from typing import Any, Optional
import httpx
from pydantic import BaseModel, Field
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# ============================================================================
# Response Models
# ============================================================================
class Device(BaseModel):
"""Device from Home Assistant."""
entity_id: str
name: str
state: str
domain: str
area: Optional[str] = None
attributes: dict[str, Any] = Field(default_factory=dict)
class DeviceState(BaseModel):
"""Detailed state of a device."""
entity_id: str
state: str
attributes: dict[str, Any] = Field(default_factory=dict)
last_changed: Optional[str] = None
last_updated: Optional[str] = None
class Scene(BaseModel):
"""Scene from Home Assistant."""
entity_id: str
name: str
friendly_name: Optional[str] = None
class Script(BaseModel):
"""Script from Home Assistant."""
entity_id: str
name: str
description: Optional[str] = None
last_triggered: Optional[str] = None
class Automation(BaseModel):
"""Automation from Home Assistant."""
entity_id: str
name: str
state: str = "on"
last_triggered: Optional[str] = None
class HistoryEntry(BaseModel):
"""History entry for an entity."""
state: str
timestamp: str
attributes: dict[str, Any] = Field(default_factory=dict)
class ControlResult(BaseModel):
"""Result of a device control operation."""
success: bool
entity_id: str
action: str
message: str = ""
class Area(BaseModel):
"""Area/room from Home Assistant."""
area_id: str
name: str
device_count: int = 0
# ============================================================================
# Client
# ============================================================================
class CoreAPIClient:
"""
Async HTTP client for Core-API (Home Assistant wrapper).
Usage:
async with CoreAPIClient() as client:
devices = await client.list_devices()
"""
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 30,
):
"""
Initialize the client.
Args:
base_url: Core-API URL (defaults to config)
api_key: API key for authentication (defaults to config)
timeout: Request timeout in seconds
"""
self.base_url = base_url or str(config.CORE_API_HOST)
self.api_key = api_key or config.CORE_API_KEY
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def __aenter__(self) -> "CoreAPIClient":
"""Create HTTP client on context entry."""
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=self.timeout,
)
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Close HTTP client on context exit."""
if self._client:
await self._client.aclose()
self._client = None
def _ensure_client(self) -> httpx.AsyncClient:
"""Ensure client is initialized."""
if self._client is None:
raise RuntimeError(
"Client not initialized. Use 'async with CoreAPIClient() as client:'"
)
return self._client
# ========================================================================
# Device Discovery
# ========================================================================
async def list_devices(
self,
domain: Optional[str] = None,
area: Optional[str] = None,
) -> list[Device]:
"""
List devices, optionally filtered by domain or area.
Args:
domain: Filter by domain (light, switch, climate, etc.)
area: Filter by area (living_room, bedroom, etc.)
Returns:
List of devices matching filters
"""
client = self._ensure_client()
params: dict[str, str] = {}
if domain:
params["domain"] = domain
if area:
params["area"] = area
logger.debug("core_api_list_devices", domain=domain, area=area)
response = await client.get("/devices", params=params or None)
response.raise_for_status()
data = response.json()
return [Device(**d) for d in data.get("devices", [])]
async def list_areas(self) -> list[Area]:
"""
List all areas/rooms in Home Assistant.
Returns:
List of areas with device counts
"""
client = self._ensure_client()
logger.debug("core_api_list_areas")
response = await client.get("/areas")
response.raise_for_status()
data = response.json()
return [Area(**a) for a in data.get("areas", [])]
async def get_device_state(self, entity_id: str) -> DeviceState:
"""
Get the current state of a specific device.
Args:
entity_id: Home Assistant entity ID (e.g., light.living_room)
Returns:
Current device state with attributes
"""
client = self._ensure_client()
logger.debug("core_api_get_state", entity_id=entity_id)
response = await client.get(f"/entities/{entity_id}")
response.raise_for_status()
return DeviceState(**response.json())
# ========================================================================
# Device Control
# ========================================================================
async def turn_on(
self,
entity_id: str,
brightness: Optional[int] = None,
color_temp: Optional[int] = None,
rgb_color: Optional[tuple[int, int, int]] = None,
) -> ControlResult:
"""
Turn on a device.
Args:
entity_id: Device to turn on
brightness: Optional brightness (0-255) for lights
color_temp: Optional color temperature in Kelvin for lights
rgb_color: Optional RGB color tuple for lights
Returns:
Result of the operation
"""
client = self._ensure_client()
payload: dict[str, Any] = {"action": "turn_on"}
if brightness is not None:
payload["brightness"] = brightness
if color_temp is not None:
payload["color_temp"] = color_temp
if rgb_color is not None:
payload["rgb_color"] = list(rgb_color)
logger.info("core_api_turn_on", entity_id=entity_id, payload=payload)
response = await client.post(
f"/devices/{entity_id}/control",
json=payload,
)
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=entity_id,
action="turn_on",
message=data.get("message", ""),
)
async def turn_off(self, entity_id: str) -> ControlResult:
"""
Turn off a device.
Args:
entity_id: Device to turn off
Returns:
Result of the operation
"""
client = self._ensure_client()
logger.info("core_api_turn_off", entity_id=entity_id)
response = await client.post(
f"/devices/{entity_id}/control",
json={"action": "turn_off"},
)
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=entity_id,
action="turn_off",
message=data.get("message", ""),
)
async def toggle(self, entity_id: str) -> ControlResult:
"""
Toggle a device's state.
Args:
entity_id: Device to toggle
Returns:
Result of the operation
"""
client = self._ensure_client()
logger.info("core_api_toggle", entity_id=entity_id)
response = await client.post(
f"/devices/{entity_id}/control",
json={"action": "toggle"},
)
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=entity_id,
action="toggle",
message=data.get("message", ""),
)
# ========================================================================
# Scenes
# ========================================================================
async def list_scenes(self) -> list[Scene]:
"""
List all available scenes.
Returns:
List of scenes
"""
client = self._ensure_client()
logger.debug("core_api_list_scenes")
response = await client.get("/scenes")
response.raise_for_status()
data = response.json()
return [Scene(**s) for s in data.get("scenes", [])]
async def activate_scene(self, scene_id: str) -> ControlResult:
"""
Activate a scene.
Args:
scene_id: Scene entity ID (e.g., scene.movie_night)
Returns:
Result of the operation
"""
client = self._ensure_client()
logger.info("core_api_activate_scene", scene_id=scene_id)
response = await client.post(f"/scenes/{scene_id}/activate")
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=scene_id,
action="activate",
message=data.get("message", ""),
)
# ========================================================================
# Scripts
# ========================================================================
async def list_scripts(self) -> list[Script]:
"""
List all available scripts.
Returns:
List of scripts
"""
client = self._ensure_client()
logger.debug("core_api_list_scripts")
response = await client.get("/scripts")
response.raise_for_status()
data = response.json()
return [Script(**s) for s in data.get("scripts", [])]
async def run_script(
self,
script_id: str,
variables: Optional[dict[str, Any]] = None,
) -> ControlResult:
"""
Run a script.
Args:
script_id: Script entity ID (e.g., script.good_morning)
variables: Optional variables to pass to the script
Returns:
Result of the operation
"""
client = self._ensure_client()
payload: dict[str, Any] = {}
if variables:
payload["variables"] = variables
logger.info("core_api_run_script", script_id=script_id)
response = await client.post(
f"/scripts/{script_id}/run",
json=payload or None,
)
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=script_id,
action="run",
message=data.get("message", ""),
)
# ========================================================================
# Automations
# ========================================================================
async def list_automations(self) -> list[Automation]:
"""
List all automations.
Returns:
List of automations with their states
"""
client = self._ensure_client()
logger.debug("core_api_list_automations")
response = await client.get("/automations")
response.raise_for_status()
data = response.json()
return [Automation(**a) for a in data.get("automations", [])]
async def toggle_automation(
self,
automation_id: str,
enable: bool,
) -> ControlResult:
"""
Enable or disable an automation.
Args:
automation_id: Automation entity ID
enable: True to enable, False to disable
Returns:
Result of the operation
"""
client = self._ensure_client()
logger.info(
"core_api_toggle_automation",
automation_id=automation_id,
enable=enable,
)
response = await client.post(
f"/automations/{automation_id}/toggle",
json={"enable": enable},
)
response.raise_for_status()
data = response.json()
return ControlResult(
success=data.get("success", True),
entity_id=automation_id,
action="enable" if enable else "disable",
message=data.get("message", ""),
)
# ========================================================================
# History
# ========================================================================
async def get_history(
self,
entity_id: str,
hours: int = 24,
) -> list[HistoryEntry]:
"""
Get history for an entity.
Args:
entity_id: Entity to get history for
hours: Number of hours of history (default: 24)
Returns:
List of historical state entries
"""
client = self._ensure_client()
logger.debug("core_api_get_history", entity_id=entity_id, hours=hours)
response = await client.get(
"/history",
params={"entity_id": entity_id, "hours": hours},
)
response.raise_for_status()
data = response.json()
return [HistoryEntry(**h) for h in data.get("history", [])]
# ========================================================================
# Health Check
# ========================================================================
async def health_check(self) -> bool:
"""
Check if core-api and Home Assistant are healthy.
Returns:
True if healthy, False otherwise
"""
try:
client = self._ensure_client()
response = await client.get("/health")
return response.status_code == 200
except Exception as e:
logger.warning("core_api_health_check_failed", error=str(e))
return False
# Global client factory
async def get_core_api_client() -> CoreAPIClient:
"""
Get a core-api client instance.
Usage:
async with get_core_api_client() as client:
devices = await client.list_devices()
"""
return CoreAPIClient()
+562
View File
@@ -0,0 +1,562 @@
"""
Housekeeper tools for PydanticAI agent.
These tools wrap the core-api service and are registered with
The Housekeeper agent for home automation tasks.
"""
from src.agents.housekeeper.client import CoreAPIClient
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# ============================================================================
# Device Discovery
# ============================================================================
async def list_devices(
domain: str | None = None,
area: str | None = None,
) -> str:
"""
List available devices in the smart home.
Use this to discover what devices can be controlled.
Can filter by domain (device type) or area (room).
Args:
domain: Device type filter (light, switch, climate, cover, fan, etc.)
area: Room/area filter (living_room, bedroom, kitchen, etc.)
Returns:
List of devices with their current states
Examples:
list_devices() # All devices
list_devices(domain="light") # Only lights
list_devices(area="living_room") # Living room devices
"""
try:
async with CoreAPIClient() as client:
devices = await client.list_devices(domain=domain, area=area)
if not devices:
filters = []
if domain:
filters.append(f"domain={domain}")
if area:
filters.append(f"area={area}")
filter_str = f" with filters: {', '.join(filters)}" if filters else ""
return f"No devices found{filter_str}"
# Group by domain for readability
by_domain: dict[str, list] = {}
for device in devices:
by_domain.setdefault(device.domain, []).append(device)
output_parts = ["## Smart Home Devices\n"]
for dom, dom_devices in sorted(by_domain.items()):
output_parts.append(f"### {dom.title()}s")
for device in dom_devices:
state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state
area_str = f" ({device.area})" if device.area else ""
output_parts.append(f"- **{device.name}**{area_str}: {state_icon}")
output_parts.append(f" ID: `{device.entity_id}`")
output_parts.append("")
logger.info("housekeeper_list_devices", count=len(devices))
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_list_devices_error", error=str(e))
return f"Error listing devices: {str(e)}"
async def list_areas() -> str:
"""
List all areas/rooms in the smart home.
Use this to discover what rooms/areas are configured in Home Assistant.
Useful before filtering devices by area.
Returns:
List of areas with device counts
Examples:
list_areas() # See all rooms/areas
"""
try:
async with CoreAPIClient() as client:
areas = await client.list_areas()
if not areas:
return "No areas found in Home Assistant"
output_parts = ["## Smart Home Areas\n"]
for area in sorted(areas, key=lambda a: a.name):
device_str = f" ({area.device_count} devices)" if area.device_count else ""
output_parts.append(f"- **{area.name}**{device_str}")
output_parts.append(f" ID: `{area.area_id}`")
output_parts.append("")
output_parts.append(f"*{len(areas)} areas total*")
logger.info("housekeeper_list_areas", count=len(areas))
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_list_areas_error", error=str(e))
return f"Error listing areas: {str(e)}"
async def get_device_state(entity_id: str) -> str:
"""
Get the current state and attributes of a specific device.
Use this to check a device's detailed status before or after control.
Args:
entity_id: The device entity ID (e.g., light.living_room, switch.coffee_maker)
Returns:
Detailed device state including all attributes
Examples:
get_device_state("light.living_room")
get_device_state("climate.bedroom")
"""
try:
async with CoreAPIClient() as client:
state = await client.get_device_state(entity_id)
output_parts = [
f"## Device: {entity_id}",
f"**State:** {state.state}",
]
if state.last_changed:
output_parts.append(f"**Last Changed:** {state.last_changed}")
if state.attributes:
output_parts.append("\n**Attributes:**")
for key, value in state.attributes.items():
if key not in ("friendly_name", "entity_id"):
output_parts.append(f"- {key}: {value}")
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_get_state_error", error=str(e), entity_id=entity_id)
return f"Error getting state for {entity_id}: {str(e)}"
# ============================================================================
# Device Control
# ============================================================================
async def turn_on(
entity_id: str,
brightness: int | None = None,
color_temp: int | None = None,
) -> str:
"""
Turn on a device.
For lights, can optionally set brightness and color temperature.
Args:
entity_id: Device to turn on (e.g., light.living_room, switch.coffee_maker)
brightness: Optional brightness for lights (0-255, where 255 is full brightness)
color_temp: Optional color temperature in Kelvin (2700=warm, 6500=cool)
Returns:
Confirmation of the action
Examples:
turn_on("light.living_room") # Turn on at current brightness
turn_on("light.bedroom", brightness=128) # Turn on at 50% brightness
turn_on("light.office", brightness=255, color_temp=4000) # Full, neutral white
turn_on("switch.coffee_maker") # Turn on a switch
"""
try:
async with CoreAPIClient() as client:
result = await client.turn_on(
entity_id=entity_id,
brightness=brightness,
color_temp=color_temp,
)
if result.success:
extras = []
if brightness is not None:
extras.append(f"brightness {brightness}/255")
if color_temp is not None:
extras.append(f"color temp {color_temp}K")
extra_str = f" ({', '.join(extras)})" if extras else ""
return f"Turned on {entity_id}{extra_str}"
else:
return f"Failed to turn on {entity_id}: {result.message}"
except Exception as e:
logger.error("housekeeper_turn_on_error", error=str(e), entity_id=entity_id)
return f"Error turning on {entity_id}: {str(e)}"
async def turn_off(entity_id: str) -> str:
"""
Turn off a device.
Args:
entity_id: Device to turn off (e.g., light.living_room, switch.coffee_maker)
Returns:
Confirmation of the action
Examples:
turn_off("light.living_room")
turn_off("switch.coffee_maker")
"""
try:
async with CoreAPIClient() as client:
result = await client.turn_off(entity_id=entity_id)
if result.success:
return f"Turned off {entity_id}"
else:
return f"Failed to turn off {entity_id}: {result.message}"
except Exception as e:
logger.error("housekeeper_turn_off_error", error=str(e), entity_id=entity_id)
return f"Error turning off {entity_id}: {str(e)}"
async def toggle(entity_id: str) -> str:
"""
Toggle a device's state (on becomes off, off becomes on).
Args:
entity_id: Device to toggle
Returns:
Confirmation with the new state
Examples:
toggle("light.living_room")
toggle("switch.fan")
"""
try:
async with CoreAPIClient() as client:
result = await client.toggle(entity_id=entity_id)
if result.success:
return f"Toggled {entity_id}"
else:
return f"Failed to toggle {entity_id}: {result.message}"
except Exception as e:
logger.error("housekeeper_toggle_error", error=str(e), entity_id=entity_id)
return f"Error toggling {entity_id}: {str(e)}"
# ============================================================================
# Scenes
# ============================================================================
async def list_scenes() -> str:
"""
List all available scenes.
Scenes are pre-configured combinations of device states.
Returns:
List of available scenes
Examples:
list_scenes()
"""
try:
async with CoreAPIClient() as client:
scenes = await client.list_scenes()
if not scenes:
return "No scenes found"
output_parts = ["## Available Scenes\n"]
for scene in scenes:
name = scene.friendly_name or scene.name
output_parts.append(f"- **{name}**")
output_parts.append(f" ID: `{scene.entity_id}`")
logger.info("housekeeper_list_scenes", count=len(scenes))
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_list_scenes_error", error=str(e))
return f"Error listing scenes: {str(e)}"
async def activate_scene(scene_id: str) -> str:
"""
Activate a scene.
This sets all devices in the scene to their configured states.
Args:
scene_id: Scene entity ID (e.g., scene.movie_night, scene.good_morning)
Returns:
Confirmation of activation
Examples:
activate_scene("scene.movie_night")
activate_scene("scene.good_morning")
"""
try:
async with CoreAPIClient() as client:
result = await client.activate_scene(scene_id=scene_id)
if result.success:
return f"Activated scene: {scene_id}"
else:
return f"Failed to activate {scene_id}: {result.message}"
except Exception as e:
logger.error("housekeeper_activate_scene_error", error=str(e), scene_id=scene_id)
return f"Error activating scene {scene_id}: {str(e)}"
# ============================================================================
# Scripts
# ============================================================================
async def list_scripts() -> str:
"""
List all available automation scripts.
Scripts are sequences of actions that can be triggered manually.
Returns:
List of available scripts
Examples:
list_scripts()
"""
try:
async with CoreAPIClient() as client:
scripts = await client.list_scripts()
if not scripts:
return "No scripts found"
output_parts = ["## Available Scripts\n"]
for script in scripts:
output_parts.append(f"- **{script.name}**")
if script.description:
output_parts.append(f" {script.description}")
output_parts.append(f" ID: `{script.entity_id}`")
if script.last_triggered:
output_parts.append(f" Last run: {script.last_triggered}")
logger.info("housekeeper_list_scripts", count=len(scripts))
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_list_scripts_error", error=str(e))
return f"Error listing scripts: {str(e)}"
async def run_script(script_id: str) -> str:
"""
Run an automation script.
Args:
script_id: Script entity ID (e.g., script.good_morning, script.bedtime)
Returns:
Confirmation of execution
Examples:
run_script("script.good_morning")
run_script("script.all_lights_off")
"""
try:
async with CoreAPIClient() as client:
result = await client.run_script(script_id=script_id)
if result.success:
return f"Running script: {script_id}"
else:
return f"Failed to run {script_id}: {result.message}"
except Exception as e:
logger.error("housekeeper_run_script_error", error=str(e), script_id=script_id)
return f"Error running script {script_id}: {str(e)}"
# ============================================================================
# Automations
# ============================================================================
async def list_automations() -> str:
"""
List all automations and their current states.
Automations are event-triggered rules that run automatically.
Returns:
List of automations with enabled/disabled status
Examples:
list_automations()
"""
try:
async with CoreAPIClient() as client:
automations = await client.list_automations()
if not automations:
return "No automations found"
output_parts = ["## Automations\n"]
# Group by state
enabled = [a for a in automations if a.state == "on"]
disabled = [a for a in automations if a.state != "on"]
if enabled:
output_parts.append("### Enabled")
for auto in enabled:
output_parts.append(f"- **{auto.name}**")
output_parts.append(f" ID: `{auto.entity_id}`")
if auto.last_triggered:
output_parts.append(f" Last triggered: {auto.last_triggered}")
output_parts.append("")
if disabled:
output_parts.append("### Disabled")
for auto in disabled:
output_parts.append(f"- **{auto.name}**")
output_parts.append(f" ID: `{auto.entity_id}`")
logger.info("housekeeper_list_automations", count=len(automations))
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_list_automations_error", error=str(e))
return f"Error listing automations: {str(e)}"
async def toggle_automation(automation_id: str, enable: bool) -> str:
"""
Enable or disable an automation.
Args:
automation_id: Automation entity ID
enable: True to enable, False to disable
Returns:
Confirmation of the change
Examples:
toggle_automation("automation.morning_lights", enable=True)
toggle_automation("automation.vacation_mode", enable=False)
"""
try:
async with CoreAPIClient() as client:
result = await client.toggle_automation(
automation_id=automation_id,
enable=enable,
)
action = "Enabled" if enable else "Disabled"
if result.success:
return f"{action} automation: {automation_id}"
else:
return f"Failed to {action.lower()} {automation_id}: {result.message}"
except Exception as e:
logger.error(
"housekeeper_toggle_automation_error",
error=str(e),
automation_id=automation_id,
)
return f"Error toggling automation {automation_id}: {str(e)}"
# ============================================================================
# History
# ============================================================================
async def get_history(entity_id: str, hours: int = 24) -> str:
"""
Get the state history of a device.
Useful for understanding patterns or troubleshooting.
Args:
entity_id: Device to get history for
hours: Number of hours of history (default: 24)
Returns:
List of state changes over the time period
Examples:
get_history("light.living_room")
get_history("climate.bedroom", hours=48)
"""
try:
async with CoreAPIClient() as client:
history = await client.get_history(entity_id=entity_id, hours=hours)
if not history:
return f"No history found for {entity_id} in the last {hours} hours"
output_parts = [f"## History: {entity_id}", f"*Last {hours} hours*\n"]
for entry in history[-20:]: # Show last 20 entries
output_parts.append(f"- **{entry.timestamp}**: {entry.state}")
if len(history) > 20:
output_parts.append(f"\n*(showing last 20 of {len(history)} entries)*")
return "\n".join(output_parts)
except Exception as e:
logger.error("housekeeper_get_history_error", error=str(e), entity_id=entity_id)
return f"Error getting history for {entity_id}: {str(e)}"
# ============================================================================
# Tool Collection for Registration
# ============================================================================
# All tools available to The Housekeeper
HOUSEKEEPER_TOOLS = [
# Discovery
list_areas,
list_devices,
get_device_state,
# Control
turn_on,
turn_off,
toggle,
# Scenes
list_scenes,
activate_scene,
# Scripts
list_scripts,
run_script,
# Automations
list_automations,
toggle_automation,
# History
get_history,
]
+14
View File
@@ -124,6 +124,20 @@ class Config(BaseSettings):
description="Library-Desk request timeout in seconds"
)
# Core-API Configuration (The Housekeeper backend)
CORE_API_HOST: HttpUrl = Field(
default="http://localhost:8090",
description="Core-API URL for Home Assistant integration"
)
CORE_API_KEY: str = Field(
default="",
description="API key for Core-API authentication"
)
CORE_API_TIMEOUT: int = Field(
default=30,
description="Core-API request timeout in seconds"
)
# Qdrant Configuration (Memory vector storage)
QDRANT_HOST: str = Field(
default="localhost",
+6 -2
View File
@@ -223,13 +223,17 @@ class HouseholdRegistry:
>>> # Returns: [delegate_to_librarian, calculate, datetime, ...]
>>> # Instead of: [hybrid_search, search_wiki, create_wiki_page, ... (16 tools)]
"""
from src.agents.delegation import delegate_to_librarian, delegate_to_biographer
from src.agents.delegation import (
delegate_to_biographer,
delegate_to_housekeeper,
delegate_to_librarian,
)
# Map of expert names to their delegation wrappers
delegation_wrappers = {
"librarian": delegate_to_librarian,
"biographer": delegate_to_biographer,
# Future: "home_automation": delegate_to_home_automation,
"housekeeper": delegate_to_housekeeper,
}
tools = []
+11
View File
@@ -6,6 +6,7 @@ This module should be called during application startup to register
all household members.
"""
from src.agents.biographer import register_biographer
from src.agents.housekeeper import register_housekeeper
from src.agents.librarian import register_librarian
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
from src.core.household_registry import get_household_registry
@@ -64,6 +65,16 @@ def register_household_members():
error=str(e),
)
# Register The Housekeeper (Home Automation)
try:
register_housekeeper()
except Exception as e:
# Don't fail startup if Housekeeper registration fails
logger.warning(
"housekeeper_registration_failed",
error=str(e),
)
logger.info(
"household_registration_complete",
total_members=len(registry),