Compare commits

...
2 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 49f0da8068 feat: two-phase execution, think slugs, query enrichment (v1.6.0)
Build and Push / build (release) Successful in 1m14s
Two-Phase Tatlock Execution:
- orchestrate_tool_calls() for Phase 1 coordination
- synthesize_from_results() for Phase 2 butler-toned synthesis
- Guarantees butler personality in all responses

Automatic Think Slugs:
- Deterministic butler-perspective messages during expert delegation
- ActionType enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
- HOUSEHOLD_THINK_MESSAGES mapping for all experts
- Streaming delegation wrappers with automatic think messages

Steward Query Enrichment:
- Auto-fill user context (location, timezone) when not specified
- _build_enriched_query() with regex word boundary matching
- enriched_query field in StewardRecommendation schema

Documentation:
- ORCHESTRATION_SCENARIOS.md rewritten with Mermaid diagrams
- New Housekeeper and Biographer scenarios
- TESTING_IMPROVEMENTS.md for future LLM testing patterns

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:00:32 +01:00
jpmschweitzerandClaude Opus 4.5 a1b8fe46e8 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>
2025-12-15 10:24:35 +01:00
29 changed files with 4306 additions and 280 deletions
+10
View File
@@ -42,5 +42,15 @@ ENABLE_BENCHMARKS=true
# - production: jpmschweitzer (real user)
# Uncomment to override: DEFAULT_USER=your_username
# Library-Desk Configuration (The Librarian backend)
# LIBRARY_DESK_HOST=http://localhost:8089
# LIBRARY_DESK_API_KEY=your-library-desk-api-key
# LIBRARY_DESK_TIMEOUT=60
# Core-API Configuration (The Housekeeper backend)
# CORE_API_HOST=http://localhost:8090
# CORE_API_KEY=your-core-api-key
# CORE_API_TIMEOUT=30
# CORS (comma-separated list)
CORS_ORIGINS=["*"]
+1 -1
View File
@@ -19,7 +19,7 @@ This document contains instructions and documentation references for AI assistan
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
* **Start the local server** with `./wakeup.sh` - logs are written to `logs/server.log` for easy tailing
* **Auto-reload**: The wakeup script runs uvicorn in reload mode - code changes are picked up automatically without restart (except for requirements.txt changes)
* **Test REST endpoints** against `http://localhost:8123` using curl or similar tools
* **Test REST endpoints** against `http://localhost:8777` using curl or similar tools
* **Only deploy** when a phase or feature is complete and tested locally
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts)
+80 -1
View File
@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.6.0] - 2025-12-15
### Added
#### Two-Phase Tatlock Execution
- **Phase 1: Orchestration** - Executes tool calls and expert delegations, returns structured results
- **Phase 2: Synthesis** - Synthesizes butler-toned response from gathered results
- `orchestrate_tool_calls()` method in TatlockAgent for coordination phase
- `synthesize_from_results()` method in TatlockAgent for synthesis phase
- Guarantees butler personality in all responses by separating coordination from response generation
#### Automatic Think Slugs
- **Deterministic butler-perspective messages** during expert delegation (no LLM involved)
- `ActionType` enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
- `HOUSEHOLD_THINK_MESSAGES` mapping with butler-perspective messages for all experts:
- Librarian: "Allow me to consult the archives, sir." / "I'm having the Librarian prepare a new entry."
- Biographer: "Let me consult the household records." / "I've asked the Biographer to take note, sir."
- Housekeeper: "I'm instructing the household staff now, sir." / "Allow me to inquire with the household staff."
- `_detect_action_type()` function for keyword-based action detection
- `get_think_message()` helper for retrieving appropriate messages
- Streaming delegation wrappers: `stream_delegate_to_librarian()`, `stream_delegate_to_biographer()`, `stream_delegate_to_housekeeper()`
- `STREAMING_DELEGATION_WRAPPERS` mapping in delegation.py
- `get_streaming_delegation_tools()` method in HouseholdRegistry
#### Steward Query Enrichment
- **Auto-fill user context** (location, timezone) when not specified in query
- `_build_enriched_query()` function in steward service
- Regex word boundary matching for accurate location detection (avoids false positives)
- `enriched_query` field added to `StewardRecommendation` schema
- Automatic enrichment for weather queries (location), time queries (timezone), temperature preferences
#### Documentation
- **ORCHESTRATION_SCENARIOS.md** completely rewritten with:
- Mermaid flow diagrams for two-phase execution
- 4 new Housekeeper scenarios (light control, device status, parallel delegation)
- Biographer memory recording scenario
- Complete think slug reference tables
- Action type detection tables
- Updated architecture mindmap
- **TESTING_IMPROVEMENTS.md** - LLM testing best practices for future implementation
### Changed
- `create_response_with_steward()` now uses two-phase execution
- `_direct_delegation()` routes through synthesis phase for consistent butler tone
- `_execute_single_delegation()` now supports housekeeper
- Streaming response handler integrated with think slug system
- All 326 unit tests passing
## [1.5.0] - 2025-12-15
### Added
#### The Housekeeper Agent
- **New home automation expert agent** following the Librarian pattern
- `CoreAPIClient` for communicating with core-api service (Home Assistant wrapper)
- 13 tools for home automation:
- 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`
- PydanticAI agent with system prompt for home automation tasks
- `HouseholdCapability` registration with domains: lights, switches, automation, home, smart home, scene, script, device, climate, fan, cover, blinds
- `delegate_to_housekeeper()` delegation wrapper
- Config settings: `CORE_API_HOST`, `CORE_API_KEY`, `CORE_API_TIMEOUT`
#### Development Port Change
- **Dev server port changed from 8123 to 8777** to avoid conflict with Home Assistant default port
- Updated `wakeup.sh`, E2E tests, and documentation
### Changed
- All unit tests pass (421 passed, 5 xfailed)
- Housekeeper registered on startup alongside Librarian and Biographer
## [1.4.0] - 2025-12-14
### Added
@@ -580,7 +657,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CORS middleware
- Exception handlers (OpenAI-compatible error format)
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...main
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.6.0...main
[1.6.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.5.0...v1.6.0
[1.5.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...v1.5.0
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
[1.3.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.2...v1.3.3
[1.3.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.1...v1.3.2
+677 -216
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
# Testing Improvements for LLM Outputs
## Problem
LLM outputs are non-deterministic. Tests checking for exact string matches fail when the LLM writes "thirty-seven" instead of "37".
## Proposed Solutions
### 1. LLM-as-Judge Pattern
Use a smaller/faster model to evaluate semantic correctness:
```python
async def llm_judge(output: str, criteria: str) -> bool:
"""Use LLM to evaluate if output meets criteria."""
prompt = f"""
Evaluate if this output is correct:
Output: {output}
Criteria: {criteria}
Answer only YES or NO.
"""
result = await judge_model.run(prompt)
return "YES" in result.output.upper()
# Usage in test:
assert await llm_judge(
response,
"The answer correctly states that sqrt(144) + 25 = 37"
)
```
### 2. Fuzzy/Regex Matching
For numeric answers, accept multiple representations:
```python
import re
def contains_number(text: str, number: int) -> bool:
"""Check if text contains number in any form."""
patterns = [
rf'\b{number}\b', # Digit form
number_to_words(number), # Word form
]
return any(re.search(p, text, re.I) for p in patterns)
# Usage:
assert contains_number(response, 37) # Matches "37" or "thirty-seven"
```
### 3. DeepEval Framework
```python
from deepeval.metrics import AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase
def test_calculation():
test_case = LLMTestCase(
input="What is sqrt(144) + 25?",
actual_output=response,
expected_output="37"
)
metric = AnswerRelevancyMetric(threshold=0.7)
assert metric.measure(test_case)
```
### 4. pytest-evals Plugin
Minimal pytest plugin for LLM testing with metrics collection.
```bash
pip install pytest-evals
```
### 5. Multiple Runs with Threshold
Run flaky tests multiple times and require majority pass:
```python
@pytest.mark.flaky(reruns=3, reruns_delay=1)
def test_llm_response():
...
```
Or custom:
```python
@pytest.mark.parametrize("run", range(3))
def test_llm_response(run):
...
# Aggregate results across runs
```
## Resources
- [DeepEval](https://github.com/confident-ai/deepeval) - LLM evaluation framework
- [pytest-evals](https://github.com/AlmogBaku/pytest-evals) - pytest plugin for LLM evals
- [LLM Testing Guide 2025](https://www.confident-ai.com/blog/llm-testing-in-2024-top-methods-and-strategies)
- [Testing LLM Applications - Langfuse](https://langfuse.com/blog/2025-10-21-testing-llm-applications)
## Implementation Priority
1. Add fuzzy number matching helper (quick win)
2. Evaluate DeepEval for complex output testing
3. Consider LLM-as-judge for semantic correctness
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tatlock"
version = "1.4.0"
version = "1.6.0"
description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12"
dependencies = []
+299 -2
View File
@@ -9,13 +9,136 @@ This implements the agent-as-tool pattern recommended by PydanticAI:
agents call other agents via tool wrappers, keeping each agent focused.
"""
from dataclasses import dataclass, field
from typing import Callable, Optional, Any
from enum import Enum
from typing import AsyncGenerator, Callable, Optional, Any
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# =============================================================================
# Action Types for Think Slug Selection
# =============================================================================
class ActionType(Enum):
"""
Categories of actions for selecting appropriate think messages.
Each expert has different action types that warrant different
butler-perspective messages to the user.
"""
RETRIEVE = "retrieve" # Looking up existing information
RESEARCH = "research" # Conducting new research (web search, etc.)
CREATE = "create" # Creating new content (pages, notes)
CONTROL = "control" # Controlling devices/automations
RECORD = "record" # Recording memories/notes
# =============================================================================
# Household Think Messages (Butler's Perspective)
# =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
"librarian": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to consult the archives, sir.</think>",
"success": "<think>The Librarian has compiled the relevant findings.</think>",
"error": "<think>I'm afraid the archives proved difficult to access.</think>",
},
ActionType.RESEARCH: {
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>",
"success": "<think>The Librarian has returned with findings, sir.</think>",
"error": "<think>The research proved inconclusive, I'm afraid.</think>",
},
ActionType.CREATE: {
"start": "<think>I'm having the Librarian prepare a new entry.</think>",
"success": "<think>The new material has been properly catalogued, sir.</think>",
"error": "<think>I'm afraid there was difficulty filing the entry.</think>",
},
},
"biographer": {
ActionType.RETRIEVE: {
"start": "<think>Let me consult the household records.</think>",
"success": "<think>The Biographer has located the relevant information, sir.</think>",
"error": "<think>I'm unable to locate those particular records.</think>",
},
ActionType.RECORD: {
"start": "<think>I've asked the Biographer to take note of this, sir.</think>",
"success": "<think>The household records have been updated accordingly.</think>",
"error": "<think>I'm afraid there was difficulty recording the entry.</think>",
},
},
"housekeeper": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to inquire with the household staff.</think>",
"success": "<think>The staff reports the current status, sir.</think>",
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>",
},
ActionType.CONTROL: {
"start": "<think>I'm instructing the household staff now, sir.</think>",
"success": "<think>The household has been configured as requested.</think>",
"error": "<think>I'm afraid the staff reports an issue with that request.</think>",
},
},
}
def _detect_action_type(expert: str, task: str) -> ActionType:
"""
Detect action type from expert name and task description.
Used to select appropriate butler-perspective think messages.
Args:
expert: Name of the expert (librarian, biographer, housekeeper)
task: Task description
Returns:
ActionType: Detected action type for message selection
"""
task_lower = task.lower()
if expert == "librarian":
if any(w in task_lower for w in ["search", "find", "look up", "research"]):
if any(w in task_lower for w in ["web", "online", "internet"]):
return ActionType.RESEARCH
return ActionType.RETRIEVE
if any(w in task_lower for w in ["create", "write", "add", "make", "new"]):
return ActionType.CREATE
return ActionType.RETRIEVE
elif expert == "biographer":
if any(w in task_lower for w in ["remember", "note", "record", "save", "store"]):
return ActionType.RECORD
return ActionType.RETRIEVE
elif expert == "housekeeper":
if any(w in task_lower for w in ["turn", "set", "activate", "enable", "disable", "toggle"]):
return ActionType.CONTROL
return ActionType.RETRIEVE
return ActionType.RETRIEVE
def get_think_message(expert: str, task: str, phase: str) -> str:
"""
Get the appropriate think message for an expert delegation.
Args:
expert: Name of the expert
task: Task description (used to detect action type)
phase: One of "start", "success", "error"
Returns:
str: Butler-perspective think message
"""
action_type = _detect_action_type(expert, task)
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
return action_messages.get(phase, f"<think>Consulting {expert}...</think>")
@dataclass
class DelegationTask:
"""
@@ -224,6 +347,180 @@ 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),
)
# =============================================================================
# Streaming Delegation Wrappers (with Think Messages)
# =============================================================================
async def stream_delegate_to_librarian(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Librarian with automatic think messages.
Yields butler-perspective think messages before and after the delegation,
allowing the UI to show progress to the user.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
# Yield start message (deterministic)
yield get_think_message("librarian", task, "start") + "\n"
# Execute delegation
result = await delegate_to_librarian(task, context)
# Yield completion message (deterministic)
if result.success:
yield get_think_message("librarian", task, "success") + "\n"
else:
yield get_think_message("librarian", task, "error") + "\n"
# Yield result marker for extraction
yield f"__DELEGATION_RESULT__:librarian:{result.output}"
async def stream_delegate_to_biographer(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Biographer with automatic think messages.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
yield get_think_message("biographer", task, "start") + "\n"
result = await delegate_to_biographer(task, context)
if result.success:
yield get_think_message("biographer", task, "success") + "\n"
else:
yield get_think_message("biographer", task, "error") + "\n"
yield f"__DELEGATION_RESULT__:biographer:{result.output}"
async def stream_delegate_to_housekeeper(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Housekeeper with automatic think messages.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
yield get_think_message("housekeeper", task, "start") + "\n"
result = await delegate_to_housekeeper(task, context)
if result.success:
yield get_think_message("housekeeper", task, "success") + "\n"
else:
yield get_think_message("housekeeper", task, "error") + "\n"
yield f"__DELEGATION_RESULT__:housekeeper:{result.output}"
# Mapping of streaming delegation wrappers
STREAMING_DELEGATION_WRAPPERS = {
"librarian": stream_delegate_to_librarian,
"biographer": stream_delegate_to_biographer,
"housekeeper": stream_delegate_to_housekeeper,
}
# 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,
]
+4
View File
@@ -60,6 +60,10 @@ class StewardRecommendation(BaseModel):
default_factory=dict,
description="Pre-fetched user context from memory (profile, preferences)"
)
enriched_query: str = Field(
default="",
description="User query with auto-filled context (location, timezone) when not specified"
)
def format_for_butler(self) -> str:
"""
+66
View File
@@ -149,6 +149,68 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
return None
def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) -> str:
"""
Build an enriched query by appending user context when not specified.
When the user asks location-dependent questions (weather, nearby, etc.)
without specifying a location, this appends their known location.
Similarly for timezone-dependent queries.
Args:
user_request: The user's original request
memory_context: Pre-fetched memory context with profile/preferences
Returns:
str: Query with context appended, or original query if no enrichment needed
Example:
>>> query = _build_enriched_query(
... "What's the weather?",
... {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
... )
>>> query
"What's the weather?\n\n[User Context: location=Amsterdam, timezone=Europe/Amsterdam]"
"""
if not memory_context:
return user_request
request_lower = user_request.lower()
profile = memory_context.get("profile", {})
preferences = memory_context.get("preferences", {})
context_parts = []
# Check if location is needed and not specified
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
# Use word boundary pattern to avoid false positives like "at" in "what"
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
if any(word in request_lower for word in location_keywords):
if not location_specified and profile.get("location"):
context_parts.append(f"location={profile['location']}")
# Check if timezone is needed and not specified
time_keywords = ["time", "schedule", "meeting", "appointment", "when", "today", "tomorrow"]
timezone_specified = any(word in request_lower for word in ["timezone", "tz", "utc", "gmt"])
if any(word in request_lower for word in time_keywords):
if not timezone_specified and profile.get("timezone"):
context_parts.append(f"timezone={profile['timezone']}")
# Add preferences if relevant
if preferences.get("temperature_unit") and "weather" in request_lower:
context_parts.append(f"temperature_unit={preferences['temperature_unit']}")
# Build enriched query
if context_parts:
context_str = ", ".join(context_parts)
return f"{user_request}\n\n[User Context: {context_str}]"
return user_request
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
"""
Pre-fetch user context that might be needed for this request.
@@ -277,6 +339,9 @@ async def analyze_request(
context = _extract_conversation_context(analysis_text, conversation_history)
missing = _extract_missing_capabilities(analysis_text)
# Build enriched query with auto-filled context
enriched_query = _build_enriched_query(user_request, memory_context)
recommendation = StewardRecommendation(
recommended_capabilities=capabilities,
reasoning=analysis_text,
@@ -284,6 +349,7 @@ async def analyze_request(
conversation_context=context,
missing_capabilities=missing,
memory_context=memory_context,
enriched_query=enriched_query,
)
# Update log context with results
+235
View File
@@ -630,6 +630,241 @@ class TatlockAgent(AgentInterface):
logger.info("tatlock_scoped_run_complete")
async def orchestrate_tool_calls(
self,
user_message: str,
steward_note: str,
scoped_tools: list[Any],
message_history: list[dict],
tool_tracker: Any = None,
) -> dict[str, Any]:
"""
Phase 1: Execute tool calls and delegations, return structured results.
This is the coordination phase where Tatlock orchestrates tool calls
and expert delegations. The raw output is captured for Phase 2 synthesis.
Args:
user_message: The user's original message
steward_note: Note from Steward (invisible to user)
scoped_tools: List of tool definitions from household registry
message_history: Conversation history
tool_tracker: Optional tool call tracker for benchmarking
Returns:
dict with:
- tools_called: List of tool names that were called
- expert_results: Dict mapping expert names to their outputs
- tool_outputs: Dict mapping tool names to their outputs
- raw_output: The agent's raw text output
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.settings import ModelSettings
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
UserPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
)
logger.info(
"tatlock_orchestrate_tool_calls",
user_message_preview=user_message[:100],
scoped_tool_count=len(scoped_tools),
history_length=len(message_history),
)
# Create a fresh agent instance with scoped tools only
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
)
# Create agent with scoped tools
scoped_agent = Agent(
ollama_model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
tools=scoped_tools,
)
# Prepend Steward's note to the request
enriched_message = f"{steward_note}\n\n{user_message}"
# Convert message history to PydanticAI format
pydantic_history = []
for msg in message_history:
role = msg.get("role")
content = msg.get("content", "")
if not content or not content.strip():
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
# Run with scoped tools and tracker
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
)
# Extract tool calls and results from the agent's messages
tools_called = []
expert_results = {}
tool_outputs = {}
# Parse through new messages to find tool calls and returns
for msg in result.new_messages():
if isinstance(msg, ModelResponse):
for part in msg.parts:
if isinstance(part, ToolCallPart):
tools_called.append(part.tool_name)
elif isinstance(msg, ModelRequest):
for part in msg.parts:
if isinstance(part, ToolReturnPart):
tool_name = part.tool_name
content = part.content
# Categorize as expert result or tool output
if tool_name.startswith("delegate_to_"):
expert_name = tool_name.replace("delegate_to_", "")
expert_results[expert_name] = content
else:
tool_outputs[tool_name] = content
logger.info(
"tatlock_orchestration_complete",
tools_called=tools_called,
expert_count=len(expert_results),
tool_output_count=len(tool_outputs),
)
return {
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": tool_outputs,
"raw_output": result.output,
}
async def synthesize_from_results(
self,
user_message: str,
orchestration_results: dict[str, Any],
message_history: list[dict],
) -> str:
"""
Phase 2: Synthesize butler-toned response from gathered results.
This is the synthesis phase where Tatlock takes the coordination
results and produces a properly butler-toned response.
Args:
user_message: The user's original message
orchestration_results: Results from orchestrate_tool_calls()
message_history: Conversation history
Returns:
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
logger.info(
"tatlock_synthesize_from_results",
user_message_preview=user_message[:100],
expert_count=len(orchestration_results.get("expert_results", {})),
tool_count=len(orchestration_results.get("tool_outputs", {})),
)
# Build synthesis prompt with all available information
synthesis_parts = []
synthesis_parts.append(f"The user asked: {user_message}")
synthesis_parts.append("")
# Add expert findings if any
if orchestration_results.get("expert_results"):
synthesis_parts.append("Expert findings:")
for expert, result in orchestration_results["expert_results"].items():
synthesis_parts.append(f"- {expert.title()}: {result}")
synthesis_parts.append("")
# Add tool outputs if any
if orchestration_results.get("tool_outputs"):
synthesis_parts.append("Tool results:")
for tool, result in orchestration_results["tool_outputs"].items():
synthesis_parts.append(f"- {tool}: {result}")
synthesis_parts.append("")
synthesis_parts.append(
"Based on this information, provide a response to the user. "
"Maintain your butler personality - address them as 'sir', "
"use formal but personable language, and be helpful."
)
synthesis_prompt = "\n".join(synthesis_parts)
# Create synthesis agent (no tools needed)
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
)
# Synthesis agent uses butler prompt but no tools
synthesis_agent = Agent(
ollama_model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
# No tools for synthesis phase
)
# Convert message history to PydanticAI format
pydantic_history = []
for msg in message_history:
role = msg.get("role")
content = msg.get("content", "")
if not content or not content.strip():
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
# Run synthesis
result = await synthesis_agent.run(
synthesis_prompt,
message_history=pydantic_history if pydantic_history else None,
)
logger.info(
"tatlock_synthesis_complete",
response_preview=result.output[:100],
)
return result.output
async def get_capabilities(self) -> dict:
"""Return current capabilities."""
return {
+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",
+65 -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 = []
@@ -269,6 +273,65 @@ class HouseholdRegistry:
return tools
def get_streaming_delegation_tools(self, names: list[str]) -> list[Any]:
"""
Get streaming delegation wrapper tools for specified capabilities.
Similar to get_delegation_tools() but returns streaming wrappers
that yield butler-perspective think messages during execution.
These wrappers emit think slugs like:
- "Allow me to consult the archives, sir."
- "The Librarian has compiled the relevant findings."
Args:
names: List of member names to include
Returns:
List of streaming delegation wrappers and/or raw tools
Example:
>>> tools = registry.get_streaming_delegation_tools(["librarian"])
>>> async for chunk in tools[0](task="Search for Docker"):
... print(chunk) # Yields think messages then result
"""
from src.agents.delegation import STREAMING_DELEGATION_WRAPPERS
tools = []
for name in names:
member = self._members.get(name)
if not member:
logger.warning(
"household_member_not_found",
requested_name=name,
available_names=list(self._members.keys()),
)
continue
# Check if this member has a streaming delegation wrapper
if name in STREAMING_DELEGATION_WRAPPERS and member.agent is not None:
tools.append(STREAMING_DELEGATION_WRAPPERS[name])
logger.debug(
"streaming_delegation_wrapper_added",
member=name,
)
else:
# No agent = direct tools (e.g., tatlock_core)
tools.extend(member.tools)
logger.debug(
"raw_tools_added",
member=name,
tool_count=len(member.tools),
)
logger.info(
"streaming_delegation_tools_created",
requested_members=names,
total_tools=len(tools),
)
return tools
def list_members(self) -> list[str]:
"""
List all registered member names.
+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),
+105 -24
View File
@@ -43,7 +43,7 @@ async def _execute_single_delegation(
Execute a single delegation to an agent.
Args:
agent_name: Name of agent (biographer, librarian)
agent_name: Name of agent (biographer, librarian, housekeeper)
task: Task description
tracker: Tool call tracker
@@ -67,6 +67,13 @@ async def _execute_single_delegation(
await tracker.track_call("delegate_to_librarian", duration)
return (agent_name, result.output)
elif agent_name == "housekeeper":
from src.agents.delegation import delegate_to_housekeeper
result = await delegate_to_housekeeper(task=task)
duration = time.time() - start_time
await tracker.track_call("delegate_to_housekeeper", duration)
return (agent_name, result.output)
else:
return (agent_name, f"Unknown agent: {agent_name}")
@@ -244,6 +251,68 @@ async def _direct_delegation(
return "\n\n".join(results) if results else "I apologize, sir. No delegation results available."
async def _direct_delegation_with_results(
user_message: str,
recommendation: "StewardRecommendation",
tracker: "ToolCallTracker",
conversation_id: str,
) -> dict:
"""
Directly delegate to expert agents and return structured results.
This is the Phase 1 variant of direct delegation that returns results
in the same format as TatlockAgent.orchestrate_tool_calls() for
consistent Phase 2 synthesis.
Args:
user_message: User's request
recommendation: Steward's recommendation
tracker: Tool call tracker
conversation_id: Conversation ID
Returns:
dict: Orchestration results with expert_results, tool_outputs, etc.
"""
logger.info(
"direct_delegation_with_results",
agents=recommendation.recommended_capabilities,
conversation_id=conversation_id,
)
expert_results = {}
tools_called = []
for agent in recommendation.recommended_capabilities:
try:
agent_name, result = await _execute_single_delegation(
agent, user_message, tracker
)
expert_results[agent_name] = result
tools_called.append(f"delegate_to_{agent_name}")
logger.info(
"direct_delegation_result",
agent=agent_name,
result_preview=result[:100] if result else "empty",
conversation_id=conversation_id,
)
except Exception as e:
logger.error(
"direct_delegation_failed",
agent=agent,
error=str(e),
conversation_id=conversation_id,
)
expert_results[agent] = f"Error: {e}"
return {
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {}, # No tool outputs for direct delegation
"raw_output": "", # No raw output for direct delegation
}
# Global conversation history tracker
# In production, this would be backed by a database or Redis
_conversation_history = ConversationHistory(max_turns=20)
@@ -379,12 +448,13 @@ async def create_response(request: ResponseRequest) -> Response:
async def create_response_with_steward(request: ResponseRequest) -> Response:
"""
Create response using Steward preprocessing (Phase 2 flow).
Create response using Steward preprocessing and two-phase Tatlock execution.
This is the two-tier architecture where:
This is the two-tier architecture with two-phase synthesis:
1. Steward analyzes the request and recommends capabilities
2. Tatlock runs with scoped tools based on recommendations
3. Tool usage is tracked for benchmarking
2. Phase 1: Tatlock orchestrates tool calls and expert delegations
3. Phase 2: Tatlock synthesizes butler-toned response from results
4. Tool usage is tracked for benchmarking
Args:
request: Response request
@@ -420,37 +490,39 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
conversation_id=conversation_id,
)
# Phase 1: Steward preprocessing
# Steward preprocessing
enriched = await preprocess_request(
user_message,
conversation_history=conversation_history,
conversation_id=conversation_id,
)
# Phase 2: Initialize tool tracker
# Initialize tool tracker
tracker = ToolCallTracker(
recommended_capabilities=enriched.recommendation.recommended_capabilities,
conversation_id=conversation_id,
)
# Phase 3: Check if direct delegation is recommended
# If Steward recommends ONLY delegation agents (biographer/librarian),
# skip Tatlock and delegate directly
# Check if direct delegation is recommended
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
# we still use two-phase but delegate directly in Phase 1
delegation_agents = {"biographer", "librarian", "housekeeper"}
delegation_only = all(
cap in ("biographer", "librarian")
cap in delegation_agents
for cap in enriched.recommendation.recommended_capabilities
) and enriched.recommendation.recommended_capabilities
from src.agents.tatlock import TatlockAgent
tatlock = TatlockAgent()
if delegation_only:
tatlock_response = await _direct_delegation(
# Direct delegation path - collect results then synthesize
orchestration_results = await _direct_delegation_with_results(
user_message, enriched.recommendation, tracker, conversation_id
)
else:
# Phase 3a: Run Tatlock with scoped tools
from src.agents.tatlock import TatlockAgent
tatlock = TatlockAgent()
tatlock_response = await tatlock.run_with_scoped_tools(
# Phase 1: Orchestrate tool calls
orchestration_results = await tatlock.orchestrate_tool_calls(
user_message=user_message,
steward_note=enriched.steward_note,
scoped_tools=enriched.scoped_tools,
@@ -458,14 +530,23 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
tool_tracker=tracker,
)
# Phase 3b: Check for text-based delegation fallback
# If Tatlock outputs [DELEGATE:...] instead of calling the function,
# we parse and execute it here
tatlock_response = await _handle_text_delegation(
tatlock_response, tracker, conversation_id
)
# Handle text-based delegation fallback if present
if "[DELEGATE:" in orchestration_results.get("raw_output", ""):
text_delegation_results = await _handle_text_delegation(
orchestration_results["raw_output"], tracker, conversation_id
)
# Add text delegation results to expert_results
if text_delegation_results != orchestration_results["raw_output"]:
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
# Phase 4: Finalize tool tracking
# Phase 2: Synthesize butler-toned response from all results
tatlock_response = await tatlock.synthesize_from_results(
user_message=user_message,
orchestration_results=orchestration_results,
message_history=conversation_history,
)
# Finalize tool tracking
await tracker.finalize()
# Build response output items
+134 -19
View File
@@ -118,11 +118,12 @@ class StreamingCoordinator:
request: "ResponseRequest" # type: ignore # Forward reference
) -> AsyncGenerator[StreamEvent, None]:
"""
Stream response with Steward preprocessing (Phase 2 flow).
Stream response with Steward preprocessing and two-phase Tatlock execution.
Streams in order:
1. Steward's analysis as reasoning summary
2. Tatlock's response as output text
2. Think slugs during expert delegation (butler-perspective messages)
3. Synthesized butler-toned response as output text
Args:
request: Response request
@@ -130,11 +131,17 @@ class StreamingCoordinator:
Yields:
StreamEvent: Stream of SSE events
"""
from src.responses.service import _calculate_usage, generate_id, _conversation_history
from src.responses.service import (
_calculate_usage,
generate_id,
_conversation_history,
_direct_delegation_with_results,
)
from src.core.preprocessing import preprocess_request
from src.core.tool_tracking import ToolCallTracker
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
from src.agents.tatlock import TatlockAgent
from src.agents.delegation import get_think_message, STREAMING_DELEGATION_WRAPPERS
import asyncio
output_items = []
@@ -152,7 +159,7 @@ class StreamingCoordinator:
conversation_history = request.input[:-1] if len(request.input) > 1 else []
# Phase 1: Steward preprocessing
# Steward preprocessing
enriched = await preprocess_request(
user_message,
conversation_history=conversation_history,
@@ -179,31 +186,60 @@ class StreamingCoordinator:
)
output_items.append(reasoning_item)
# Phase 2: Initialize tool tracker
# Initialize tool tracker
tracker = ToolCallTracker(
recommended_capabilities=enriched.recommendation.recommended_capabilities,
conversation_id=conversation_id,
)
# Phase 3: Stream Tatlock's response with scoped tools
tatlock = TatlockAgent()
tatlock_response_parts = []
# Check if direct delegation is recommended
delegation_agents = {"biographer", "librarian", "housekeeper"}
delegation_only = all(
cap in delegation_agents
for cap in enriched.recommendation.recommended_capabilities
) and enriched.recommendation.recommended_capabilities
async for chunk in tatlock.run_with_scoped_tools_stream(
tatlock = TatlockAgent()
if delegation_only:
# Direct delegation path with streaming think slugs
orchestration_results = await self._stream_direct_delegation(
user_message=user_message,
recommendation=enriched.recommendation,
tracker=tracker,
conversation_id=conversation_id,
)
# Stream think slugs that were collected during delegation
for think_msg in orchestration_results.get("think_messages", []):
yield ReasoningSummaryDelta(delta=think_msg)
await asyncio.sleep(0.05)
else:
# Phase 1: Orchestrate tool calls
orchestration_results = await tatlock.orchestrate_tool_calls(
user_message=user_message,
steward_note=enriched.steward_note,
scoped_tools=enriched.scoped_tools,
message_history=conversation_history,
tool_tracker=tracker,
)
# Phase 2: Synthesize butler-toned response
tatlock_response = await tatlock.synthesize_from_results(
user_message=user_message,
steward_note=enriched.steward_note,
scoped_tools=enriched.scoped_tools,
orchestration_results=orchestration_results,
message_history=conversation_history,
tool_tracker=tracker,
):
tatlock_response_parts.append(chunk)
yield OutputTextDelta(delta=chunk)
)
# Stream the synthesized response
chunk_size = 50
for i in range(0, len(tatlock_response), chunk_size):
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
await asyncio.sleep(0.02)
yield OutputTextDone()
# Combine response for output item
tatlock_response = "".join(tatlock_response_parts)
# Add Tatlock message to output items
message_item = MessageOutputItem(
id=f"msg_{generate_id()}",
@@ -217,7 +253,7 @@ class StreamingCoordinator:
)
output_items.append(message_item)
# Phase 4: Finalize tool tracking
# Finalize tool tracking
await tracker.finalize()
# Calculate usage and build final response
@@ -241,6 +277,85 @@ class StreamingCoordinator:
# Stream error event
yield self._create_error_event(e)
async def _stream_direct_delegation(
self,
user_message: str,
recommendation: "StewardRecommendation", # type: ignore
tracker: "ToolCallTracker", # type: ignore
conversation_id: str,
) -> dict:
"""
Execute direct delegation with streaming think messages.
Collects think messages as delegations execute for streaming to client.
Args:
user_message: User's request
recommendation: Steward's recommendation
tracker: Tool call tracker
conversation_id: Conversation ID
Returns:
dict: Orchestration results with think_messages list
"""
from src.agents.delegation import (
get_think_message,
delegate_to_librarian,
delegate_to_biographer,
delegate_to_housekeeper,
)
import time as time_module
expert_results = {}
tools_called = []
think_messages = []
for agent in recommendation.recommended_capabilities:
# Emit start think message
start_msg = get_think_message(agent, user_message, "start")
think_messages.append(start_msg + "\n")
start_time = time_module.time()
try:
# Execute delegation
if agent == "librarian":
result = await delegate_to_librarian(task=user_message)
elif agent == "biographer":
result = await delegate_to_biographer(task=user_message)
elif agent == "housekeeper":
result = await delegate_to_housekeeper(task=user_message)
else:
result = None
duration = time_module.time() - start_time
await tracker.track_call(f"delegate_to_{agent}", duration)
if result and result.success:
expert_results[agent] = result.output
tools_called.append(f"delegate_to_{agent}")
# Emit success think message
success_msg = get_think_message(agent, user_message, "success")
think_messages.append(success_msg + "\n")
else:
error_msg = result.error if result else "Unknown error"
expert_results[agent] = f"Error: {error_msg}"
# Emit error think message
error_think = get_think_message(agent, user_message, "error")
think_messages.append(error_think + "\n")
except Exception as e:
expert_results[agent] = f"Error: {e}"
error_think = get_think_message(agent, user_message, "error")
think_messages.append(error_think + "\n")
return {
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {},
"raw_output": "",
"think_messages": think_messages,
}
async def stream_response(
self,
request: "ResponseRequest" # type: ignore # Forward reference
+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
+100 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
from src.agents.steward.service import analyze_request, format_steward_note
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
from src.core.startup import initialize_application
@@ -199,3 +199,102 @@ class TestFormatStewardNote:
assert "⚠️ Missing:" in note
assert "Advanced research" in note
@pytest.mark.unit
class TestBuildEnrichedQuery:
"""Tests for _build_enriched_query function."""
def test_no_enrichment_without_context(self):
"""Test no enrichment when memory context is empty."""
query = "What's the weather?"
result = _build_enriched_query(query, {})
assert result == query
def test_enrichment_adds_location(self):
"""Test location is appended for weather queries."""
query = "What's the weather?"
memory_context = {
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
}
result = _build_enriched_query(query, memory_context)
assert "location=Amsterdam" in result
assert query in result
assert "[User Context:" in result
def test_no_location_when_specified(self):
"""Test location is not appended when already specified."""
query = "What's the weather in London?"
memory_context = {
"profile": {"location": "Amsterdam"}
}
result = _build_enriched_query(query, memory_context)
# Should not add Amsterdam since location is specified
assert result == query
def test_enrichment_adds_timezone(self):
"""Test timezone is appended for time queries."""
query = "What time is it?"
memory_context = {
"profile": {"timezone": "Europe/Amsterdam"}
}
result = _build_enriched_query(query, memory_context)
assert "timezone=Europe/Amsterdam" in result
def test_no_timezone_when_specified(self):
"""Test timezone is not appended when already specified."""
query = "What time is it in UTC?"
memory_context = {
"profile": {"timezone": "Europe/Amsterdam"}
}
result = _build_enriched_query(query, memory_context)
assert result == query
def test_enrichment_adds_temperature_unit(self):
"""Test temperature unit is appended for weather queries."""
query = "What's the weather?"
memory_context = {
"profile": {"location": "Amsterdam"},
"preferences": {"temperature_unit": "celsius"}
}
result = _build_enriched_query(query, memory_context)
assert "temperature_unit=celsius" in result
def test_multiple_context_fields(self):
"""Test multiple context fields are appended."""
query = "What time and weather today?"
memory_context = {
"profile": {
"location": "Amsterdam",
"timezone": "Europe/Amsterdam"
},
"preferences": {"temperature_unit": "celsius"}
}
result = _build_enriched_query(query, memory_context)
assert "location=Amsterdam" in result
assert "timezone=Europe/Amsterdam" in result
assert "temperature_unit=celsius" in result
def test_no_enrichment_for_unrelated_query(self):
"""Test no enrichment for queries that don't need context."""
query = "Tell me a joke"
memory_context = {
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
}
result = _build_enriched_query(query, memory_context)
assert result == query
+160
View File
@@ -8,9 +8,14 @@ import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.agents.delegation import (
ActionType,
DelegationTask,
DelegationResult,
HOUSEHOLD_THINK_MESSAGES,
STREAMING_DELEGATION_WRAPPERS,
delegate_to_librarian,
get_think_message,
_detect_action_type,
)
@@ -193,3 +198,158 @@ class TestDelegateToLibrarian:
result = await delegate_to_librarian(task=original_task)
assert result.task == original_task
@pytest.mark.unit
class TestActionType:
"""Tests for the ActionType enum."""
def test_action_type_values(self):
"""Test ActionType enum values."""
assert ActionType.RETRIEVE.value == "retrieve"
assert ActionType.RESEARCH.value == "research"
assert ActionType.CREATE.value == "create"
assert ActionType.CONTROL.value == "control"
assert ActionType.RECORD.value == "record"
def test_action_type_is_enum(self):
"""Test ActionType is proper enum."""
assert len(ActionType) == 5
@pytest.mark.unit
class TestHouseholdThinkMessages:
"""Tests for HOUSEHOLD_THINK_MESSAGES mapping."""
def test_librarian_has_messages(self):
"""Test librarian has think messages."""
assert "librarian" in HOUSEHOLD_THINK_MESSAGES
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["librarian"]
assert ActionType.RESEARCH in HOUSEHOLD_THINK_MESSAGES["librarian"]
assert ActionType.CREATE in HOUSEHOLD_THINK_MESSAGES["librarian"]
def test_biographer_has_messages(self):
"""Test biographer has think messages."""
assert "biographer" in HOUSEHOLD_THINK_MESSAGES
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["biographer"]
assert ActionType.RECORD in HOUSEHOLD_THINK_MESSAGES["biographer"]
def test_housekeeper_has_messages(self):
"""Test housekeeper has think messages."""
assert "housekeeper" in HOUSEHOLD_THINK_MESSAGES
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
assert ActionType.CONTROL in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
def test_messages_have_phases(self):
"""Test each action type has start/success/error messages."""
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
for action_type, messages in action_types.items():
assert "start" in messages, f"{expert}/{action_type} missing 'start'"
assert "success" in messages, f"{expert}/{action_type} missing 'success'"
assert "error" in messages, f"{expert}/{action_type} missing 'error'"
def test_messages_are_think_tags(self):
"""Test messages are wrapped in <think> tags."""
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
for action_type, messages in action_types.items():
for phase, msg in messages.items():
assert msg.startswith("<think>"), f"{expert}/{action_type}/{phase}"
assert msg.endswith("</think>"), f"{expert}/{action_type}/{phase}"
@pytest.mark.unit
class TestDetectActionType:
"""Tests for _detect_action_type function."""
def test_librarian_search_is_retrieve(self):
"""Test librarian search tasks are RETRIEVE."""
assert _detect_action_type("librarian", "search for Docker info") == ActionType.RETRIEVE
assert _detect_action_type("librarian", "find information about CI/CD") == ActionType.RETRIEVE
assert _detect_action_type("librarian", "look up Kubernetes docs") == ActionType.RETRIEVE
def test_librarian_web_search_is_research(self):
"""Test librarian web search tasks are RESEARCH."""
assert _detect_action_type("librarian", "search the web for news") == ActionType.RESEARCH
assert _detect_action_type("librarian", "find online resources") == ActionType.RESEARCH
assert _detect_action_type("librarian", "research internet sources") == ActionType.RESEARCH
def test_librarian_create_is_create(self):
"""Test librarian creation tasks are CREATE."""
assert _detect_action_type("librarian", "create a wiki page") == ActionType.CREATE
assert _detect_action_type("librarian", "write a new article") == ActionType.CREATE
assert _detect_action_type("librarian", "add a new entry") == ActionType.CREATE
def test_biographer_recall_is_retrieve(self):
"""Test biographer recall tasks are RETRIEVE."""
assert _detect_action_type("biographer", "what car do I drive?") == ActionType.RETRIEVE
assert _detect_action_type("biographer", "what is my job?") == ActionType.RETRIEVE
def test_biographer_record_is_record(self):
"""Test biographer record tasks are RECORD."""
assert _detect_action_type("biographer", "remember that I work at Acme") == ActionType.RECORD
assert _detect_action_type("biographer", "note that my car is a Tesla") == ActionType.RECORD
assert _detect_action_type("biographer", "save my preference for dark mode") == ActionType.RECORD
def test_housekeeper_status_is_retrieve(self):
"""Test housekeeper status tasks are RETRIEVE."""
assert _detect_action_type("housekeeper", "what devices are in the bedroom?") == ActionType.RETRIEVE
assert _detect_action_type("housekeeper", "is the living room light on?") == ActionType.RETRIEVE
def test_housekeeper_control_is_control(self):
"""Test housekeeper control tasks are CONTROL."""
assert _detect_action_type("housekeeper", "turn on the lights") == ActionType.CONTROL
assert _detect_action_type("housekeeper", "set brightness to 50%") == ActionType.CONTROL
assert _detect_action_type("housekeeper", "activate the movie scene") == ActionType.CONTROL
assert _detect_action_type("housekeeper", "toggle the fan") == ActionType.CONTROL
@pytest.mark.unit
class TestGetThinkMessage:
"""Tests for get_think_message function."""
def test_librarian_retrieve_start(self):
"""Test getting librarian retrieve start message."""
msg = get_think_message("librarian", "search for Docker", "start")
assert "<think>" in msg
assert "</think>" in msg
def test_librarian_create_success(self):
"""Test getting librarian create success message."""
msg = get_think_message("librarian", "create a wiki page", "success")
assert "<think>" in msg
assert "catalogued" in msg.lower()
def test_biographer_record_start(self):
"""Test getting biographer record start message."""
msg = get_think_message("biographer", "remember my preference", "start")
assert "<think>" in msg
assert "note" in msg.lower() or "biographer" in msg.lower()
def test_housekeeper_control_success(self):
"""Test getting housekeeper control success message."""
msg = get_think_message("housekeeper", "turn on the lights", "success")
assert "<think>" in msg
assert "configured" in msg.lower()
def test_unknown_expert_fallback(self):
"""Test unknown expert gets fallback message."""
msg = get_think_message("unknown_expert", "some task", "start")
assert "<think>" in msg
assert "unknown_expert" in msg.lower()
@pytest.mark.unit
class TestStreamingDelegationWrappers:
"""Tests for streaming delegation wrapper mapping."""
def test_streaming_wrappers_exist(self):
"""Test streaming wrappers mapping has all experts."""
assert "librarian" in STREAMING_DELEGATION_WRAPPERS
assert "biographer" in STREAMING_DELEGATION_WRAPPERS
assert "housekeeper" in STREAMING_DELEGATION_WRAPPERS
def test_streaming_wrappers_are_async_generators(self):
"""Test streaming wrappers are async generator functions."""
import inspect
for name, wrapper in STREAMING_DELEGATION_WRAPPERS.items():
assert inspect.isasyncgenfunction(wrapper), f"{name} is not an async generator"
+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"
+7 -7
View File
@@ -13,11 +13,11 @@ NC='\033[0m' # No Color
echo -e "${GREEN}Starting Tatlock server...${NC}"
# Check if port 8000 is already in use
if lsof -Pi :8000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
echo -e "${RED}Error: Port 8000 is already in use${NC}"
echo "Run: lsof -i :8000 to see what's using it"
echo "Or run: kill \$(lsof -t -i:8000) to stop it"
# Check if port 8777 is already in use
if lsof -Pi :8777 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
echo -e "${RED}Error: Port 8777 is already in use${NC}"
echo "Run: lsof -i :8777 to see what's using it"
echo "Or run: kill \$(lsof -t -i:8777) to stop it"
exit 1
fi
@@ -43,8 +43,8 @@ LOG_FILE="$LOGS_DIR/server.log"
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
# Start the server
echo -e "${GREEN}Starting uvicorn server on http://localhost:8123${NC}"
echo -e "${GREEN}Starting uvicorn server on http://localhost:8777${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
echo ""
uvicorn src.main:app --reload --host 0.0.0.0 --port 8123 2>&1 | tee "$LOG_FILE"
uvicorn src.main:app --reload --host 0.0.0.0 --port 8777 2>&1 | tee "$LOG_FILE"