Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d50dda77 | ||
|
|
583c407edd | ||
|
|
404e8fc106 | ||
|
|
54a27b481a | ||
|
|
9980e4764c | ||
|
|
4907798e74 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user