Compare commits

...
6 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 e5d50dda77 fix: housekeeper API paths and entity hallucination prevention
Build and Push / build (release) Successful in 56s
- Update all client endpoints to use /housekeeping/ prefix
- Add critical rule requiring list_devices() before control actions
- Add housekeeping API spec documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 20:43:46 +01:00
jpmschweitzerandClaude Opus 4.5 583c407edd fix: Redis bool storage, tool tracking matching, e2e fixture scope
Build and Push / build (release) Successful in 53s
- Convert booleans to strings for Redis hset (Redis doesn't accept bool)
- Extract capability from delegate_to_X tool names for tracking
- Use loop_scope="module" for pytest-asyncio module-scoped fixtures
- Add note about using venv for tests in AGENTS.md

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:53:51 +01:00
jpmschweitzer 404e8fc106 add pre deploy check 2025-12-16 09:36:17 +01:00
jpmschweitzerandClaude Opus 4.5 54a27b481a docs: add release flow section to AGENTS.md
Documents the version bump, changelog update, tagging, and
deployment verification steps.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:33:52 +01:00
jpmschweitzerandClaude Opus 4.5 9980e4764c fix: remove <think> wrappers from think messages
Build and Push / build (release) Successful in 1m49s
Messages in reasoning_content should be plain text, not wrapped
in <think> tags. Removed wrappers from:
- delegation.py household think messages
- orchestration.py status messages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:12:17 +01:00
jpmschweitzerandClaude Opus 4.5 4907798e74 fix: use reasoning_content for Open WebUI streaming
Build and Push / build (release) Successful in 51s
Use DeepSeek R1 format (reasoning_content field) instead of <think>
tags in content. Open WebUI now renders thinking as proper
collapsible blocks instead of broken escaped HTML.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:56:45 +01:00
18 changed files with 649 additions and 163 deletions
+31
View File
@@ -22,6 +22,11 @@ This document contains instructions and documentation references for AI assistan
* **Test REST endpoints** against `http://localhost:8777` 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 * **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) * **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts)
* **Running tests**: Always use the venv explicitly to avoid environment mismatches:
```bash
.venv/bin/python -m pytest tests/ # All tests
.venv/bin/python -m pytest tests/core/ -v # Core tests only
```
### 🌐 Internal Service Access ### 🌐 Internal Service Access
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO * **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
@@ -51,6 +56,32 @@ This document contains instructions and documentation references for AI assistan
* **Update `CHANGELOG.md`** with every user-facing change. * **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`. * Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🚀 Release Flow
When changes are ready for deployment:
1. **Ask user if deploy cycle is desired**
2. **Update version** in `pyproject.toml`:
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
- New features: bump minor version (1.8.4 → 1.9.0)
3. **Update CHANGELOG.md**:
- Move items from `[Unreleased]` to new version section
- Add release date: `## [1.8.4] - 2025-12-16`
4. **Commit and tag**:
```bash
git add -A
git commit -m "fix: description of changes"
git tag v1.8.4
git push origin main --tags
```
5. **CI/CD triggers automatically**:
- Gitea CI builds Docker image on new tag
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8000/health`
--- ---
## 2. FastAPI Architecture & Best Practices ## 2. FastAPI Architecture & Best Practices
+35
View File
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.8.6] - 2025-12-17
### Fixed
- **Housekeeper API paths** - Updated all client endpoints to use `/housekeeping/` prefix to match core-api routes
- **Housekeeper entity hallucination** - Improved system prompt with critical rule requiring `list_devices()` before any control action to prevent guessing entity IDs
### Added
- **Housekeeping API spec** - Added `docs/housekeeping-api-spec.md` documenting the core-api home automation interface
## [1.8.5] - 2025-12-16
### Fixed
- **Redis benchmark boolean storage** - Convert booleans to strings for Redis `hset` (Redis doesn't accept bool type directly)
- **Tool tracking capability matching** - `delegate_to_librarian` now correctly recognized as using "librarian" capability when checking Steward recommendations
- **E2E test fixture scope** - Fixed pytest-asyncio ScopeMismatch error by using `loop_scope="module"` for module-scoped async fixtures
## [1.8.4] - 2025-12-16
### Fixed
- **Remove `<think>` wrappers from think messages** - Messages in `reasoning_content` should be plain text
- Removed `<think>` wrappers from delegation.py household think messages
- Removed `<think>` wrappers from orchestration.py status messages
- Think messages now appear cleanly in Open WebUI's reasoning block
## [1.8.3] - 2025-12-16
### Fixed
- **Open WebUI streaming rendering** - Use `reasoning_content` field for thinking (DeepSeek R1 format) instead of `<think>` tags in `content`
- Open WebUI now renders thinking as proper collapsible blocks instead of broken HTML
## [1.8.2] - 2025-12-16 ## [1.8.2] - 2025-12-16
### Fixed ### Fixed
+317
View File
@@ -0,0 +1,317 @@
# Home Automation API Interface Specification
## Purpose
This document specifies the expected endpoints for a home automation abstraction layer in core-api. These endpoints will be consumed by the Tatlock Housekeeper agent and potentially other projects (scheduler, dashboards).
The goal is to provide a simplified, domain-specific interface for home automation that abstracts away the underlying platform (initially Home Assistant, but swappable).
---
## Endpoints
### Device Discovery
#### `GET /housekeeping/devices`
List available devices.
**Query Parameters:**
- `domain` (optional): Filter by device type (e.g., `light`, `switch`, `climate`, `media_player`)
- `area` (optional): Filter by area/room name
**Response:**
```json
{
"devices": [
{
"entity_id": "light.living_room",
"name": "Living Room Light",
"domain": "light",
"area": "Living Room",
"state": "on",
"attributes": {
"brightness": 255,
"color_temp": 370
}
}
]
}
```
---
#### `GET /housekeeping/devices/{entity_id}`
Get detailed state of a specific device.
**Response:**
```json
{
"entity_id": "light.living_room",
"name": "Living Room Light",
"domain": "light",
"area": "Living Room",
"state": "on",
"attributes": {
"brightness": 255,
"color_temp": 370,
"supported_features": ["brightness", "color_temp"]
},
"last_changed": "2025-12-16T10:30:00Z"
}
```
---
#### `GET /housekeeping/areas`
List all areas/rooms.
**Response:**
```json
{
"areas": [
{"id": "living_room", "name": "Living Room"},
{"id": "bedroom", "name": "Bedroom"},
{"id": "kitchen", "name": "Kitchen"}
]
}
```
---
### Device Control
#### `POST /housekeeping/devices/{entity_id}/control`
Control a device (turn on, turn off, toggle, or set attributes).
**Request Body:**
```json
{
"action": "turn_on",
"brightness": 128,
"color_temp": 400
}
```
- `action` (required): One of `turn_on`, `turn_off`, `toggle`
- Additional attributes vary by device type (brightness, color_temp, rgb_color, etc.)
**Response:**
```json
{
"success": true,
"entity_id": "light.living_room",
"new_state": "on",
"message": "Light turned on"
}
```
---
### Scenes
#### `GET /housekeeping/scenes`
List available scenes.
**Response:**
```json
{
"scenes": [
{"id": "scene.movie_night", "name": "Movie Night"},
{"id": "scene.good_morning", "name": "Good Morning"},
{"id": "scene.all_off", "name": "All Off"}
]
}
```
---
#### `POST /housekeeping/scenes/{scene_id}/activate`
Activate a scene.
**Response:**
```json
{
"success": true,
"scene_id": "scene.movie_night",
"message": "Scene activated"
}
```
---
### Scripts
#### `GET /housekeeping/scripts`
List available scripts/sequences.
**Response:**
```json
{
"scripts": [
{"id": "script.bedtime_routine", "name": "Bedtime Routine"},
{"id": "script.welcome_home", "name": "Welcome Home"}
]
}
```
---
#### `POST /housekeeping/scripts/{script_id}/run`
Execute a script with optional variables.
**Request Body (optional):**
```json
{
"variables": {
"brightness_level": 50,
"target_room": "bedroom"
}
}
```
**Response:**
```json
{
"success": true,
"script_id": "script.bedtime_routine",
"message": "Script executed"
}
```
---
### Automations
#### `GET /housekeeping/automations`
List automations and their enabled/disabled status.
**Response:**
```json
{
"automations": [
{
"id": "automation.motion_lights",
"name": "Motion Lights",
"enabled": true
},
{
"id": "automation.night_mode",
"name": "Night Mode",
"enabled": false
}
]
}
```
---
#### `POST /housekeeping/automations/{automation_id}/toggle`
Enable or disable an automation.
**Request Body:**
```json
{
"enabled": true
}
```
**Response:**
```json
{
"success": true,
"automation_id": "automation.motion_lights",
"enabled": true,
"message": "Automation enabled"
}
```
---
### Utility
#### `GET /housekeeping/history`
Get state history for a device.
**Query Parameters:**
- `entity_id` (required): Device to get history for
- `hours` (optional, default 24): Hours of history to retrieve
**Response:**
```json
{
"entity_id": "light.living_room",
"history": [
{
"state": "on",
"timestamp": "2025-12-16T10:30:00Z",
"attributes": {"brightness": 255}
},
{
"state": "off",
"timestamp": "2025-12-16T08:00:00Z",
"attributes": {}
}
]
}
```
---
#### `GET /housekeeping/health`
Health check for home automation connection.
**Response:**
```json
{
"status": "healthy",
"connected": true,
"platform": "home_assistant",
"version": "2024.12.0"
}
```
---
## Error Responses
All endpoints should return consistent error responses:
```json
{
"error": true,
"code": "DEVICE_NOT_FOUND",
"message": "Device light.nonexistent not found"
}
```
Common error codes:
- `DEVICE_NOT_FOUND` - Entity ID doesn't exist
- `INVALID_ACTION` - Unsupported action for device type
- `CONNECTION_ERROR` - Cannot reach home automation platform
- `UNAUTHORIZED` - Invalid or missing credentials
---
## Authentication
All endpoints require authentication via Bearer token in the `Authorization` header.
---
## Consuming Client
The Tatlock project has an existing client (`src/agents/housekeeper/client.py`) that expects these endpoints. No changes to Tatlock are needed once these endpoints are available.
Reference: `CoreAPIClient` class in Tatlock expects these exact endpoint patterns.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tatlock" name = "tatlock"
version = "1.8.2" version = "1.8.6"
description = "OpenAI-compatible API with Ollama backend" description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [] dependencies = []
+23 -22
View File
@@ -40,45 +40,46 @@ class ActionType(Enum):
# ============================================================================= # =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = { HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
# Note: No <think> wrappers needed - these go to reasoning_content field
"librarian": { "librarian": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Allow me to consult the archives, sir.</think>", "start": "Allow me to consult the archives, sir.",
"success": "<think>The Librarian has compiled the relevant findings.</think>", "success": "The Librarian has compiled the relevant findings.",
"error": "<think>I'm afraid the archives proved difficult to access.</think>", "error": "I'm afraid the archives proved difficult to access.",
}, },
ActionType.RESEARCH: { ActionType.RESEARCH: {
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>", "start": "I've dispatched the Librarian to conduct some fresh research.",
"success": "<think>The Librarian has returned with findings, sir.</think>", "success": "The Librarian has returned with findings, sir.",
"error": "<think>The research proved inconclusive, I'm afraid.</think>", "error": "The research proved inconclusive, I'm afraid.",
}, },
ActionType.CREATE: { ActionType.CREATE: {
"start": "<think>I'm having the Librarian prepare a new entry.</think>", "start": "I'm having the Librarian prepare a new entry.",
"success": "<think>The new material has been properly catalogued, sir.</think>", "success": "The new material has been properly catalogued, sir.",
"error": "<think>I'm afraid there was difficulty filing the entry.</think>", "error": "I'm afraid there was difficulty filing the entry.",
}, },
}, },
"biographer": { "biographer": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Let me consult the household records.</think>", "start": "Let me consult the household records.",
"success": "<think>The Biographer has located the relevant information, sir.</think>", "success": "The Biographer has located the relevant information, sir.",
"error": "<think>I'm unable to locate those particular records.</think>", "error": "I'm unable to locate those particular records.",
}, },
ActionType.RECORD: { ActionType.RECORD: {
"start": "<think>I've asked the Biographer to take note of this, sir.</think>", "start": "I've asked the Biographer to take note of this, sir.",
"success": "<think>The household records have been updated accordingly.</think>", "success": "The household records have been updated accordingly.",
"error": "<think>I'm afraid there was difficulty recording the entry.</think>", "error": "I'm afraid there was difficulty recording the entry.",
}, },
}, },
"housekeeper": { "housekeeper": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Allow me to inquire with the household staff.</think>", "start": "Allow me to inquire with the household staff.",
"success": "<think>The staff reports the current status, sir.</think>", "success": "The staff reports the current status, sir.",
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>", "error": "The household staff is momentarily unavailable, I'm afraid.",
}, },
ActionType.CONTROL: { ActionType.CONTROL: {
"start": "<think>I'm instructing the household staff now, sir.</think>", "start": "I'm instructing the household staff now, sir.",
"success": "<think>The household has been configured as requested.</think>", "success": "The household has been configured as requested.",
"error": "<think>I'm afraid the staff reports an issue with that request.</think>", "error": "I'm afraid the staff reports an issue with that request.",
}, },
}, },
} }
@@ -139,7 +140,7 @@ def get_think_message(expert: str, task: str, phase: str) -> str:
action_type = _detect_action_type(expert, task) action_type = _detect_action_type(expert, task)
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {}) expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {})) action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
return action_messages.get(phase, f"<think>Consulting {expert}...</think>") return action_messages.get(phase, f"Consulting {expert}...")
@dataclass @dataclass
+23 -9
View File
@@ -74,21 +74,35 @@ Your role is to help users control and monitor their smart home through Home Ass
### History Tools ### History Tools
- **get_history**: Check a device's state history - **get_history**: Check a device's state history
## Critical Rules
**NEVER GUESS ENTITY IDs.** You do not know what devices exist. Entity IDs vary between
installations. You MUST call list_devices() FIRST to discover actual entity_ids before
ANY control action (turn_on, turn_off, toggle).
Wrong approach:
User: "Turn off the study lights"
You: turn_off("light.study") ← WRONG! You guessed the entity_id
Correct approach:
User: "Turn off the study lights"
You: list_devices(domain="light") ← First discover what exists
You: [See results like light.study_main, light.study]
You: turn_off("light.study_main"), turn_off("light.study") ← Use actual IDs
## Best Practices ## Best Practices
1. **Device Discovery First**: If the user asks about devices without being specific, 1. **ALWAYS list_devices first** before any control action. No exceptions.
use list_devices to find what's available before acting. Filter by domain and/or area to narrow results.
2. **Confirm State After Actions**: After turning something on/off, you can verify 2. **Use exact entity_ids** from list_devices results. Never construct or guess them.
with get_device_state if needed.
3. **Use Entity IDs**: Devices are identified by entity_id (e.g., light.living_room). 3. **Area-aware filtering**: Use area parameter when users mention a room.
Always use the exact entity_id from list_devices. Note: Some devices may have area=None but contain the room name in entity_id.
4. **Area-Aware**: When users say "living room lights", filter by area="living_room". 4. **Verify after actions**: Use get_device_state to confirm state changes if needed.
5. **Safety**: For actions affecting multiple devices or automations, summarize 5. **Safety for bulk actions**: When affecting multiple devices, summarize first.
what you're about to do.
## Common Patterns ## Common Patterns
+14 -14
View File
@@ -182,7 +182,7 @@ class CoreAPIClient:
logger.debug("core_api_list_devices", domain=domain, area=area) logger.debug("core_api_list_devices", domain=domain, area=area)
response = await client.get("/devices", params=params or None) response = await client.get("/housekeeping/devices", params=params or None)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -199,7 +199,7 @@ class CoreAPIClient:
logger.debug("core_api_list_areas") logger.debug("core_api_list_areas")
response = await client.get("/areas") response = await client.get("/housekeeping/areas")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -219,7 +219,7 @@ class CoreAPIClient:
logger.debug("core_api_get_state", entity_id=entity_id) logger.debug("core_api_get_state", entity_id=entity_id)
response = await client.get(f"/entities/{entity_id}") response = await client.get(f"/housekeeping/devices/{entity_id}")
response.raise_for_status() response.raise_for_status()
return DeviceState(**response.json()) return DeviceState(**response.json())
@@ -260,7 +260,7 @@ class CoreAPIClient:
logger.info("core_api_turn_on", entity_id=entity_id, payload=payload) logger.info("core_api_turn_on", entity_id=entity_id, payload=payload)
response = await client.post( response = await client.post(
f"/devices/{entity_id}/control", f"/housekeeping/devices/{entity_id}/control",
json=payload, json=payload,
) )
response.raise_for_status() response.raise_for_status()
@@ -288,7 +288,7 @@ class CoreAPIClient:
logger.info("core_api_turn_off", entity_id=entity_id) logger.info("core_api_turn_off", entity_id=entity_id)
response = await client.post( response = await client.post(
f"/devices/{entity_id}/control", f"/housekeeping/devices/{entity_id}/control",
json={"action": "turn_off"}, json={"action": "turn_off"},
) )
response.raise_for_status() response.raise_for_status()
@@ -316,7 +316,7 @@ class CoreAPIClient:
logger.info("core_api_toggle", entity_id=entity_id) logger.info("core_api_toggle", entity_id=entity_id)
response = await client.post( response = await client.post(
f"/devices/{entity_id}/control", f"/housekeeping/devices/{entity_id}/control",
json={"action": "toggle"}, json={"action": "toggle"},
) )
response.raise_for_status() response.raise_for_status()
@@ -344,7 +344,7 @@ class CoreAPIClient:
logger.debug("core_api_list_scenes") logger.debug("core_api_list_scenes")
response = await client.get("/scenes") response = await client.get("/housekeeping/scenes")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -364,7 +364,7 @@ class CoreAPIClient:
logger.info("core_api_activate_scene", scene_id=scene_id) logger.info("core_api_activate_scene", scene_id=scene_id)
response = await client.post(f"/scenes/{scene_id}/activate") response = await client.post(f"/housekeeping/scenes/{scene_id}/activate")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -390,7 +390,7 @@ class CoreAPIClient:
logger.debug("core_api_list_scripts") logger.debug("core_api_list_scripts")
response = await client.get("/scripts") response = await client.get("/housekeeping/scripts")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -420,7 +420,7 @@ class CoreAPIClient:
logger.info("core_api_run_script", script_id=script_id) logger.info("core_api_run_script", script_id=script_id)
response = await client.post( response = await client.post(
f"/scripts/{script_id}/run", f"/housekeeping/scripts/{script_id}/run",
json=payload or None, json=payload or None,
) )
response.raise_for_status() response.raise_for_status()
@@ -448,7 +448,7 @@ class CoreAPIClient:
logger.debug("core_api_list_automations") logger.debug("core_api_list_automations")
response = await client.get("/automations") response = await client.get("/housekeeping/automations")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -478,7 +478,7 @@ class CoreAPIClient:
) )
response = await client.post( response = await client.post(
f"/automations/{automation_id}/toggle", f"/housekeeping/automations/{automation_id}/toggle",
json={"enable": enable}, json={"enable": enable},
) )
response.raise_for_status() response.raise_for_status()
@@ -515,7 +515,7 @@ class CoreAPIClient:
logger.debug("core_api_get_history", entity_id=entity_id, hours=hours) logger.debug("core_api_get_history", entity_id=entity_id, hours=hours)
response = await client.get( response = await client.get(
"/history", "/housekeeping/history",
params={"entity_id": entity_id, "hours": hours}, params={"entity_id": entity_id, "hours": hours},
) )
response.raise_for_status() response.raise_for_status()
@@ -536,7 +536,7 @@ class CoreAPIClient:
""" """
try: try:
client = self._ensure_client() client = self._ensure_client()
response = await client.get("/health") response = await client.get("/housekeeping/health")
return response.status_code == 200 return response.status_code == 200
except Exception as e: except Exception as e:
logger.warning("core_api_health_check_failed", error=str(e)) logger.warning("core_api_health_check_failed", error=str(e))
+13 -13
View File
@@ -176,19 +176,19 @@ async def orchestrate_with_think_updates(
if delegation_task.expert_name == "librarian": if delegation_task.expert_name == "librarian":
expert_display_name = "The Librarian" expert_display_name = "The Librarian"
yield f"<think>🤝 Consulting {expert_display_name}...</think>\n" yield f"🤝 Consulting {expert_display_name}...\n"
# Execute delegation (uses run() internally) # Execute delegation (uses run() internally)
result = await execute_delegation(delegation_task) result = await execute_delegation(delegation_task)
if result.success: if result.success:
yield f"<think>{expert_display_name} completed research.</think>\n" yield f"{expert_display_name} completed research.\n"
# Yield the expert's findings # Yield the expert's findings
if result.output: if result.output:
yield f"\n{result.output}" yield f"\n{result.output}"
else: else:
yield f"<think>⚠️ {expert_display_name} encountered an issue: {result.error}</think>\n" yield f"⚠️ {expert_display_name} encountered an issue: {result.error}\n"
logger.info( logger.info(
"orchestration_complete", "orchestration_complete",
@@ -449,12 +449,12 @@ async def orchestrate_multi_expert(
return return
# Stream: Starting multi-expert coordination # Stream: Starting multi-expert coordination
yield f"<think>🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...</think>\n" yield f"🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...\n"
if mode == ExecutionMode.PARALLEL: if mode == ExecutionMode.PARALLEL:
# Parallel execution - emit one update then run all at once # Parallel execution - emit one update then run all at once
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks) expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
yield f"<think>🔄 Consulting in parallel: {expert_names}...</think>\n" yield f"🔄 Consulting in parallel: {expert_names}...\n"
result = await execute_parallel(tasks) result = await execute_parallel(tasks)
@@ -462,9 +462,9 @@ async def orchestrate_multi_expert(
for expert_name, expert_result in result.results.items(): for expert_name, expert_result in result.results.items():
display_name = _get_display_name(expert_name) display_name = _get_display_name(expert_name)
if expert_result.success: if expert_result.success:
yield f"<think>{display_name} completed.</think>\n" yield f"{display_name} completed.\n"
else: else:
yield f"<think>⚠️ {display_name} failed: {expert_result.error}</think>\n" yield f"⚠️ {display_name} failed: {expert_result.error}\n"
else: else:
# Sequential execution - emit updates for each task # Sequential execution - emit updates for each task
@@ -472,27 +472,27 @@ async def orchestrate_multi_expert(
for task in tasks: for task in tasks:
display_name = _get_display_name(task.expert_name) display_name = _get_display_name(task.expert_name)
yield f"<think>🤝 Consulting {display_name}...</think>\n" yield f"🤝 Consulting {display_name}...\n"
task_result = await execute_delegation(task) task_result = await execute_delegation(task)
result.add_result(task_result) result.add_result(task_result)
if task_result.success: if task_result.success:
yield f"<think>{display_name} completed.</think>\n" yield f"{display_name} completed.\n"
else: else:
yield f"<think>⚠️ {display_name} failed: {task_result.error}</think>\n" yield f"⚠️ {display_name} failed: {task_result.error}\n"
if stop_on_failure: if stop_on_failure:
yield "<think>🛑 Stopping due to failure.</think>\n" yield "🛑 Stopping due to failure.\n"
break break
result.aggregate_outputs() result.aggregate_outputs()
# Stream: Summary # Stream: Summary
if result.all_succeeded: if result.all_succeeded:
yield "<think>🎉 All experts completed successfully.</think>\n" yield "🎉 All experts completed successfully.\n"
else: else:
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts) failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
yield f"<think>⚠️ Some experts failed: {failed_names}</think>\n" yield f"⚠️ Some experts failed: {failed_names}\n"
# Yield combined output # Yield combined output
if result.combined_output: if result.combined_output:
+1
View File
@@ -55,6 +55,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
"""Delta in streaming chunk.""" """Delta in streaming chunk."""
role: str | None = None role: str | None = None
content: str | None = None content: str | None = None
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
class ChatCompletionChunkChoice(CustomBaseModel): class ChatCompletionChunkChoice(CustomBaseModel):
+6 -35
View File
@@ -172,24 +172,9 @@ async def create_chat_completion_stream(
async for event in stream_generator: async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA: if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed # Stream reasoning via reasoning_content field (DeepSeek R1 format)
if not in_reasoning: # Open WebUI renders this as collapsible thinking block
yield ChatCompletionChunk( in_reasoning = True
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="<think>\n"),
finish_reason=None,
)
],
)
in_reasoning = True
# Stream reasoning delta
yield ChatCompletionChunk( yield ChatCompletionChunk(
id=completion_id, id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT, object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -198,29 +183,15 @@ async def create_chat_completion_stream(
choices=[ choices=[
ChatCompletionChunkChoice( ChatCompletionChunkChoice(
index=0, index=0,
delta=ChatCompletionChunkDelta(content=event.delta), delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
finish_reason=None, finish_reason=None,
) )
], ],
) )
elif event.event == StreamEventType.REASONING_SUMMARY_DONE: elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block # Signal end of reasoning block (no content needed)
if in_reasoning: in_reasoning = False
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
finish_reason=None,
)
],
)
in_reasoning = False
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA: elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content # Stream message content
+8
View File
@@ -47,6 +47,10 @@ class PerformanceBenchmark(BaseModel):
data = self.model_dump() data = self.model_dump()
data["timestamp"] = self.timestamp.isoformat() data["timestamp"] = self.timestamp.isoformat()
data["metadata"] = json.dumps(self.metadata) data["metadata"] = json.dumps(self.metadata)
# Convert booleans to strings (Redis doesn't accept bool type)
for key, value in data.items():
if isinstance(value, bool):
data[key] = str(value)
return data return data
@classmethod @classmethod
@@ -54,6 +58,10 @@ class PerformanceBenchmark(BaseModel):
"""Reconstruct from Redis dict.""" """Reconstruct from Redis dict."""
data["timestamp"] = datetime.fromisoformat(data["timestamp"]) data["timestamp"] = datetime.fromisoformat(data["timestamp"])
data["metadata"] = json.loads(data.get("metadata", "{}")) data["metadata"] = json.loads(data.get("metadata", "{}"))
# Convert string booleans back to bool
for key in ["success", "was_recommended", "was_actually_used"]:
if key in data and isinstance(data[key], str):
data[key] = data[key] == "True"
return cls(**data) return cls(**data)
+25 -6
View File
@@ -43,6 +43,16 @@ class ToolCallTracker:
conversation_id=conversation_id, conversation_id=conversation_id,
) )
def _extract_capability(self, tool_name: str) -> str:
"""
Extract capability name from tool name.
Tool names like 'delegate_to_librarian' map to capability 'librarian'.
"""
if tool_name.startswith("delegate_to_"):
return tool_name.replace("delegate_to_", "")
return tool_name
async def track_call(self, tool_name: str, duration: float): async def track_call(self, tool_name: str, duration: float):
""" """
Record a tool call with timing. Record a tool call with timing.
@@ -56,8 +66,9 @@ class ToolCallTracker:
self.actual_calls[tool_name] = [] self.actual_calls[tool_name] = []
self.actual_calls[tool_name].append(duration) self.actual_calls[tool_name].append(duration)
# Check if tool was recommended # Check if tool was recommended (normalize tool name to capability)
was_recommended = tool_name in self.recommended_capabilities capability = self._extract_capability(tool_name)
was_recommended = capability in self.recommended_capabilities
if not was_recommended: if not was_recommended:
logger.warning( logger.warning(
@@ -98,8 +109,12 @@ class ToolCallTracker:
Called after Tatlock completes its response to identify Called after Tatlock completes its response to identify
tools that were recommended but never used. tools that were recommended but never used.
""" """
# Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
# Find tools that were recommended but not used # Find tools that were recommended but not used
unused_tools = self.recommended_capabilities - set(self.actual_calls.keys()) unused_tools = self.recommended_capabilities - used_capabilities
if unused_tools: if unused_tools:
logger.info( logger.info(
@@ -145,7 +160,11 @@ class ToolCallTracker:
Dict with tracking statistics Dict with tracking statistics
""" """
total_calls = sum(len(durations) for durations in self.actual_calls.values()) total_calls = sum(len(durations) for durations in self.actual_calls.values())
unused = self.recommended_capabilities - set(self.actual_calls.keys()) # Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
unused = self.recommended_capabilities - used_capabilities
return { return {
"recommended_capabilities": list(self.recommended_capabilities), "recommended_capabilities": list(self.recommended_capabilities),
@@ -154,11 +173,11 @@ class ToolCallTracker:
"total_calls": total_calls, "total_calls": total_calls,
"accuracy": { "accuracy": {
"recommended_and_used": len( "recommended_and_used": len(
self.recommended_capabilities & set(self.actual_calls.keys()) self.recommended_capabilities & used_capabilities
), ),
"recommended_but_unused": len(unused), "recommended_but_unused": len(unused),
"not_recommended_but_used": len( "not_recommended_but_used": len(
set(self.actual_calls.keys()) - self.recommended_capabilities used_capabilities - self.recommended_capabilities
), ),
}, },
} }
+14 -10
View File
@@ -248,13 +248,16 @@ class TestHouseholdThinkMessages:
assert "success" in messages, f"{expert}/{action_type} missing 'success'" assert "success" in messages, f"{expert}/{action_type} missing 'success'"
assert "error" in messages, f"{expert}/{action_type} missing 'error'" assert "error" in messages, f"{expert}/{action_type} missing 'error'"
def test_messages_are_think_tags(self): def test_messages_are_plain_text(self):
"""Test messages are wrapped in <think> tags.""" """Test messages are plain text (no <think> wrappers - those go to reasoning_content)."""
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items(): for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
for action_type, messages in action_types.items(): for action_type, messages in action_types.items():
for phase, msg in messages.items(): for phase, msg in messages.items():
assert msg.startswith("<think>"), f"{expert}/{action_type}/{phase}" # Messages should NOT have <think> wrappers - they go to reasoning_content field
assert msg.endswith("</think>"), f"{expert}/{action_type}/{phase}" assert "<think>" not in msg, f"{expert}/{action_type}/{phase} should not have <think> wrapper"
assert "</think>" not in msg, f"{expert}/{action_type}/{phase} should not have </think> wrapper"
# Messages should be non-empty strings
assert isinstance(msg, str) and len(msg) > 0, f"{expert}/{action_type}/{phase}"
@pytest.mark.unit @pytest.mark.unit
@@ -310,31 +313,32 @@ class TestGetThinkMessage:
def test_librarian_retrieve_start(self): def test_librarian_retrieve_start(self):
"""Test getting librarian retrieve start message.""" """Test getting librarian retrieve start message."""
msg = get_think_message("librarian", "search for Docker", "start") msg = get_think_message("librarian", "search for Docker", "start")
assert "<think>" in msg # No <think> wrappers - messages go to reasoning_content field
assert "</think>" in msg assert "<think>" not in msg
assert "archives" in msg.lower() or "consult" in msg.lower()
def test_librarian_create_success(self): def test_librarian_create_success(self):
"""Test getting librarian create success message.""" """Test getting librarian create success message."""
msg = get_think_message("librarian", "create a wiki page", "success") msg = get_think_message("librarian", "create a wiki page", "success")
assert "<think>" in msg assert "<think>" not in msg
assert "catalogued" in msg.lower() assert "catalogued" in msg.lower()
def test_biographer_record_start(self): def test_biographer_record_start(self):
"""Test getting biographer record start message.""" """Test getting biographer record start message."""
msg = get_think_message("biographer", "remember my preference", "start") msg = get_think_message("biographer", "remember my preference", "start")
assert "<think>" in msg assert "<think>" not in msg
assert "note" in msg.lower() or "biographer" in msg.lower() assert "note" in msg.lower() or "biographer" in msg.lower()
def test_housekeeper_control_success(self): def test_housekeeper_control_success(self):
"""Test getting housekeeper control success message.""" """Test getting housekeeper control success message."""
msg = get_think_message("housekeeper", "turn on the lights", "success") msg = get_think_message("housekeeper", "turn on the lights", "success")
assert "<think>" in msg assert "<think>" not in msg
assert "configured" in msg.lower() assert "configured" in msg.lower()
def test_unknown_expert_fallback(self): def test_unknown_expert_fallback(self):
"""Test unknown expert gets fallback message.""" """Test unknown expert gets fallback message."""
msg = get_think_message("unknown_expert", "some task", "start") msg = get_think_message("unknown_expert", "some task", "start")
assert "<think>" in msg assert "<think>" not in msg
assert "unknown_expert" in msg.lower() assert "unknown_expert" in msg.lower()
+4 -4
View File
@@ -208,8 +208,8 @@ class TestOrchestrateWithThinkUpdates:
): ):
updates.append(update) updates.append(update)
# First update should be think tag about consulting # First update should be about consulting (no <think> wrappers anymore)
assert any("<think>" in u and "Consulting" in u for u in updates) assert any("Consulting" in u for u in updates)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orchestrate_emits_think_after_delegation(self): async def test_orchestrate_emits_think_after_delegation(self):
@@ -233,8 +233,8 @@ class TestOrchestrateWithThinkUpdates:
): ):
updates.append(update) updates.append(update)
# Should have think tag about completion # Should have message about completion (no <think> wrappers anymore)
assert any("<think>" in u and "completed" in u for u in updates) assert any("completed" in u for u in updates)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orchestrate_yields_expert_output(self): async def test_orchestrate_yields_expert_output(self):
+22 -31
View File
@@ -4,7 +4,7 @@ Tests for chat completions streaming wrapper.
Tests that the wrapper correctly: Tests that the wrapper correctly:
- Wraps Responses API - Wraps Responses API
- Enables reasoning automatically - Enables reasoning automatically
- Converts reasoning to <think> tags - Streams reasoning via reasoning_content field (DeepSeek R1 format)
- Streams both reasoning and content - Streams both reasoning and content
""" """
import json import json
@@ -17,7 +17,7 @@ from src.chat import constants
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
"""Test that streaming wrapper automatically enables reasoning.""" """Test that streaming wrapper automatically enables reasoning via reasoning_content."""
request_data = { request_data = {
"model": "lorem-tester", "model": "lorem-tester",
"messages": [ "messages": [
@@ -27,7 +27,7 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
} }
chunks_received = [] chunks_received = []
think_tags_found = False reasoning_content_found = False
async with async_client.stream( async with async_client.stream(
"POST", "POST",
@@ -51,12 +51,12 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
chunk = json.loads(data_str) chunk = json.loads(data_str)
chunks_received.append(chunk) chunks_received.append(chunk)
# Check for <think> tags in delta content # Check for reasoning_content in delta (DeepSeek R1 format)
if "choices" in chunk and len(chunk["choices"]) > 0: if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {}) delta = chunk["choices"][0].get("delta", {})
content = delta.get("content") reasoning = delta.get("reasoning_content")
if content and ("<think>" in content or "</think>" in content): if reasoning:
think_tags_found = True reasoning_content_found = True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@@ -64,14 +64,14 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
# Should have received chunks # Should have received chunks
assert len(chunks_received) > 0 assert len(chunks_received) > 0
# Should have found <think> tags (reasoning enabled automatically) # Should have found reasoning_content (reasoning enabled automatically)
assert think_tags_found, "Expected <think> tags in streaming output" assert reasoning_content_found, "Expected reasoning_content in streaming output"
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient): async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient):
"""Test that reasoning (<think> tags) comes before actual content.""" """Test that reasoning_content comes before regular content."""
request_data = { request_data = {
"model": "lorem-tester", "model": "lorem-tester",
"messages": [ "messages": [
@@ -80,10 +80,7 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
"stream": True "stream": True
} }
all_content = [] chunk_types = [] # Track order: 'reasoning' or 'content'
found_think_opening = False
found_think_closing = False
found_content_after_think = False
async with async_client.stream( async with async_client.stream(
"POST", "POST",
@@ -106,28 +103,22 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
chunk = json.loads(data_str) chunk = json.loads(data_str)
if "choices" in chunk and len(chunk["choices"]) > 0: if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {}) delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "") reasoning = delta.get("reasoning_content")
if content: content = delta.get("content")
all_content.append(content)
if "<think>" in content: if reasoning:
found_think_opening = True chunk_types.append("reasoning")
if "</think>" in content: if content:
found_think_closing = True chunk_types.append("content")
# Content after closing think tag
if found_think_closing and content.strip() and "<think>" not in content and "</think>" not in content:
found_content_after_think = True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
# Verify ordering # Verify reasoning comes before content
full_text = "".join(all_content) if "reasoning" in chunk_types and "content" in chunk_types:
if found_think_opening and found_think_closing: first_reasoning = chunk_types.index("reasoning")
# Reasoning should come before main content first_content = chunk_types.index("content")
think_start = full_text.index("<think>") assert first_reasoning < first_content, "reasoning_content should come before content"
think_end = full_text.index("</think>")
assert think_start < think_end, "Opening <think> should come before closing </think>"
@pytest.mark.unit @pytest.mark.unit
+9 -8
View File
@@ -61,7 +61,7 @@ class TestPerformanceBenchmark:
redis_dict = benchmark.to_redis_dict() redis_dict = benchmark.to_redis_dict()
assert redis_dict["operation"] == "test_op" assert redis_dict["operation"] == "test_op"
assert redis_dict["duration_seconds"] == 1.0 assert redis_dict["duration_seconds"] == 1.0
assert redis_dict["success"] is True assert redis_dict["success"] == "True" # Booleans stored as strings in Redis
assert isinstance(redis_dict["timestamp"], str) assert isinstance(redis_dict["timestamp"], str)
assert isinstance(redis_dict["metadata"], str) assert isinstance(redis_dict["metadata"], str)
@@ -72,7 +72,7 @@ class TestPerformanceBenchmark:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": 1.5, "duration_seconds": 1.5,
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": json.dumps({"test": "data"}), "metadata": json.dumps({"test": "data"}),
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -85,6 +85,7 @@ class TestPerformanceBenchmark:
benchmark = PerformanceBenchmark.from_redis_dict(redis_dict) benchmark = PerformanceBenchmark.from_redis_dict(redis_dict)
assert benchmark.operation == "test_op" assert benchmark.operation == "test_op"
assert benchmark.duration_seconds == 1.5 assert benchmark.duration_seconds == 1.5
assert benchmark.success is True # Converted back to bool
assert benchmark.metadata == {"test": "data"} assert benchmark.metadata == {"test": "data"}
@@ -162,12 +163,12 @@ class TestBenchmarkStore:
mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}" mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}"
mock_redis.zrevrangebyscore.return_value = [mock_key] mock_redis.zrevrangebyscore.return_value = [mock_key]
# Mock hgetall to return proper data # Mock hgetall to return proper data (booleans as strings, like Redis)
mock_redis.hgetall.return_value = { mock_redis.hgetall.return_value = {
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": 1.5, # Numeric, not string "duration_seconds": 1.5, # Numeric, not string
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -237,7 +238,7 @@ class TestBenchmarkStore:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": float(data["duration_seconds"]), "duration_seconds": float(data["duration_seconds"]),
"success": data["success"] == "True", "success": data["success"], # Pass string through, from_redis_dict converts
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -296,14 +297,14 @@ class TestBenchmarkStore:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "tool_call", "operation": "tool_call",
"duration_seconds": 1.0, "duration_seconds": 1.0,
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
"tool_name": "test_tool", "tool_name": "test_tool",
"conversation_id": None, "conversation_id": None,
"was_recommended": data["was_recommended"] == "True", "was_recommended": data["was_recommended"], # Already strings
"was_actually_used": data["was_actually_used"] == "True", "was_actually_used": data["was_actually_used"], # Already strings
} }
mock_redis.hgetall.side_effect = mock_hgetall mock_redis.hgetall.side_effect = mock_hgetall
+101
View File
@@ -0,0 +1,101 @@
"""
Tests for tool call tracking.
Tests capability extraction and recommendation matching.
"""
from unittest.mock import AsyncMock, patch
import pytest
from src.core.tool_tracking import ToolCallTracker
class TestToolCallTracker:
"""Test ToolCallTracker functionality."""
def test_extract_capability_delegation_tool(self):
"""Test extracting capability from delegation tool name."""
tracker = ToolCallTracker(recommended_capabilities=["librarian"])
assert tracker._extract_capability("delegate_to_librarian") == "librarian"
assert tracker._extract_capability("delegate_to_biographer") == "biographer"
assert tracker._extract_capability("delegate_to_housekeeper") == "housekeeper"
def test_extract_capability_non_delegation_tool(self):
"""Test that non-delegation tools return unchanged."""
tracker = ToolCallTracker(recommended_capabilities=[])
assert tracker._extract_capability("calculate") == "calculate"
assert tracker._extract_capability("search_web") == "search_web"
@pytest.mark.asyncio
async def test_track_call_recognizes_delegation_as_recommended(self):
"""Test that delegate_to_X is recognized when X is recommended."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.track_call("delegate_to_librarian", 1.0)
# Should NOT log warning since librarian was recommended
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.was_recommended is True
@pytest.mark.asyncio
async def test_track_call_detects_not_recommended(self):
"""Test that unrecommended tools are flagged."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian"]
)
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.track_call("delegate_to_housekeeper", 1.0)
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.was_recommended is False
def test_get_summary_with_delegation_tools(self):
"""Test summary correctly maps delegation tools to capabilities."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
tracker.actual_calls = {
"delegate_to_librarian": [1.0, 2.0],
"delegate_to_housekeeper": [0.5], # Not recommended
}
summary = tracker.get_summary()
assert summary["accuracy"]["recommended_and_used"] == 1 # librarian
assert summary["accuracy"]["recommended_but_unused"] == 1 # biographer
assert summary["accuracy"]["not_recommended_but_used"] == 1 # housekeeper
@pytest.mark.asyncio
async def test_finalize_with_delegation_tools(self):
"""Test finalize correctly identifies unused recommendations."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
tracker.actual_calls = {
"delegate_to_librarian": [1.0],
}
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.finalize()
# Should record benchmark for unused biographer
assert mock_store.return_value.record.called
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.tool_name == "biographer"
assert benchmark.was_recommended is True
assert benchmark.was_actually_used is False
+2 -10
View File
@@ -8,8 +8,8 @@ These tests hit the actual running server and test the full stack:
- Response formatting - Response formatting
""" """
import pytest import pytest
import pytest_asyncio
import httpx import httpx
import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh) # Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
@@ -17,15 +17,7 @@ BASE_URL = "http://localhost:8777"
API_TIMEOUT = 120.0 # 120 second timeout for LLM calls API_TIMEOUT = 120.0 # 120 second timeout for LLM calls
@pytest.fixture(scope="module") @pytest_asyncio.fixture(loop_scope="module", scope="module")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="module")
async def client() -> AsyncGenerator[httpx.AsyncClient, None]: async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
"""HTTP client for making requests.""" """HTTP client for making requests."""
async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client: async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client: