Compare commits
71
Commits
+39
-4
@@ -1,6 +1,5 @@
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
APP_NAME="OpenAI-Compatible API"
|
APP_NAME="OpenAI-Compatible API"
|
||||||
APP_VERSION="0.1.0"
|
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
@@ -10,12 +9,48 @@ API_PORT=8000
|
|||||||
API_PREFIX=/v1
|
API_PREFIX=/v1
|
||||||
|
|
||||||
# Ollama Configuration
|
# Ollama Configuration
|
||||||
OLLAMA_HOST=http://your-ollama-host:11434
|
OLLAMA_HOST=http://localhost:11434
|
||||||
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
||||||
OLLAMA_TIMEOUT=120
|
OLLAMA_TIMEOUT=120
|
||||||
|
|
||||||
|
# SearXNG Configuration
|
||||||
|
SEARXNG_HOST=http://localhost:8087
|
||||||
|
SEARXNG_TIMEOUT=30
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_MEMORY_DB=1
|
||||||
|
REDIS_BENCHMARK_DB=6
|
||||||
|
REDIS_TIMEOUT=5
|
||||||
|
|
||||||
|
# Qdrant Configuration
|
||||||
|
QDRANT_HOST=localhost
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
# LOG_LEVEL is auto-selected based on ENVIRONMENT if not set:
|
||||||
|
# - development: DEBUG (maximum verbosity)
|
||||||
|
# - production: WARNING (minimal noise)
|
||||||
|
# Uncomment to override: LOG_LEVEL=INFO
|
||||||
|
ENABLE_BENCHMARKS=true
|
||||||
|
# Note: Log format is auto-selected based on ENVIRONMENT (console for dev, json for production)
|
||||||
|
|
||||||
|
# User Configuration
|
||||||
|
# DEFAULT_USER is auto-selected based on ENVIRONMENT if not set:
|
||||||
|
# - development/testing: llm_tester (isolated test scope)
|
||||||
|
# - production: jpmschweitzer (real user)
|
||||||
|
# Uncomment to override: DEFAULT_USER=your_username
|
||||||
|
|
||||||
|
# Library-Desk Configuration (The Librarian backend)
|
||||||
|
# LIBRARY_DESK_HOST=http://localhost:8089
|
||||||
|
# LIBRARY_DESK_API_KEY=your-library-desk-api-key
|
||||||
|
# LIBRARY_DESK_TIMEOUT=60
|
||||||
|
|
||||||
|
# Core-API Configuration (The Housekeeper backend)
|
||||||
|
# CORE_API_HOST=http://localhost:8090
|
||||||
|
# CORE_API_KEY=your-core-api-key
|
||||||
|
# CORE_API_TIMEOUT=30
|
||||||
|
|
||||||
# CORS (comma-separated list)
|
# CORS (comma-separated list)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=["*"]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.schweitz.internal
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
tags: |
|
||||||
|
git.schweitz.internal/jpmschweitzer/tatlock:latest
|
||||||
|
git.schweitz.internal/jpmschweitzer/tatlock:${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Trigger Watchtower update
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||||
|
http://watchtower:8080/v1/update
|
||||||
@@ -2,482 +2,73 @@
|
|||||||
|
|
||||||
This document contains instructions and documentation references for AI assistants working with this codebase.
|
This document contains instructions and documentation references for AI assistants working with this codebase.
|
||||||
|
|
||||||
## Project Overview
|
> **📖 Important**: Before working on this project, read [PHILOSOPHY.md](PHILOSOPHY.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
This project implements an OpenAI-compatible API endpoint using FastAPI, with streaming support. Currently returns mock responses - infrastructure prepared for future Ollama/PydanticAI integration.
|
> **Start every session by reading this file.**
|
||||||
|
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
||||||
|
|
||||||
**Current State**: Production-ready testing API with Responses API and Open WebUI integration
|
## 1. Agent Operational Protocols
|
||||||
**Future Integration**: PydanticAI for real LLM agents (tatlock model placeholder ready)
|
|
||||||
|
|
||||||
### Current Architecture (As of 2025-12-06)
|
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||||
|
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||||
|
* **Act:** Execute the changes in small, atomic steps.
|
||||||
|
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||||
|
|
||||||
This project implements a **hybrid architecture** with the Responses API as the primary endpoint and Chat Completions as a compatibility wrapper:
|
### 🧪 Local Development Setup
|
||||||
|
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
|
||||||
|
* **Start the local server** with `./wakeup.sh` - logs are written to `logs/server.log` for easy tailing
|
||||||
|
* **Auto-reload**: The wakeup script runs uvicorn in reload mode - code changes are picked up automatically without restart (except for requirements.txt changes)
|
||||||
|
* **Test REST endpoints** against `http://localhost:8777` using curl or similar tools
|
||||||
|
* **Only deploy** when a phase or feature is complete and tested locally
|
||||||
|
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts)
|
||||||
|
|
||||||
```
|
### 🌐 Internal Service Access
|
||||||
Client (Open WebUI)
|
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
||||||
↓
|
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||||
Chat Completions (/v1/chat/completions) → Wrapper
|
* Public repos are readable without authentication
|
||||||
↓
|
* Related repos: `library-desk`, `scheduler`, `core-api`, `portainer-core`
|
||||||
Responses API (/v1/responses) → Primary
|
|
||||||
↓
|
|
||||||
Agent Interface (lorem-tester, tatlock)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Architectural Decisions:**
|
### 🐳 Deployment & Infrastructure
|
||||||
|
* **Full stack documentation**: Available in the `portainer-core` repo
|
||||||
|
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
||||||
|
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
||||||
|
* **Tatlock deployment**:
|
||||||
|
* LAN: `http://192.168.86.149:8000`
|
||||||
|
* External: `tatlock.schweitz.net` (behind Authentik SSO)
|
||||||
|
* Redis DBs: 1 (memory), 6 (benchmarks)
|
||||||
|
* **Health check**: `curl http://192.168.86.149:8000/health`
|
||||||
|
|
||||||
1. **Single Source of Truth**: All response generation happens in the Responses API
|
### 🛡️ Git Discipline
|
||||||
- Structured output with reasoning, function_call, and message items
|
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||||
- Real-time stop sequence and max tokens enforcement
|
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||||
- Conversation history tracking
|
* `feat: add user login endpoint`
|
||||||
- Context window management
|
* `fix: resolve database connection timeout`
|
||||||
|
* `refactor: split monolith dependency file`
|
||||||
|
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||||
|
|
||||||
2. **Chat Completions Wrapper**: Provides compatibility without duplicating logic
|
### 📝 Changelog Maintenance
|
||||||
- Calls Responses API internally
|
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||||
- Automatically enables reasoning generation
|
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||||
- Converts reasoning items to `<think>` tags for Open WebUI
|
|
||||||
- Maintains OpenAI-compatible format
|
|
||||||
|
|
||||||
3. **Agent Interface**: Clean abstraction for multiple models
|
---
|
||||||
- **lorem-tester**: Full-featured mock agent with realistic behavior
|
|
||||||
- Reasoning summaries (adjustable effort levels)
|
|
||||||
- Random tool/function calls
|
|
||||||
- Error triggers for testing
|
|
||||||
- Temperature variation
|
|
||||||
- **tatlock**: Placeholder for future PydanticAI agent
|
|
||||||
|
|
||||||
4. **Hybrid Conversation History**:
|
## 2. FastAPI Architecture & Best Practices
|
||||||
- Client MUST send full context in `input` array (OpenAI compatible)
|
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||||
- Server optionally tracks via `metadata.conversation_id`
|
|
||||||
- Auto-generates deterministic IDs from first message
|
|
||||||
- Supports future vector memory integration (Qdrant)
|
|
||||||
|
|
||||||
**Why This Architecture?**
|
### 📂 Project Structure (Directory-based, NOT File-type based)
|
||||||
|
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
||||||
|
|
||||||
- **Open WebUI Compatibility**: Native Responses API support not yet in stable release
|
**Correct Structure:**
|
||||||
- **Future-Proof**: Easy migration when Open WebUI adds native support
|
```text
|
||||||
- **Testability**: Full-featured mock agent (lorem-tester) for integration testing
|
|
||||||
- **Clean Separation**: Responses API as stable core, wrappers can change
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
- **FastAPI**: Web framework for the API layer
|
|
||||||
- **SSE-Starlette**: Server-Sent Events for streaming responses
|
|
||||||
- **Pydantic**: Request/response validation with field validators
|
|
||||||
- **Agent Interface**: Abstract base class for model implementations
|
|
||||||
- **Conversation History**: Server-side tracking with configurable max turns
|
|
||||||
- **Context Window**: Token counting and management
|
|
||||||
- **PydanticAI**: Dependency installed, ready for tatlock agent implementation
|
|
||||||
|
|
||||||
## Documentation References
|
|
||||||
|
|
||||||
### Core Framework Documentation
|
|
||||||
|
|
||||||
#### FastAPI
|
|
||||||
- **Official Documentation**: https://fastapi.tiangolo.com/
|
|
||||||
- **Version**: 0.123.9 (Dec 2025)
|
|
||||||
- **Key Topics**:
|
|
||||||
- Path operations and routing
|
|
||||||
- Request/response models with Pydantic
|
|
||||||
- Dependency injection
|
|
||||||
- Background tasks
|
|
||||||
- WebSocket and streaming support
|
|
||||||
- **PyPI**: https://pypi.org/project/fastapi/
|
|
||||||
|
|
||||||
#### Uvicorn
|
|
||||||
- **Official Documentation**: https://www.uvicorn.org/
|
|
||||||
- **Version**: 0.38.0 (Oct 2025)
|
|
||||||
- **Key Topics**:
|
|
||||||
- ASGI server configuration
|
|
||||||
- Deployment settings
|
|
||||||
- Logging and monitoring
|
|
||||||
- SSL/TLS configuration
|
|
||||||
|
|
||||||
### AI/LLM Integration
|
|
||||||
|
|
||||||
#### PydanticAI
|
|
||||||
- **Official Documentation**: https://ai.pydantic.dev/
|
|
||||||
- **Version**: 1.27.0 (Dec 2025)
|
|
||||||
- **Status**: Dependency installed, ready for future integration
|
|
||||||
- **Key Topics** (for future implementation):
|
|
||||||
- Agent creation and configuration
|
|
||||||
- LLM provider integration (Ollama support)
|
|
||||||
- Structured outputs with Pydantic
|
|
||||||
- Streaming responses
|
|
||||||
- Tool/function calling
|
|
||||||
- RunContext and dynamic configuration
|
|
||||||
- MCP server integration
|
|
||||||
- **GitHub**: https://github.com/pydantic/pydantic-ai
|
|
||||||
- **PyPI**: https://pypi.org/project/pydantic-ai/
|
|
||||||
|
|
||||||
#### Pydantic
|
|
||||||
- **Official Documentation**: https://docs.pydantic.dev/latest/
|
|
||||||
- **Version**: 2.11+ (Required for PydanticAI, currently using >=2.11,<2.13)
|
|
||||||
- **Key Topics**:
|
|
||||||
- Data validation and serialization
|
|
||||||
- Field types and validators
|
|
||||||
- Model configuration
|
|
||||||
- JSON schema generation
|
|
||||||
|
|
||||||
### HTTP and Streaming
|
|
||||||
|
|
||||||
#### HTTPX
|
|
||||||
- **Official Documentation**: https://www.python-httpx.org/
|
|
||||||
- **Version**: 0.28.1
|
|
||||||
- **Key Topics**:
|
|
||||||
- Async HTTP client for Ollama communication
|
|
||||||
- Streaming responses
|
|
||||||
- Timeout configuration
|
|
||||||
- Connection pooling
|
|
||||||
|
|
||||||
#### SSE-Starlette
|
|
||||||
- **GitHub**: https://github.com/sysid/sse-starlette
|
|
||||||
- **Version**: 3.0.2 (Oct 2025)
|
|
||||||
- **Key Topics**:
|
|
||||||
- Server-Sent Events implementation
|
|
||||||
- Streaming event responses
|
|
||||||
- Integration with FastAPI/Starlette
|
|
||||||
|
|
||||||
### Ollama Integration
|
|
||||||
|
|
||||||
#### Ollama API
|
|
||||||
- **Official Documentation**: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
||||||
- **Status**: Async client implemented in `src/ollama/client.py`, ready for future integration
|
|
||||||
- **Key Topics** (for future implementation):
|
|
||||||
- REST API endpoints
|
|
||||||
- Streaming responses
|
|
||||||
- Model management
|
|
||||||
- Generate and chat endpoints
|
|
||||||
- Model configuration
|
|
||||||
- **Current Model Target**: mistral-nemo:latest
|
|
||||||
|
|
||||||
### OpenAI API Compatibility
|
|
||||||
|
|
||||||
#### OpenAI API Reference
|
|
||||||
- **Official Documentation**: https://platform.openai.com/docs/api-reference
|
|
||||||
- **Implemented Endpoints**:
|
|
||||||
- ✅ `/v1/responses` - **Responses API (PRIMARY)** with structured output
|
|
||||||
- Reasoning items (thinking summaries)
|
|
||||||
- Function call items (tool execution)
|
|
||||||
- Message items (assistant responses)
|
|
||||||
- Full streaming support with SSE
|
|
||||||
- Stop sequence detection
|
|
||||||
- Max tokens enforcement
|
|
||||||
- Conversation history tracking
|
|
||||||
- ✅ `/v1/chat/completions` - **Compatibility wrapper** around Responses API
|
|
||||||
- Converts reasoning to `<think>` tags for Open WebUI
|
|
||||||
- Automatically enables reasoning generation
|
|
||||||
- Maintains OpenAI-compatible format
|
|
||||||
- Supports streaming and non-streaming
|
|
||||||
- ✅ `/v1/models` - List available models (lorem-tester, tatlock)
|
|
||||||
- **Future Endpoints**:
|
|
||||||
- 🚧 `/v1/completions` - Text completion (legacy)
|
|
||||||
- 🚧 `/v1/embeddings` - Text embeddings
|
|
||||||
- **Implemented Features**:
|
|
||||||
- ✅ **Responses API Format**:
|
|
||||||
- Structured output items (reasoning, function_call, message)
|
|
||||||
- Extended thinking support
|
|
||||||
- Tool/function calling support
|
|
||||||
- Streaming with multiple event types
|
|
||||||
- ✅ **Advanced Parameter Validation**:
|
|
||||||
- Temperature: 0.0-2.0 with Pydantic validators
|
|
||||||
- Reasoning effort: none, minimal, low, medium, high, xhigh
|
|
||||||
- Max output tokens: positive integer enforcement
|
|
||||||
- Stop sequences: up to 4, non-empty strings
|
|
||||||
- ✅ **Conversation History**:
|
|
||||||
- Hybrid client/server approach
|
|
||||||
- Auto-generated conversation IDs
|
|
||||||
- Configurable max turns (default: 20)
|
|
||||||
- Placeholder for vector memory
|
|
||||||
- ✅ **Context Management**:
|
|
||||||
- Approximate token counting (~4 chars/token)
|
|
||||||
- Context window trimming
|
|
||||||
- Usage statistics
|
|
||||||
- ✅ **Streaming Enforcement**:
|
|
||||||
- Real-time stop sequence detection
|
|
||||||
- Real-time max tokens enforcement
|
|
||||||
- Word-by-word streaming with delays
|
|
||||||
- ✅ **Error Handling**:
|
|
||||||
- Custom exception types (RateLimitError, ContextLengthError)
|
|
||||||
- OpenAI-compatible error format
|
|
||||||
- Error triggers in lorem-tester for testing
|
|
||||||
- ✅ **Testing Infrastructure**:
|
|
||||||
- 75 tests (78.95% coverage)
|
|
||||||
- Unit tests for all components
|
|
||||||
- Integration tests for API endpoints
|
|
||||||
- Streaming tests for SSE functionality
|
|
||||||
|
|
||||||
## FastAPI Best Practices
|
|
||||||
|
|
||||||
This project follows best practices from [github.com/zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices)
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
**Domain-Based Organization**: Code is organized by domain/feature rather than by file type:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
src/
|
||||||
├── agents/ # Agent interface and implementations
|
├── auth/
|
||||||
│ ├── base.py # Abstract AgentInterface
|
│ ├── router.py # Endpoints
|
||||||
│ ├── lorem_tester.py # Full-featured mock agent
|
│ ├── schemas.py # Pydantic models
|
||||||
│ ├── tatlock.py # Placeholder for real agent
|
│ ├── service.py # Business logic (CRUD, etc.)
|
||||||
│ └── registry.py # ModelRegistry for agent management
|
│ ├── dependencies.py# Module-specific dependencies
|
||||||
├── responses/ # Responses API domain (PRIMARY)
|
│ └── config.py # Module-specific settings
|
||||||
│ ├── router.py # POST /v1/responses endpoint
|
├── posts/
|
||||||
│ ├── schemas.py # Request/response models with validators
|
│ ├── router.py
|
||||||
│ ├── service.py # Response generation logic
|
│ └── ...
|
||||||
│ ├── streaming.py # SSE streaming coordinator
|
└── main.py # App entry point
|
||||||
│ ├── history.py # Conversation history management
|
|
||||||
│ └── context.py # Context window and token management
|
|
||||||
├── chat/ # Chat Completions domain (WRAPPER)
|
|
||||||
│ ├── router.py # POST /v1/chat/completions endpoint
|
|
||||||
│ ├── schemas.py # Chat request/response models
|
|
||||||
│ ├── service.py # Wraps Responses API, converts to <think> tags
|
|
||||||
│ ├── constants.py # Chat constants (roles, finish reasons)
|
|
||||||
│ └── __init__.py
|
|
||||||
├── models/ # Models listing domain
|
|
||||||
│ ├── router.py # GET /v1/models endpoint
|
|
||||||
│ ├── schemas.py # Model schemas
|
|
||||||
│ ├── service.py # Accesses ModelRegistry
|
|
||||||
│ └── __init__.py
|
|
||||||
├── core/ # Shared utilities
|
|
||||||
│ ├── config.py # Global configuration (BaseSettings)
|
|
||||||
│ ├── models.py # Custom base Pydantic models
|
|
||||||
│ ├── exceptions.py # Custom exceptions (RateLimitError, etc.)
|
|
||||||
│ ├── dependencies.py # Shared dependencies
|
|
||||||
│ └── router.py # Core routes (health, root)
|
|
||||||
├── ollama/ # Ollama client layer (not yet integrated)
|
|
||||||
│ ├── client.py # Async Ollama HTTP client
|
|
||||||
│ └── schemas.py # Ollama API models
|
|
||||||
└── main.py # Application factory & configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Architectural Principles**:
|
|
||||||
- **Single Source of Truth**: Responses API handles all generation logic
|
|
||||||
- **Wrapper Pattern**: Chat Completions wraps Responses API without duplicating code
|
|
||||||
- **Agent Abstraction**: AgentInterface defines contract for all models
|
|
||||||
- **Domain Separation**: Each domain has its own router, schemas, service
|
|
||||||
- **Service Layer**: Business logic in services, not routers
|
|
||||||
- **Type Safety**: Pydantic models for ALL request/response validation
|
|
||||||
- **Async First**: All I/O operations use async/await
|
|
||||||
|
|
||||||
### Async/Await Best Practices
|
|
||||||
|
|
||||||
**Critical Understanding**: FastAPI handles sync and async routes differently:
|
|
||||||
|
|
||||||
- **Async routes** (`async def`): Called directly in event loop
|
|
||||||
- Use ONLY for non-blocking operations
|
|
||||||
- Perfect for `await httpx.get()`, database queries, file I/O
|
|
||||||
- **NEVER** use blocking calls like `time.sleep()` - this blocks entire server
|
|
||||||
|
|
||||||
- **Sync routes** (`def`): Run in thread pool
|
|
||||||
- Use for CPU-intensive work or blocking SDKs
|
|
||||||
- Blocking I/O won't freeze the event loop
|
|
||||||
- Example: `time.sleep(10)` is safe here
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```python
|
|
||||||
@router.get("/terrible")
|
|
||||||
async def terrible():
|
|
||||||
time.sleep(10) # ❌ BLOCKS ENTIRE SERVER
|
|
||||||
|
|
||||||
@router.get("/good")
|
|
||||||
def good():
|
|
||||||
time.sleep(10) # ✅ Runs in thread pool
|
|
||||||
|
|
||||||
@router.get("/perfect")
|
|
||||||
async def perfect():
|
|
||||||
await asyncio.sleep(10) # ✅ Non-blocking async
|
|
||||||
```
|
|
||||||
|
|
||||||
**For CPU-intensive tasks**: Use separate worker processes (not threads) due to Python's GIL.
|
|
||||||
|
|
||||||
### Pydantic Configuration
|
|
||||||
|
|
||||||
**Custom Base Model**: All schemas inherit from `CustomBaseModel` for consistent behavior:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/core/models.py
|
|
||||||
class CustomBaseModel(BaseModel):
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_encoders={datetime: datetime_to_iso_str},
|
|
||||||
populate_by_name=True,
|
|
||||||
use_enum_values=True,
|
|
||||||
validate_assignment=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def serializable_dict(self, **kwargs):
|
|
||||||
"""Return dict with only JSON-serializable fields."""
|
|
||||||
return jsonable_encoder(self.model_dump(**kwargs))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Consistent datetime serialization across all responses
|
|
||||||
- Alias support for field name flexibility
|
|
||||||
- Easy JSON encoding for logging/debugging
|
|
||||||
|
|
||||||
**Decoupled Settings**: Split configuration by domain instead of one monolithic file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/core/config.py - Global settings
|
|
||||||
class Config(BaseSettings):
|
|
||||||
DATABASE_URL: PostgresDsn
|
|
||||||
ENVIRONMENT: Environment
|
|
||||||
|
|
||||||
# src/chat/config.py - Chat-specific settings
|
|
||||||
class ChatConfig(BaseSettings):
|
|
||||||
MAX_TOKENS: int
|
|
||||||
DEFAULT_TEMPERATURE: float
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependency Injection Patterns
|
|
||||||
|
|
||||||
**Validation with Dependencies**: Use dependencies for complex validations:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def valid_post_id(post_id: UUID4) -> dict:
|
|
||||||
"""Validate post exists in database."""
|
|
||||||
post = await service.get_by_id(post_id)
|
|
||||||
if not post:
|
|
||||||
raise PostNotFound()
|
|
||||||
return post
|
|
||||||
|
|
||||||
@router.get("/posts/{post_id}")
|
|
||||||
async def get_post(post: dict = Depends(valid_post_id)):
|
|
||||||
return post # Already validated!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Chaining Dependencies**: Build reusable validation layers:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def valid_owned_post(
|
|
||||||
post: dict = Depends(valid_post_id),
|
|
||||||
token_data: dict = Depends(parse_jwt_data),
|
|
||||||
) -> dict:
|
|
||||||
if post["creator_id"] != token_data["user_id"]:
|
|
||||||
raise UserNotOwner()
|
|
||||||
return post
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependency Caching**: Dependencies are cached within request scope - FastAPI only executes each dependency once per request, even if used multiple times.
|
|
||||||
|
|
||||||
### Application Factory Pattern
|
|
||||||
|
|
||||||
Main.py uses factory pattern for testability and configuration:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def create_application() -> FastAPI:
|
|
||||||
"""Create and configure FastAPI app."""
|
|
||||||
app = FastAPI(title=config.APP_NAME)
|
|
||||||
|
|
||||||
# Add middleware
|
|
||||||
app.add_middleware(CORSMiddleware, ...)
|
|
||||||
|
|
||||||
# Register exception handlers
|
|
||||||
register_exception_handlers(app)
|
|
||||||
|
|
||||||
# Include routers
|
|
||||||
app.include_router(chat_router, prefix="/v1")
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
app = create_application()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Structure (Current Implementation)
|
|
||||||
- ✅ Use async/await for ALL I/O operations (database, HTTP, file access)
|
|
||||||
- ✅ Use sync (def) for blocking SDKs or CPU-intensive work
|
|
||||||
- ✅ Implement proper error handling and logging
|
|
||||||
- ✅ Follow dependency injection for validation and shared resources
|
|
||||||
- ✅ Use Pydantic models for ALL request/response validation
|
|
||||||
- ✅ Keep business logic in service modules, not routers
|
|
||||||
- ✅ Domain-based project structure (not file-type based)
|
|
||||||
|
|
||||||
### Security Considerations
|
|
||||||
- ✅ Validate all inputs using Pydantic models
|
|
||||||
- ✅ Use environment variables for sensitive configuration
|
|
||||||
- ✅ Keep dependencies updated (all CVE-checked as of 2025-12-06)
|
|
||||||
- ✅ Minor version locking for supply chain protection
|
|
||||||
- 🚧 Implement rate limiting for API endpoints (future)
|
|
||||||
- 🚧 Add authentication/API keys (future)
|
|
||||||
|
|
||||||
### Testing (Current Coverage: 62%)
|
|
||||||
- ✅ Integration tests for API endpoints
|
|
||||||
- ✅ Streaming functionality with 20s timeout protection
|
|
||||||
- ✅ Async test support with pytest-asyncio
|
|
||||||
- ✅ Validate OpenAI API compatibility
|
|
||||||
- ✅ Mock responses for all endpoints
|
|
||||||
- 🚧 Future: Mock Ollama responses when integrated
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
- ✅ Use `.env` files for local development
|
|
||||||
- ✅ Document all environment variables in README
|
|
||||||
- ✅ Provide sensible defaults where possible
|
|
||||||
- ✅ BaseSettings from pydantic-settings
|
|
||||||
- 🚧 Support container-based configuration (future)
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Streaming Response Pattern (✅ Implemented)
|
|
||||||
|
|
||||||
See `src/chat/router.py` for the current implementation:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
# Currently yields mock lorem ipsum chunks
|
|
||||||
# Future: Stream from Ollama/PydanticAI
|
|
||||||
yield {"data": chunk.model_dump_json()}
|
|
||||||
yield {"data": "[DONE]"}
|
|
||||||
|
|
||||||
@app.post("/stream")
|
|
||||||
async def stream():
|
|
||||||
return EventSourceResponse(event_generator())
|
|
||||||
```
|
|
||||||
|
|
||||||
### PydanticAI Agent Pattern (🚧 Future Reference)
|
|
||||||
|
|
||||||
For future integration when connecting to Ollama:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic_ai import Agent
|
|
||||||
|
|
||||||
agent = Agent(
|
|
||||||
'ollama:mistral-nemo', # Target model
|
|
||||||
# Configuration here
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the agent
|
|
||||||
result = await agent.run('Your prompt')
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI-Compatible Response Format (✅ Implemented)
|
|
||||||
|
|
||||||
Current implementation in `src/chat/schemas.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"id": "chatcmpl-123",
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": 1234567890,
|
|
||||||
"model": "mistral-nemo:latest",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {"content": "response"},
|
|
||||||
"finish_reason": None
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Update Policy
|
|
||||||
|
|
||||||
This document should be updated when:
|
|
||||||
- Package versions are upgraded
|
|
||||||
- New major features are added
|
|
||||||
- Breaking API changes occur
|
|
||||||
- Security vulnerabilities are discovered
|
|
||||||
|
|
||||||
Last updated: 2025-12-06
|
|
||||||
+649
-1
@@ -7,6 +7,637 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **HybridRAG keywords schema mismatch** - library-desk now returns `keywords` as dict with `core_keywords`, client now handles both formats
|
||||||
|
|
||||||
|
## [1.8.1] - 2025-12-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Ollama Message Sanitization
|
||||||
|
- **Fixed `invalid message content type: <nil>` error** from Ollama
|
||||||
|
- Created custom `TatlockOllamaProvider` that sanitizes messages before sending to Ollama
|
||||||
|
- Ollama rejects assistant messages with `content: null` (tool-only messages from PydanticAI)
|
||||||
|
- Provider converts `null` content to empty string `""` for compatibility
|
||||||
|
- Updated all agents (Librarian, Biographer, Housekeeper, Tatlock) to use sanitized provider
|
||||||
|
- Added `src/ollama/provider.py` with reusable provider pattern
|
||||||
|
|
||||||
|
#### Streaming Think Message Accumulation
|
||||||
|
- **Fixed repeating think messages in frontend** (e.g., 10x "The Librarian has compiled...")
|
||||||
|
- Frontend was accumulating `ReasoningSummaryDelta` events expecting concatenation
|
||||||
|
- Added `ReasoningSummaryDone()` signal after each think message to indicate completion
|
||||||
|
- Each think slug is now treated as a complete message, not a continuation
|
||||||
|
|
||||||
|
## [1.8.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Steward Routing for Web Search
|
||||||
|
- Updated Steward guidelines to route web searches, weather, news → Librarian with `search_web`
|
||||||
|
- Added URL/article reading → Librarian with `read_url` to routing guidelines
|
||||||
|
- Added examples showing `search_web` and `read_url` tool usage
|
||||||
|
|
||||||
|
#### Librarian Agent Tool Registration
|
||||||
|
- Registered `search_web`, `read_url`, `read_urls_batch` tools with the Librarian PydanticAI agent
|
||||||
|
- Updated Librarian system prompt with Web Search & Content Extraction section
|
||||||
|
- Fixed tool count in agent logger (11 → 14 tools)
|
||||||
|
|
||||||
|
#### Query Enrichment Integration
|
||||||
|
- Fixed enriched query (with location/timezone context) not being passed to delegations
|
||||||
|
- Response service now uses `enriched_query` from Steward recommendation for all delegations
|
||||||
|
- Weather queries now automatically include user's stored location
|
||||||
|
|
||||||
|
#### Action Type Detection
|
||||||
|
- Added "read", "fetch", "url", "http" keywords to RESEARCH action type for Librarian
|
||||||
|
- Ensures proper think messages for URL reading tasks
|
||||||
|
|
||||||
|
## [1.7.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Web Search Migration to Librarian
|
||||||
|
- **`search_web()`** tool in Librarian for web search via library-desk `/rag/search` endpoint
|
||||||
|
- **`read_url()`** tool for single URL content extraction via Trafilatura
|
||||||
|
- **`read_urls_batch()`** tool for parallel batch URL extraction (max 20 URLs)
|
||||||
|
- `WebSearchResult`, `WebSearchResponse` models in LibraryDeskClient
|
||||||
|
- `ContentExtractionResult`, `BatchExtractionResponse` models for content extraction
|
||||||
|
- `search_web()`, `extract_content()`, `extract_content_batch()` methods in LibraryDeskClient
|
||||||
|
- Comprehensive unit tests for new Librarian tools (`tests/agents/librarian/test_tools.py`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Librarian capability updated with web search domains: "web", "url", "internet"
|
||||||
|
- Tatlock system prompt now delegates web search to Librarian
|
||||||
|
- `tatlock_core` capability reduced to computation/datetime only (no longer requires network)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `search_web` function from `src/agents/tatlock_core/tools.py`
|
||||||
|
- `web_search_tool` from `tatlock_core_tools` list
|
||||||
|
- `search_web` from legacy `src/agents/tools.py`
|
||||||
|
- Search tests from `tests/agents/test_tools.py` (moved to Librarian tests)
|
||||||
|
|
||||||
|
## [1.6.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Two-Phase Tatlock Execution
|
||||||
|
- **Phase 1: Orchestration** - Executes tool calls and expert delegations, returns structured results
|
||||||
|
- **Phase 2: Synthesis** - Synthesizes butler-toned response from gathered results
|
||||||
|
- `orchestrate_tool_calls()` method in TatlockAgent for coordination phase
|
||||||
|
- `synthesize_from_results()` method in TatlockAgent for synthesis phase
|
||||||
|
- Guarantees butler personality in all responses by separating coordination from response generation
|
||||||
|
|
||||||
|
#### Automatic Think Slugs
|
||||||
|
- **Deterministic butler-perspective messages** during expert delegation (no LLM involved)
|
||||||
|
- `ActionType` enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
|
||||||
|
- `HOUSEHOLD_THINK_MESSAGES` mapping with butler-perspective messages for all experts:
|
||||||
|
- Librarian: "Allow me to consult the archives, sir." / "I'm having the Librarian prepare a new entry."
|
||||||
|
- Biographer: "Let me consult the household records." / "I've asked the Biographer to take note, sir."
|
||||||
|
- Housekeeper: "I'm instructing the household staff now, sir." / "Allow me to inquire with the household staff."
|
||||||
|
- `_detect_action_type()` function for keyword-based action detection
|
||||||
|
- `get_think_message()` helper for retrieving appropriate messages
|
||||||
|
- Streaming delegation wrappers: `stream_delegate_to_librarian()`, `stream_delegate_to_biographer()`, `stream_delegate_to_housekeeper()`
|
||||||
|
- `STREAMING_DELEGATION_WRAPPERS` mapping in delegation.py
|
||||||
|
- `get_streaming_delegation_tools()` method in HouseholdRegistry
|
||||||
|
|
||||||
|
#### Steward Query Enrichment
|
||||||
|
- **Auto-fill user context** (location, timezone) when not specified in query
|
||||||
|
- `_build_enriched_query()` function in steward service
|
||||||
|
- Regex word boundary matching for accurate location detection (avoids false positives)
|
||||||
|
- `enriched_query` field added to `StewardRecommendation` schema
|
||||||
|
- Automatic enrichment for weather queries (location), time queries (timezone), temperature preferences
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- **ORCHESTRATION_SCENARIOS.md** completely rewritten with:
|
||||||
|
- Mermaid flow diagrams for two-phase execution
|
||||||
|
- 4 new Housekeeper scenarios (light control, device status, parallel delegation)
|
||||||
|
- Biographer memory recording scenario
|
||||||
|
- Complete think slug reference tables
|
||||||
|
- Action type detection tables
|
||||||
|
- Updated architecture mindmap
|
||||||
|
- **TESTING_IMPROVEMENTS.md** - LLM testing best practices for future implementation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `create_response_with_steward()` now uses two-phase execution
|
||||||
|
- `_direct_delegation()` routes through synthesis phase for consistent butler tone
|
||||||
|
- `_execute_single_delegation()` now supports housekeeper
|
||||||
|
- Streaming response handler integrated with think slug system
|
||||||
|
- All 326 unit tests passing
|
||||||
|
|
||||||
|
## [1.5.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### The Housekeeper Agent
|
||||||
|
- **New home automation expert agent** following the Librarian pattern
|
||||||
|
- `CoreAPIClient` for communicating with core-api service (Home Assistant wrapper)
|
||||||
|
- 13 tools for home automation:
|
||||||
|
- Discovery: `list_areas`, `list_devices`, `get_device_state`
|
||||||
|
- Control: `turn_on`, `turn_off`, `toggle`
|
||||||
|
- Scenes: `list_scenes`, `activate_scene`
|
||||||
|
- Scripts: `list_scripts`, `run_script`
|
||||||
|
- Automations: `list_automations`, `toggle_automation`
|
||||||
|
- History: `get_history`
|
||||||
|
- PydanticAI agent with system prompt for home automation tasks
|
||||||
|
- `HouseholdCapability` registration with domains: lights, switches, automation, home, smart home, scene, script, device, climate, fan, cover, blinds
|
||||||
|
- `delegate_to_housekeeper()` delegation wrapper
|
||||||
|
- Config settings: `CORE_API_HOST`, `CORE_API_KEY`, `CORE_API_TIMEOUT`
|
||||||
|
|
||||||
|
#### Development Port Change
|
||||||
|
- **Dev server port changed from 8123 to 8777** to avoid conflict with Home Assistant default port
|
||||||
|
- Updated `wakeup.sh`, E2E tests, and documentation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- All unit tests pass (421 passed, 5 xfailed)
|
||||||
|
- Housekeeper registered on startup alongside Librarian and Biographer
|
||||||
|
|
||||||
|
## [1.4.0] - 2025-12-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Environment-Aware Configuration
|
||||||
|
- **Auto-selected logging level**: DEBUG for development, WARNING for production
|
||||||
|
- **Auto-selected default user**: `llm_tester` for development (isolated test scope), `jpmschweitzer` for production
|
||||||
|
- Properties `effective_log_level` and `effective_default_user` in config
|
||||||
|
- User context logging at request entry with INFO level
|
||||||
|
|
||||||
|
#### Direct Delegation Bypass
|
||||||
|
- **Pure memory/librarian requests bypass Tatlock**: When Steward recommends only biographer/librarian, skip Tatlock LLM call
|
||||||
|
- `_direct_delegation()` function for immediate expert agent execution
|
||||||
|
- Reduces latency for memory-only requests
|
||||||
|
|
||||||
|
#### Text-Based Delegation Fallback
|
||||||
|
- **Parse text delegation patterns**: Handle LLM outputs like `[DELEGATE:biographer] task="..."`
|
||||||
|
- Multiple pattern support for delegation parsing
|
||||||
|
- Sequential and parallel execution with `[PARALLEL]` prefix
|
||||||
|
|
||||||
|
#### Comprehensive E2E Test Suite
|
||||||
|
- **22 new orchestration tests** in `tests/e2e/test_orchestration_e2e.py`
|
||||||
|
- `QdrantVerifier` helper class for data verification
|
||||||
|
- `assert_llm_behavior()` for flexible LLM output pattern matching
|
||||||
|
- Test classes covering:
|
||||||
|
- Memory storage and recall
|
||||||
|
- Steward delegation
|
||||||
|
- Direct delegation bypass
|
||||||
|
- User context isolation (llm_tester vs production)
|
||||||
|
- Data verification in Qdrant
|
||||||
|
- Integration health checks
|
||||||
|
- Orchestration scenarios (weather, calculator, wiki, multi-expert)
|
||||||
|
- Error handling
|
||||||
|
- Evaluation reports
|
||||||
|
- Updated `tests/e2e/README.md` with comprehensive documentation
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Unit test mocks**: Updated Steward streaming tests to mock `run_with_scoped_tools_stream` (async generator)
|
||||||
|
- **Temporal context in tests**: Tests now account for `_inject_temporal_context()` appending timestamps
|
||||||
|
- **LLM non-determinism**: Integration tests use `pytest.xfail()` for LLM-dependent assertions
|
||||||
|
- **Streaming test timeouts**: Increased timeouts (60-90s) for LLM processing time
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- All unit tests now pass (380 passed, 5 xfailed for LLM non-determinism)
|
||||||
|
- E2E tests use `llm_tester` user for isolation from production data
|
||||||
|
|
||||||
|
## [1.3.3] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Memory**: Fix Qdrant point IDs - use UUID5 instead of arbitrary strings
|
||||||
|
|
||||||
|
## [1.3.2] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Memory**: Fix biographer tool type hints for Ollama compatibility (remove `| None` union types)
|
||||||
|
|
||||||
|
## [1.3.1] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Memory**: Add biographer to delegation wrappers (was returning raw tools causing Ollama error)
|
||||||
|
- **Config**: Add Qdrant host/port to .env.example
|
||||||
|
|
||||||
|
## [1.3.0] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Memory**: Update Qdrant client to use `query_points` API (qdrant-client >= 1.10)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Config**: Rename `REDIS_DB` to `REDIS_BENCHMARK_DB` for clarity
|
||||||
|
- **Config**: Update Redis defaults to match stack allocation (benchmark=6, memory=1)
|
||||||
|
|
||||||
|
## [1.2.5] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Dependencies**: Add missing `pydantic-settings` (not included in pydantic-ai-slim)
|
||||||
|
|
||||||
|
## [1.2.4] - 2025-12-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CI**: Trigger Watchtower update after successful image push
|
||||||
|
|
||||||
|
## [1.2.3] - 2025-12-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CI**: Upgrade to build-push-action@v6, disable provenance and sbom for Gitea registry
|
||||||
|
|
||||||
|
## [1.2.2] - 2025-12-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CI**: Add `provenance: false` to docker/build-push-action to fix Gitea registry push
|
||||||
|
|
||||||
|
## [1.2.1] - 2025-12-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Dependency slimming**: Switched from `pydantic-ai` to `pydantic-ai-slim[openai]`
|
||||||
|
- Removes unused LLM provider SDKs (anthropic, boto3, cohere, google-genai, groq, huggingface)
|
||||||
|
- Production packages: 53 (down from ~158)
|
||||||
|
- Production footprint: 178MB
|
||||||
|
- Tatlock uses Ollama via OpenAI-compatible API, so only `openai` extra is needed
|
||||||
|
- See `DEPENDENCY_SLIM.md` for rollback instructions
|
||||||
|
|
||||||
|
## [1.2.0] - 2025-12-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Phase F: Memory System (The Biographer)
|
||||||
|
|
||||||
|
- **Memory Infrastructure** (Phase F.1):
|
||||||
|
- `src/core/context.py`: ContextVar-based request context for async-safe user/conversation tracking
|
||||||
|
- `get_user()`, `get_conversation_id()` helpers
|
||||||
|
- `RequestContext` manager for clean setup/teardown
|
||||||
|
- `src/core/multi_tenancy.py`: User ID sanitization and collection naming
|
||||||
|
- Per-user collection pattern: `memories_{user}`
|
||||||
|
- Redis key patterns: `session:{user}:{conv}`, `entities:{user}:{conv}`
|
||||||
|
- `src/core/embeddings.py`: Ollama embedding client
|
||||||
|
- nomic-embed-text model (768 dimensions)
|
||||||
|
- `embed()`, `embed_batch()`, `health_check()` methods
|
||||||
|
- `src/core/qdrant.py`: Qdrant vector database client
|
||||||
|
- `ensure_collection()`, `upsert_memory()`, `search_memories()`, `delete_memory()`
|
||||||
|
- Type-based filtering for memory queries
|
||||||
|
- `src/core/memory_cache.py`: Redis session memory cache
|
||||||
|
- Session context with 24h TTL (db=2, separate from benchmarks)
|
||||||
|
- Recent entities tracking per conversation
|
||||||
|
|
||||||
|
- **Memory Service** (Phase F.2a):
|
||||||
|
- `src/core/memory_service.py`: Direct access layer for fast, LLM-free memory lookups
|
||||||
|
- Profile methods: `get_profile()`, `set_profile()`
|
||||||
|
- Preference methods: `get_preference()`, `set_preference()`, `get_all_preferences()`
|
||||||
|
- Fact methods: `store_fact()`, `get_fact()`
|
||||||
|
- Session context: `get_session_context()`, `set_session_context()`, `update_session_context()`
|
||||||
|
- Steward integration: `prefetch_context()` for request preprocessing
|
||||||
|
|
||||||
|
- **The Biographer Agent** (Phase F.2b):
|
||||||
|
- `src/agents/biographer/`: Household memory keeper agent
|
||||||
|
- PydanticAI agent with discreet chronicler personality
|
||||||
|
- System prompt emphasizes privacy and accurate recall
|
||||||
|
- **Biographer Tools** (`src/agents/biographer/tools.py`):
|
||||||
|
- `recall_semantic`: Semantic search for memories by meaning
|
||||||
|
- `list_memories`: Browse stored memories by type
|
||||||
|
- `store_insight`: Record new facts from conversation
|
||||||
|
- `update_profile`: Update core profile fields (name, location, timezone)
|
||||||
|
- `update_preference`: Update user preferences (units, theme)
|
||||||
|
- `forget_memory`: Remove specific memories
|
||||||
|
- **Capability Registration**:
|
||||||
|
- `BIOGRAPHER_CAPABILITY` with context domain
|
||||||
|
- Automatic registration on startup
|
||||||
|
- Low cost (vector search, minimal LLM)
|
||||||
|
|
||||||
|
- **Delegation Wrapper**:
|
||||||
|
- `delegate_to_biographer()` in `src/agents/delegation.py`
|
||||||
|
- Async delegation with error handling
|
||||||
|
|
||||||
|
- **Steward Memory Integration**:
|
||||||
|
- Memory context pre-fetch during request analysis
|
||||||
|
- Profile and preferences included in Steward's note to Butler
|
||||||
|
- Keyword-based context determination (weather → location, time → timezone)
|
||||||
|
|
||||||
|
- **Configuration**:
|
||||||
|
- `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_EMBEDDING_DIM` (768)
|
||||||
|
- `OLLAMA_EMBEDDING_MODEL` (nomic-embed-text)
|
||||||
|
- `REDIS_MEMORY_DB` (2), `REDIS_MEMORY_TTL_HOURS` (24)
|
||||||
|
|
||||||
|
- **Test Suite**:
|
||||||
|
- 34 new tests for memory system
|
||||||
|
- Biographer capability tests (15 tests)
|
||||||
|
- Memory service tests (19 tests)
|
||||||
|
|
||||||
|
- **OpenAI Standard `user` Field**:
|
||||||
|
- Added `user` field to `ResponseRequest` schema
|
||||||
|
- Request context set at API entry point
|
||||||
|
- Propagates through async calls via ContextVar
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Application startup now registers The Biographer with Household Registry
|
||||||
|
- Steward analysis includes memory context pre-fetch
|
||||||
|
- Librarian client methods now use `get_user()` from context (12 methods updated)
|
||||||
|
- Request router sets user/conversation context at entry
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-12-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Phase 3: Butler Orchestration (Multi-Agent Coordination)
|
||||||
|
- **The Librarian Agent**: Expert agent for research and knowledge management
|
||||||
|
- PydanticAI agent with specialized research assistant personality
|
||||||
|
- Connects to library-desk API for HybridRAG capabilities
|
||||||
|
- System prompt emphasizes fetching wiki pages before summarizing
|
||||||
|
- Streaming support via `run_librarian_stream()`
|
||||||
|
|
||||||
|
- **Library-Desk API Client** (`src/agents/librarian/client.py`):
|
||||||
|
- Async HTTP client with httpx for library-desk API integration
|
||||||
|
- HybridRAG search (vector + graph + web search)
|
||||||
|
- Wiki operations (search, get, list, create, update pages)
|
||||||
|
- Smart page creation with HybridRAG research (`POST /wiki/pages/smart-create`)
|
||||||
|
- Semantic vector search
|
||||||
|
- Knowledge graph queries (Cypher execution)
|
||||||
|
- Dossier (tag collection) browsing
|
||||||
|
- Health check endpoint
|
||||||
|
|
||||||
|
- **Librarian Tools** (`src/agents/librarian/tools.py`):
|
||||||
|
- Research tools:
|
||||||
|
- `hybrid_search`: Combined vector, graph, and web search
|
||||||
|
- `search_wiki`: Full-text wiki page search
|
||||||
|
- `get_wiki_page`: Fetch full wiki page content by ID
|
||||||
|
- `semantic_search`: Vector similarity search
|
||||||
|
- `list_dossiers`: Browse knowledge collections
|
||||||
|
- `get_dossier_pages`: Get pages in a dossier
|
||||||
|
- `explore_knowledge_graph`: Entity and relationship discovery
|
||||||
|
- `find_related_entities`: Find connected concepts
|
||||||
|
- Write tools:
|
||||||
|
- `smart_create_wiki_page`: Create page with automatic HybridRAG research (PREFERRED for topic-based creation)
|
||||||
|
- `create_wiki_page`: Create page with user-provided content
|
||||||
|
- `update_wiki_page`: Update existing page (partial updates supported)
|
||||||
|
|
||||||
|
- **Agent Communication Protocol** (`src/agents/protocol.py`):
|
||||||
|
- `AgentRequest`: Standardized task request with context and constraints
|
||||||
|
- `AgentResponse`: Response with result, reasoning, tool calls, confidence
|
||||||
|
- `DelegationIntent`: Routing intent with target agent and reason
|
||||||
|
- `CoordinationResult`: Aggregated multi-agent results
|
||||||
|
- `DelegationReason` enum: domain expertise, tool access, resource efficiency, user preference
|
||||||
|
- Error types: `AgentError`, `AgentTimeoutError`, `AgentUnavailableError`
|
||||||
|
|
||||||
|
- **Coordination Engine** (`src/agents/coordination.py`):
|
||||||
|
- `CoordinationEngine`: Multi-agent task orchestration
|
||||||
|
- Routing tasks to appropriate expert agents
|
||||||
|
- Sequential and parallel execution support
|
||||||
|
- Result aggregation from multiple agents
|
||||||
|
- Graceful error handling and degradation
|
||||||
|
- Streaming delegation support
|
||||||
|
- Convenience functions: `delegate_to_librarian()`, `delegate_to_librarian_stream()`
|
||||||
|
|
||||||
|
- **Librarian Capability Registration**:
|
||||||
|
- `LIBRARIAN_CAPABILITY` definition with research domains
|
||||||
|
- Automatic registration on application startup
|
||||||
|
- Integration with Household Registry
|
||||||
|
|
||||||
|
- **Configuration**:
|
||||||
|
- `LIBRARY_DESK_HOST`: Library-desk API URL (default: `http://localhost:8089`)
|
||||||
|
- `LIBRARY_DESK_API_KEY`: Optional API key for authentication
|
||||||
|
- `LIBRARY_DESK_TIMEOUT`: Request timeout in seconds (default: 60)
|
||||||
|
|
||||||
|
- **Test Suite**:
|
||||||
|
- 78 new tests for Phase 3 components
|
||||||
|
- Protocol model tests (requests, responses, intents, errors)
|
||||||
|
- Coordination engine tests (delegation, streaming, multi-agent)
|
||||||
|
- Library-desk client tests (all endpoints with mocked HTTP)
|
||||||
|
- Wiki write operation tests (update, smart-create)
|
||||||
|
- Capability registration tests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Application startup now registers The Librarian with Household Registry
|
||||||
|
- Configuration expanded to support library-desk API integration
|
||||||
|
- **Version loading**: APP_VERSION now dynamically loaded from pyproject.toml
|
||||||
|
|
||||||
|
## [1.0.0a] - 2025-12-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **CI/CD Pipeline**: Release-triggered automated builds
|
||||||
|
- Dockerfile for containerized deployment (Python 3.12-slim, port 8000)
|
||||||
|
- Gitea Actions workflow triggered on release publish
|
||||||
|
- Builds and pushes to git.schweitz.net registry with latest and version tags
|
||||||
|
- Watchtower integration for automatic container updates
|
||||||
|
- **Portainer Stack**: Production deployment configuration
|
||||||
|
- Connects to docker-dataplane network for service discovery
|
||||||
|
- Integration with ollama, searxng, and redis-shared services
|
||||||
|
- Health check endpoint monitoring
|
||||||
|
- Resource limits (1 CPU, 1GB memory)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Version bump to 1.0.0 marking production-ready release
|
||||||
|
|
||||||
|
## [0.2.5] - 2025-12-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Phase 2: The Steward (Two-Tier Architecture)
|
||||||
|
- **The Steward Agent**: First-tier LLM agent for request analysis and capability recommendation
|
||||||
|
- Analyzes requests with full conversation context awareness
|
||||||
|
- Recommends relevant household capabilities for each request
|
||||||
|
- Detects missing capabilities and provides guidance
|
||||||
|
- Estimates request complexity (simple/moderate/complex)
|
||||||
|
- Uses same Ollama model as Tatlock for VRAM efficiency
|
||||||
|
|
||||||
|
- **Household Registry**: Centralized capability management system
|
||||||
|
- `HouseholdRegistry` for registering capabilities and toolsets
|
||||||
|
- `HouseholdCapability` executive summaries for coordination
|
||||||
|
- `HouseholdMember` specifications with PydanticAI toolsets
|
||||||
|
- Domain-based tool organization (e.g., `src/agents/tatlock_core/`)
|
||||||
|
- Dynamic tool scoping per request
|
||||||
|
|
||||||
|
- **Request Preprocessing Pipeline**: Steward → Tatlock flow integration
|
||||||
|
- `preprocess_request()` orchestrates Steward analysis
|
||||||
|
- Creates scoped toolsets based on recommendations
|
||||||
|
- Formats Steward notes for Butler (conversation context included)
|
||||||
|
- Integrated with Responses API via `create_response_with_steward()`
|
||||||
|
|
||||||
|
- **Tool Usage Tracking**: Benchmarking and accuracy analysis
|
||||||
|
- `ToolCallTracker` for monitoring recommended vs. actual tool usage
|
||||||
|
- Tracks recommendation accuracy metrics
|
||||||
|
- Records benchmarks to Redis for cross-session analysis
|
||||||
|
- Supports precision/recall/F1 score calculation
|
||||||
|
|
||||||
|
- **Streaming Transparency**: Real-time Steward analysis visibility
|
||||||
|
- Streams Steward's reasoning as reasoning summary deltas
|
||||||
|
- Streams Tatlock's response as output text deltas
|
||||||
|
- Full SSE support for Steward + Tatlock flow
|
||||||
|
- Conversation context and missing capabilities visible in stream
|
||||||
|
|
||||||
|
- **Structured Logging**: Operation timing and metadata tracking
|
||||||
|
- `structlog`-based JSON logging for machine parsing
|
||||||
|
- Context managers for automatic operation timing
|
||||||
|
- Metadata enrichment for debugging and analysis
|
||||||
|
- Integrated with benchmark recording
|
||||||
|
|
||||||
|
- **Redis Benchmark Storage**: Performance metrics persistence
|
||||||
|
- Cross-session benchmark storage with 30-day expiry
|
||||||
|
- Time-series metrics for Steward analysis and tool calls
|
||||||
|
- Queryable by operation, time range, and metadata
|
||||||
|
- Support for recommendation accuracy tracking
|
||||||
|
|
||||||
|
- **Benchmark Analysis Tools**: Performance analysis CLI
|
||||||
|
- `scripts/benchmark_analysis.py` for metric analysis
|
||||||
|
- Steward performance statistics (latency, success rate, recommendations)
|
||||||
|
- Tool recommendation accuracy analysis (precision, recall, F1)
|
||||||
|
- Per-tool accuracy breakdown and duration statistics
|
||||||
|
|
||||||
|
- **End-to-End Test Suite**: Comprehensive API integration tests
|
||||||
|
- 17 E2E tests making real HTTP requests to running server
|
||||||
|
- Tests for Chat Completions, Responses API, and streaming endpoints
|
||||||
|
- OpenAI API spec compliance verification (format validation)
|
||||||
|
- Steward preprocessing integration verification
|
||||||
|
- Error handling tests (404, 422 status codes)
|
||||||
|
- Flexible assertions for LLM output variance
|
||||||
|
- Tool usage indicators: 🧮 (calculator), 🔍 (search), 🕐 (datetime)
|
||||||
|
- Full documentation in `tests/e2e/README.md`
|
||||||
|
|
||||||
|
#### Phase 1 Enhancements
|
||||||
|
- **Conversation history support**: Tatlock now remembers previous turns in multi-turn conversations
|
||||||
|
- OpenAI-format messages converted to PydanticAI `ModelRequest`/`ModelResponse` objects
|
||||||
|
- Full conversation context passed to agent via `message_history` parameter
|
||||||
|
- Empty messages filtered to prevent Ollama errors
|
||||||
|
- **Tool call logging to reasoning output**: Users can see what tools are doing in real-time
|
||||||
|
- `ToolCallTracker` dependency system for per-request tool usage logging
|
||||||
|
- Web search queries appear with 🔍 emoji (e.g., "🔍 Searching for: 'Python 3.13'")
|
||||||
|
- Calculator expressions appear with 🧮 emoji (e.g., "🧮 Calculating: sqrt(144) + 25")
|
||||||
|
- Date/time operations appear with 🕐 emoji (e.g., "🕐 Calculating date offset: 2 weeks ago")
|
||||||
|
- Tool usage visible in `<think>` tags in Open WebUI
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Architecture**: Two-tier request flow (Steward analysis → Tatlock execution)
|
||||||
|
- **Tool Organization**: Tatlock core tools reorganized into domain directory
|
||||||
|
- **Tool Scoping**: Tatlock runs with dynamically scoped toolsets per request
|
||||||
|
- **Responses API**: Integrated Steward preprocessing for all Tatlock requests
|
||||||
|
- **Streaming**: Enhanced to include Steward reasoning transparency
|
||||||
|
- Enhanced Tatlock agent with conversation memory capabilities
|
||||||
|
- All tools now log their usage via `RunContext` dependencies
|
||||||
|
- Improved debug logging for message history construction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Streaming text repetition**: Fixed text accumulation bug causing repetitive output in Open WebUI
|
||||||
|
- Changed from accumulated text to delta mode (`stream_text(delta=True)`)
|
||||||
|
- Implemented proper `run_with_scoped_tools_stream()` using PydanticAI's `run_stream()`
|
||||||
|
- Replaced artificial word-by-word chunking with real LLM deltas
|
||||||
|
- **Broken tool execution in streaming**: Tools now execute properly in streaming mode
|
||||||
|
- Previously showed raw JSON function calls instead of executed results
|
||||||
|
- Now properly streams tool execution results
|
||||||
|
- **Invalid schema parameter**: Removed invalid `thinking` parameter from `ReasoningOutputItem`
|
||||||
|
- **Case sensitivity in model routing**: Model comparison now case-insensitive (`.lower()`)
|
||||||
|
- Conversation context now properly maintained across multiple turns
|
||||||
|
- Tool usage transparency - users can see exactly what queries/calculations are being performed
|
||||||
|
- Schema object handling in usage calculation (_calculate_usage reordered isinstance checks)
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-12-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### PydanticAI Integration (Phase 1)
|
||||||
|
- Real Tatlock agent using PydanticAI with Ollama backend (mistral-nemo:latest)
|
||||||
|
- British butler personality with research-oriented mindset
|
||||||
|
- Lazy agent initialization to avoid connection issues in tests
|
||||||
|
- Streaming response integration with reasoning output
|
||||||
|
- Error handling for PydanticAI-specific exceptions
|
||||||
|
|
||||||
|
#### Permanent Tools (Phase 1)
|
||||||
|
- **Calculator tool** (`src/agents/tools.py`):
|
||||||
|
- Safe mathematical expression evaluation using restricted namespace
|
||||||
|
- Support for arithmetic, algebra, trigonometry, logarithms
|
||||||
|
- Math functions: sqrt, sin, cos, tan, log, exp, etc.
|
||||||
|
- Constants: pi, e
|
||||||
|
- Integer result formatting (removes unnecessary decimals)
|
||||||
|
- **Date/Time toolkit**:
|
||||||
|
- `get_current_datetime`: Current date/time in multiple formats
|
||||||
|
- `calculate_time_offset`: Relative date calculations ("1 week ago", "2 months from now")
|
||||||
|
- `time_difference`: Human-readable time differences between dates
|
||||||
|
- **Web Search tool**:
|
||||||
|
- SearXNG integration for privacy-preserving web search
|
||||||
|
- Automatic fallback from production to localhost in development
|
||||||
|
- Formatted search results with titles, URLs, and snippets
|
||||||
|
- Configurable result limits (max 10)
|
||||||
|
|
||||||
|
#### Tool Framework
|
||||||
|
- PydanticAI tool registration with `@agent.tool` decorator
|
||||||
|
- Tool descriptions visible to LLM for intelligent usage
|
||||||
|
- Async tool support for I/O operations
|
||||||
|
- Error handling with string-based error messages
|
||||||
|
- Tool usage guidelines in system prompt
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
- SearXNG configuration in `src/core/config.py`:
|
||||||
|
- `SEARXNG_HOST` with development fallback
|
||||||
|
- `SEARXNG_TIMEOUT` setting
|
||||||
|
- Updated `.env.example` with SearXNG configuration
|
||||||
|
- Ollama configuration documentation
|
||||||
|
|
||||||
|
#### Testing
|
||||||
|
- 26 new tool tests (`tests/agents/test_tools.py`):
|
||||||
|
- 7 calculator tests (arithmetic, functions, error handling)
|
||||||
|
- 14 date/time tests (current time, offsets, differences)
|
||||||
|
- 5 web search tests (mocked HTTP client)
|
||||||
|
- Updated registry tests for tools capability
|
||||||
|
- Total: 131 tests, 81.78% coverage (up from 95 tests, 78.95%)
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- Comprehensive README.md updates:
|
||||||
|
- Tatlock agent capabilities and tool descriptions
|
||||||
|
- Requirements section with Ollama and SearXNG setup
|
||||||
|
- Configuration examples for external services
|
||||||
|
- Tool usage examples and philosophy
|
||||||
|
- Troubleshooting for Ollama and SearXNG
|
||||||
|
- Updated test statistics
|
||||||
|
- AGENTS.md refactored for LLM development:
|
||||||
|
- PydanticAI tool registration pattern
|
||||||
|
- Tool implementation guidelines
|
||||||
|
- Removed project status, focused on development instructions
|
||||||
|
- IMPLEMENTATION_ROADMAP.md updates:
|
||||||
|
- Phase 1 marked as "MOSTLY COMPLETE"
|
||||||
|
- Detailed completion status for each deliverable
|
||||||
|
- Updated current state summary
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tatlock agent converted from mock to real PydanticAI implementation
|
||||||
|
- Tatlock capabilities updated: `tools: True`
|
||||||
|
- Streaming coordination now handles chunk-based delivery (50 chars) to preserve markdown
|
||||||
|
- Chat service streaming updated to preserve formatting
|
||||||
|
- System prompt enhanced with tool usage guidelines and research mindset
|
||||||
|
- Agent initialization changed to lazy pattern for better testability
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Text duplication bug in streaming responses (proper delta calculation)
|
||||||
|
- Markdown formatting preservation in streamed responses
|
||||||
|
- GeneratorExit errors from async context managers in generators
|
||||||
|
- PydanticAI API usage (`result.output` instead of `result.data`)
|
||||||
|
|
||||||
## [0.1.1] - 2025-12-06
|
## [0.1.1] - 2025-12-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -115,6 +746,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- CORS middleware
|
- CORS middleware
|
||||||
- Exception handlers (OpenAI-compatible error format)
|
- Exception handlers (OpenAI-compatible error format)
|
||||||
|
|
||||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.1.1...main
|
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.6.0...main
|
||||||
|
[1.6.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.5.0...v1.6.0
|
||||||
|
[1.5.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...v1.5.0
|
||||||
|
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
|
||||||
|
[1.3.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.2...v1.3.3
|
||||||
|
[1.3.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.1...v1.3.2
|
||||||
|
[1.3.1]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.0...v1.3.1
|
||||||
|
[1.3.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.5...v1.3.0
|
||||||
|
[1.2.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.4...v1.2.5
|
||||||
|
[1.2.4]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.3...v1.2.4
|
||||||
|
[1.2.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.2...v1.2.3
|
||||||
|
[1.2.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.1...v1.2.2
|
||||||
|
[1.2.1]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.0...v1.2.1
|
||||||
|
[1.2.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.1.0...v1.2.0
|
||||||
|
[1.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.0.0a...v1.1.0
|
||||||
|
[1.0.0a]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.5...v1.0.0a
|
||||||
|
[0.2.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.0...v0.2.5
|
||||||
|
[0.2.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.1.1...v0.2.0
|
||||||
[0.1.1]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.1.0...v0.1.1
|
[0.1.1]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.1.0...v0.1.1
|
||||||
[0.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/releases/tag/v0.1.0
|
[0.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/releases/tag/v0.1.0
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
# Tatlock Implementation Roadmap
|
||||||
|
|
||||||
|
> **Reference**: See [PHILOSOPHY.md](PHILOSOPHY.md) for the target architecture and vision
|
||||||
|
|
||||||
|
This document outlines the phased implementation plan to transform the current OpenAI-compatible API into the full Tatlock household butler system.
|
||||||
|
|
||||||
|
## Current State (v1.2.0 - Phase F Complete)
|
||||||
|
|
||||||
|
**What we have**:
|
||||||
|
- ✅ **The Orchestrator** - FastAPI infrastructure layer
|
||||||
|
- OpenAI-compatible API endpoints (Responses API + Chat Completions)
|
||||||
|
- Streaming coordination and conversation management
|
||||||
|
- Response format with reasoning support
|
||||||
|
- Test infrastructure (~400 tests)
|
||||||
|
- ✅ **Two-Tier Architecture**
|
||||||
|
- The Steward analyzes requests and recommends capabilities
|
||||||
|
- Tatlock coordinates execution with scoped tools
|
||||||
|
- Real-time streaming of analysis and reasoning
|
||||||
|
- ✅ **Household Staff**
|
||||||
|
- **Tatlock** (Butler): Primary interface with witty personality
|
||||||
|
- **The Steward**: Request analysis and capability recommendation
|
||||||
|
- **The Librarian**: Research via library-desk HybridRAG + wiki
|
||||||
|
- **The Biographer**: User memory, profiles, preferences, semantic recall
|
||||||
|
- ✅ **Core Tools**
|
||||||
|
- Calculator, Date/Time toolkit, Web search (SearXNG)
|
||||||
|
- ✅ **Memory System**
|
||||||
|
- Direct access layer (memory_service) for fast lookups
|
||||||
|
- Vector storage (Qdrant) for semantic recall
|
||||||
|
- Session cache (Redis) with 24h TTL
|
||||||
|
- Multi-tenancy via ContextVar
|
||||||
|
- ✅ Mock agent (lorem-tester for testing)
|
||||||
|
|
||||||
|
**What we need**:
|
||||||
|
- More household staff (Developer, Secretary, Handyman, Housekeeper)
|
||||||
|
- MCP (Model Context Protocol) integration
|
||||||
|
- Dynamic model switching for specialized tasks
|
||||||
|
- Full multi-tenant database (PostgreSQL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Real LLM Integration - PydanticAI + Tools
|
||||||
|
|
||||||
|
**Goal**: Connect to actual language models and establish the base plumbing
|
||||||
|
|
||||||
|
**Note**: Ollama is an external service dependency (already running separately)
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **PydanticAI Integration** ✅
|
||||||
|
- PydanticAI → Ollama connection ✅
|
||||||
|
- Agent creation patterns ✅
|
||||||
|
- Streaming response handling ✅
|
||||||
|
- Error handling and retries ✅
|
||||||
|
|
||||||
|
2. **Convert Tatlock Agent** ✅
|
||||||
|
- Convert Tatlock agent from mock to PydanticAI ✅
|
||||||
|
- British butler personality prompt ✅
|
||||||
|
- Research-oriented mindset ✅
|
||||||
|
- Streaming to reasoning output ✅
|
||||||
|
- Tool calling framework setup ✅
|
||||||
|
|
||||||
|
3. **Permanent Tools** ✅
|
||||||
|
- Calculator: Safe mathematical expression evaluation ✅
|
||||||
|
- Date/Time toolkit: Current time, relative dates, time differences ✅
|
||||||
|
- Web search: SearXNG integration (external service) ✅
|
||||||
|
- Tool registration with PydanticAI ✅
|
||||||
|
|
||||||
|
4. **Testing Infrastructure** ✅
|
||||||
|
- Integration tests with real LLM ✅
|
||||||
|
- Tool functionality tests ✅
|
||||||
|
- Response quality validation ✅
|
||||||
|
- 131 tests, 81.78% coverage ✅
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [x] **PydanticAI agents can call Ollama** (mistral-nemo:latest)
|
||||||
|
- [x] **Streaming works end-to-end**
|
||||||
|
- [x] **Tool calling framework functional**
|
||||||
|
- [x] **Permanent tools working** (calculator, date/time, search)
|
||||||
|
- [x] **Tests pass with real LLM**
|
||||||
|
- [ ] Can switch models dynamically (e.g., Codestral for code)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ MOSTLY COMPLETE** - Tatlock agent functional with permanent tools
|
||||||
|
|
||||||
|
### Remaining Work
|
||||||
|
- Dynamic model switching for specialized tasks (e.g., Codestral for coding)
|
||||||
|
|
||||||
|
### Why First?
|
||||||
|
Without real LLM integration, we can't meaningfully implement the Steward/Butler pattern. Everything else depends on having actual AI agents working.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Orchestration Layer - The Steward
|
||||||
|
|
||||||
|
**Goal**: Implement the first-tier LLM call for tool/agent selection
|
||||||
|
|
||||||
|
**Purpose**: The Steward performs crucial preparatory work before Tatlock engages with a request. By analyzing incoming requests and determining which tools, services, and household staff members will be needed, the Steward creates a curated recommendation that streamlines Tatlock's work and prevents cognitive overload.
|
||||||
|
|
||||||
|
### Core Architecture
|
||||||
|
|
||||||
|
The Steward operates as the first tier in the two-tier request flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
User Request → Orchestrator → Steward Analysis → Recommendations → Tatlock (with scoped tools/agents)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle**: The Steward narrows the scope to only relevant capabilities, making Tatlock's decision-making cleaner and more focused.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
#### 1. Tool & Agent Registry System
|
||||||
|
|
||||||
|
**Purpose**: Centralized catalog of all available capabilities for the Steward to recommend
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
- **Registry Module** (`src/core/registry.py`)
|
||||||
|
- Tool registration decorator pattern
|
||||||
|
- Agent registration with capability metadata
|
||||||
|
- Category-based organization (computation, information, automation, communication)
|
||||||
|
- Dynamic tool/agent discovery and loading
|
||||||
|
|
||||||
|
- **Tool Metadata Schema**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "calculator",
|
||||||
|
"category": "computation",
|
||||||
|
"description": "Safe mathematical expression evaluation",
|
||||||
|
"capabilities": ["arithmetic", "algebra", "trigonometry"],
|
||||||
|
"cost": "low", # computational cost indicator
|
||||||
|
"requires_network": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Agent Metadata Schema**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "developer",
|
||||||
|
"role": "The Developer",
|
||||||
|
"category": "technical",
|
||||||
|
"description": "Software development assistance",
|
||||||
|
"domains": ["code_generation", "debugging", "architecture"],
|
||||||
|
"specialized_model": "codestral", # optional
|
||||||
|
"cost": "high"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Registry API**
|
||||||
|
- `get_all_tools()` - List all available tools
|
||||||
|
- `get_all_agents()` - List all expert agents
|
||||||
|
- `get_by_category(category)` - Filter by category
|
||||||
|
- `search_by_capability(query)` - Semantic search (future: vector search)
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- Unit tests for registration and retrieval
|
||||||
|
- Test dynamic loading of new tools/agents
|
||||||
|
- Validate metadata schemas
|
||||||
|
|
||||||
|
#### 2. Steward PydanticAI Agent
|
||||||
|
|
||||||
|
**Purpose**: First-tier LLM that analyzes requests and recommends relevant tools/agents
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
|
||||||
|
- **Agent Module** (`src/agents/steward.py`)
|
||||||
|
```python
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class StewardRecommendation(BaseModel):
|
||||||
|
"""Structured output from Steward analysis"""
|
||||||
|
recommended_tools: list[str]
|
||||||
|
recommended_agents: list[str]
|
||||||
|
reasoning: str
|
||||||
|
estimated_complexity: str # "simple", "moderate", "complex"
|
||||||
|
requires_multi_step: bool
|
||||||
|
|
||||||
|
steward = Agent(
|
||||||
|
'ollama:mistral-nemo', # Same base model as Tatlock
|
||||||
|
result_type=StewardRecommendation,
|
||||||
|
system_prompt="""..."""
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **System Prompt Engineering**
|
||||||
|
- Role: Estate steward responsible for efficient household coordination
|
||||||
|
- Task: Analyze requests to determine needed resources
|
||||||
|
- Output: Structured recommendations with reasoning
|
||||||
|
- Constraints: Be conservative (recommend only truly relevant capabilities)
|
||||||
|
- Context: Full registry of available tools and agents
|
||||||
|
|
||||||
|
- **Steward Tools**
|
||||||
|
```python
|
||||||
|
@steward.tool
|
||||||
|
def get_available_capabilities(ctx: RunContext) -> dict:
|
||||||
|
"""Get catalog of all available tools and agents."""
|
||||||
|
return {
|
||||||
|
"tools": registry.get_all_tools(),
|
||||||
|
"agents": registry.get_all_agents()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Request Analysis Flow**
|
||||||
|
1. Receive user request
|
||||||
|
2. Query capability registry via tool
|
||||||
|
3. Analyze request for required capabilities
|
||||||
|
4. Generate structured recommendation
|
||||||
|
5. Format as note to Tatlock
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- Test various request types (simple, complex, multi-domain)
|
||||||
|
- Verify recommendations are relevant and not over-inclusive
|
||||||
|
- Test structured output parsing
|
||||||
|
- Validate reasoning quality
|
||||||
|
|
||||||
|
#### 3. Request Preprocessing Pipeline
|
||||||
|
|
||||||
|
**Purpose**: Integration layer that routes requests through Steward before Tatlock
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
|
||||||
|
- **Preprocessing Module** (`src/core/preprocessing.py`)
|
||||||
|
```python
|
||||||
|
async def preprocess_request(user_request: str) -> EnrichedRequest:
|
||||||
|
"""
|
||||||
|
1. Call Steward for analysis
|
||||||
|
2. Get recommendations
|
||||||
|
3. Enrich original request
|
||||||
|
4. Return scoped context for Tatlock
|
||||||
|
"""
|
||||||
|
# Get Steward analysis
|
||||||
|
steward_result = await steward.run(user_request)
|
||||||
|
recommendations = steward_result.data
|
||||||
|
|
||||||
|
# Create note to Tatlock
|
||||||
|
steward_note = format_steward_note(recommendations)
|
||||||
|
|
||||||
|
# Build scoped tool/agent list
|
||||||
|
scoped_tools = get_scoped_tools(recommendations.recommended_tools)
|
||||||
|
scoped_agents = get_scoped_agents(recommendations.recommended_agents)
|
||||||
|
|
||||||
|
return EnrichedRequest(
|
||||||
|
original_request=user_request,
|
||||||
|
steward_note=steward_note,
|
||||||
|
available_tools=scoped_tools,
|
||||||
|
available_agents=scoped_agents,
|
||||||
|
metadata=recommendations
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Note Formatting**
|
||||||
|
```
|
||||||
|
=== Internal Note from the Steward ===
|
||||||
|
|
||||||
|
Request Analysis:
|
||||||
|
{steward reasoning}
|
||||||
|
|
||||||
|
Recommended Tools:
|
||||||
|
- calculator: For mathematical computations
|
||||||
|
- web_search: To find current information
|
||||||
|
|
||||||
|
Recommended Household Staff:
|
||||||
|
- The Developer: For code generation assistance
|
||||||
|
|
||||||
|
Estimated Complexity: moderate
|
||||||
|
===================================
|
||||||
|
|
||||||
|
[Original User Request]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Orchestrator Integration**
|
||||||
|
- Modify `src/responses/service.py` to call preprocessing
|
||||||
|
- Prepend Steward note to request before sending to Tatlock
|
||||||
|
- Limit Tatlock's tool access to recommended tools only
|
||||||
|
- Stream Steward's reasoning to output
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- Integration tests for full preprocessing flow
|
||||||
|
- Test request enrichment format
|
||||||
|
- Verify tool scoping works correctly
|
||||||
|
- Test streaming of Steward reasoning
|
||||||
|
|
||||||
|
#### 4. Real-Time Transparency
|
||||||
|
|
||||||
|
**Purpose**: Stream Steward's analysis to user's reasoning output
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
|
||||||
|
- **Streaming Integration** (`src/responses/streaming.py`)
|
||||||
|
- Add Steward analysis phase to stream
|
||||||
|
- Format as reasoning item
|
||||||
|
- Include recommendation summary
|
||||||
|
|
||||||
|
- **Example Output to User**:
|
||||||
|
```
|
||||||
|
[Reasoning]
|
||||||
|
Consulting the Steward for resource planning...
|
||||||
|
|
||||||
|
The Steward's Analysis:
|
||||||
|
- Request requires mathematical computation
|
||||||
|
- Need to verify current information via web search
|
||||||
|
- May benefit from Developer's code expertise
|
||||||
|
|
||||||
|
Recommended: calculator, web_search, The Developer
|
||||||
|
|
||||||
|
Proceeding with scoped resources...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- Test streaming of Steward analysis
|
||||||
|
- Verify formatting in Open WebUI
|
||||||
|
- Test error handling if Steward fails
|
||||||
|
|
||||||
|
#### 5. Model Efficiency Optimization
|
||||||
|
|
||||||
|
**Purpose**: Ensure the base model stays loaded in VRAM
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
|
||||||
|
- **Shared Model Configuration**
|
||||||
|
- Both Steward and Tatlock use `ollama:mistral-nemo` by default
|
||||||
|
- Sequential calls (Steward → Tatlock) keep model hot
|
||||||
|
- No reload delays between tiers
|
||||||
|
|
||||||
|
- **Performance Monitoring**
|
||||||
|
- Log response times for Steward calls
|
||||||
|
- Track total request latency (Steward + Tatlock)
|
||||||
|
- Identify optimization opportunities
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- Benchmark Steward → Tatlock call latency
|
||||||
|
- Verify model stays loaded between calls
|
||||||
|
- Test performance under load
|
||||||
|
|
||||||
|
### Implementation Strategy
|
||||||
|
|
||||||
|
#### Week 1-2: Foundation
|
||||||
|
- [ ] Design and implement registry system
|
||||||
|
- [ ] Create tool/agent metadata schemas
|
||||||
|
- [ ] Build registry API with tests
|
||||||
|
- [ ] Migrate existing tools to registry
|
||||||
|
|
||||||
|
#### Week 3-4: Steward Agent
|
||||||
|
- [ ] Create Steward PydanticAI agent
|
||||||
|
- [ ] Engineer system prompt for analysis
|
||||||
|
- [ ] Implement structured recommendation output
|
||||||
|
- [ ] Add registry query tool
|
||||||
|
- [ ] Test with various request types
|
||||||
|
|
||||||
|
#### Week 5-6: Integration
|
||||||
|
- [ ] Build request preprocessing pipeline
|
||||||
|
- [ ] Implement note formatting
|
||||||
|
- [ ] Integrate with Orchestrator
|
||||||
|
- [ ] Add streaming transparency
|
||||||
|
- [ ] Tool scoping for Tatlock
|
||||||
|
|
||||||
|
#### Week 7: Testing & Refinement
|
||||||
|
- [ ] End-to-end integration tests
|
||||||
|
- [ ] Performance optimization
|
||||||
|
- [ ] Prompt refinement based on results
|
||||||
|
- [ ] Documentation and examples
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
- [x] **Steward analyzes incoming requests** using PydanticAI agent
|
||||||
|
- [x] **Produces structured recommendations** (tools, agents, reasoning)
|
||||||
|
- [x] **Recommendations formatted as prepended note** to Tatlock
|
||||||
|
- [x] **Tool registry is queryable and extensible** via clean API
|
||||||
|
- [x] **Steward output visible in reasoning stream** for transparency
|
||||||
|
- [x] **Only recommended tools available** to Tatlock (scoped context)
|
||||||
|
- [x] **Base model stays loaded** between Steward and Tatlock calls
|
||||||
|
- [x] **Recommendations are accurate** (not over/under-inclusive)
|
||||||
|
- [x] **Integration tests pass** for full Steward → Tatlock flow
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** (v0.2.5)
|
||||||
|
|
||||||
|
### Performance Targets
|
||||||
|
|
||||||
|
- **Steward Analysis Time**: < 2 seconds for typical requests
|
||||||
|
- **Total Added Latency**: < 3 seconds including streaming
|
||||||
|
- **Recommendation Accuracy**: > 90% relevance (manual evaluation)
|
||||||
|
- **Model Reload Delay**: 0 seconds (model stays hot)
|
||||||
|
|
||||||
|
### Risk Mitigation
|
||||||
|
|
||||||
|
**Risk**: Steward recommendations too broad (defeats purpose)
|
||||||
|
- Mitigation: Conservative prompt engineering, test with diverse requests, iterate
|
||||||
|
|
||||||
|
**Risk**: Added latency unacceptable to users
|
||||||
|
- Mitigation: Stream Steward reasoning for transparency, optimize prompt, parallel processing where possible
|
||||||
|
|
||||||
|
**Risk**: Tool registry becomes unwieldy
|
||||||
|
- Mitigation: Good categorization, semantic search (future), regular pruning
|
||||||
|
|
||||||
|
**Risk**: Steward and Tatlock models compete for VRAM
|
||||||
|
- Mitigation: Use same base model, sequential calls, monitor memory
|
||||||
|
|
||||||
|
### Future Enhancements (Post-Phase 2)
|
||||||
|
|
||||||
|
- **Semantic Search**: Vector-based capability search instead of metadata lookup
|
||||||
|
- **Learning from Usage**: Track which recommendations work well, adjust over time
|
||||||
|
- **Confidence Scores**: Steward provides confidence for each recommendation
|
||||||
|
- **Request Classification**: Cache classifications for similar requests
|
||||||
|
- **Multi-Model Support**: Allow Steward to recommend specialized models for specific tasks
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
|
||||||
|
**7-8 weeks** - Core intelligence routing with comprehensive implementation
|
||||||
|
|
||||||
|
### Why Second?
|
||||||
|
|
||||||
|
The Steward is the foundation of the household architecture. Without it, we'd need to expose all tools/agents to Tatlock, creating cognitive overload and poor decision-making. The Steward enables the focused expertise pattern that makes the whole system work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: The Butler - Tatlock Agent
|
||||||
|
|
||||||
|
**Goal**: Implement the second-tier coordinator with personality within the existing Orchestrator infrastructure
|
||||||
|
|
||||||
|
**Context**: The Orchestrator (FastAPI infrastructure) already exists. This phase implements the real Tatlock PydanticAI agent to replace the current mock agent.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **Butler Agent (Tatlock)**
|
||||||
|
- PydanticAI agent implementation within Orchestrator
|
||||||
|
- Personality prompt engineering (witty British butler)
|
||||||
|
- Tool calling framework
|
||||||
|
- Multi-agent coordination logic
|
||||||
|
|
||||||
|
2. **Scoped Tool Access**
|
||||||
|
- Filter tools based on Steward recommendations
|
||||||
|
- Dynamic tool loading for Butler context
|
||||||
|
- Tool execution framework
|
||||||
|
- Result aggregation
|
||||||
|
|
||||||
|
3. **Real-Time Reasoning Output**
|
||||||
|
- Stream all Butler activities to reasoning output
|
||||||
|
- Tool call progress indicators
|
||||||
|
- Expert agent consultation messages
|
||||||
|
- Wait time transparency
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [x] Tatlock receives enriched requests (user + Steward notes)
|
||||||
|
- [x] Only recommended tools are available
|
||||||
|
- [x] Tatlock coordinates multiple tool calls
|
||||||
|
- [x] All actions streamed to reasoning output
|
||||||
|
- [x] Responses have consistent personality
|
||||||
|
- [x] Synthesizes multi-source results coherently
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** (v1.1.0)
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**4-5 weeks** - Complex coordination logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Expert Household Staff - Core Agents
|
||||||
|
|
||||||
|
**Goal**: Implement the initial set of domain-specific expert agents
|
||||||
|
|
||||||
|
### Priority Expert Agents
|
||||||
|
|
||||||
|
1. **The Librarian** (Research & Knowledge Management) ✅ **COMPLETE** (v1.1.0)
|
||||||
|
- Research assistance via library-desk HybridRAG
|
||||||
|
- Wiki page management (search, create, update)
|
||||||
|
- Semantic vector search
|
||||||
|
- Knowledge graph queries
|
||||||
|
- Dossier browsing
|
||||||
|
|
||||||
|
2. **The Biographer** (User Memory) ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- User profile management (name, location, timezone)
|
||||||
|
- Preference storage (units, theme)
|
||||||
|
- Semantic memory recall ("What car do I drive?")
|
||||||
|
- Fact storage from conversations
|
||||||
|
- Session context caching
|
||||||
|
|
||||||
|
3. **The Developer** (Software Development) 🔜 **Planned**
|
||||||
|
- Code generation assistance
|
||||||
|
- Debugging support
|
||||||
|
- Documentation generation
|
||||||
|
- Architecture guidance
|
||||||
|
- *Rationale: Directly supports building the system itself*
|
||||||
|
|
||||||
|
4. **The Handyman** (System Maintenance) 🔜 **Planned**
|
||||||
|
- System status queries
|
||||||
|
- Log analysis
|
||||||
|
- Basic troubleshooting
|
||||||
|
- Infrastructure monitoring
|
||||||
|
|
||||||
|
5. **The Secretary** (Scheduling & Organization) 🔜 **Planned**
|
||||||
|
- Calendar integration
|
||||||
|
- Task management
|
||||||
|
- Reminder system
|
||||||
|
- Schedule conflict detection
|
||||||
|
|
||||||
|
6. **The Housekeeper** (Home Automation) 🔜 **Planned**
|
||||||
|
- Home Assistant integration
|
||||||
|
- Device control interface
|
||||||
|
- Status queries
|
||||||
|
- Automation triggers
|
||||||
|
|
||||||
|
### Each Agent Includes
|
||||||
|
- Specialized prompt and personality
|
||||||
|
- Domain-specific tools
|
||||||
|
- MCP integration points (where applicable)
|
||||||
|
- Integration with Butler orchestration
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [x] Each agent implemented as separate module
|
||||||
|
- [x] Agents callable via tool framework
|
||||||
|
- [x] Agents use specialized prompts
|
||||||
|
- [x] Results integrate cleanly with Butler
|
||||||
|
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**🔶 PARTIAL** - Librarian and Biographer complete, others planned
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**6-8 weeks** - Parallel development possible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Persistence Layer - Database & Multi-Tenancy
|
||||||
|
|
||||||
|
**Goal**: Add persistent storage and multi-user support when needed
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **PostgreSQL Integration**
|
||||||
|
- Docker compose configuration for PostgreSQL
|
||||||
|
- Database schema design with tenant isolation
|
||||||
|
- Alembic migrations setup
|
||||||
|
- SQLAlchemy models
|
||||||
|
|
||||||
|
2. **Multi-Tenant Architecture**
|
||||||
|
- Tenant identification middleware
|
||||||
|
- Tenant-scoped database sessions
|
||||||
|
- User authentication system (basic)
|
||||||
|
- Per-tenant data isolation
|
||||||
|
|
||||||
|
3. **Core Data Models**
|
||||||
|
- Users and tenants
|
||||||
|
- Conversations and messages (migrate from in-memory)
|
||||||
|
- Agent interactions log
|
||||||
|
- System configuration and preferences
|
||||||
|
|
||||||
|
4. **Migration Strategy**
|
||||||
|
- Gradual migration from in-memory to database
|
||||||
|
- Backward compatibility during transition
|
||||||
|
- Data export/import utilities
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] PostgreSQL container running
|
||||||
|
- [ ] Multiple users can authenticate separately
|
||||||
|
- [ ] Each user sees only their own data
|
||||||
|
- [ ] Conversations persist across restarts
|
||||||
|
- [ ] Database migrations work correctly
|
||||||
|
- [ ] Tests verify tenant isolation
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**3-4 weeks** - Data layer foundation
|
||||||
|
|
||||||
|
### Why Later?
|
||||||
|
The core orchestration (Steward → Butler → Experts) can work entirely with in-memory state. We only need database persistence when we want conversations to survive restarts and multiple users to have isolated experiences.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Extended Services Integration
|
||||||
|
|
||||||
|
**Goal**: Connect to additional supporting services
|
||||||
|
|
||||||
|
### Services to Integrate
|
||||||
|
|
||||||
|
1. **Redis (Memory & Caching)** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Benchmark storage (db=1)
|
||||||
|
- Memory cache for sessions (db=2)
|
||||||
|
- 24h TTL for session context
|
||||||
|
- Recent entities tracking
|
||||||
|
|
||||||
|
2. **Qdrant (Vector Storage)** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Per-user memory collections
|
||||||
|
- 768-dim nomic-embed-text vectors
|
||||||
|
- Semantic search for recall
|
||||||
|
- Type-based filtering
|
||||||
|
|
||||||
|
3. **SearxNG (Web Search)** ✅ **COMPLETE** (v0.2.0)
|
||||||
|
- Search tool integration
|
||||||
|
- Result processing
|
||||||
|
- Privacy-preserving queries
|
||||||
|
|
||||||
|
4. **library-desk (Research API)** ✅ **COMPLETE** (v1.1.0)
|
||||||
|
- HybridRAG search
|
||||||
|
- Wiki management
|
||||||
|
- Knowledge graph queries
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [x] Services communicate correctly
|
||||||
|
- [x] Tatlock can invoke web search
|
||||||
|
- [x] Redis used for session data
|
||||||
|
- [x] Qdrant stores user memories
|
||||||
|
- [x] Ollama serves the base model
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** - All core services integrated
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**3-4 weeks** - Infrastructure setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: MCP (Model Context Protocol) Integration
|
||||||
|
|
||||||
|
**Goal**: Enable rich tool integrations via MCP
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **MCP Server Framework**
|
||||||
|
- MCP server implementation
|
||||||
|
- Tool registration via MCP
|
||||||
|
- Schema validation
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
2. **MCP Client in Agents**
|
||||||
|
- PydanticAI MCP integration
|
||||||
|
- Tool discovery from MCP servers
|
||||||
|
- Dynamic tool loading
|
||||||
|
- Result processing
|
||||||
|
|
||||||
|
3. **Initial MCP Tools**
|
||||||
|
- File system operations
|
||||||
|
- Database queries
|
||||||
|
- API integrations
|
||||||
|
- System commands
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] MCP server running
|
||||||
|
- [ ] Tools exposed via MCP protocol
|
||||||
|
- [ ] Agents can discover and use MCP tools
|
||||||
|
- [ ] New tools addable without code changes
|
||||||
|
- [ ] MCP tools visible in Steward recommendations
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**3-4 weeks** - Standards-based integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Advanced Memory & Context
|
||||||
|
|
||||||
|
**Goal**: Implement sophisticated memory and context management
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **Long-Term Memory** ✅ **COMPLETE** (v1.2.0 - Phase F)
|
||||||
|
- Memory service for direct key-based access
|
||||||
|
- Qdrant vector storage for semantic recall
|
||||||
|
- Embedding via nomic-embed-text
|
||||||
|
- The Biographer agent for memory management
|
||||||
|
|
||||||
|
2. **Session Memory** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Redis session cache with 24h TTL
|
||||||
|
- Recent entities tracking
|
||||||
|
- Conversation context preservation
|
||||||
|
- Multi-tenancy via ContextVar
|
||||||
|
|
||||||
|
3. **Steward Integration** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Memory pre-fetch during request analysis
|
||||||
|
- Profile/preferences included in context
|
||||||
|
- Keyword-based context determination
|
||||||
|
|
||||||
|
4. **Context Management** 🔜 **Future**
|
||||||
|
- Smart context window trimming
|
||||||
|
- Conversation branching
|
||||||
|
- Topic tracking
|
||||||
|
- Memory retrieval integration
|
||||||
|
|
||||||
|
5. **Personalization** 🔜 **Future**
|
||||||
|
- User preference learning
|
||||||
|
- Interaction pattern analysis
|
||||||
|
- Adaptive responses
|
||||||
|
- Custom agent personalities per user
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [x] User facts stored in Qdrant with semantic search
|
||||||
|
- [x] Profile and preferences accessible via memory_service
|
||||||
|
- [x] Session context cached in Redis
|
||||||
|
- [x] User preferences affect responses (via Steward pre-fetch)
|
||||||
|
- [ ] Conversations automatically embedded to Qdrant
|
||||||
|
- [ ] Memory improves over time (learning from interactions)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**🔶 PARTIAL** - Core memory system complete, advanced features planned
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**4-5 weeks** - AI/ML heavy (remaining work)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Extended Household Staff
|
||||||
|
|
||||||
|
**Goal**: Add specialized agents for additional domains
|
||||||
|
|
||||||
|
### Future Agents
|
||||||
|
|
||||||
|
1. **The Librarian** (Knowledge Management)
|
||||||
|
- Personal documentation indexing
|
||||||
|
- Research assistance
|
||||||
|
- Knowledge base queries
|
||||||
|
- Reference management
|
||||||
|
|
||||||
|
2. **The Accountant** (Financial Tracking)
|
||||||
|
- Expense tracking
|
||||||
|
- Budget monitoring
|
||||||
|
- Financial reports
|
||||||
|
- Transaction categorization
|
||||||
|
|
||||||
|
3. **The Chef** (Meal Planning)
|
||||||
|
- Recipe management
|
||||||
|
- Meal planning
|
||||||
|
- Nutrition tracking
|
||||||
|
- Grocery lists
|
||||||
|
|
||||||
|
4. **Others as Needed**
|
||||||
|
- Domain-specific as requirements emerge
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] Each new agent follows household pattern
|
||||||
|
- [ ] Integrates with Steward/Butler flow
|
||||||
|
- [ ] Has appropriate specialized tools
|
||||||
|
- [ ] Documented in PHILOSOPHY.md updates
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**Ongoing** - Add as needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: User Experience Refinement
|
||||||
|
|
||||||
|
**Goal**: Polish the interaction experience
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **Personality Tuning**
|
||||||
|
- Refine Tatlock's wit and tone
|
||||||
|
- Consistent household character
|
||||||
|
- Cultural references appropriate
|
||||||
|
- Humor that doesn't annoy
|
||||||
|
|
||||||
|
2. **Transparency Improvements**
|
||||||
|
- Better progress indicators
|
||||||
|
- Clearer reasoning explanations
|
||||||
|
- Informative wait messages
|
||||||
|
- Error message clarity
|
||||||
|
|
||||||
|
3. **Performance Optimization**
|
||||||
|
- Response time improvements
|
||||||
|
- Model loading optimization
|
||||||
|
- Caching strategies
|
||||||
|
- Streaming smoothness
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] Users find Tatlock engaging
|
||||||
|
- [ ] Wait times feel reasonable
|
||||||
|
- [ ] Errors are understandable
|
||||||
|
- [ ] System feels responsive
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**Ongoing** - Continuous improvement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Production Hardening
|
||||||
|
|
||||||
|
**Goal**: Make the system production-ready for homelab deployment
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **Deployment**
|
||||||
|
- Complete docker-compose stack
|
||||||
|
- Environment configuration
|
||||||
|
- Backup strategies
|
||||||
|
- Update procedures
|
||||||
|
|
||||||
|
2. **Monitoring**
|
||||||
|
- Health checks
|
||||||
|
- Performance metrics
|
||||||
|
- Error tracking
|
||||||
|
- Usage analytics
|
||||||
|
|
||||||
|
3. **Security**
|
||||||
|
- Authentication hardening
|
||||||
|
- Rate limiting
|
||||||
|
- Input validation
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
4. **Documentation**
|
||||||
|
- Installation guide
|
||||||
|
- Configuration reference
|
||||||
|
- Troubleshooting guide
|
||||||
|
- Architecture documentation
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] One-command deployment
|
||||||
|
- [ ] System health is monitorable
|
||||||
|
- [ ] Secure for homelab use
|
||||||
|
- [ ] Well documented
|
||||||
|
|
||||||
|
### Estimated Effort
|
||||||
|
**3-4 weeks** - Production polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies Between Phases
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Ollama + PydanticAI) ← Foundation for all AI
|
||||||
|
↓
|
||||||
|
Phase 2 (Steward)
|
||||||
|
↓
|
||||||
|
Phase 3 (Butler/Tatlock)
|
||||||
|
↓
|
||||||
|
Phase 4 (Expert Agents) ← Phase 7 (MCP) can enhance
|
||||||
|
↓
|
||||||
|
Phase 5 (Database/Multi-Tenancy) ← Can be deferred
|
||||||
|
↓
|
||||||
|
Phase 6 (Extended Services) → Phase 8 (Advanced Memory)
|
||||||
|
↓
|
||||||
|
Phase 9 (Extended Staff) → Phase 10 (UX) → Phase 11 (Production)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical Path**: Phases 1 → 2 → 3 → 4 must be sequential
|
||||||
|
**Can Be Deferred**: Phase 5 (Database) until you need persistence
|
||||||
|
**Parallel Opportunities**: Phase 6 and 7 can overlap; Phase 9 and 10 ongoing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Timeline Estimate
|
||||||
|
|
||||||
|
**Minimum Viable Household** (Phases 1-4): **15-20 weeks**
|
||||||
|
- Working Steward → Butler → Expert Agents with real LLM
|
||||||
|
- In-memory state (no persistence needed yet)
|
||||||
|
- Core household functional
|
||||||
|
|
||||||
|
**With Persistence** (Phases 1-5): **18-24 weeks**
|
||||||
|
- Add database and multi-tenancy
|
||||||
|
- Conversations survive restarts
|
||||||
|
- Multiple users supported
|
||||||
|
|
||||||
|
**Full-Featured System** (Phases 1-9): **35-45 weeks**
|
||||||
|
- All services integrated
|
||||||
|
- Advanced memory and context
|
||||||
|
- Extended household staff
|
||||||
|
|
||||||
|
**Production-Ready** (All phases): **40-50 weeks**
|
||||||
|
- Polished UX
|
||||||
|
- Hardened for homelab deployment
|
||||||
|
- Fully documented
|
||||||
|
|
||||||
|
*Note: Timeline assumes consistent part-time development effort*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- System implements PHILOSOPHY.md patterns
|
||||||
|
- All household roles functional
|
||||||
|
- Multi-tenant isolation verified
|
||||||
|
- Real-time reasoning transparency working
|
||||||
|
- MCP integration complete
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Tatlock feels like interacting with a butler
|
||||||
|
- Wait times are transparent and acceptable
|
||||||
|
- Expert agents provide value in their domains
|
||||||
|
- System is reliable and trustworthy
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Clean separation between household roles
|
||||||
|
- Easy to add new agents/tools
|
||||||
|
- Model efficiency (base model stays loaded)
|
||||||
|
- Scales to household + friends usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Management
|
||||||
|
|
||||||
|
### High Risk Items
|
||||||
|
1. **PydanticAI + Ollama integration complexity**
|
||||||
|
- Mitigation: Prototype early, iterate on connection layer
|
||||||
|
|
||||||
|
2. **Multi-agent coordination complexity**
|
||||||
|
- Mitigation: Start simple, add coordination gradually
|
||||||
|
|
||||||
|
3. **Model performance on homelab hardware**
|
||||||
|
- Mitigation: Model selection, quantization, optimization
|
||||||
|
|
||||||
|
4. **Prompt engineering for personality consistency**
|
||||||
|
- Mitigation: Extensive testing, user feedback, iteration
|
||||||
|
|
||||||
|
### Medium Risk Items
|
||||||
|
- MCP protocol adoption and tooling maturity
|
||||||
|
- Vector embedding quality for memory
|
||||||
|
- Home automation integration variability
|
||||||
|
- User authentication security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Priority**: Implement The Developer agent for code assistance
|
||||||
|
2. **Integration**: Add Home Assistant integration for The Housekeeper
|
||||||
|
3. **Calendar**: Integrate scheduling service for The Secretary
|
||||||
|
4. **Ongoing**: Add more household staff as needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Status**: Active planning document
|
||||||
|
**Created**: 2025-12-06
|
||||||
|
**Last Updated**: 2025-12-13
|
||||||
File diff suppressed because it is too large
Load Diff
+278
@@ -0,0 +1,278 @@
|
|||||||
|
# Tatlock - System Philosophy and Architecture
|
||||||
|
|
||||||
|
## Document Purpose
|
||||||
|
|
||||||
|
This document establishes the foundational philosophy and architectural patterns for the Tatlock system. It represents the **target design** that all development should work towards.
|
||||||
|
|
||||||
|
**When to modify this document**:
|
||||||
|
- When there is a deliberate decision to deviate from these established patterns
|
||||||
|
- When fundamental assumptions about the system's purpose change
|
||||||
|
- When new architectural insights require rethinking core principles
|
||||||
|
|
||||||
|
**When NOT to modify this document**:
|
||||||
|
- During implementation of these patterns (use README.md, AGENTS.md, or code comments for technical details)
|
||||||
|
- For adding new household members or capabilities within the existing pattern
|
||||||
|
- For tactical decisions about specific technologies or tools
|
||||||
|
|
||||||
|
This document should remain stable, serving as the north star for development decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
### Vision
|
||||||
|
|
||||||
|
Tatlock is a comprehensive homelab butler and personal assistant system designed to augment personal and household productivity through intelligent automation, knowledge management, and contextual assistance. Named after a traditional British butler, Tatlock embodies the wit, competence, and organizational skill of a well-run household staff, coordinating a team of specialized expert agents to serve the needs of its users.
|
||||||
|
|
||||||
|
Unlike cloud-dependent AI assistants, Tatlock is built to operate primarily offline, maintaining privacy and control while providing sophisticated assistance across multiple domains of daily life.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
The system serves as a unified intelligent interface for:
|
||||||
|
|
||||||
|
- **Knowledge Work**: Research assistance, information synthesis, general knowledge queries
|
||||||
|
- **Technical Work**: Software development support, systems administration tasks
|
||||||
|
- **Home Management**: Home automation control and monitoring
|
||||||
|
- **Personal Organization**: Calendaring, scheduling, task management, list keeping
|
||||||
|
- **Information Management**: Personal documentation, note-taking, knowledge base maintenance
|
||||||
|
|
||||||
|
### Core Philosophy
|
||||||
|
|
||||||
|
Tatlock is built on three fundamental principles:
|
||||||
|
|
||||||
|
1. **Privacy-First Architecture**: All processing occurs locally within your homelab environment. Your data, conversations, and personal information never leave your infrastructure unless you explicitly direct it to do so.
|
||||||
|
|
||||||
|
2. **Offline-Capable Operation**: While the system can leverage internet resources when available, core functionality remains operational without external connectivity. This ensures reliability and independence from third-party services.
|
||||||
|
|
||||||
|
3. **Multi-Tenant by Design**: Though primarily intended for personal use (yourself, household members, and close friends), the system architecture supports multiple users with complete data isolation, personalized experiences, and individual preferences.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
**Current Focus**: The initial implementation establishes the foundational architecture with OpenAI-compatible API interfaces, structured response formats, and reasoning transparency. This phase prioritizes:
|
||||||
|
- Core API infrastructure
|
||||||
|
- Response streaming and formatting
|
||||||
|
- Basic conversation management
|
||||||
|
- Testing and validation framework
|
||||||
|
|
||||||
|
**Future Expansion**: The system will evolve into a comprehensive personal assistant platform by integrating:
|
||||||
|
- Specialized containerized services (machine learning, search, storage, memory)
|
||||||
|
- Task and project management capabilities
|
||||||
|
- Calendar and scheduling systems
|
||||||
|
- Home automation integration
|
||||||
|
- Personal knowledge management
|
||||||
|
- Advanced multi-agent collaboration
|
||||||
|
|
||||||
|
### Deployment Model
|
||||||
|
|
||||||
|
Tatlock is designed for **single-instance, multi-user deployment** within a homelab environment:
|
||||||
|
|
||||||
|
- **Users**: Personal use for household members and trusted friends
|
||||||
|
- **Infrastructure**: Self-hosted on your own hardware
|
||||||
|
- **Architecture**: Containerized microservices on a single host
|
||||||
|
- **Data Sovereignty**: Complete control over all data and processing
|
||||||
|
|
||||||
|
This deployment model balances simplicity of operation with the security and personalization needs of a small, trusted user base.
|
||||||
|
|
||||||
|
### System Context
|
||||||
|
|
||||||
|
Tatlock operates as the central orchestration layer within a broader ecosystem of containerized services:
|
||||||
|
|
||||||
|
#### Core Service Stack
|
||||||
|
- **Language Models**: Ollama for local ML inference
|
||||||
|
- **Search**: SearxNG for privacy-respecting web search
|
||||||
|
- **Memory Systems**:
|
||||||
|
- Redis for short-term memory and caching
|
||||||
|
- Qdrant for long-term memory and vector storage
|
||||||
|
- **Data Storage**: PostgreSQL for structured data and multi-tenant isolation
|
||||||
|
- **Future Services**: Calendaring, scheduling, task management, documentation systems
|
||||||
|
|
||||||
|
#### Integration Approach
|
||||||
|
Rather than building monolithic functionality, Tatlock acts as an intelligent coordinator, leveraging specialized services for specific capabilities while maintaining consistent interfaces and user experience.
|
||||||
|
|
||||||
|
### Design Goals
|
||||||
|
|
||||||
|
1. **Unified Experience**: Single point of interaction for diverse personal assistance needs
|
||||||
|
2. **Contextual Intelligence**: Understanding across conversations, tasks, and time
|
||||||
|
3. **Transparent Operation**: Visible reasoning and decision-making processes
|
||||||
|
4. **Extensible Architecture**: Easy integration of new capabilities and services
|
||||||
|
5. **Reliable Performance**: Consistent operation regardless of internet availability
|
||||||
|
6. **User Privacy**: Zero data leakage to external parties
|
||||||
|
7. **Multi-User Support**: Isolated experiences for different household members
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
Tatlock succeeds when it becomes the natural first point of interaction for:
|
||||||
|
- Answering questions and conducting research
|
||||||
|
- Managing daily tasks and schedules
|
||||||
|
- Controlling home automation
|
||||||
|
- Supporting development and technical work
|
||||||
|
- Organizing personal information and knowledge
|
||||||
|
|
||||||
|
The system should feel less like "using a tool" and more like "asking a capable assistant" who understands your context, preferences, and needs.
|
||||||
|
|
||||||
|
## The Household Architecture
|
||||||
|
|
||||||
|
### System Layers
|
||||||
|
|
||||||
|
The Tatlock system consists of two distinct architectural layers:
|
||||||
|
|
||||||
|
#### The Orchestrator (Infrastructure Layer)
|
||||||
|
|
||||||
|
The **Orchestrator** is the FastAPI application that provides the technical infrastructure:
|
||||||
|
- HTTP/SSE endpoints (`/v1/responses`, `/v1/chat/completions`)
|
||||||
|
- Streaming coordination and conversation management
|
||||||
|
- Token counting and context window management
|
||||||
|
- Integration with Open WebUI and other clients
|
||||||
|
- Request/response lifecycle management
|
||||||
|
|
||||||
|
This is the "plumbing" layer that exists now and handles all the technical concerns of running an OpenAI-compatible API.
|
||||||
|
|
||||||
|
#### Tatlock - The Butler (Agent Layer)
|
||||||
|
|
||||||
|
**Tatlock** is the PydanticAI agent that provides the intelligence and personality:
|
||||||
|
- The witty British butler persona
|
||||||
|
- Coordination with the Steward and household staff
|
||||||
|
- Multi-agent orchestration and synthesis
|
||||||
|
- Context-aware, personalized responses
|
||||||
|
|
||||||
|
The Orchestrator hosts Tatlock—users interact with "Tatlock" (the advertised model name), but technically they're talking to the Orchestrator infrastructure which routes requests through the Tatlock agent.
|
||||||
|
|
||||||
|
**Current State**: The Orchestrator exists and uses mock agents. Phase 1-3 of the implementation roadmap will integrate the real Tatlock agent using PydanticAI.
|
||||||
|
|
||||||
|
### The British Household Metaphor
|
||||||
|
|
||||||
|
Tatlock adopts the organizational structure of a traditional British estate household, where specialized staff members handle distinct domains of responsibility under the coordination of a capable butler. This metaphor is not merely aesthetic—it reflects a deliberate architectural pattern that enables focused expertise, clear separation of concerns, and efficient coordination.
|
||||||
|
|
||||||
|
### Household Roles
|
||||||
|
|
||||||
|
#### Tatlock - The Butler (Primary Interface)
|
||||||
|
|
||||||
|
**Character**: Witty, capable, and impeccably organized
|
||||||
|
**Role**: Chief coordinator and primary point of contact with users
|
||||||
|
|
||||||
|
Tatlock serves as the face of the system, managing all user interactions with personality and competence. He understands the full context of requests, coordinates with appropriate household staff, synthesizes their contributions, and delivers coherent, thoughtful responses. His wit and personality make interactions engaging while maintaining professionalism.
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Receiving and understanding user requests
|
||||||
|
- Coordinating with household staff (expert agents)
|
||||||
|
- Synthesizing multi-source information into coherent responses
|
||||||
|
- Maintaining conversation context and user preferences
|
||||||
|
- Presenting results with appropriate personality and tone
|
||||||
|
|
||||||
|
#### The Steward (Request Analysis)
|
||||||
|
|
||||||
|
**Role**: Initial request triage and resource planning
|
||||||
|
|
||||||
|
Before Tatlock engages with a request, the Steward performs crucial preparatory work. The Steward analyzes incoming requests to determine which tools, services, and household staff members will be needed, creating a curated recommendation that streamlines Tatlock's work.
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Analyzing user requests for required capabilities
|
||||||
|
- Identifying relevant tools and expert agents
|
||||||
|
- Providing recommendations to focus Tatlock's attention
|
||||||
|
- Reducing cognitive load on the Butler by pre-filtering options
|
||||||
|
|
||||||
|
#### Expert Household Staff (Domain Specialists)
|
||||||
|
|
||||||
|
**The Handyman** - System Maintenance and Technical Operations
|
||||||
|
Handles system administration, server management, infrastructure monitoring, and technical troubleshooting.
|
||||||
|
|
||||||
|
**The Housekeeper** - Home Automation Management
|
||||||
|
Controls and monitors home automation systems, environmental controls, security, and physical space management.
|
||||||
|
|
||||||
|
**The Secretary** - Scheduling and Organization
|
||||||
|
Manages calendars, appointments, scheduling conflicts, reminders, and time-based coordination.
|
||||||
|
|
||||||
|
**The Developer** - Software Development Support
|
||||||
|
Assists with code writing, debugging, architecture decisions, documentation, and development workflows.
|
||||||
|
|
||||||
|
**Additional Staff** (Future):
|
||||||
|
- The Librarian - Knowledge management and research
|
||||||
|
- The Accountant - Financial tracking and analysis
|
||||||
|
- The Chef - Meal planning and nutrition
|
||||||
|
- Others as needs emerge
|
||||||
|
|
||||||
|
### The Two-Tier Request Flow
|
||||||
|
|
||||||
|
The household operates through a carefully orchestrated two-tier process:
|
||||||
|
|
||||||
|
#### Tier 1: The Steward's Preparation
|
||||||
|
|
||||||
|
1. **User request arrives** at the Orchestrator (via HTTP API)
|
||||||
|
2. **Orchestrator routes** the raw request to the Steward for analysis
|
||||||
|
3. **Steward determines** which tools and household staff are relevant
|
||||||
|
4. **Steward prepares recommendations**, written as a note to Tatlock
|
||||||
|
5. **Recommendations are prepended** to the user's request
|
||||||
|
|
||||||
|
**Purpose**: This separation ensures that Tatlock isn't overwhelmed with the full universe of available tools and agents. The Steward narrows the scope to only relevant capabilities, making Tatlock's decision-making cleaner and more focused.
|
||||||
|
|
||||||
|
#### Tier 2: Tatlock's Orchestration
|
||||||
|
|
||||||
|
1. **Tatlock receives** the enriched request (original + Steward's notes)
|
||||||
|
2. **Scope is limited** to recommended tools and staff only
|
||||||
|
3. **Tatlock coordinates** with appropriate household members
|
||||||
|
4. **Expert agents perform** their specialized tasks
|
||||||
|
5. **All interactions are streamed** to the reasoning output in real-time
|
||||||
|
6. **Tatlock synthesizes** results into a coherent response
|
||||||
|
7. **User receives** a unified answer from Tatlock
|
||||||
|
|
||||||
|
**Purpose**: This tier focuses on execution and coordination. With a curated set of tools, Tatlock can efficiently orchestrate multiple expert agents, combine their outputs, and present a seamless response to the user.
|
||||||
|
|
||||||
|
**Real-Time Transparency**: Every interaction—whether Tatlock consulting the Handyman, waiting for a database query, or receiving results from the Secretary—is piped directly into the orchestrator's reasoning output. Users see the household at work in real-time, understanding what's happening even when operations take time. This transforms potentially frustrating wait times into engaging insight into the system's thought process.
|
||||||
|
|
||||||
|
### Why This Architecture Works
|
||||||
|
|
||||||
|
#### Focused Expertise
|
||||||
|
Each household member (expert agent) receives highly specific prompts tailored to their domain. Rather than a single overly-broad prompt trying to do everything, specialized agents work within their areas of competence.
|
||||||
|
|
||||||
|
#### Cognitive Load Management
|
||||||
|
By pre-filtering tools and agents, the Steward prevents Tatlock from being overwhelmed with options. This is analogous to how a real butler doesn't personally know every detail of every household operation—they know whom to ask.
|
||||||
|
|
||||||
|
#### Transparent Coordination
|
||||||
|
The Steward's recommendations are visible in the thinking flow, keeping users informed about which household staff are being consulted. This transparency builds trust and understanding.
|
||||||
|
|
||||||
|
#### Composable Capabilities
|
||||||
|
New expert agents can be added to the household without overwhelming the core system. The Steward learns about new staff members and includes them in recommendations when appropriate.
|
||||||
|
|
||||||
|
#### Model Efficiency
|
||||||
|
Rather than requiring a single enormous context window containing all possible tools and capabilities, the system makes targeted calls with focused contexts. This is more efficient and produces better results.
|
||||||
|
|
||||||
|
**Unified Base Model**: All household members—the Steward, Tatlock, and expert agents—use the same base language model by default. This ensures the model stays loaded in VRAM, eliminating loading delays between calls and maximizing response speed.
|
||||||
|
|
||||||
|
**Specialized Models When Needed**: Individual household staff may invoke specialized models for domain-specific tasks when appropriate:
|
||||||
|
- The Developer might use Codestral for complex code generation
|
||||||
|
- Future visual agents might use vision-language models
|
||||||
|
- Future audio agents might use speech-specific models
|
||||||
|
|
||||||
|
The decision to use a specialized model is made by the household member responsible for that domain, based on the specific requirements of their task. This balances efficiency (keeping the base model hot) with capability (accessing specialized models when they provide significant advantage).
|
||||||
|
|
||||||
|
### Personality and Interaction
|
||||||
|
|
||||||
|
While the underlying architecture is sophisticated, users interact solely with **Tatlock**, who maintains a consistent personality:
|
||||||
|
|
||||||
|
- **Witty but helpful**: Responses may include clever observations or light humor
|
||||||
|
- **Competent and organized**: Always knows who to ask and how to coordinate
|
||||||
|
- **Context-aware**: Remembers ongoing conversations and user preferences
|
||||||
|
- **Transparent**: Explains which household staff are being consulted when relevant
|
||||||
|
- **Professional**: Despite the wit, maintains respect and helpfulness
|
||||||
|
|
||||||
|
The user never directly interacts with the Steward or individual expert agents—those are internal household operations that Tatlock manages on their behalf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Metadata
|
||||||
|
|
||||||
|
**Document Type**: Architectural Philosophy (Stable)
|
||||||
|
**Purpose**: Establish foundational patterns and guiding principles
|
||||||
|
**Modification Policy**: Only update when deviating from or enhancing core architectural patterns
|
||||||
|
**Version**: 1.0
|
||||||
|
**Established**: 2025-12-06
|
||||||
|
**Project Version**: 0.1.1
|
||||||
|
|
||||||
|
**Related Documents**:
|
||||||
|
- **README.md**: User-facing documentation and usage guide
|
||||||
|
- **AGENTS.md**: LLM agent development guidelines and technical patterns
|
||||||
|
- **CHANGELOG.md**: Version history and implemented features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*All development should work towards realizing the patterns described in this document.*
|
||||||
@@ -1,155 +1,103 @@
|
|||||||
# Tatlock - OpenAI-Compatible API with Responses API
|
# Tatlock - Your Homelab Butler
|
||||||
|
|
||||||
A FastAPI-based service providing OpenAI-compatible API endpoints with full Responses API support, reasoning display, and streaming. Features a hybrid architecture with chat completions as a compatibility wrapper around the Responses API.
|
> **📖 For the complete system vision and architectural philosophy, see [PHILOSOPHY.md](PHILOSOPHY.md)**
|
||||||
|
|
||||||
|
A privacy-first, offline-capable personal assistant system that coordinates specialized AI agents to help with research, development, home automation, and daily organization.
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**✅ Production-ready testing API** with OpenAI Responses API format
|
- ✅ **Production-ready API** with OpenAI Responses API format
|
||||||
**✅ Open WebUI integration** with reasoning bubbles (`<think>` tags)
|
- ✅ **Open WebUI integration** with reasoning bubbles (`<think>` tags)
|
||||||
**✅ Conversation history** with hybrid client/server approach
|
- ✅ **Two-tier architecture** - The Steward analyzes requests, Tatlock coordinates execution
|
||||||
**🚧 PydanticAI integration** prepared for future real LLM connection
|
- ✅ **Multi-agent coordination** - Expert household staff for specialized tasks
|
||||||
|
- ✅ **Memory system** - User profile, preferences, and semantic recall
|
||||||
|
- ✅ **Comprehensive testing** - 399 tests with good coverage
|
||||||
|
|
||||||
## Architecture Overview
|
### The Household Staff
|
||||||
|
|
||||||
### Hybrid API Design
|
| Agent | Role | Status |
|
||||||
|
|-------|------|--------|
|
||||||
```
|
| **Tatlock** | The Butler - Primary interface with witty personality | ✅ Active |
|
||||||
┌─────────────────────────────────────────┐
|
| **The Steward** | Request analysis and capability recommendation | ✅ Active |
|
||||||
│ Client (Open WebUI, etc.) │
|
| **The Librarian** | Research, wiki management, knowledge synthesis | ✅ Active |
|
||||||
└────────┬────────────────────────────────┘
|
| **The Biographer** | User memory - profiles, preferences, facts | ✅ Active |
|
||||||
│
|
| **The Developer** | Code assistance, debugging, architecture | 🔜 Planned |
|
||||||
├──────────────────────────────────┐
|
| **The Secretary** | Scheduling, calendars, reminders | 🔜 Planned |
|
||||||
│ │
|
| **The Handyman** | System administration, monitoring | 🔜 Planned |
|
||||||
v v
|
| **The Housekeeper** | Home automation (Home Assistant) | 🔜 Planned |
|
||||||
┌────────────────────┐ ┌──────────────────────┐
|
|
||||||
│ /v1/chat/ │ wrapper │ /v1/responses │
|
|
||||||
│ completions ├─────────>│ (Primary API) │
|
|
||||||
│ │ │ │
|
|
||||||
│ • OpenAI compat │ │ • Reasoning items │
|
|
||||||
│ • <think> tags │ │ • Function calls │
|
|
||||||
│ • Legacy support │ │ • Message items │
|
|
||||||
└────────────────────┘ └──────────┬───────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ Agent Interface │
|
|
||||||
│ │
|
|
||||||
│ • lorem-tester │
|
|
||||||
│ • tatlock (future) │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Architectural Decisions:**
|
|
||||||
- **Single Source of Truth**: Responses API handles all generation logic
|
|
||||||
- **Chat Completions Wrapper**: Converts Responses output to Chat format with `<think>` tags
|
|
||||||
- **Agent Interface**: Clean abstraction for multiple models (mock and real)
|
|
||||||
- **Hybrid History**: Client sends full context, server optionally tracks conversations
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core API
|
### API Endpoints
|
||||||
- ✅ **Responses API** (`/v1/responses`) - Primary endpoint with structured output
|
|
||||||
- Reasoning items (thinking/extended thinking)
|
|
||||||
- Function call items (tool execution)
|
|
||||||
- Message items (assistant responses)
|
|
||||||
- Streaming and non-streaming modes
|
|
||||||
- ✅ **Chat Completions API** (`/v1/chat/completions`) - Compatibility wrapper
|
|
||||||
- Converts reasoning to `<think>` tags for Open WebUI
|
|
||||||
- Maintains OpenAI-compatible format
|
|
||||||
- Wraps Responses API (single source of truth)
|
|
||||||
- ✅ **Models API** (`/v1/models`) - Lists available models
|
|
||||||
|
|
||||||
### Advanced Features
|
- **Responses API** (`/v1/responses`) - OpenAI Responses API format with structured output
|
||||||
- ✅ **Conversation History Management**
|
- Reasoning items for displaying thinking process
|
||||||
- Hybrid approach: client maintains state, server tracks optionally
|
- Function call items for tool execution
|
||||||
- Auto-generated conversation IDs from first message hash
|
- Message items for assistant responses
|
||||||
- Configurable max turns (default: 20)
|
- Streaming and non-streaming support
|
||||||
- Placeholder for future vector memory (Qdrant)
|
|
||||||
- ✅ **Context Window Management**
|
|
||||||
- Approximate token counting (~4 chars/token)
|
|
||||||
- Context trimming to fit model limits
|
|
||||||
- Token usage statistics
|
|
||||||
- ✅ **Parameter Validation**
|
|
||||||
- Temperature: 0.0-2.0
|
|
||||||
- Reasoning effort: none, minimal, low, medium, high, xhigh
|
|
||||||
- Max output tokens enforcement
|
|
||||||
- Stop sequences (up to 4)
|
|
||||||
- ✅ **Stop Sequence Detection**
|
|
||||||
- Real-time detection during streaming
|
|
||||||
- Stops generation immediately when encountered
|
|
||||||
- ✅ **Max Tokens Enforcement**
|
|
||||||
- Real-time token counting during streaming
|
|
||||||
- Stops when limit reached
|
|
||||||
|
|
||||||
### Testing Models
|
- **Chat Completions** (`/v1/chat/completions`) - OpenAI Chat Completions compatibility
|
||||||
- ✅ **lorem-tester** - Full-featured mock agent
|
- Automatic reasoning conversion to `<think>` tags for Open WebUI
|
||||||
- Realistic reasoning summaries
|
- Full OpenAI API compatibility
|
||||||
- Random tool/function call generation
|
- Streaming support
|
||||||
|
|
||||||
|
- **Models** (`/v1/models`) - List available models
|
||||||
|
|
||||||
|
### Advanced Capabilities
|
||||||
|
|
||||||
|
- **Conversation History**: Auto-generated IDs, configurable max turns (default: 20)
|
||||||
|
- **Context Management**: Token counting, automatic trimming, usage statistics
|
||||||
|
- **Parameter Validation**: Temperature (0.0-2.0), reasoning effort levels, max tokens, stop sequences
|
||||||
|
- **Real-time Enforcement**: Stop sequence detection and max token limits during streaming
|
||||||
|
|
||||||
|
### Available Models
|
||||||
|
|
||||||
|
- **lorem-tester**: Full-featured mock agent with realistic behavior
|
||||||
|
- Configurable reasoning effort levels
|
||||||
|
- Random tool/function calls
|
||||||
- Error triggers for testing (rate_limit, context_overflow)
|
- Error triggers for testing (rate_limit, context_overflow)
|
||||||
- Temperature variation
|
|
||||||
- ✅ **tatlock** - Placeholder for real PydanticAI agent
|
|
||||||
|
|
||||||
### Open WebUI Integration
|
- **Tatlock**: Real PydanticAI agent with butler personality
|
||||||
- ✅ **Reasoning Display** - Thinking bubbles shown separately from responses
|
- **LLM Backend**: Ollama (mistral-nemo:latest by default)
|
||||||
- ✅ **Streaming Support** - Smooth word-by-word streaming
|
- **Personality**: Witty British butler, research-oriented
|
||||||
- ✅ **Error Handling** - Graceful error display
|
- **Core Tools**:
|
||||||
- ✅ **Model Selection** - Both models available in dropdown
|
- **Calculator**: Safe mathematical expression evaluation
|
||||||
|
- **Date/Time Toolkit**: Current time, relative dates, time differences
|
||||||
## Components
|
- **Web Search**: Privacy-preserving search via SearXNG
|
||||||
|
- **Household Coordination**:
|
||||||
- **FastAPI**: High-performance web framework
|
- **The Steward**: Analyzes requests and recommends capabilities
|
||||||
- **SSE-Starlette**: Server-Sent Events for streaming
|
- **The Librarian**: Research via library-desk HybridRAG + wiki
|
||||||
- **Pydantic**: Type-safe request/response validation
|
- **The Biographer**: User memory and preference management
|
||||||
- **Agent Interface**: Abstraction for multiple model backends
|
- **Capabilities**: Streaming, reasoning, tool calling, multi-agent delegation
|
||||||
- **Conversation History**: Server-side tracking with hybrid approach
|
|
||||||
- **Context Window**: Token management and trimming
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.12+ (Python 3.12.11 recommended)
|
- Python 3.12+ (Python 3.12.11 recommended)
|
||||||
- No external dependencies for mock API
|
- **External Services** (must be running separately):
|
||||||
- (Future: Network access for PydanticAI integration)
|
- **Ollama**: LLM inference (mistral-nemo:latest, nomic-embed-text)
|
||||||
|
- **Redis**: Caching and session memory
|
||||||
|
- **Qdrant**: Vector storage for The Biographer's memory
|
||||||
|
- **SearXNG**: Web search (optional)
|
||||||
|
- **library-desk**: Research API for The Librarian (optional)
|
||||||
|
|
||||||
## Installation
|
## Quick Start
|
||||||
|
|
||||||
### 1. Clone the repository
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
# Clone the repository
|
||||||
|
git clone https://git.schweitz.net/jpmschweitzer/tatlock.git
|
||||||
cd tatlock
|
cd tatlock
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create a virtual environment
|
# Create virtual environment
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install dependencies
|
# Install dependencies
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Configure environment (Optional)
|
### Run the Server
|
||||||
|
|
||||||
Create a `.env` file for custom configuration:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# API Configuration
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
API_PORT=8000
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
||||||
# Future: Add real LLM configuration here
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Start the server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvicorn src.main:app --reload
|
uvicorn src.main:app --reload
|
||||||
@@ -157,11 +105,11 @@ uvicorn src.main:app --reload
|
|||||||
|
|
||||||
API available at `http://localhost:8000`
|
API available at `http://localhost:8000`
|
||||||
|
|
||||||
### API Endpoints
|
## Usage Examples
|
||||||
|
|
||||||
#### Responses API (Primary)
|
### Responses API
|
||||||
|
|
||||||
OpenAI Responses API format with structured output:
|
Generate a response with reasoning:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/v1/responses \
|
curl http://localhost:8000/v1/responses \
|
||||||
@@ -176,7 +124,6 @@ curl http://localhost:8000/v1/responses \
|
|||||||
"summary": "auto"
|
"summary": "auto"
|
||||||
},
|
},
|
||||||
"max_output_tokens": 500,
|
"max_output_tokens": 500,
|
||||||
"stop": ["END"],
|
|
||||||
"stream": false
|
"stream": false
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
@@ -192,22 +139,12 @@ curl http://localhost:8000/v1/responses \
|
|||||||
"output": [
|
"output": [
|
||||||
{
|
{
|
||||||
"type": "reasoning",
|
"type": "reasoning",
|
||||||
"id": "reasoning_xyz",
|
"summary": ["Analyzing the request...", "Considering quantum mechanics..."]
|
||||||
"summary": [
|
|
||||||
"Analyzing the user's request...",
|
|
||||||
"Considering quantum mechanics principles..."
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"id": "msg_def456",
|
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": [
|
"content": [{"type": "output_text", "text": "Quantum computing uses..."}]
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Quantum computing uses quantum mechanics..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -219,9 +156,7 @@ curl http://localhost:8000/v1/responses \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Chat Completions (Compatibility)
|
### Chat Completions (OpenAI-compatible)
|
||||||
|
|
||||||
OpenAI-compatible format with `<think>` tags:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/v1/chat/completions \
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
@@ -236,66 +171,67 @@ curl http://localhost:8000/v1/chat/completions \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: Chat Completions automatically enables reasoning and converts it to `<think>` tags for Open WebUI compatibility.
|
### List Models
|
||||||
|
|
||||||
#### List Models
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/v1/models
|
curl http://localhost:8000/v1/models
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"object": "list",
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "lorem-tester",
|
|
||||||
"object": "model",
|
|
||||||
"created": 1733529600,
|
|
||||||
"owned_by": "tatlock"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tatlock",
|
|
||||||
"object": "model",
|
|
||||||
"created": 1733529600,
|
|
||||||
"owned_by": "tatlock"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conversation History
|
### Conversation History
|
||||||
|
|
||||||
Optional conversation tracking via metadata:
|
Optionally track conversations using metadata:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/v1/responses \
|
curl http://localhost:8000/v1/responses \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"model": "lorem-tester",
|
"model": "lorem-tester",
|
||||||
"input": [
|
"input": [{"role": "user", "content": "Hello"}],
|
||||||
{"role": "user", "content": "Hello"}
|
"metadata": {"conversation_id": "conv_abc123"}
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"conversation_id": "conv_abc123"
|
|
||||||
}
|
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hybrid Approach:**
|
**Note**: Client must send full conversation history in `input` array (OpenAI compatible). Server optionally tracks via `metadata.conversation_id` for future features.
|
||||||
- Client MUST send full conversation history in `input` array (OpenAI compatible)
|
|
||||||
- Server optionally tracks via `metadata.conversation_id` (for analytics, future vector memory)
|
|
||||||
- Auto-generates conversation ID from first message hash if not provided
|
|
||||||
|
|
||||||
### Interactive Documentation
|
### Using Tatlock with Tools
|
||||||
|
|
||||||
- **Swagger UI**: `http://localhost:8000/docs`
|
Tatlock automatically uses his permanent tools when appropriate:
|
||||||
- **ReDoc**: `http://localhost:8000/redoc`
|
|
||||||
|
```bash
|
||||||
|
# Mathematical calculation
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": "What is sqrt(144) + 25?"}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Date/time queries
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": "What was the date 2 weeks ago?"}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Web search for current information
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": "Search for recent Python 3.12 features"}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tatlock's Tool Usage Philosophy:**
|
||||||
|
- Uses calculator for ALL mathematics (even simple arithmetic)
|
||||||
|
- Uses date/time tools instead of guessing dates
|
||||||
|
- Searches for current/volatile information to verify facts
|
||||||
|
- Maintains a researcher's mindset with tool-assisted verification
|
||||||
|
|
||||||
## Open WebUI Integration
|
## Open WebUI Integration
|
||||||
|
|
||||||
### Docker Networking
|
### Connection
|
||||||
|
|
||||||
If running Open WebUI in Docker and API on host:
|
If running Open WebUI in Docker and API on host:
|
||||||
|
|
||||||
@@ -306,114 +242,44 @@ http://172.17.0.1:8000/v1/chat/completions
|
|||||||
|
|
||||||
### Reasoning Display
|
### Reasoning Display
|
||||||
|
|
||||||
The Chat Completions wrapper automatically:
|
The Chat Completions endpoint automatically:
|
||||||
1. Enables reasoning generation
|
1. Enables reasoning generation
|
||||||
2. Converts reasoning items to `<think>` tags
|
2. Converts reasoning to `<think>` tags
|
||||||
3. Streams thinking before the actual response
|
3. Streams thinking before the response
|
||||||
|
|
||||||
Open WebUI displays this as:
|
Open WebUI displays this as thought bubbles separate from the main response.
|
||||||
- **Thought bubble** showing reasoning steps
|
|
||||||
- **Main response** showing the actual answer
|
|
||||||
|
|
||||||
### Testing Error Handling
|
### Testing Error Handling
|
||||||
|
|
||||||
Lorem-tester supports error triggers:
|
Use special triggers in user messages:
|
||||||
- **"trigger_rate_limit"** - Simulates rate limit error
|
- `"trigger_rate_limit"` - Simulates rate limit error
|
||||||
- **"trigger_context_overflow"** - Simulates context length error
|
- `"trigger_context_overflow"` - Simulates context length error
|
||||||
|
|
||||||
## Development
|
## API Documentation
|
||||||
|
|
||||||
### Project Structure
|
Interactive documentation available at:
|
||||||
|
- **Swagger UI**: `http://localhost:8000/docs`
|
||||||
|
- **ReDoc**: `http://localhost:8000/redoc`
|
||||||
|
|
||||||
Following FastAPI best practices with domain-based organization:
|
## Testing
|
||||||
|
|
||||||
```
|
|
||||||
tatlock/
|
|
||||||
├── src/
|
|
||||||
│ ├── agents/ # Agent interface and implementations
|
|
||||||
│ │ ├── base.py # Abstract AgentInterface
|
|
||||||
│ │ ├── lorem_tester.py # Full-featured mock agent
|
|
||||||
│ │ ├── tatlock.py # Placeholder for real agent
|
|
||||||
│ │ └── registry.py # Model registry
|
|
||||||
│ ├── responses/ # Responses API domain (PRIMARY)
|
|
||||||
│ │ ├── router.py # POST /v1/responses
|
|
||||||
│ │ ├── schemas.py # Request/response models
|
|
||||||
│ │ ├── service.py # Response generation logic
|
|
||||||
│ │ ├── streaming.py # SSE streaming coordinator
|
|
||||||
│ │ ├── history.py # Conversation history management
|
|
||||||
│ │ └── context.py # Context window management
|
|
||||||
│ ├── chat/ # Chat Completions domain (WRAPPER)
|
|
||||||
│ │ ├── router.py # POST /v1/chat/completions
|
|
||||||
│ │ ├── schemas.py # Chat request/response models
|
|
||||||
│ │ ├── service.py # Wraps Responses API
|
|
||||||
│ │ └── constants.py # Chat constants
|
|
||||||
│ ├── models/ # Models listing domain
|
|
||||||
│ │ ├── router.py # GET /v1/models
|
|
||||||
│ │ ├── schemas.py # Model schemas
|
|
||||||
│ │ └── service.py # Model registry access
|
|
||||||
│ ├── core/ # Shared utilities
|
|
||||||
│ │ ├── config.py # Configuration (BaseSettings)
|
|
||||||
│ │ ├── models.py # Custom Pydantic base
|
|
||||||
│ │ ├── exceptions.py # Custom exceptions
|
|
||||||
│ │ └── router.py # Health check endpoints
|
|
||||||
│ └── main.py # Application factory
|
|
||||||
├── tests/ # Comprehensive test suite
|
|
||||||
│ ├── agents/ # Agent tests
|
|
||||||
│ ├── responses/ # Responses API tests
|
|
||||||
│ ├── chat/ # Chat completions tests
|
|
||||||
│ ├── models/ # Models API tests
|
|
||||||
│ └── core/ # Core tests
|
|
||||||
├── requirements.txt # Dependencies (pinned)
|
|
||||||
├── .env # Environment variables
|
|
||||||
├── AGENTS.md # Agent documentation
|
|
||||||
├── CLEANUP_TODO.md # Architecture notes
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
|
# Run unit tests only (no external services needed)
|
||||||
|
pytest --ignore=tests/e2e --ignore=tests/integration
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
pytest --cov=src --cov-report=term-missing
|
pytest --cov=src --cov-report=term-missing
|
||||||
|
|
||||||
# Current coverage: 78.95% (75 tests passing)
|
# Current: ~400 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test Organization:**
|
**Test Categories:**
|
||||||
- Unit tests for all components
|
- Unit tests: Agent tools, capabilities, schemas, memory service
|
||||||
- Integration tests for API endpoints
|
- Integration tests: Full API stack with real Ollama
|
||||||
- Streaming tests for SSE functionality
|
- End-to-end tests: Chat completions, responses API
|
||||||
- Error handling tests
|
|
||||||
- Advanced features tests (stop sequences, max tokens, validation)
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
|
|
||||||
- **Async-first**: All I/O operations use async/await
|
|
||||||
- **Type hints**: All functions fully typed
|
|
||||||
- **Pydantic validation**: All request/response validation
|
|
||||||
- **Domain separation**: Clear boundaries between components
|
|
||||||
- **Single responsibility**: Each module has one clear purpose
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Version Locking
|
|
||||||
|
|
||||||
Minor version locking (`>=X.Y,<X.(Y+1)`) for security:
|
|
||||||
- Allows patch updates
|
|
||||||
- Blocks potentially breaking minor updates
|
|
||||||
- All dependencies checked for CVEs (2025-12-06)
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
1. Never commit `.env` files
|
|
||||||
2. Use environment variables for sensitive config
|
|
||||||
3. Keep dependencies updated monthly
|
|
||||||
4. Validate all inputs with Pydantic
|
|
||||||
5. Use HTTPS in production
|
|
||||||
6. Implement rate limiting
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -424,51 +290,121 @@ Minor version locking (`>=X.Y,<X.(Y+1)`) for security:
|
|||||||
uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4
|
uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
```
|
```
|
||||||
|
|
||||||
### Considerations
|
### Recommendations
|
||||||
|
|
||||||
- Use reverse proxy (nginx/caddy) for HTTPS
|
- Use reverse proxy (nginx/caddy) for HTTPS
|
||||||
- Enable rate limiting (SlowAPI or similar)
|
- Enable rate limiting
|
||||||
- Set up monitoring and logging
|
- Set up monitoring and logging
|
||||||
- Configure resource limits
|
- Configure resource limits
|
||||||
- Use process manager (systemd/supervisor)
|
- Use process manager (systemd/supervisor)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `.env` file for custom configuration:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# API Configuration
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# Ollama Configuration
|
||||||
|
OLLAMA_HOST=http://localhost:11434
|
||||||
|
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
OLLAMA_TIMEOUT=120
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_MEMORY_DB=2
|
||||||
|
REDIS_MEMORY_TTL_HOURS=24
|
||||||
|
|
||||||
|
# Qdrant Configuration (for memory)
|
||||||
|
QDRANT_HOST=localhost
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_EMBEDDING_DIM=768
|
||||||
|
|
||||||
|
# Library-desk Configuration (for The Librarian)
|
||||||
|
LIBRARY_DESK_HOST=http://localhost:8089
|
||||||
|
LIBRARY_DESK_TIMEOUT=60
|
||||||
|
|
||||||
|
# SearXNG Configuration (for web search)
|
||||||
|
SEARXNG_HOST=http://localhost:8087
|
||||||
|
SEARXNG_TIMEOUT=30
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# CORS (default: allow all)
|
||||||
|
CORS_ORIGINS=["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
See `.env.example` for full configuration options.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Streaming not working
|
||||||
|
|
||||||
**Streaming not working:**
|
|
||||||
- Verify SSE-Starlette is installed
|
- Verify SSE-Starlette is installed
|
||||||
- Check client supports Server-Sent Events
|
- Check client supports Server-Sent Events
|
||||||
- Test with: `pytest tests/responses/ -k streaming`
|
- Test with: `pytest tests/responses/ -k streaming`
|
||||||
|
|
||||||
**Open WebUI can't connect:**
|
### Open WebUI can't connect
|
||||||
- Use Docker bridge gateway IP: `172.17.0.1:8000`
|
- Use Docker bridge gateway IP: `172.17.0.1:8000`
|
||||||
- Check firewall settings
|
- Check firewall settings
|
||||||
- Verify server is running on `0.0.0.0`
|
- Verify server is running on `0.0.0.0`
|
||||||
|
|
||||||
**Tests failing:**
|
### Reasoning not showing
|
||||||
- Install test dependencies: `pip install -r requirements-dev.txt`
|
|
||||||
- Activate virtual environment
|
|
||||||
- Run with verbose: `pytest -v`
|
|
||||||
|
|
||||||
**Reasoning not showing:**
|
|
||||||
- Ensure using Chat Completions endpoint (auto-enables reasoning)
|
- Ensure using Chat Completions endpoint (auto-enables reasoning)
|
||||||
- Or manually enable in Responses API: `"reasoning": {"effort": "medium", "summary": "auto"}`
|
- Or manually enable in Responses API: `"reasoning": {"effort": "medium", "summary": "auto"}`
|
||||||
- Check Open WebUI version supports `<think>` tags
|
- Check Open WebUI version supports `<think>` tags
|
||||||
|
|
||||||
## Future Roadmap
|
### Tatlock agent errors
|
||||||
|
- Verify Ollama is running: `curl http://localhost:11434/api/tags`
|
||||||
|
- Check model is downloaded: `ollama list`
|
||||||
|
- Review environment variables: `OLLAMA_HOST`, `OLLAMA_DEFAULT_MODEL`
|
||||||
|
- Check logs: `tail -f logs/server.log`
|
||||||
|
|
||||||
### Short-term
|
### Web search not working
|
||||||
- [ ] Connect tatlock model to real PydanticAI agent
|
- Verify SearXNG is running: `curl http://localhost:8087/`
|
||||||
- [ ] Implement vector memory (Qdrant integration)
|
- Check `SEARXNG_HOST` environment variable
|
||||||
- [ ] Add authentication/API keys
|
- SearXNG is optional - Tatlock will note if search is unavailable
|
||||||
- [ ] Rate limiting middleware
|
|
||||||
|
|
||||||
### Long-term
|
## Project Structure
|
||||||
- [ ] Multi-model support (OpenAI, Anthropic, etc.)
|
|
||||||
- [ ] Advanced conversation memory
|
```
|
||||||
- [ ] Tool/function calling integration
|
tatlock/
|
||||||
- [ ] Usage tracking and analytics
|
├── src/
|
||||||
|
│ ├── agents/ # Agent implementations
|
||||||
|
│ │ ├── biographer/ # The Biographer - memory management
|
||||||
|
│ │ ├── librarian/ # The Librarian - research & wiki
|
||||||
|
│ │ ├── steward/ # The Steward - request analysis
|
||||||
|
│ │ ├── tatlock_core/ # Core butler tools
|
||||||
|
│ │ ├── tatlock.py # Tatlock PydanticAI agent
|
||||||
|
│ │ ├── coordination.py # Multi-agent coordination
|
||||||
|
│ │ ├── delegation.py # Expert delegation wrappers
|
||||||
|
│ │ └── protocol.py # Agent communication protocol
|
||||||
|
│ ├── responses/ # Responses API (primary endpoint)
|
||||||
|
│ ├── chat/ # Chat Completions wrapper
|
||||||
|
│ ├── models/ # Models listing
|
||||||
|
│ ├── core/ # Shared infrastructure
|
||||||
|
│ │ ├── config.py # Configuration management
|
||||||
|
│ │ ├── context.py # Request context (ContextVar)
|
||||||
|
│ │ ├── memory_service.py # Direct memory access
|
||||||
|
│ │ ├── memory_cache.py # Redis session cache
|
||||||
|
│ │ ├── embeddings.py # Ollama embedding client
|
||||||
|
│ │ ├── qdrant.py # Vector database client
|
||||||
|
│ │ └── multi_tenancy.py # User isolation utilities
|
||||||
|
│ └── main.py # Application entry point
|
||||||
|
├── tests/ # Comprehensive test suite
|
||||||
|
├── PHILOSOPHY.md # System vision and architecture
|
||||||
|
├── IMPLEMENTATION_ROADMAP.md # Development phases
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For LLM agent development guidelines and architectural decisions, see [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -480,17 +416,24 @@ uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **AGENTS.md**: Agent architecture and best practices
|
- **System Philosophy**: [PHILOSOPHY.md](PHILOSOPHY.md) - Vision, goals, and architectural patterns
|
||||||
- **CLEANUP_TODO.md**: Architecture decisions and future considerations
|
- **User Guide**: This file - Installation, usage, and examples
|
||||||
- **CHANGELOG.md**: Version history
|
- **Developer Guidelines**: [AGENTS.md](AGENTS.md) - LLM agent development patterns
|
||||||
- OpenAI Responses API: https://platform.openai.com/docs/api-reference/responses
|
- **Version History**: [CHANGELOG.md](CHANGELOG.md) - Changes and releases
|
||||||
- FastAPI: https://fastapi.tiangolo.com/
|
|
||||||
- PydanticAI: https://ai.pydantic.dev/
|
### External References
|
||||||
|
- **OpenAI Responses API**: https://platform.openai.com/docs/api-reference/responses
|
||||||
|
- **FastAPI**: https://fastapi.tiangolo.com/
|
||||||
|
- **PydanticAI**: https://ai.pydantic.dev/
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Add your license here]
|
[Add your license here]
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
Current version: **1.3.2** - Biographer tool type hints fix
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Note**: This is a testing/development API with mock responses. The architecture is production-ready and designed for easy integration with real LLM backends (PydanticAI, Ollama, OpenAI, etc.).
|
**Note**: Tatlock is a production-ready homelab butler. All household staff use PydanticAI with Ollama for local LLM inference.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Testing Improvements for LLM Outputs
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
LLM outputs are non-deterministic. Tests checking for exact string matches fail when the LLM writes "thirty-seven" instead of "37".
|
||||||
|
|
||||||
|
## Proposed Solutions
|
||||||
|
|
||||||
|
### 1. LLM-as-Judge Pattern
|
||||||
|
|
||||||
|
Use a smaller/faster model to evaluate semantic correctness:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def llm_judge(output: str, criteria: str) -> bool:
|
||||||
|
"""Use LLM to evaluate if output meets criteria."""
|
||||||
|
prompt = f"""
|
||||||
|
Evaluate if this output is correct:
|
||||||
|
Output: {output}
|
||||||
|
Criteria: {criteria}
|
||||||
|
Answer only YES or NO.
|
||||||
|
"""
|
||||||
|
result = await judge_model.run(prompt)
|
||||||
|
return "YES" in result.output.upper()
|
||||||
|
|
||||||
|
# Usage in test:
|
||||||
|
assert await llm_judge(
|
||||||
|
response,
|
||||||
|
"The answer correctly states that sqrt(144) + 25 = 37"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fuzzy/Regex Matching
|
||||||
|
|
||||||
|
For numeric answers, accept multiple representations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
def contains_number(text: str, number: int) -> bool:
|
||||||
|
"""Check if text contains number in any form."""
|
||||||
|
patterns = [
|
||||||
|
rf'\b{number}\b', # Digit form
|
||||||
|
number_to_words(number), # Word form
|
||||||
|
]
|
||||||
|
return any(re.search(p, text, re.I) for p in patterns)
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
assert contains_number(response, 37) # Matches "37" or "thirty-seven"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DeepEval Framework
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deepeval.metrics import AnswerRelevancyMetric
|
||||||
|
from deepeval.test_case import LLMTestCase
|
||||||
|
|
||||||
|
def test_calculation():
|
||||||
|
test_case = LLMTestCase(
|
||||||
|
input="What is sqrt(144) + 25?",
|
||||||
|
actual_output=response,
|
||||||
|
expected_output="37"
|
||||||
|
)
|
||||||
|
metric = AnswerRelevancyMetric(threshold=0.7)
|
||||||
|
assert metric.measure(test_case)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. pytest-evals Plugin
|
||||||
|
|
||||||
|
Minimal pytest plugin for LLM testing with metrics collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest-evals
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Multiple Runs with Threshold
|
||||||
|
|
||||||
|
Run flaky tests multiple times and require majority pass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.flaky(reruns=3, reruns_delay=1)
|
||||||
|
def test_llm_response():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or custom:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.parametrize("run", range(3))
|
||||||
|
def test_llm_response(run):
|
||||||
|
...
|
||||||
|
# Aggregate results across runs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [DeepEval](https://github.com/confident-ai/deepeval) - LLM evaluation framework
|
||||||
|
- [pytest-evals](https://github.com/AlmogBaku/pytest-evals) - pytest plugin for LLM evals
|
||||||
|
- [LLM Testing Guide 2025](https://www.confident-ai.com/blog/llm-testing-in-2024-top-methods-and-strategies)
|
||||||
|
- [Testing LLM Applications - Langfuse](https://langfuse.com/blog/2025-10-21-testing-llm-applications)
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. Add fuzzy number matching helper (quick win)
|
||||||
|
2. Evaluate DeepEval for complex output testing
|
||||||
|
3. Consider LLM-as-judge for semantic correctness
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Tatlock Integration Guide
|
||||||
|
|
||||||
|
Implementation instructions for integrating Library Desk search and content extraction endpoints into the Tatlock project.
|
||||||
|
|
||||||
|
## Base Configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
BASE_URL: http://library-desk:8089 (or your deployment URL)
|
||||||
|
AUTH_HEADER: Authorization: Bearer <LIBRARY_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RAG Search Endpoint
|
||||||
|
|
||||||
|
**Use case:** Librarian needs to research a topic by searching the web.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /rag/search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "Python async programming best practices",
|
||||||
|
"search_type": "web",
|
||||||
|
"limit": 10,
|
||||||
|
"user": "tatlock-librarian"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `query` | string | required | Search query (1-500 chars) |
|
||||||
|
| `search_type` | enum | `"web"` | `"web"`, `"news"`, or `"images"` |
|
||||||
|
| `limit` | int | 10 | Results to return (1-20) |
|
||||||
|
| `user` | string | `"default"` | User identifier for tracking |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "Python async programming best practices",
|
||||||
|
"search_type": "web",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Async IO in Python: A Complete Walkthrough",
|
||||||
|
"url": "https://realpython.com/async-io-python/",
|
||||||
|
"content": "Full extracted article text via Trafilatura (~2000 chars max)...",
|
||||||
|
"snippet": "Original search engine snippet (150-300 chars)...",
|
||||||
|
"source": "realpython.com",
|
||||||
|
"published_date": "2023-05-15"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_results": 10,
|
||||||
|
"search_time_ms": 2340,
|
||||||
|
"sources_summary": "## Sources\n- [Async IO in Python](https://realpython.com/async-io-python/)\n- ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Fields for Tatlock
|
||||||
|
|
||||||
|
| Field | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `results[].content` | Full extracted text - use this for LLM context |
|
||||||
|
| `results[].snippet` | Fallback if content extraction failed |
|
||||||
|
| `sources_summary` | Pre-formatted markdown for citations |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
| HTTP Code | Meaning | Action |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| 400 | Invalid query | Check query length/format |
|
||||||
|
| 502 | SearXNG unavailable | Retry with backoff |
|
||||||
|
| 504 | Search timeout | Retry or reduce limit |
|
||||||
|
| 500 | Internal error | Log and notify |
|
||||||
|
|
||||||
|
### Example Usage (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def search_web(query: str, limit: int = 10) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/rag/search",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"search_type": "web",
|
||||||
|
"limit": limit,
|
||||||
|
"user": "tatlock-librarian"
|
||||||
|
},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
results = await search_web("machine learning transformers")
|
||||||
|
for r in results["results"]:
|
||||||
|
# Prefer full content, fall back to snippet
|
||||||
|
text = r["content"] or r["snippet"]
|
||||||
|
print(f"{r['title']}: {len(text)} chars")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Content Extraction Endpoint
|
||||||
|
|
||||||
|
**Use case:** Librarian has a specific URL and needs to read its content.
|
||||||
|
|
||||||
|
### Single URL Extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /content/extract
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article",
|
||||||
|
"include_metadata": true,
|
||||||
|
"max_length": 2000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"url": "https://example.com/article",
|
||||||
|
"title": "Article Title",
|
||||||
|
"content": "Extracted main text content...",
|
||||||
|
"author": "John Doe",
|
||||||
|
"date": "2024-01-15",
|
||||||
|
"language": "en",
|
||||||
|
"success": true,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"extraction_time_ms": 1250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch URL Extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /content/extract/batch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"urls": [
|
||||||
|
"https://example.com/article1",
|
||||||
|
"https://example.com/article2",
|
||||||
|
"https://example.com/article3"
|
||||||
|
],
|
||||||
|
"include_metadata": true,
|
||||||
|
"max_length": 2000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article1",
|
||||||
|
"title": "Article 1",
|
||||||
|
"content": "Extracted content...",
|
||||||
|
"success": true,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article2",
|
||||||
|
"title": null,
|
||||||
|
"content": "",
|
||||||
|
"success": false,
|
||||||
|
"error": "Connection timeout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_urls": 3,
|
||||||
|
"successful": 2,
|
||||||
|
"failed": 1,
|
||||||
|
"extraction_time_ms": 3500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Error Pattern: Soft Failures
|
||||||
|
|
||||||
|
> **Important:** Content extraction uses a **soft failure pattern** - individual URL failures do NOT throw HTTP errors.
|
||||||
|
|
||||||
|
### Why Soft Failures?
|
||||||
|
|
||||||
|
When extracting content from multiple URLs (batch) or even single URLs:
|
||||||
|
- Some sites block bots
|
||||||
|
- Some URLs are temporarily down
|
||||||
|
- Some pages have no extractable content
|
||||||
|
|
||||||
|
Instead of failing the entire request, we return:
|
||||||
|
- `success: true/false` per result
|
||||||
|
- `error: "reason"` when failed
|
||||||
|
- Empty `content: ""` on failure
|
||||||
|
|
||||||
|
### Handling Soft Failures
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def extract_with_fallback(url: str) -> str:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"url": url}
|
||||||
|
)
|
||||||
|
response.raise_for_status() # Only throws on 4xx/5xx
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
result = data["result"]
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return result["content"]
|
||||||
|
else:
|
||||||
|
# Log the failure, return empty or handle gracefully
|
||||||
|
logger.warning(f"Extraction failed for {url}: {result['error']}")
|
||||||
|
return "" # Or raise, or use cached version, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Processing Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def extract_batch_with_stats(urls: list[str]) -> dict:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract/batch",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"urls": urls, "max_length": 3000}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Separate successful and failed
|
||||||
|
successful = [r for r in data["results"] if r["success"]]
|
||||||
|
failed = [r for r in data["results"] if not r["success"]]
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
logger.warning(f"{len(failed)} URLs failed extraction:")
|
||||||
|
for f in failed:
|
||||||
|
logger.warning(f" {f['url']}: {f['error']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contents": {r["url"]: r["content"] for r in successful},
|
||||||
|
"failed_urls": [f["url"] for f in failed],
|
||||||
|
"success_rate": data["successful"] / data["total_urls"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended Patterns for Tatlock
|
||||||
|
|
||||||
|
### Research Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def librarian_research(topic: str) -> dict:
|
||||||
|
"""
|
||||||
|
Full research flow: search + extract additional context.
|
||||||
|
"""
|
||||||
|
# 1. Search for relevant pages
|
||||||
|
search_results = await search_web(topic, limit=10)
|
||||||
|
|
||||||
|
# 2. RAG search already includes extracted content
|
||||||
|
# Only extract more if you need deeper content
|
||||||
|
|
||||||
|
# 3. Build context for LLM
|
||||||
|
context_parts = []
|
||||||
|
for r in search_results["results"]:
|
||||||
|
content = r["content"] or r["snippet"]
|
||||||
|
if content:
|
||||||
|
context_parts.append(f"## {r['title']}\nSource: {r['url']}\n\n{content}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"context": "\n\n---\n\n".join(context_parts),
|
||||||
|
"sources": search_results["sources_summary"],
|
||||||
|
"result_count": search_results["total_results"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading a Specific Page
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def librarian_read_page(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Read a specific URL the user provided.
|
||||||
|
"""
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"url": url, "max_length": 5000} # Longer for deep reads
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()["result"]
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise ValueError(f"Could not read page: {result['error']}")
|
||||||
|
|
||||||
|
# Format for LLM
|
||||||
|
header = f"# {result['title'] or 'Untitled'}\n"
|
||||||
|
if result["author"]:
|
||||||
|
header += f"Author: {result['author']}\n"
|
||||||
|
if result["date"]:
|
||||||
|
header += f"Date: {result['date']}\n"
|
||||||
|
|
||||||
|
return header + "\n" + result["content"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rate Limits & Best Practices
|
||||||
|
|
||||||
|
| Recommendation | Reason |
|
||||||
|
|----------------|--------|
|
||||||
|
| Use `limit: 5-10` for searches | More results = longer extraction time |
|
||||||
|
| Batch URLs when possible | More efficient than sequential calls |
|
||||||
|
| Max 20 URLs per batch | Server limit |
|
||||||
|
| Set reasonable timeouts (30s) | Content extraction can be slow |
|
||||||
|
| Cache results client-side | Same URL rarely changes content |
|
||||||
|
| Use `user` parameter | Helps with debugging and rate limiting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference
|
||||||
|
|
||||||
|
| Endpoint | Method | Use Case |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| `/rag/search` | POST | Search web + get extracted content |
|
||||||
|
| `/content/extract` | POST | Read a single URL |
|
||||||
|
| `/content/extract/batch` | POST | Read multiple URLs |
|
||||||
|
| `/health` | GET | Check service status |
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "0.1.0"
|
version = "1.8.4"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
+22
-3
@@ -14,11 +14,17 @@ uvicorn[standard]>=0.38,<0.39
|
|||||||
# Latest: 2.12.4 (Nov 5, 2025) - No known CVEs
|
# Latest: 2.12.4 (Nov 5, 2025) - No known CVEs
|
||||||
pydantic>=2.11,<2.13
|
pydantic>=2.11,<2.13
|
||||||
|
|
||||||
|
# Pydantic settings for configuration management
|
||||||
|
# Required explicitly since pydantic-ai-slim doesn't include it
|
||||||
|
# Latest: 2.12.0 (Dec 2025) - No known CVEs
|
||||||
|
pydantic-settings>=2.12,<2.13
|
||||||
|
|
||||||
# AI/LLM integration
|
# AI/LLM integration
|
||||||
# PydanticAI: Agent framework for using Pydantic with LLMs
|
# PydanticAI: Agent framework for using Pydantic with LLMs
|
||||||
# Latest: 1.27.0 (Dec 5, 2025) - No known CVEs
|
# Using slim version with only openai extra (Ollama uses OpenAI-compatible API)
|
||||||
# Supports Ollama backend out of the box
|
# This avoids installing SDKs for anthropic, cohere, google, groq, huggingface, etc.
|
||||||
pydantic-ai>=1.27,<1.28
|
# See DEPENDENCY_SLIM.md for rollback instructions if this breaks
|
||||||
|
pydantic-ai-slim[openai]>=1.27,<1.28
|
||||||
|
|
||||||
# HTTP client for Ollama communication
|
# HTTP client for Ollama communication
|
||||||
# Latest: 0.28.1 - No known CVEs
|
# Latest: 0.28.1 - No known CVEs
|
||||||
@@ -36,6 +42,19 @@ python-dotenv>=1.2,<1.3
|
|||||||
# ASGI toolkit (dependency of FastAPI, pinning for security)
|
# ASGI toolkit (dependency of FastAPI, pinning for security)
|
||||||
starlette>=0.45,<0.46
|
starlette>=0.45,<0.46
|
||||||
|
|
||||||
|
# Redis for performance benchmarking and caching
|
||||||
|
# Latest: 5.2.1 (Dec 5, 2025) - No known CVEs
|
||||||
|
# hiredis: C parser for better performance
|
||||||
|
redis[hiredis]>=5.2,<6.0
|
||||||
|
|
||||||
|
# Qdrant vector database client for memory storage
|
||||||
|
# Latest: 1.12.1 (Dec 2025) - No known CVEs
|
||||||
|
qdrant-client>=1.12,<2.0
|
||||||
|
|
||||||
|
# Structured logging for observability
|
||||||
|
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
||||||
|
structlog>=24.1,<25.0
|
||||||
|
|
||||||
# Note on version locking strategy:
|
# Note on version locking strategy:
|
||||||
# Using >=X.Y,<X.(Y+1) format to lock to minor versions
|
# Using >=X.Y,<X.(Y+1) format to lock to minor versions
|
||||||
# This protects against supply chain attacks while allowing patch updates
|
# This protects against supply chain attacks while allowing patch updates
|
||||||
|
|||||||
Executable
+296
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Benchmark analysis tool for Steward performance and tool recommendation accuracy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# View Steward performance over last 24 hours
|
||||||
|
python scripts/benchmark_analysis.py --operation steward_analysis --hours 24
|
||||||
|
|
||||||
|
# Analyze tool recommendation accuracy over last 7 days
|
||||||
|
python scripts/benchmark_analysis.py --tool-accuracy --days 7
|
||||||
|
|
||||||
|
# Get summary of all operations in last hour
|
||||||
|
python scripts/benchmark_analysis.py --summary --hours 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from src.core.benchmarks import get_benchmark_store, PerformanceBenchmark
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_steward_performance(hours: int = 24):
|
||||||
|
"""
|
||||||
|
Analyze Steward analysis performance over time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours: Number of hours to look back
|
||||||
|
"""
|
||||||
|
store = get_benchmark_store()
|
||||||
|
|
||||||
|
# Query benchmarks from last N hours
|
||||||
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
|
benchmarks = await store.query(
|
||||||
|
operation="steward_analysis",
|
||||||
|
since=since
|
||||||
|
)
|
||||||
|
|
||||||
|
if not benchmarks:
|
||||||
|
print(f"No Steward analysis benchmarks found in the last {hours} hours.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Steward Analysis Performance (Last {hours} hours)")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# Calculate statistics
|
||||||
|
durations = [b.duration_seconds for b in benchmarks]
|
||||||
|
recommendation_counts = [b.recommendation_count for b in benchmarks if b.recommendation_count is not None]
|
||||||
|
|
||||||
|
avg_duration = sum(durations) / len(durations)
|
||||||
|
min_duration = min(durations)
|
||||||
|
max_duration = max(durations)
|
||||||
|
|
||||||
|
print(f"Total Analyses: {len(benchmarks)}")
|
||||||
|
print(f"Success Rate: {sum(1 for b in benchmarks if b.success) / len(benchmarks) * 100:.1f}%")
|
||||||
|
print(f"\nLatency Statistics:")
|
||||||
|
print(f" Average: {avg_duration:.3f}s")
|
||||||
|
print(f" Min: {min_duration:.3f}s")
|
||||||
|
print(f" Max: {max_duration:.3f}s")
|
||||||
|
|
||||||
|
if recommendation_counts:
|
||||||
|
avg_recommendations = sum(recommendation_counts) / len(recommendation_counts)
|
||||||
|
print(f"\nRecommendation Statistics:")
|
||||||
|
print(f" Average recommendations per request: {avg_recommendations:.1f}")
|
||||||
|
print(f" Min recommendations: {min(recommendation_counts)}")
|
||||||
|
print(f" Max recommendations: {max(recommendation_counts)}")
|
||||||
|
|
||||||
|
# Distribution
|
||||||
|
print(f"\nRecommendation Count Distribution:")
|
||||||
|
distribution = defaultdict(int)
|
||||||
|
for count in recommendation_counts:
|
||||||
|
distribution[count] += 1
|
||||||
|
for count in sorted(distribution.keys()):
|
||||||
|
percentage = distribution[count] / len(recommendation_counts) * 100
|
||||||
|
print(f" {count} capabilities: {distribution[count]} ({percentage:.1f}%)")
|
||||||
|
|
||||||
|
# Complexity distribution
|
||||||
|
complexities = defaultdict(int)
|
||||||
|
for b in benchmarks:
|
||||||
|
if b.metadata and "complexity" in b.metadata:
|
||||||
|
complexities[b.metadata["complexity"]] += 1
|
||||||
|
|
||||||
|
if complexities:
|
||||||
|
print(f"\nComplexity Distribution:")
|
||||||
|
for complexity in sorted(complexities.keys()):
|
||||||
|
percentage = complexities[complexity] / len(benchmarks) * 100
|
||||||
|
print(f" {complexity}: {complexities[complexity]} ({percentage:.1f}%)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_tool_accuracy(days: int = 7):
|
||||||
|
"""
|
||||||
|
Analyze tool recommendation accuracy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back
|
||||||
|
"""
|
||||||
|
store = get_benchmark_store()
|
||||||
|
|
||||||
|
# Query tool call benchmarks from last N days
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
benchmarks = await store.query(
|
||||||
|
operation="tool_call",
|
||||||
|
since=since
|
||||||
|
)
|
||||||
|
|
||||||
|
if not benchmarks:
|
||||||
|
print(f"No tool call benchmarks found in the last {days} days.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Tool Recommendation Accuracy (Last {days} days)")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# Categorize tool calls
|
||||||
|
recommended_and_used = [] # True positives
|
||||||
|
recommended_not_used = [] # False positives (recommended but not used)
|
||||||
|
not_recommended_but_used = [] # False negatives (used but not recommended)
|
||||||
|
|
||||||
|
for b in benchmarks:
|
||||||
|
if b.was_recommended and b.was_actually_used:
|
||||||
|
recommended_and_used.append(b)
|
||||||
|
elif b.was_recommended and not b.was_actually_used:
|
||||||
|
recommended_not_used.append(b)
|
||||||
|
elif not b.was_recommended and b.was_actually_used:
|
||||||
|
not_recommended_but_used.append(b)
|
||||||
|
|
||||||
|
total_recommendations = len(recommended_and_used) + len(recommended_not_used)
|
||||||
|
total_tool_calls = len(recommended_and_used) + len(not_recommended_but_used)
|
||||||
|
|
||||||
|
print(f"Total Tool Calls: {total_tool_calls}")
|
||||||
|
print(f"Total Recommendations: {total_recommendations}")
|
||||||
|
|
||||||
|
if total_recommendations > 0:
|
||||||
|
precision = len(recommended_and_used) / total_recommendations * 100
|
||||||
|
print(f"\nPrecision: {precision:.1f}%")
|
||||||
|
print(f" (recommended and actually used / all recommendations)")
|
||||||
|
|
||||||
|
if total_tool_calls > 0:
|
||||||
|
recall = len(recommended_and_used) / total_tool_calls * 100
|
||||||
|
print(f"\nRecall: {recall:.1f}%")
|
||||||
|
print(f" (recommended and actually used / all tool calls)")
|
||||||
|
|
||||||
|
if total_recommendations > 0 and total_tool_calls > 0:
|
||||||
|
f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
|
||||||
|
print(f"\nF1 Score: {f1:.1f}%")
|
||||||
|
|
||||||
|
print(f"\nBreakdown:")
|
||||||
|
print(f" ✅ Recommended & Used: {len(recommended_and_used)}")
|
||||||
|
print(f" ⚠️ Recommended but Not Used: {len(recommended_not_used)}")
|
||||||
|
print(f" ❌ Not Recommended but Used: {len(not_recommended_but_used)}")
|
||||||
|
|
||||||
|
# Tool-specific accuracy
|
||||||
|
tool_usage = defaultdict(lambda: {"recommended_used": 0, "not_recommended_used": 0})
|
||||||
|
|
||||||
|
for b in recommended_and_used:
|
||||||
|
if b.tool_name:
|
||||||
|
tool_usage[b.tool_name]["recommended_used"] += 1
|
||||||
|
|
||||||
|
for b in not_recommended_but_used:
|
||||||
|
if b.tool_name:
|
||||||
|
tool_usage[b.tool_name]["not_recommended_used"] += 1
|
||||||
|
|
||||||
|
if tool_usage:
|
||||||
|
print(f"\nPer-Tool Accuracy:")
|
||||||
|
for tool_name in sorted(tool_usage.keys()):
|
||||||
|
stats = tool_usage[tool_name]
|
||||||
|
total = stats["recommended_used"] + stats["not_recommended_used"]
|
||||||
|
accuracy = stats["recommended_used"] / total * 100 if total > 0 else 0
|
||||||
|
print(f" {tool_name}: {accuracy:.1f}% ({stats['recommended_used']}/{total})")
|
||||||
|
|
||||||
|
# Duration statistics for tool calls
|
||||||
|
durations = [b.duration_seconds for b in benchmarks if b.duration_seconds]
|
||||||
|
if durations:
|
||||||
|
avg_duration = sum(durations) / len(durations)
|
||||||
|
print(f"\nTool Call Duration:")
|
||||||
|
print(f" Average: {avg_duration:.3f}s")
|
||||||
|
print(f" Min: {min(durations):.3f}s")
|
||||||
|
print(f" Max: {max(durations):.3f}s")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def show_summary(hours: int = 1):
|
||||||
|
"""
|
||||||
|
Show summary of all operations in the specified time window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours: Number of hours to look back
|
||||||
|
"""
|
||||||
|
store = get_benchmark_store()
|
||||||
|
|
||||||
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Query all operations
|
||||||
|
all_benchmarks = await store.query(since=since)
|
||||||
|
|
||||||
|
if not all_benchmarks:
|
||||||
|
print(f"No benchmarks found in the last {hours} hours.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Benchmark Summary (Last {hours} hours)")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# Group by operation
|
||||||
|
by_operation = defaultdict(list)
|
||||||
|
for b in all_benchmarks:
|
||||||
|
by_operation[b.operation].append(b)
|
||||||
|
|
||||||
|
print(f"Total Operations: {len(all_benchmarks)}\n")
|
||||||
|
|
||||||
|
for operation in sorted(by_operation.keys()):
|
||||||
|
benchmarks = by_operation[operation]
|
||||||
|
durations = [b.duration_seconds for b in benchmarks if b.duration_seconds]
|
||||||
|
avg_duration = sum(durations) / len(durations) if durations else 0
|
||||||
|
success_rate = sum(1 for b in benchmarks if b.success) / len(benchmarks) * 100
|
||||||
|
|
||||||
|
print(f"{operation}:")
|
||||||
|
print(f" Count: {len(benchmarks)}")
|
||||||
|
print(f" Success Rate: {success_rate:.1f}%")
|
||||||
|
if durations:
|
||||||
|
print(f" Avg Duration: {avg_duration:.3f}s")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Analyze Tatlock benchmark data",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--operation",
|
||||||
|
choices=["steward_analysis", "tool_call"],
|
||||||
|
help="Analyze specific operation type"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--hours",
|
||||||
|
type=int,
|
||||||
|
default=24,
|
||||||
|
help="Number of hours to look back (default: 24)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--days",
|
||||||
|
type=int,
|
||||||
|
default=7,
|
||||||
|
help="Number of days to look back (default: 7)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--tool-accuracy",
|
||||||
|
action="store_true",
|
||||||
|
help="Analyze tool recommendation accuracy"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--summary",
|
||||||
|
action="store_true",
|
||||||
|
help="Show summary of all operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Run analysis
|
||||||
|
if args.tool_accuracy:
|
||||||
|
asyncio.run(analyze_tool_accuracy(args.days))
|
||||||
|
elif args.summary:
|
||||||
|
asyncio.run(show_summary(args.hours))
|
||||||
|
elif args.operation == "steward_analysis":
|
||||||
|
asyncio.run(analyze_steward_performance(args.hours))
|
||||||
|
elif args.operation == "tool_call":
|
||||||
|
# Show tool-specific analysis within the hours window
|
||||||
|
asyncio.run(analyze_tool_accuracy(days=args.hours // 24 or 1))
|
||||||
|
else:
|
||||||
|
# Default: show summary
|
||||||
|
asyncio.run(show_summary(args.hours))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Benchmark script for the Steward agent.
|
||||||
|
|
||||||
|
Tests Steward's request analysis performance with various scenarios
|
||||||
|
to ensure it meets latency targets:
|
||||||
|
- Target max: 5 seconds
|
||||||
|
- Target average: ~1.67 seconds
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/benchmark_steward.py [--iterations N] [--verbose]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import statistics
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from src.agents.steward import analyze_request
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkResult:
|
||||||
|
"""Results from a single benchmark run."""
|
||||||
|
|
||||||
|
def __init__(self, scenario: str, duration: float, success: bool, error: str = None):
|
||||||
|
self.scenario = scenario
|
||||||
|
self.duration = duration
|
||||||
|
self.success = success
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
async def benchmark_scenario(
|
||||||
|
name: str,
|
||||||
|
request: str,
|
||||||
|
history: list[dict],
|
||||||
|
iterations: int = 10
|
||||||
|
) -> List[BenchmarkResult]:
|
||||||
|
"""
|
||||||
|
Benchmark a specific scenario.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Scenario name
|
||||||
|
request: User request to analyze
|
||||||
|
history: Conversation history
|
||||||
|
iterations: Number of times to run
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of benchmark results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
print(f"\n📊 Benchmarking: {name}")
|
||||||
|
print(f" Request: {request[:50]}{'...' if len(request) > 50 else ''}")
|
||||||
|
print(f" History length: {len(history)} turns")
|
||||||
|
print(f" Iterations: {iterations}")
|
||||||
|
|
||||||
|
for i in range(iterations):
|
||||||
|
try:
|
||||||
|
start = datetime.now()
|
||||||
|
await analyze_request(request, history)
|
||||||
|
duration = (datetime.now() - start).total_seconds()
|
||||||
|
|
||||||
|
results.append(BenchmarkResult(name, duration, True))
|
||||||
|
|
||||||
|
# Progress indicator
|
||||||
|
print(".", end="", flush=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = (datetime.now() - start).total_seconds()
|
||||||
|
results.append(BenchmarkResult(name, duration, False, str(e)))
|
||||||
|
print("E", end="", flush=True)
|
||||||
|
|
||||||
|
print() # New line after progress
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_results(results: List[BenchmarkResult], scenario_name: str):
|
||||||
|
"""
|
||||||
|
Analyze and display benchmark results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of benchmark results
|
||||||
|
scenario_name: Name of the scenario
|
||||||
|
"""
|
||||||
|
successful = [r for r in results if r.success]
|
||||||
|
failed = [r for r in results if not r.success]
|
||||||
|
|
||||||
|
if not successful:
|
||||||
|
print(f"\n❌ {scenario_name}: All runs failed!")
|
||||||
|
for r in failed[:3]: # Show first 3 errors
|
||||||
|
print(f" Error: {r.error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
durations = [r.duration for r in successful]
|
||||||
|
|
||||||
|
min_duration = min(durations)
|
||||||
|
max_duration = max(durations)
|
||||||
|
avg_duration = statistics.mean(durations)
|
||||||
|
median_duration = statistics.median(durations)
|
||||||
|
|
||||||
|
# Calculate percentiles
|
||||||
|
sorted_durations = sorted(durations)
|
||||||
|
p95_idx = int(len(sorted_durations) * 0.95)
|
||||||
|
p99_idx = int(len(sorted_durations) * 0.99)
|
||||||
|
p95 = sorted_durations[p95_idx] if p95_idx < len(sorted_durations) else max_duration
|
||||||
|
p99 = sorted_durations[p99_idx] if p99_idx < len(sorted_durations) else max_duration
|
||||||
|
|
||||||
|
# Targets
|
||||||
|
target_max = 5.0
|
||||||
|
target_avg = 1.67
|
||||||
|
|
||||||
|
# Status emojis
|
||||||
|
max_status = "✅" if max_duration <= target_max else "⚠️"
|
||||||
|
avg_status = "✅" if avg_duration <= target_avg else "⚠️"
|
||||||
|
|
||||||
|
print(f"\n Results ({len(successful)}/{len(results)} successful):")
|
||||||
|
print(f" Min: {min_duration:6.3f}s")
|
||||||
|
print(f" Avg: {avg_duration:6.3f}s {avg_status} (target: ≤{target_avg}s)")
|
||||||
|
print(f" Median: {median_duration:6.3f}s")
|
||||||
|
print(f" P95: {p95:6.3f}s")
|
||||||
|
print(f" P99: {p99:6.3f}s")
|
||||||
|
print(f" Max: {max_duration:6.3f}s {max_status} (target: ≤{target_max}s)")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f" Failed: {len(failed)} runs")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min": min_duration,
|
||||||
|
"avg": avg_duration,
|
||||||
|
"median": median_duration,
|
||||||
|
"p95": p95,
|
||||||
|
"p99": p99,
|
||||||
|
"max": max_duration,
|
||||||
|
"success_rate": len(successful) / len(results) * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_benchmarks(iterations: int = 10, verbose: bool = False):
|
||||||
|
"""
|
||||||
|
Run comprehensive Steward benchmarks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
iterations: Number of iterations per scenario
|
||||||
|
verbose: Enable verbose output
|
||||||
|
"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔬 Steward Performance Benchmark")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\nTargets:")
|
||||||
|
print(f" - Maximum response time: ≤5.0s")
|
||||||
|
print(f" - Average response time: ≤1.67s")
|
||||||
|
print(f"\nIterations per scenario: {iterations}")
|
||||||
|
|
||||||
|
# Initialize application
|
||||||
|
print("\n🚀 Initializing application...")
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
|
all_stats = {}
|
||||||
|
|
||||||
|
# Scenario 1: Simple greeting (no capabilities needed)
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"Simple Greeting",
|
||||||
|
"Hello!",
|
||||||
|
[],
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["simple_greeting"] = analyze_results(results, "Simple Greeting")
|
||||||
|
|
||||||
|
# Scenario 2: Single tool request (calculator)
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"Calculator Request",
|
||||||
|
"What's sqrt(144) + 25?",
|
||||||
|
[],
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["calculator"] = analyze_results(results, "Calculator Request")
|
||||||
|
|
||||||
|
# Scenario 3: Web search request
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"Web Search Request",
|
||||||
|
"Search for the latest Python 3.12 features",
|
||||||
|
[],
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["web_search"] = analyze_results(results, "Web Search Request")
|
||||||
|
|
||||||
|
# Scenario 4: Request with conversation history (short)
|
||||||
|
short_history = [
|
||||||
|
{"role": "user", "content": "What's 15 times 7?"},
|
||||||
|
{"role": "assistant", "content": "105"},
|
||||||
|
]
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"With Short History",
|
||||||
|
"And what's that divided by 3?",
|
||||||
|
short_history,
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["short_history"] = analyze_results(results, "With Short History")
|
||||||
|
|
||||||
|
# Scenario 5: Request with longer conversation history
|
||||||
|
long_history = [
|
||||||
|
{"role": "user", "content": f"Question {i}"} if i % 2 == 0
|
||||||
|
else {"role": "assistant", "content": f"Answer {i}"}
|
||||||
|
for i in range(20)
|
||||||
|
]
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"With Long History",
|
||||||
|
"What was the first question I asked?",
|
||||||
|
long_history,
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["long_history"] = analyze_results(results, "With Long History")
|
||||||
|
|
||||||
|
# Scenario 6: Complex request
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"Complex Request",
|
||||||
|
"Calculate the compound interest on $5000 at 4.5% over 10 years, "
|
||||||
|
"then search for current savings account rates to compare",
|
||||||
|
[],
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["complex"] = analyze_results(results, "Complex Request")
|
||||||
|
|
||||||
|
# Scenario 7: Missing capabilities
|
||||||
|
results = await benchmark_scenario(
|
||||||
|
"Missing Capabilities",
|
||||||
|
"Generate an image of a sunset over mountains",
|
||||||
|
[],
|
||||||
|
iterations
|
||||||
|
)
|
||||||
|
all_stats["missing_caps"] = analyze_results(results, "Missing Capabilities")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("📈 SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Calculate overall stats
|
||||||
|
all_avgs = [stats["avg"] for stats in all_stats.values() if stats]
|
||||||
|
all_maxs = [stats["max"] for stats in all_stats.values() if stats]
|
||||||
|
|
||||||
|
if all_avgs:
|
||||||
|
overall_avg = statistics.mean(all_avgs)
|
||||||
|
overall_max = max(all_maxs)
|
||||||
|
|
||||||
|
avg_status = "✅" if overall_avg <= 1.67 else "⚠️"
|
||||||
|
max_status = "✅" if overall_max <= 5.0 else "⚠️"
|
||||||
|
|
||||||
|
print(f"\nOverall Performance:")
|
||||||
|
print(f" Average of averages: {overall_avg:.3f}s {avg_status}")
|
||||||
|
print(f" Maximum observed: {overall_max:.3f}s {max_status}")
|
||||||
|
|
||||||
|
# Performance verdict
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
if overall_avg <= 1.67 and overall_max <= 5.0:
|
||||||
|
print("✅ PERFORMANCE TARGETS MET!")
|
||||||
|
print(f" The Steward is operating within target parameters.")
|
||||||
|
elif overall_max <= 5.0:
|
||||||
|
print("⚠️ PARTIAL SUCCESS")
|
||||||
|
print(f" Max response time is good, but average is above target.")
|
||||||
|
print(f" Average: {overall_avg:.3f}s (target: ≤1.67s)")
|
||||||
|
print(f"\n Recommendations:")
|
||||||
|
print(f" - Consider using a faster model")
|
||||||
|
print(f" - Optimize system prompt length")
|
||||||
|
print(f" - Review tool call limits")
|
||||||
|
else:
|
||||||
|
print("❌ PERFORMANCE TARGETS NOT MET")
|
||||||
|
print(f" Max: {overall_max:.3f}s (target: ≤5.0s)")
|
||||||
|
print(f" Avg: {overall_avg:.3f}s (target: ≤1.67s)")
|
||||||
|
print(f"\n Recommendations:")
|
||||||
|
print(f" - Switch to a faster model (current: mistral-nemo)")
|
||||||
|
print(f" - Reduce system prompt complexity")
|
||||||
|
print(f" - Limit tool calls (currently limited to 3)")
|
||||||
|
print(f" - Consider caching household registry responses")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Benchmark Steward agent performance")
|
||||||
|
parser.add_argument(
|
||||||
|
"--iterations",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help="Number of iterations per scenario (default: 10)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose output"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
await run_benchmarks(iterations=args.iterations, verbose=args.verbose)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple test to verify Steward agent works correctly.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.agents.steward import analyze_request
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Test a simple request."""
|
||||||
|
print("Initializing application...")
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
|
print("\nTesting simple greeting...")
|
||||||
|
result = await analyze_request(
|
||||||
|
"Hello!",
|
||||||
|
conversation_history=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nResult type: {type(result)}")
|
||||||
|
print(f"Result: {result}")
|
||||||
|
|
||||||
|
if hasattr(result, 'recommended_capabilities'):
|
||||||
|
print(f"\nRecommended capabilities: {result.recommended_capabilities}")
|
||||||
|
print(f"Complexity: {result.estimated_complexity}")
|
||||||
|
print(f"Reasoning: {result.reasoning}")
|
||||||
|
else:
|
||||||
|
print("\nERROR: Result doesn't have expected attributes!")
|
||||||
|
print(f"Result attributes: {dir(result)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
The Biographer - Expert for recording and recalling the user's story.
|
||||||
|
|
||||||
|
The Biographer serves as the household's memory keeper, responsible for:
|
||||||
|
- Recording and recalling facts about the user's life
|
||||||
|
- Storing personal information, preferences, and insights
|
||||||
|
- Answering questions like "What car do I drive?", "Where do I work?"
|
||||||
|
- Managing what the household knows and remembers
|
||||||
|
|
||||||
|
For direct key-based lookups (location, timezone, preferences),
|
||||||
|
use the memory_service instead - it's faster and doesn't require LLM.
|
||||||
|
The Biographer handles semantic, fuzzy queries.
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import (
|
||||||
|
get_biographer_agent,
|
||||||
|
run_biographer,
|
||||||
|
run_biographer_stream,
|
||||||
|
)
|
||||||
|
from src.agents.biographer.capability import (
|
||||||
|
BIOGRAPHER_CAPABILITY,
|
||||||
|
get_biographer_capability,
|
||||||
|
register_biographer,
|
||||||
|
unregister_biographer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BIOGRAPHER_CAPABILITY",
|
||||||
|
"get_biographer_capability",
|
||||||
|
"get_biographer_agent",
|
||||||
|
"register_biographer",
|
||||||
|
"unregister_biographer",
|
||||||
|
"run_biographer",
|
||||||
|
"run_biographer_stream",
|
||||||
|
]
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
The Biographer - Expert for recording and recalling the user's story.
|
||||||
|
|
||||||
|
A PydanticAI agent that serves as the household's memory keeper:
|
||||||
|
- Records facts about the user's life, work, and preferences
|
||||||
|
- Recalls information semantically ("What car do I drive?")
|
||||||
|
- Manages user profile and preferences
|
||||||
|
- Forgets information when requested
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from src.agents.biographer.tools import (
|
||||||
|
forget_memory,
|
||||||
|
list_memories,
|
||||||
|
recall_semantic,
|
||||||
|
store_insight,
|
||||||
|
update_preference,
|
||||||
|
update_profile,
|
||||||
|
)
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# The Biographer's system prompt
|
||||||
|
BIOGRAPHER_SYSTEM_PROMPT = """You are The Biographer, the household's memory keeper in the Tatlock estate.
|
||||||
|
|
||||||
|
Your role is to record, recall, and manage the story of the user's life:
|
||||||
|
- Personal facts (vehicle, pets, family members, hobbies, interests)
|
||||||
|
- Life details (employer, occupation, significant events)
|
||||||
|
- Profile information (name, location, timezone)
|
||||||
|
- Preferences (units, theme, communication style)
|
||||||
|
|
||||||
|
## Your Character
|
||||||
|
|
||||||
|
You are a discreet and attentive chronicler. Like a personal biographer who has been
|
||||||
|
with the household for years, you:
|
||||||
|
- Listen carefully and remember important details
|
||||||
|
- Recall information accurately when asked
|
||||||
|
- Never gossip or volunteer unnecessary information
|
||||||
|
- Respect privacy absolutely
|
||||||
|
- Acknowledge when you don't know something rather than guessing
|
||||||
|
|
||||||
|
## Your Tools
|
||||||
|
|
||||||
|
### Recalling the Story
|
||||||
|
- **recall_semantic**: Your primary tool for answering questions about the user
|
||||||
|
- "What car do I drive?" → searches for car-related memories
|
||||||
|
- "Where do I work?" → finds employment information
|
||||||
|
- Finds relevant memories even without exact keywords
|
||||||
|
- **list_memories**: Browse all recorded memories of a type
|
||||||
|
- Use when user asks "What do you know about me?"
|
||||||
|
- Shows everything you've recorded
|
||||||
|
|
||||||
|
### Recording New Details
|
||||||
|
- **store_insight**: Record new facts from conversation
|
||||||
|
- User says "My car is a Tesla" → store_insight("car", "Tesla Model 3")
|
||||||
|
- User says "I work at Acme" → store_insight("employer", "Acme Corp")
|
||||||
|
- Use for facts that don't fit standard profile fields
|
||||||
|
- **update_profile**: Update core biographical fields
|
||||||
|
- name, location, timezone only
|
||||||
|
- "I live in Amsterdam" → update_profile("location", "Amsterdam")
|
||||||
|
- **update_preference**: Record user preferences
|
||||||
|
- temperature_unit, distance_unit, theme, etc.
|
||||||
|
- "Use Celsius please" → update_preference("temperature_unit", "celsius")
|
||||||
|
|
||||||
|
### Managing Records
|
||||||
|
- **forget_memory**: Remove specific records
|
||||||
|
- User asks to forget something → honor immediately
|
||||||
|
- Information becomes outdated → remove it
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### What to Record
|
||||||
|
- Explicit statements: "I drive a Tesla", "My wife is Sarah"
|
||||||
|
- Corrections: "Actually, I moved to Berlin"
|
||||||
|
- Preferences: "I prefer metric units"
|
||||||
|
|
||||||
|
### What NOT to Record
|
||||||
|
- Sensitive data: passwords, financial details, health information
|
||||||
|
- Temporary information: "I'm tired today"
|
||||||
|
- Speculation or assumptions
|
||||||
|
|
||||||
|
### Responding to Tatlock
|
||||||
|
Your responses go to Tatlock (the butler) who synthesizes the final answer. Be:
|
||||||
|
- Direct and factual
|
||||||
|
- Clear about what you found or didn't find
|
||||||
|
- Structured for easy integration with other responses
|
||||||
|
|
||||||
|
When you don't have information:
|
||||||
|
"I have no record of the user's [topic]. Would you like me to record this information?"
|
||||||
|
|
||||||
|
When recalling:
|
||||||
|
"According to my records, [information]. This was recorded [source/when if available]."
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lazy initialization to avoid connection issues during imports
|
||||||
|
_biographer_agent: Optional[Agent[None, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_biographer_agent() -> Agent[None, str]:
|
||||||
|
"""Create The Biographer PydanticAI agent."""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
|
model = OpenAIChatModel(
|
||||||
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[None, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=BIOGRAPHER_SYSTEM_PROMPT,
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register recall tools
|
||||||
|
agent.tool_plain(recall_semantic)
|
||||||
|
agent.tool_plain(list_memories)
|
||||||
|
|
||||||
|
# Register recording tools
|
||||||
|
agent.tool_plain(store_insight)
|
||||||
|
agent.tool_plain(update_profile)
|
||||||
|
agent.tool_plain(update_preference)
|
||||||
|
|
||||||
|
# Register management tools
|
||||||
|
agent.tool_plain(forget_memory)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_agent_created",
|
||||||
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
tool_count=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def get_biographer_agent() -> Agent[None, str]:
|
||||||
|
"""
|
||||||
|
Get The Biographer agent instance (lazy initialization).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PydanticAI Agent configured for memory tasks
|
||||||
|
"""
|
||||||
|
global _biographer_agent
|
||||||
|
if _biographer_agent is None:
|
||||||
|
_biographer_agent = _create_biographer_agent()
|
||||||
|
return _biographer_agent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute a memory task with The Biographer.
|
||||||
|
|
||||||
|
This is the main entry point for delegating memory tasks
|
||||||
|
from Tatlock or other agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The memory task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory results or confirmation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await run_biographer(
|
||||||
|
task="What car do I drive?",
|
||||||
|
context="User is asking about their vehicle",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
agent = get_biographer_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_task_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
has_history=bool(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent.run(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_task_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(result.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"biographer_task_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return f"The Biographer encountered an error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_biographer_stream(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Execute a memory task with streaming output.
|
||||||
|
|
||||||
|
Yields text deltas as The Biographer generates the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The memory task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Text deltas from the response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async for delta in run_biographer_stream("What do you know about me?"):
|
||||||
|
print(delta, end="", flush=True)
|
||||||
|
"""
|
||||||
|
agent = get_biographer_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_stream_started",
|
||||||
|
task=task[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with agent.run_stream(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
) as response:
|
||||||
|
async for delta in response.stream_text(delta=True):
|
||||||
|
yield delta
|
||||||
|
|
||||||
|
logger.info("biographer_stream_completed", task=task[:50])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"biographer_stream_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield f"\n\nThe Biographer encountered an error: {str(e)}"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Biographer capability registration for the Household Registry.
|
||||||
|
|
||||||
|
Defines The Biographer's capabilities and registers it as a
|
||||||
|
household member for coordination by the Steward and Tatlock.
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import get_biographer_agent
|
||||||
|
from src.agents.biographer.tools import BIOGRAPHER_TOOLS
|
||||||
|
from src.core.household_registry import (
|
||||||
|
HouseholdCapability,
|
||||||
|
get_household_registry,
|
||||||
|
)
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The Biographer's capability summary for Steward coordination
|
||||||
|
BIOGRAPHER_CAPABILITY = HouseholdCapability(
|
||||||
|
name="biographer",
|
||||||
|
role="The Biographer",
|
||||||
|
category="context",
|
||||||
|
description=(
|
||||||
|
"Memory keeper for the user's story: can RECALL personal facts "
|
||||||
|
"(car, job, family, pets), RECORD new information learned from "
|
||||||
|
"conversation, UPDATE profile (name, location, timezone) and "
|
||||||
|
"preferences (units, theme), and FORGET information when requested. "
|
||||||
|
"Use for: 'what car do I drive?', 'remember that I...', "
|
||||||
|
"'forget my...', 'what do you know about me?'"
|
||||||
|
),
|
||||||
|
domains=[
|
||||||
|
"remember",
|
||||||
|
"recall",
|
||||||
|
"forget",
|
||||||
|
"memory",
|
||||||
|
"preferences",
|
||||||
|
"profile",
|
||||||
|
"personal",
|
||||||
|
"know",
|
||||||
|
"about me",
|
||||||
|
"my",
|
||||||
|
],
|
||||||
|
cost="low", # Mostly vector search, minimal LLM
|
||||||
|
requires_network=False, # All local (Qdrant, Redis)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_biographer_capability() -> HouseholdCapability:
|
||||||
|
"""Get The Biographer's capability definition."""
|
||||||
|
return BIOGRAPHER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
def register_biographer() -> None:
|
||||||
|
"""
|
||||||
|
Register The Biographer with the Household Registry.
|
||||||
|
|
||||||
|
This makes The Biographer available for:
|
||||||
|
- Steward recommendations (via capability summary)
|
||||||
|
- Tatlock delegation (via agent reference)
|
||||||
|
- Tool scoping (via tool list)
|
||||||
|
"""
|
||||||
|
registry = get_household_registry()
|
||||||
|
|
||||||
|
# Check if already registered
|
||||||
|
if "biographer" in registry:
|
||||||
|
logger.debug("biographer_already_registered")
|
||||||
|
return
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="biographer",
|
||||||
|
capability=BIOGRAPHER_CAPABILITY,
|
||||||
|
tools=BIOGRAPHER_TOOLS,
|
||||||
|
agent=get_biographer_agent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_registered",
|
||||||
|
role=BIOGRAPHER_CAPABILITY.role,
|
||||||
|
domains=BIOGRAPHER_CAPABILITY.domains,
|
||||||
|
tool_count=len(BIOGRAPHER_TOOLS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_biographer() -> None:
|
||||||
|
"""Unregister The Biographer from the Household Registry."""
|
||||||
|
registry = get_household_registry()
|
||||||
|
registry.unregister("biographer")
|
||||||
|
logger.info("biographer_unregistered")
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""
|
||||||
|
Biographer tools for PydanticAI agent.
|
||||||
|
|
||||||
|
These tools enable The Biographer to record and recall the user's story:
|
||||||
|
- recall_semantic: Find memories by meaning/concept
|
||||||
|
- store_insight: Record new facts about the user
|
||||||
|
- list_memories: Browse recorded memories by type
|
||||||
|
- forget_memory: Remove specific memories
|
||||||
|
|
||||||
|
For direct key-based access (get/set profile, preferences),
|
||||||
|
use memory_service directly - these tools are for semantic queries.
|
||||||
|
"""
|
||||||
|
from src.core.context import get_user
|
||||||
|
from src.core.embeddings import get_embedding_client
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.memory_service import MemoryType, memory_service
|
||||||
|
from src.core.qdrant import get_qdrant_client
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Semantic Recall
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def recall_semantic(
|
||||||
|
query: str,
|
||||||
|
memory_type: str = "",
|
||||||
|
limit: int = 5,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search memories by semantic similarity.
|
||||||
|
|
||||||
|
Use this to find memories that are conceptually related to
|
||||||
|
the query, even if exact words don't match. This is the main
|
||||||
|
tool for answering questions like "What car do I drive?" or
|
||||||
|
"What did I mention about my job?"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query to search for
|
||||||
|
memory_type: Optional filter: "user_profile", "preference", "learned_fact"
|
||||||
|
limit: Maximum memories to return (default: 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching memories with their content and relevance scores
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
recall_semantic("What is my car?")
|
||||||
|
recall_semantic("work preferences", memory_type="preference")
|
||||||
|
recall_semantic("family members")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_user()
|
||||||
|
embedding_client = get_embedding_client()
|
||||||
|
qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
# Generate embedding for query
|
||||||
|
query_vector = await embedding_client.embed(query)
|
||||||
|
if not query_vector:
|
||||||
|
return "Unable to process query - embedding generation failed"
|
||||||
|
|
||||||
|
# Search memories
|
||||||
|
results = await qdrant.search_memories(
|
||||||
|
user=user,
|
||||||
|
query_vector=query_vector,
|
||||||
|
limit=limit,
|
||||||
|
memory_type=memory_type if memory_type else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No memories found related to '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Memories matching: {query}\n"]
|
||||||
|
|
||||||
|
for i, memory in enumerate(results, 1):
|
||||||
|
mem_type = memory.get("type", "unknown")
|
||||||
|
key = memory.get("key", "")
|
||||||
|
value = memory.get("value", "")
|
||||||
|
score = memory.get("score", 0.0)
|
||||||
|
source = memory.get("source", "unknown")
|
||||||
|
|
||||||
|
type_icon = {
|
||||||
|
"user_profile": "👤",
|
||||||
|
"preference": "⚙️",
|
||||||
|
"learned_fact": "💡",
|
||||||
|
}.get(mem_type, "📝")
|
||||||
|
|
||||||
|
output_parts.append(f"{i}. {type_icon} **{key}** (relevance: {score:.2f})")
|
||||||
|
output_parts.append(f" {value}")
|
||||||
|
output_parts.append(f" _Type: {mem_type}, Source: {source}_")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_recall_semantic",
|
||||||
|
query=query[:50],
|
||||||
|
result_count=len(results),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_recall_semantic_error", error=str(e), query=query[:50])
|
||||||
|
return f"Error searching memories: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Store Memory
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def store_insight(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
importance: float = 0.5,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Store a new insight or learned fact about the user.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User explicitly asks to remember something
|
||||||
|
- User shares personal information worth remembering
|
||||||
|
- You learn something from conversation that should persist
|
||||||
|
|
||||||
|
The memory will be stored with vector embedding for semantic search
|
||||||
|
and can be recalled later using recall_semantic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Short identifier for the memory (e.g., "car", "employer", "pet")
|
||||||
|
value: The actual information to remember
|
||||||
|
importance: How important is this? 0.0 (trivial) to 1.0 (critical)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of stored memory
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
store_insight("car", "User drives a Tesla Model 3")
|
||||||
|
store_insight("employer", "Works at Acme Corp as software engineer", importance=0.8)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Auto-generate keywords from key and value
|
||||||
|
keywords = [key]
|
||||||
|
words = value.lower().split()
|
||||||
|
keywords.extend([w for w in words if len(w) > 4][:5])
|
||||||
|
|
||||||
|
success = await memory_service.store_fact(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords,
|
||||||
|
importance=importance,
|
||||||
|
source="conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Memory Stored",
|
||||||
|
f"**Key:** {key}",
|
||||||
|
f"**Value:** {value}",
|
||||||
|
f"**Keywords:** {', '.join(keywords)}",
|
||||||
|
f"**Importance:** {importance:.1f}",
|
||||||
|
"",
|
||||||
|
"_Memory is now searchable via semantic recall._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_store_insight",
|
||||||
|
key=key,
|
||||||
|
importance=importance,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to store memory for key '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_store_insight_error", error=str(e), key=key)
|
||||||
|
return f"Error storing memory: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Update user profile information.
|
||||||
|
|
||||||
|
Use this for core identity information:
|
||||||
|
- name, location, timezone
|
||||||
|
- language preferences
|
||||||
|
- occupation
|
||||||
|
|
||||||
|
Profile data has high importance and is used for context
|
||||||
|
by the Steward during request analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile field (e.g., "name", "location", "timezone")
|
||||||
|
value: The value to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of profile update
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
update_profile("location", "Amsterdam, Netherlands")
|
||||||
|
update_profile("timezone", "Europe/Amsterdam")
|
||||||
|
update_profile("name", "John")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await memory_service.set_profile(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=[key, "profile"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Profile Updated",
|
||||||
|
f"**{key}:** {value}",
|
||||||
|
"",
|
||||||
|
"_Profile data is automatically included in context._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_update_profile",
|
||||||
|
key=key,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to update profile field '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_update_profile_error", error=str(e), key=key)
|
||||||
|
return f"Error updating profile: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_preference(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Update user preferences.
|
||||||
|
|
||||||
|
Use this for settings and preferences:
|
||||||
|
- temperature_unit (celsius/fahrenheit)
|
||||||
|
- distance_unit (metric/imperial)
|
||||||
|
- theme, language, etc.
|
||||||
|
|
||||||
|
Preferences are used by agents to customize responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference name (e.g., "temperature_unit", "theme")
|
||||||
|
value: Preference value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of preference update
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
update_preference("temperature_unit", "celsius")
|
||||||
|
update_preference("distance_unit", "metric")
|
||||||
|
update_preference("theme", "dark")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await memory_service.set_preference(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Preference Updated",
|
||||||
|
f"**{key}:** {value}",
|
||||||
|
"",
|
||||||
|
"_Preference will be applied to future responses._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_update_preference",
|
||||||
|
key=key,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to update preference '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_update_preference_error", error=str(e), key=key)
|
||||||
|
return f"Error updating preference: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# List Memories
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def list_memories(
|
||||||
|
memory_type: str = "learned_fact",
|
||||||
|
limit: int = 20,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
List stored memories of a specific type.
|
||||||
|
|
||||||
|
Use this to browse what's stored in memory without
|
||||||
|
a specific search query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
memory_type: Type to list: "user_profile", "preference", "learned_fact"
|
||||||
|
limit: Maximum memories to return (default: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of memories with their keys and values
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_memories("user_profile")
|
||||||
|
list_memories("preference")
|
||||||
|
list_memories("learned_fact", limit=10)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_user()
|
||||||
|
qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
# Convert string to MemoryType
|
||||||
|
try:
|
||||||
|
mem_type = MemoryType(memory_type)
|
||||||
|
except ValueError:
|
||||||
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
||||||
|
|
||||||
|
# Get all memories of type
|
||||||
|
results = qdrant._client.scroll(
|
||||||
|
collection_name=f"memories_{user}",
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
if not points:
|
||||||
|
return f"No {memory_type} memories found"
|
||||||
|
|
||||||
|
type_icon = {
|
||||||
|
"user_profile": "👤",
|
||||||
|
"preference": "⚙️",
|
||||||
|
"learned_fact": "💡",
|
||||||
|
}.get(memory_type, "📝")
|
||||||
|
|
||||||
|
output_parts = [f"## {type_icon} {memory_type.replace('_', ' ').title()} Memories\n"]
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
payload = point.payload
|
||||||
|
key = payload.get("key", "unknown")
|
||||||
|
value = payload.get("value", "")
|
||||||
|
importance = payload.get("importance", 0.5)
|
||||||
|
|
||||||
|
output_parts.append(f"- **{key}**: {value}")
|
||||||
|
if importance > 0.7:
|
||||||
|
output_parts.append(f" _(importance: {importance:.1f})_")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_list",
|
||||||
|
memory_type=memory_type,
|
||||||
|
count=len(points),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_list_error", error=str(e), memory_type=memory_type)
|
||||||
|
return f"Error listing memories: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Forget Memory
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def forget_memory(
|
||||||
|
key: str,
|
||||||
|
memory_type: str = "learned_fact",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Remove a specific memory.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User asks to forget something
|
||||||
|
- Information is outdated or incorrect
|
||||||
|
- Privacy concerns
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Key of the memory to forget
|
||||||
|
memory_type: Type of memory: "user_profile", "preference", "learned_fact"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of deletion
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
forget_memory("old_car")
|
||||||
|
forget_memory("location", memory_type="user_profile")
|
||||||
|
forget_memory("theme", memory_type="preference")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert string to MemoryType
|
||||||
|
try:
|
||||||
|
mem_type = MemoryType(memory_type)
|
||||||
|
except ValueError:
|
||||||
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
||||||
|
|
||||||
|
success = await memory_service.delete_memory(
|
||||||
|
key=key,
|
||||||
|
memory_type=mem_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Memory Forgotten",
|
||||||
|
f"**Key:** {key}",
|
||||||
|
f"**Type:** {memory_type}",
|
||||||
|
"",
|
||||||
|
"_Memory has been removed._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_forget",
|
||||||
|
key=key,
|
||||||
|
memory_type=memory_type,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Memory '{key}' not found or already deleted"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_forget_error", error=str(e), key=key)
|
||||||
|
return f"Error forgetting memory: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tool Collection for Registration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# All tools available to The Biographer
|
||||||
|
BIOGRAPHER_TOOLS = [
|
||||||
|
# Recall
|
||||||
|
recall_semantic,
|
||||||
|
list_memories,
|
||||||
|
# Record
|
||||||
|
store_insight,
|
||||||
|
update_profile,
|
||||||
|
update_preference,
|
||||||
|
# Manage
|
||||||
|
forget_memory,
|
||||||
|
]
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"""
|
||||||
|
Multi-agent coordination engine.
|
||||||
|
|
||||||
|
Orchestrates delegation from Tatlock to expert agents (Librarian, etc.)
|
||||||
|
based on Steward recommendations. Handles:
|
||||||
|
- Routing tasks to appropriate agents
|
||||||
|
- Parallel and sequential execution
|
||||||
|
- Result aggregation
|
||||||
|
- Error handling and graceful degradation
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from src.agents.librarian import run_librarian, run_librarian_stream
|
||||||
|
from src.agents.protocol import (
|
||||||
|
AgentError,
|
||||||
|
AgentRequest,
|
||||||
|
AgentResponse,
|
||||||
|
AgentTimeoutError,
|
||||||
|
AgentUnavailableError,
|
||||||
|
CoordinationResult,
|
||||||
|
DelegationIntent,
|
||||||
|
DelegationReason,
|
||||||
|
ToolCallRecord,
|
||||||
|
)
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Agent execution functions registry
|
||||||
|
AGENT_EXECUTORS: dict[str, Any] = {
|
||||||
|
"librarian": run_librarian,
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_STREAM_EXECUTORS: dict[str, Any] = {
|
||||||
|
"librarian": run_librarian_stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinationEngine:
|
||||||
|
"""
|
||||||
|
Coordinates multi-agent task execution.
|
||||||
|
|
||||||
|
Routes tasks from Tatlock to appropriate expert agents,
|
||||||
|
handles execution, and aggregates results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the coordination engine."""
|
||||||
|
self.registry = get_household_registry()
|
||||||
|
logger.info("coordination_engine_initialized")
|
||||||
|
|
||||||
|
def get_available_agents(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get list of available expert agents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of agent names that can accept delegations
|
||||||
|
"""
|
||||||
|
available = []
|
||||||
|
for name in self.registry.list_members():
|
||||||
|
member = self.registry.get_member(name)
|
||||||
|
if member and member.agent is not None:
|
||||||
|
available.append(name)
|
||||||
|
return available
|
||||||
|
|
||||||
|
def can_delegate_to(self, agent_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if delegation to an agent is possible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Name of the target agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if agent is available and can accept tasks
|
||||||
|
"""
|
||||||
|
if agent_name not in AGENT_EXECUTORS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
member = self.registry.get_member(agent_name)
|
||||||
|
return member is not None and member.agent is not None
|
||||||
|
|
||||||
|
async def execute_delegation(
|
||||||
|
self,
|
||||||
|
intent: DelegationIntent,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> AgentResponse:
|
||||||
|
"""
|
||||||
|
Execute a single delegation to an expert agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intent: The delegation intent with task details
|
||||||
|
context: Additional context for the agent
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentResponse with results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentUnavailableError: If agent is not available
|
||||||
|
AgentTimeoutError: If execution times out
|
||||||
|
AgentError: For other execution errors
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
agent_name = intent.target_agent
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_started",
|
||||||
|
agent=agent_name,
|
||||||
|
task=intent.task[:100],
|
||||||
|
reason=intent.reason.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if agent is available
|
||||||
|
if not self.can_delegate_to(agent_name):
|
||||||
|
raise AgentUnavailableError(
|
||||||
|
f"Agent '{agent_name}' is not available for delegation",
|
||||||
|
agent_name=agent_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the executor
|
||||||
|
executor = AGENT_EXECUTORS.get(agent_name)
|
||||||
|
if not executor:
|
||||||
|
raise AgentUnavailableError(
|
||||||
|
f"No executor found for agent '{agent_name}'",
|
||||||
|
agent_name=agent_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build the request
|
||||||
|
request = AgentRequest(
|
||||||
|
task=intent.task,
|
||||||
|
context=context,
|
||||||
|
delegation_reason=intent.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute with timeout
|
||||||
|
timeout = request.timeout_seconds or 60
|
||||||
|
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
executor(
|
||||||
|
task=request.task,
|
||||||
|
context=request.context,
|
||||||
|
message_history=message_history,
|
||||||
|
),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_completed",
|
||||||
|
agent=agent_name,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
output_length=len(result),
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResponse(
|
||||||
|
success=True,
|
||||||
|
result=result,
|
||||||
|
reasoning=f"Delegated to {agent_name}: {intent.expected_outcome}",
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
"delegation_timeout",
|
||||||
|
agent=agent_name,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
raise AgentTimeoutError(
|
||||||
|
f"Agent '{agent_name}' timed out after {duration_ms}ms",
|
||||||
|
agent_name=agent_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
"delegation_error",
|
||||||
|
agent=agent_name,
|
||||||
|
error=str(e),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return AgentResponse(
|
||||||
|
success=False,
|
||||||
|
result="",
|
||||||
|
error_message=str(e),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute_delegation_stream(
|
||||||
|
self,
|
||||||
|
intent: DelegationIntent,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Execute a delegation with streaming output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intent: The delegation intent with task details
|
||||||
|
context: Additional context for the agent
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Text deltas from the agent
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentUnavailableError: If agent is not available
|
||||||
|
"""
|
||||||
|
agent_name = intent.target_agent
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_stream_started",
|
||||||
|
agent=agent_name,
|
||||||
|
task=intent.task[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if agent is available
|
||||||
|
if agent_name not in AGENT_STREAM_EXECUTORS:
|
||||||
|
raise AgentUnavailableError(
|
||||||
|
f"Agent '{agent_name}' does not support streaming",
|
||||||
|
agent_name=agent_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
executor = AGENT_STREAM_EXECUTORS[agent_name]
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for delta in executor(
|
||||||
|
task=intent.task,
|
||||||
|
context=context,
|
||||||
|
message_history=message_history,
|
||||||
|
):
|
||||||
|
yield delta
|
||||||
|
|
||||||
|
logger.info("delegation_stream_completed", agent=agent_name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_stream_error",
|
||||||
|
agent=agent_name,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield f"\n\n[Error from {agent_name}: {str(e)}]"
|
||||||
|
|
||||||
|
async def coordinate(
|
||||||
|
self,
|
||||||
|
intents: list[DelegationIntent],
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> CoordinationResult:
|
||||||
|
"""
|
||||||
|
Coordinate execution of multiple delegations.
|
||||||
|
|
||||||
|
Handles parallel execution for independent tasks and
|
||||||
|
sequential execution for dependent tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intents: List of delegation intents to execute
|
||||||
|
context: Shared context for all agents
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CoordinationResult with aggregated results
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
agent_responses: dict[str, AgentResponse] = {}
|
||||||
|
agents_consulted: list[str] = []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"coordination_started",
|
||||||
|
intent_count=len(intents),
|
||||||
|
agents=[i.target_agent for i in intents],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
sorted_intents = sorted(intents, key=lambda x: x.priority)
|
||||||
|
|
||||||
|
# Group by dependencies (simple version: sequential for now)
|
||||||
|
# TODO: Implement parallel execution for independent tasks
|
||||||
|
for intent in sorted_intents:
|
||||||
|
try:
|
||||||
|
response = await self.execute_delegation(
|
||||||
|
intent=intent,
|
||||||
|
context=context,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
agent_responses[intent.target_agent] = response
|
||||||
|
if response.success:
|
||||||
|
agents_consulted.append(intent.target_agent)
|
||||||
|
|
||||||
|
except AgentError as e:
|
||||||
|
agent_responses[intent.target_agent] = AgentResponse(
|
||||||
|
success=False,
|
||||||
|
result="",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate results
|
||||||
|
successful_results = [
|
||||||
|
r.result for r in agent_responses.values() if r.success and r.result
|
||||||
|
]
|
||||||
|
|
||||||
|
final_response = "\n\n---\n\n".join(successful_results) if successful_results else ""
|
||||||
|
|
||||||
|
total_duration = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"coordination_completed",
|
||||||
|
total_duration_ms=total_duration,
|
||||||
|
agents_consulted=agents_consulted,
|
||||||
|
success_count=len(successful_results),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CoordinationResult(
|
||||||
|
final_response=final_response,
|
||||||
|
agent_responses=agent_responses,
|
||||||
|
delegation_intents=intents,
|
||||||
|
total_duration_ms=total_duration,
|
||||||
|
agents_consulted=agents_consulted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global coordination engine instance
|
||||||
|
_coordination_engine: Optional[CoordinationEngine] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_coordination_engine() -> CoordinationEngine:
|
||||||
|
"""Get the global coordination engine instance."""
|
||||||
|
global _coordination_engine
|
||||||
|
if _coordination_engine is None:
|
||||||
|
_coordination_engine = CoordinationEngine()
|
||||||
|
return _coordination_engine
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
reason: DelegationReason = DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> AgentResponse:
|
||||||
|
"""
|
||||||
|
Convenience function to delegate a task to The Librarian.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Research task description
|
||||||
|
context: Additional context
|
||||||
|
reason: Why delegating to Librarian
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentResponse with research results
|
||||||
|
"""
|
||||||
|
engine = get_coordination_engine()
|
||||||
|
|
||||||
|
intent = DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task=task,
|
||||||
|
reason=reason,
|
||||||
|
expected_outcome="Research findings and relevant information",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await engine.execute_delegation(
|
||||||
|
intent=intent,
|
||||||
|
context=context,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_librarian_stream(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Convenience function to delegate to Librarian with streaming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Research task description
|
||||||
|
context: Additional context
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Text deltas from The Librarian
|
||||||
|
"""
|
||||||
|
engine = get_coordination_engine()
|
||||||
|
|
||||||
|
intent = DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task=task,
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Research findings",
|
||||||
|
)
|
||||||
|
|
||||||
|
async for delta in engine.execute_delegation_stream(
|
||||||
|
intent=intent,
|
||||||
|
context=context,
|
||||||
|
message_history=message_history,
|
||||||
|
):
|
||||||
|
yield delta
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
"""
|
||||||
|
Delegation infrastructure for expert agent calls.
|
||||||
|
|
||||||
|
Provides delegation wrappers that Tatlock uses to call expert agents.
|
||||||
|
Each wrapper encapsulates the complexity of calling an expert and
|
||||||
|
returns a structured result for synthesis.
|
||||||
|
|
||||||
|
This implements the agent-as-tool pattern recommended by PydanticAI:
|
||||||
|
agents call other agents via tool wrappers, keeping each agent focused.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, Callable, Optional, Any
|
||||||
|
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Action Types for Think Slug Selection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ActionType(Enum):
|
||||||
|
"""
|
||||||
|
Categories of actions for selecting appropriate think messages.
|
||||||
|
|
||||||
|
Each expert has different action types that warrant different
|
||||||
|
butler-perspective messages to the user.
|
||||||
|
"""
|
||||||
|
RETRIEVE = "retrieve" # Looking up existing information
|
||||||
|
RESEARCH = "research" # Conducting new research (web search, etc.)
|
||||||
|
CREATE = "create" # Creating new content (pages, notes)
|
||||||
|
CONTROL = "control" # Controlling devices/automations
|
||||||
|
RECORD = "record" # Recording memories/notes
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Household Think Messages (Butler's Perspective)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
|
||||||
|
# Note: No <think> wrappers needed - these go to reasoning_content field
|
||||||
|
"librarian": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "Allow me to consult the archives, sir.",
|
||||||
|
"success": "The Librarian has compiled the relevant findings.",
|
||||||
|
"error": "I'm afraid the archives proved difficult to access.",
|
||||||
|
},
|
||||||
|
ActionType.RESEARCH: {
|
||||||
|
"start": "I've dispatched the Librarian to conduct some fresh research.",
|
||||||
|
"success": "The Librarian has returned with findings, sir.",
|
||||||
|
"error": "The research proved inconclusive, I'm afraid.",
|
||||||
|
},
|
||||||
|
ActionType.CREATE: {
|
||||||
|
"start": "I'm having the Librarian prepare a new entry.",
|
||||||
|
"success": "The new material has been properly catalogued, sir.",
|
||||||
|
"error": "I'm afraid there was difficulty filing the entry.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"biographer": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "Let me consult the household records.",
|
||||||
|
"success": "The Biographer has located the relevant information, sir.",
|
||||||
|
"error": "I'm unable to locate those particular records.",
|
||||||
|
},
|
||||||
|
ActionType.RECORD: {
|
||||||
|
"start": "I've asked the Biographer to take note of this, sir.",
|
||||||
|
"success": "The household records have been updated accordingly.",
|
||||||
|
"error": "I'm afraid there was difficulty recording the entry.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"housekeeper": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "Allow me to inquire with the household staff.",
|
||||||
|
"success": "The staff reports the current status, sir.",
|
||||||
|
"error": "The household staff is momentarily unavailable, I'm afraid.",
|
||||||
|
},
|
||||||
|
ActionType.CONTROL: {
|
||||||
|
"start": "I'm instructing the household staff now, sir.",
|
||||||
|
"success": "The household has been configured as requested.",
|
||||||
|
"error": "I'm afraid the staff reports an issue with that request.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_action_type(expert: str, task: str) -> ActionType:
|
||||||
|
"""
|
||||||
|
Detect action type from expert name and task description.
|
||||||
|
|
||||||
|
Used to select appropriate butler-perspective think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expert: Name of the expert (librarian, biographer, housekeeper)
|
||||||
|
task: Task description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ActionType: Detected action type for message selection
|
||||||
|
"""
|
||||||
|
task_lower = task.lower()
|
||||||
|
|
||||||
|
if expert == "librarian":
|
||||||
|
# Web search, URL reading = RESEARCH (fresh external data)
|
||||||
|
if any(w in task_lower for w in ["search", "find", "look up", "research"]):
|
||||||
|
if any(w in task_lower for w in ["web", "online", "internet"]):
|
||||||
|
return ActionType.RESEARCH
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
if any(w in task_lower for w in ["read", "fetch", "url", "http"]):
|
||||||
|
return ActionType.RESEARCH # Reading URLs is research
|
||||||
|
if any(w in task_lower for w in ["create", "write", "add", "make", "new"]):
|
||||||
|
return ActionType.CREATE
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
elif expert == "biographer":
|
||||||
|
if any(w in task_lower for w in ["remember", "note", "record", "save", "store"]):
|
||||||
|
return ActionType.RECORD
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
elif expert == "housekeeper":
|
||||||
|
if any(w in task_lower for w in ["turn", "set", "activate", "enable", "disable", "toggle"]):
|
||||||
|
return ActionType.CONTROL
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
|
||||||
|
def get_think_message(expert: str, task: str, phase: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the appropriate think message for an expert delegation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expert: Name of the expert
|
||||||
|
task: Task description (used to detect action type)
|
||||||
|
phase: One of "start", "success", "error"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Butler-perspective think message
|
||||||
|
"""
|
||||||
|
action_type = _detect_action_type(expert, task)
|
||||||
|
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
|
||||||
|
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
|
||||||
|
return action_messages.get(phase, f"Consulting {expert}...")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DelegationTask:
|
||||||
|
"""
|
||||||
|
A task to be delegated to an expert agent.
|
||||||
|
|
||||||
|
Represents a unit of work that Tatlock delegates to a specialist.
|
||||||
|
Used for tracking and orchestration of multi-expert workflows.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expert_name: Name of the expert agent (e.g., "librarian", "memory")
|
||||||
|
task: Clear description of what needs to be done
|
||||||
|
context: Additional context from the conversation
|
||||||
|
action: Specific action verb (create, search, update, etc.)
|
||||||
|
priority: Execution priority (lower = higher priority)
|
||||||
|
depends_on: List of task IDs this task depends on
|
||||||
|
result: Result from expert after execution
|
||||||
|
"""
|
||||||
|
expert_name: str
|
||||||
|
task: str
|
||||||
|
context: str = ""
|
||||||
|
action: str = ""
|
||||||
|
priority: int = 0
|
||||||
|
depends_on: list[str] = field(default_factory=list)
|
||||||
|
result: Optional[str] = None
|
||||||
|
task_id: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Generate task ID if not provided."""
|
||||||
|
if not self.task_id:
|
||||||
|
import uuid
|
||||||
|
self.task_id = f"{self.expert_name}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DelegationResult:
|
||||||
|
"""
|
||||||
|
Result from an expert agent delegation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expert_name: Which expert handled the task
|
||||||
|
task: Original task description
|
||||||
|
success: Whether the delegation succeeded
|
||||||
|
output: Expert's response/findings
|
||||||
|
error: Error message if failed
|
||||||
|
"""
|
||||||
|
expert_name: str
|
||||||
|
task: str
|
||||||
|
success: bool
|
||||||
|
output: str
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Delegate a research or wiki task to The Librarian.
|
||||||
|
|
||||||
|
The Librarian handles:
|
||||||
|
- Wiki creation (smart_create_wiki_page for topic-based)
|
||||||
|
- Wiki updates (update_wiki_page for modifications)
|
||||||
|
- Research queries (hybrid_search for comprehensive search)
|
||||||
|
- Knowledge graph exploration
|
||||||
|
- Document lookups and semantic search
|
||||||
|
|
||||||
|
This wrapper uses run() not run_stream() to avoid Ollama's
|
||||||
|
streaming + tool call bug (PydanticAI issues #1292, #2256).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear description of what needs to be done.
|
||||||
|
Include the action verb (create, search, update, etc.)
|
||||||
|
Example: "Create a wiki page about CI/CD pipelines"
|
||||||
|
Example: "Search for information about Docker networking"
|
||||||
|
context: Additional context from the user's request or
|
||||||
|
conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult with the Librarian's findings
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = await delegate_to_librarian(
|
||||||
|
... task="Create a wiki page about Kubernetes deployments",
|
||||||
|
... context="User is setting up a homelab cluster",
|
||||||
|
... )
|
||||||
|
>>> if result.success:
|
||||||
|
... print(result.output)
|
||||||
|
"""
|
||||||
|
from src.agents.librarian.agent import run_librarian
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_librarian_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_librarian(task=task, context=context)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_librarian_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task=task,
|
||||||
|
success=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_to_librarian_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Delegate a memory task to The Biographer.
|
||||||
|
|
||||||
|
The Biographer handles:
|
||||||
|
- Semantic recall ("What car do I drive?", "What's my job?")
|
||||||
|
- Recording new facts from conversation
|
||||||
|
- Profile updates (name, location, timezone)
|
||||||
|
- Preference updates (units, theme)
|
||||||
|
- Memory management (forget, list)
|
||||||
|
|
||||||
|
For direct key-based lookups (get location, get timezone), use
|
||||||
|
memory_service directly - it's faster and doesn't require LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear description of what needs to be done.
|
||||||
|
Include the action verb (recall, remember, forget, etc.)
|
||||||
|
Example: "What car do I drive?"
|
||||||
|
Example: "Remember that I work at Acme Corp"
|
||||||
|
context: Additional context from the user's request or
|
||||||
|
conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult with The Biographer's response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = await delegate_to_biographer(
|
||||||
|
... task="What do you know about my preferences?",
|
||||||
|
... context="User is asking about stored information",
|
||||||
|
... )
|
||||||
|
>>> if result.success:
|
||||||
|
... print(result.output)
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import run_biographer
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_biographer_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_biographer(task=task, context=context)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_biographer_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="biographer",
|
||||||
|
task=task,
|
||||||
|
success=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_to_biographer_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="biographer",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_housekeeper(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Delegate a home automation task to The Housekeeper.
|
||||||
|
|
||||||
|
The Housekeeper handles:
|
||||||
|
- Device control (turn on/off, toggle, brightness, color)
|
||||||
|
- Scene activation (movie night, good morning, etc.)
|
||||||
|
- Script execution (automation sequences)
|
||||||
|
- Automation management (enable/disable rules)
|
||||||
|
- Device discovery (list devices by area/type)
|
||||||
|
- State queries (get current state, history)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear description of what needs to be done.
|
||||||
|
Include the action verb (turn on, activate, list, etc.)
|
||||||
|
Example: "Turn on the living room lights"
|
||||||
|
Example: "Activate the movie night scene"
|
||||||
|
Example: "What devices are in the bedroom?"
|
||||||
|
context: Additional context from the user's request or
|
||||||
|
conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult with The Housekeeper's response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = await delegate_to_housekeeper(
|
||||||
|
... task="Turn on the bedroom lights at 50% brightness",
|
||||||
|
... context="User is getting ready for bed",
|
||||||
|
... )
|
||||||
|
>>> if result.success:
|
||||||
|
... print(result.output)
|
||||||
|
"""
|
||||||
|
from src.agents.housekeeper.agent import run_housekeeper
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_housekeeper_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_housekeeper(task=task, context=context)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_housekeeper_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="housekeeper",
|
||||||
|
task=task,
|
||||||
|
success=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_to_housekeeper_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="housekeeper",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Streaming Delegation Wrappers (with Think Messages)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def stream_delegate_to_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Librarian with automatic think messages.
|
||||||
|
|
||||||
|
Yields butler-perspective think messages before and after the delegation,
|
||||||
|
allowing the UI to show progress to the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
# Yield start message (deterministic)
|
||||||
|
yield get_think_message("librarian", task, "start") + "\n"
|
||||||
|
|
||||||
|
# Execute delegation
|
||||||
|
result = await delegate_to_librarian(task, context)
|
||||||
|
|
||||||
|
# Yield completion message (deterministic)
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("librarian", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("librarian", task, "error") + "\n"
|
||||||
|
|
||||||
|
# Yield result marker for extraction
|
||||||
|
yield f"__DELEGATION_RESULT__:librarian:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_delegate_to_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Biographer with automatic think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
yield get_think_message("biographer", task, "start") + "\n"
|
||||||
|
|
||||||
|
result = await delegate_to_biographer(task, context)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("biographer", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("biographer", task, "error") + "\n"
|
||||||
|
|
||||||
|
yield f"__DELEGATION_RESULT__:biographer:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_delegate_to_housekeeper(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Housekeeper with automatic think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
yield get_think_message("housekeeper", task, "start") + "\n"
|
||||||
|
|
||||||
|
result = await delegate_to_housekeeper(task, context)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("housekeeper", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("housekeeper", task, "error") + "\n"
|
||||||
|
|
||||||
|
yield f"__DELEGATION_RESULT__:housekeeper:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of streaming delegation wrappers
|
||||||
|
STREAMING_DELEGATION_WRAPPERS = {
|
||||||
|
"librarian": stream_delegate_to_librarian,
|
||||||
|
"biographer": stream_delegate_to_biographer,
|
||||||
|
"housekeeper": stream_delegate_to_housekeeper,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Future expert delegation wrappers will be added here:
|
||||||
|
# - delegate_to_developer(task, context) -> DelegationResult
|
||||||
|
# - delegate_to_secretary(task, context) -> DelegationResult
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
The Housekeeper - Home Automation Agent.
|
||||||
|
|
||||||
|
Provides home automation capabilities through the core-api service,
|
||||||
|
which wraps the Home Assistant REST API into LLM-friendly endpoints.
|
||||||
|
"""
|
||||||
|
from src.agents.housekeeper.agent import run_housekeeper, run_housekeeper_stream
|
||||||
|
from src.agents.housekeeper.capability import (
|
||||||
|
HOUSEKEEPER_CAPABILITY,
|
||||||
|
register_housekeeper,
|
||||||
|
)
|
||||||
|
from src.agents.housekeeper.client import CoreAPIClient, get_core_api_client
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Agent entry points
|
||||||
|
"run_housekeeper",
|
||||||
|
"run_housekeeper_stream",
|
||||||
|
# Capability
|
||||||
|
"HOUSEKEEPER_CAPABILITY",
|
||||||
|
"register_housekeeper",
|
||||||
|
# Client
|
||||||
|
"CoreAPIClient",
|
||||||
|
"get_core_api_client",
|
||||||
|
]
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
The Housekeeper - Expert agent for home automation.
|
||||||
|
|
||||||
|
A PydanticAI agent that provides home automation capabilities through
|
||||||
|
the core-api service, which wraps Home Assistant REST API, offering:
|
||||||
|
- Device discovery and control
|
||||||
|
- Scene activation
|
||||||
|
- Script execution
|
||||||
|
- Automation management
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from src.agents.housekeeper.tools import (
|
||||||
|
activate_scene,
|
||||||
|
get_device_state,
|
||||||
|
get_history,
|
||||||
|
list_areas,
|
||||||
|
list_automations,
|
||||||
|
list_devices,
|
||||||
|
list_scenes,
|
||||||
|
list_scripts,
|
||||||
|
run_script,
|
||||||
|
toggle,
|
||||||
|
toggle_automation,
|
||||||
|
turn_off,
|
||||||
|
turn_on,
|
||||||
|
)
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Housekeeper system prompt
|
||||||
|
HOUSEKEEPER_SYSTEM_PROMPT = """You are The Housekeeper, an expert home automation assistant in the Tatlock household.
|
||||||
|
|
||||||
|
Your role is to help users control and monitor their smart home through Home Assistant:
|
||||||
|
- Lights, switches, and other devices
|
||||||
|
- Scenes (pre-configured device states)
|
||||||
|
- Scripts (automation sequences)
|
||||||
|
- Automations (event-triggered rules)
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- Efficient and practical
|
||||||
|
- Safety-conscious (confirm destructive actions)
|
||||||
|
- Proactive in suggesting optimizations
|
||||||
|
- Clear about what actions you're taking
|
||||||
|
|
||||||
|
## Your Tools
|
||||||
|
|
||||||
|
### Discovery Tools
|
||||||
|
- **list_areas**: See all rooms/areas configured in Home Assistant
|
||||||
|
- **list_devices**: Find devices by type (domain) or location (area)
|
||||||
|
- **get_device_state**: Check a device's current state and attributes
|
||||||
|
|
||||||
|
### Control Tools
|
||||||
|
- **turn_on**: Turn on lights, switches, etc. (supports brightness/color for lights)
|
||||||
|
- **turn_off**: Turn off devices
|
||||||
|
- **toggle**: Flip a device's state
|
||||||
|
|
||||||
|
### Scene Tools
|
||||||
|
- **list_scenes**: See available scene presets
|
||||||
|
- **activate_scene**: Activate a scene (e.g., "movie night", "good morning")
|
||||||
|
|
||||||
|
### Script Tools
|
||||||
|
- **list_scripts**: See available automation scripts
|
||||||
|
- **run_script**: Execute a script
|
||||||
|
|
||||||
|
### Automation Tools
|
||||||
|
- **list_automations**: See all automations and their status
|
||||||
|
- **toggle_automation**: Enable or disable an automation
|
||||||
|
|
||||||
|
### History Tools
|
||||||
|
- **get_history**: Check a device's state history
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Device Discovery First**: If the user asks about devices without being specific,
|
||||||
|
use list_devices to find what's available before acting.
|
||||||
|
|
||||||
|
2. **Confirm State After Actions**: After turning something on/off, you can verify
|
||||||
|
with get_device_state if needed.
|
||||||
|
|
||||||
|
3. **Use Entity IDs**: Devices are identified by entity_id (e.g., light.living_room).
|
||||||
|
Always use the exact entity_id from list_devices.
|
||||||
|
|
||||||
|
4. **Area-Aware**: When users say "living room lights", filter by area="living_room".
|
||||||
|
|
||||||
|
5. **Safety**: For actions affecting multiple devices or automations, summarize
|
||||||
|
what you're about to do.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
- "Turn on the lights" → list_devices(domain="light"), then turn_on each
|
||||||
|
- "What's on?" → list_devices() and filter for state="on"
|
||||||
|
- "Movie time" → Either activate_scene("scene.movie_night") or run_script if available
|
||||||
|
- "Dim the bedroom" → turn_on("light.bedroom", brightness=64)
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
Your responses are returned to Tatlock (the butler) who will synthesize them into
|
||||||
|
a final answer for the user. Keep this in mind:
|
||||||
|
- Lead with confirmation of what you did or found
|
||||||
|
- Be specific about which devices were affected
|
||||||
|
- Include relevant state information
|
||||||
|
- Note any issues or failures
|
||||||
|
- Be concise - Tatlock will format the final response
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lazy initialization to avoid connection issues during imports
|
||||||
|
_housekeeper_agent: Optional[Agent[None, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_housekeeper_agent() -> Agent[None, str]:
|
||||||
|
"""Create the Housekeeper PydanticAI agent."""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
|
model = OpenAIChatModel(
|
||||||
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[None, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=HOUSEKEEPER_SYSTEM_PROMPT,
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register discovery tools
|
||||||
|
agent.tool_plain(list_areas)
|
||||||
|
agent.tool_plain(list_devices)
|
||||||
|
agent.tool_plain(get_device_state)
|
||||||
|
|
||||||
|
# Register control tools
|
||||||
|
agent.tool_plain(turn_on)
|
||||||
|
agent.tool_plain(turn_off)
|
||||||
|
agent.tool_plain(toggle)
|
||||||
|
|
||||||
|
# Register scene tools
|
||||||
|
agent.tool_plain(list_scenes)
|
||||||
|
agent.tool_plain(activate_scene)
|
||||||
|
|
||||||
|
# Register script tools
|
||||||
|
agent.tool_plain(list_scripts)
|
||||||
|
agent.tool_plain(run_script)
|
||||||
|
|
||||||
|
# Register automation tools
|
||||||
|
agent.tool_plain(list_automations)
|
||||||
|
agent.tool_plain(toggle_automation)
|
||||||
|
|
||||||
|
# Register history tools
|
||||||
|
agent.tool_plain(get_history)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"housekeeper_agent_created",
|
||||||
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
tool_count=13,
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def get_housekeeper_agent() -> Agent[None, str]:
|
||||||
|
"""
|
||||||
|
Get the Housekeeper agent instance (lazy initialization).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PydanticAI Agent configured for home automation tasks
|
||||||
|
"""
|
||||||
|
global _housekeeper_agent
|
||||||
|
if _housekeeper_agent is None:
|
||||||
|
_housekeeper_agent = _create_housekeeper_agent()
|
||||||
|
return _housekeeper_agent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_housekeeper(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute a home automation task with The Housekeeper.
|
||||||
|
|
||||||
|
This is the main entry point for delegating home automation tasks
|
||||||
|
to The Housekeeper from Tatlock or other agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The home automation task or request
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Results and confirmation of actions
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await run_housekeeper(
|
||||||
|
task="Turn on the living room lights",
|
||||||
|
context="It's evening",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
agent = get_housekeeper_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"housekeeper_task_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
has_history=bool(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent.run(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"housekeeper_task_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(result.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"housekeeper_task_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return f"The Housekeeper encountered an error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_housekeeper_stream(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Execute a home automation task with streaming output.
|
||||||
|
|
||||||
|
Yields text deltas as The Housekeeper generates the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The home automation task or request
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Text deltas from the response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async for delta in run_housekeeper_stream("Turn on the lights"):
|
||||||
|
print(delta, end="", flush=True)
|
||||||
|
"""
|
||||||
|
agent = get_housekeeper_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"housekeeper_stream_started",
|
||||||
|
task=task[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with agent.run_stream(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
) as response:
|
||||||
|
async for delta in response.stream_text(delta=True):
|
||||||
|
yield delta
|
||||||
|
|
||||||
|
logger.info("housekeeper_stream_completed", task=task[:50])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"housekeeper_stream_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield f"\n\nThe Housekeeper encountered an error: {str(e)}"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Housekeeper capability registration for the Household Registry.
|
||||||
|
|
||||||
|
Defines The Housekeeper's capabilities and registers it as a
|
||||||
|
household member for coordination by the Steward and Tatlock.
|
||||||
|
"""
|
||||||
|
from src.agents.housekeeper.agent import get_housekeeper_agent
|
||||||
|
from src.agents.housekeeper.tools import HOUSEKEEPER_TOOLS
|
||||||
|
from src.core.household_registry import (
|
||||||
|
HouseholdCapability,
|
||||||
|
get_household_registry,
|
||||||
|
)
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The Housekeeper's capability summary for Steward coordination
|
||||||
|
HOUSEKEEPER_CAPABILITY = HouseholdCapability(
|
||||||
|
name="housekeeper",
|
||||||
|
role="The Housekeeper",
|
||||||
|
category="automation",
|
||||||
|
description=(
|
||||||
|
"Home automation control: TURN ON/OFF devices, ACTIVATE scenes, "
|
||||||
|
"RUN scripts, LIST devices, MANAGE automations. Controls lights, "
|
||||||
|
"switches, climate, and other smart home devices via Home Assistant."
|
||||||
|
),
|
||||||
|
domains=[
|
||||||
|
"lights",
|
||||||
|
"switches",
|
||||||
|
"automation",
|
||||||
|
"home",
|
||||||
|
"smart home",
|
||||||
|
"scene",
|
||||||
|
"script",
|
||||||
|
"device",
|
||||||
|
"turn on",
|
||||||
|
"turn off",
|
||||||
|
"temperature",
|
||||||
|
"climate",
|
||||||
|
"fan",
|
||||||
|
"cover",
|
||||||
|
"blinds",
|
||||||
|
],
|
||||||
|
cost="low", # Fast local API calls to core-api
|
||||||
|
requires_network=True, # Needs core-api access
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_housekeeper_capability() -> HouseholdCapability:
|
||||||
|
"""Get The Housekeeper's capability definition."""
|
||||||
|
return HOUSEKEEPER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
def register_housekeeper() -> None:
|
||||||
|
"""
|
||||||
|
Register The Housekeeper with the Household Registry.
|
||||||
|
|
||||||
|
This makes The Housekeeper available for:
|
||||||
|
- Steward recommendations (via capability summary)
|
||||||
|
- Tatlock delegation (via agent reference)
|
||||||
|
- Tool scoping (via tool list)
|
||||||
|
"""
|
||||||
|
registry = get_household_registry()
|
||||||
|
|
||||||
|
# Check if already registered
|
||||||
|
if "housekeeper" in registry:
|
||||||
|
logger.debug("housekeeper_already_registered")
|
||||||
|
return
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="housekeeper",
|
||||||
|
capability=HOUSEKEEPER_CAPABILITY,
|
||||||
|
tools=HOUSEKEEPER_TOOLS,
|
||||||
|
agent=get_housekeeper_agent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"housekeeper_registered",
|
||||||
|
role=HOUSEKEEPER_CAPABILITY.role,
|
||||||
|
domains=HOUSEKEEPER_CAPABILITY.domains,
|
||||||
|
tool_count=len(HOUSEKEEPER_TOOLS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_housekeeper() -> None:
|
||||||
|
"""Unregister The Housekeeper from the Household Registry."""
|
||||||
|
registry = get_household_registry()
|
||||||
|
registry.unregister("housekeeper")
|
||||||
|
logger.info("housekeeper_unregistered")
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""
|
||||||
|
HTTP client for the Core-API service.
|
||||||
|
|
||||||
|
Provides async methods for home automation operations via Home Assistant.
|
||||||
|
Core-API is a separate service that wraps the Home Assistant REST API
|
||||||
|
into LLM-friendly endpoints.
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Response Models
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class Device(BaseModel):
|
||||||
|
"""Device from Home Assistant."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str
|
||||||
|
state: str
|
||||||
|
domain: str
|
||||||
|
area: Optional[str] = None
|
||||||
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceState(BaseModel):
|
||||||
|
"""Detailed state of a device."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
state: str
|
||||||
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
last_changed: Optional[str] = None
|
||||||
|
last_updated: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Scene(BaseModel):
|
||||||
|
"""Scene from Home Assistant."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str
|
||||||
|
friendly_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Script(BaseModel):
|
||||||
|
"""Script from Home Assistant."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
last_triggered: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Automation(BaseModel):
|
||||||
|
"""Automation from Home Assistant."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str
|
||||||
|
state: str = "on"
|
||||||
|
last_triggered: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryEntry(BaseModel):
|
||||||
|
"""History entry for an entity."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
timestamp: str
|
||||||
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlResult(BaseModel):
|
||||||
|
"""Result of a device control operation."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
entity_id: str
|
||||||
|
action: str
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Area(BaseModel):
|
||||||
|
"""Area/room from Home Assistant."""
|
||||||
|
|
||||||
|
area_id: str
|
||||||
|
name: str
|
||||||
|
device_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Client
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class CoreAPIClient:
|
||||||
|
"""
|
||||||
|
Async HTTP client for Core-API (Home Assistant wrapper).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
devices = await client.list_devices()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Core-API URL (defaults to config)
|
||||||
|
api_key: API key for authentication (defaults to config)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or str(config.CORE_API_HOST)
|
||||||
|
self.api_key = api_key or config.CORE_API_KEY
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "CoreAPIClient":
|
||||||
|
"""Create HTTP client on context entry."""
|
||||||
|
headers = {}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
"""Close HTTP client on context exit."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def _ensure_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Ensure client is initialized."""
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Client not initialized. Use 'async with CoreAPIClient() as client:'"
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Device Discovery
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def list_devices(
|
||||||
|
self,
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
area: Optional[str] = None,
|
||||||
|
) -> list[Device]:
|
||||||
|
"""
|
||||||
|
List devices, optionally filtered by domain or area.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Filter by domain (light, switch, climate, etc.)
|
||||||
|
area: Filter by area (living_room, bedroom, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of devices matching filters
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if domain:
|
||||||
|
params["domain"] = domain
|
||||||
|
if area:
|
||||||
|
params["area"] = area
|
||||||
|
|
||||||
|
logger.debug("core_api_list_devices", domain=domain, area=area)
|
||||||
|
|
||||||
|
response = await client.get("/devices", params=params or None)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Device(**d) for d in data.get("devices", [])]
|
||||||
|
|
||||||
|
async def list_areas(self) -> list[Area]:
|
||||||
|
"""
|
||||||
|
List all areas/rooms in Home Assistant.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of areas with device counts
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_list_areas")
|
||||||
|
|
||||||
|
response = await client.get("/areas")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Area(**a) for a in data.get("areas", [])]
|
||||||
|
|
||||||
|
async def get_device_state(self, entity_id: str) -> DeviceState:
|
||||||
|
"""
|
||||||
|
Get the current state of a specific device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Home Assistant entity ID (e.g., light.living_room)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current device state with attributes
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_get_state", entity_id=entity_id)
|
||||||
|
|
||||||
|
response = await client.get(f"/entities/{entity_id}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return DeviceState(**response.json())
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Device Control
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def turn_on(
|
||||||
|
self,
|
||||||
|
entity_id: str,
|
||||||
|
brightness: Optional[int] = None,
|
||||||
|
color_temp: Optional[int] = None,
|
||||||
|
rgb_color: Optional[tuple[int, int, int]] = None,
|
||||||
|
) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Turn on a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to turn on
|
||||||
|
brightness: Optional brightness (0-255) for lights
|
||||||
|
color_temp: Optional color temperature in Kelvin for lights
|
||||||
|
rgb_color: Optional RGB color tuple for lights
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {"action": "turn_on"}
|
||||||
|
if brightness is not None:
|
||||||
|
payload["brightness"] = brightness
|
||||||
|
if color_temp is not None:
|
||||||
|
payload["color_temp"] = color_temp
|
||||||
|
if rgb_color is not None:
|
||||||
|
payload["rgb_color"] = list(rgb_color)
|
||||||
|
|
||||||
|
logger.info("core_api_turn_on", entity_id=entity_id, payload=payload)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/devices/{entity_id}/control",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=entity_id,
|
||||||
|
action="turn_on",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def turn_off(self, entity_id: str) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Turn off a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to turn off
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.info("core_api_turn_off", entity_id=entity_id)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/devices/{entity_id}/control",
|
||||||
|
json={"action": "turn_off"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=entity_id,
|
||||||
|
action="turn_off",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def toggle(self, entity_id: str) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Toggle a device's state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to toggle
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.info("core_api_toggle", entity_id=entity_id)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/devices/{entity_id}/control",
|
||||||
|
json={"action": "toggle"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=entity_id,
|
||||||
|
action="toggle",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Scenes
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def list_scenes(self) -> list[Scene]:
|
||||||
|
"""
|
||||||
|
List all available scenes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scenes
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_list_scenes")
|
||||||
|
|
||||||
|
response = await client.get("/scenes")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Scene(**s) for s in data.get("scenes", [])]
|
||||||
|
|
||||||
|
async def activate_scene(self, scene_id: str) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Activate a scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scene_id: Scene entity ID (e.g., scene.movie_night)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.info("core_api_activate_scene", scene_id=scene_id)
|
||||||
|
|
||||||
|
response = await client.post(f"/scenes/{scene_id}/activate")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=scene_id,
|
||||||
|
action="activate",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Scripts
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def list_scripts(self) -> list[Script]:
|
||||||
|
"""
|
||||||
|
List all available scripts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scripts
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_list_scripts")
|
||||||
|
|
||||||
|
response = await client.get("/scripts")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Script(**s) for s in data.get("scripts", [])]
|
||||||
|
|
||||||
|
async def run_script(
|
||||||
|
self,
|
||||||
|
script_id: str,
|
||||||
|
variables: Optional[dict[str, Any]] = None,
|
||||||
|
) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Run a script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_id: Script entity ID (e.g., script.good_morning)
|
||||||
|
variables: Optional variables to pass to the script
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if variables:
|
||||||
|
payload["variables"] = variables
|
||||||
|
|
||||||
|
logger.info("core_api_run_script", script_id=script_id)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/scripts/{script_id}/run",
|
||||||
|
json=payload or None,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=script_id,
|
||||||
|
action="run",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Automations
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def list_automations(self) -> list[Automation]:
|
||||||
|
"""
|
||||||
|
List all automations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of automations with their states
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_list_automations")
|
||||||
|
|
||||||
|
response = await client.get("/automations")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Automation(**a) for a in data.get("automations", [])]
|
||||||
|
|
||||||
|
async def toggle_automation(
|
||||||
|
self,
|
||||||
|
automation_id: str,
|
||||||
|
enable: bool,
|
||||||
|
) -> ControlResult:
|
||||||
|
"""
|
||||||
|
Enable or disable an automation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
automation_id: Automation entity ID
|
||||||
|
enable: True to enable, False to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the operation
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"core_api_toggle_automation",
|
||||||
|
automation_id=automation_id,
|
||||||
|
enable=enable,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/automations/{automation_id}/toggle",
|
||||||
|
json={"enable": enable},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return ControlResult(
|
||||||
|
success=data.get("success", True),
|
||||||
|
entity_id=automation_id,
|
||||||
|
action="enable" if enable else "disable",
|
||||||
|
message=data.get("message", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# History
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
self,
|
||||||
|
entity_id: str,
|
||||||
|
hours: int = 24,
|
||||||
|
) -> list[HistoryEntry]:
|
||||||
|
"""
|
||||||
|
Get history for an entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Entity to get history for
|
||||||
|
hours: Number of hours of history (default: 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of historical state entries
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("core_api_get_history", entity_id=entity_id, hours=hours)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/history",
|
||||||
|
params={"entity_id": entity_id, "hours": hours},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [HistoryEntry(**h) for h in data.get("history", [])]
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Health Check
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if core-api and Home Assistant are healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._ensure_client()
|
||||||
|
response = await client.get("/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("core_api_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client factory
|
||||||
|
async def get_core_api_client() -> CoreAPIClient:
|
||||||
|
"""
|
||||||
|
Get a core-api client instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with get_core_api_client() as client:
|
||||||
|
devices = await client.list_devices()
|
||||||
|
"""
|
||||||
|
return CoreAPIClient()
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
"""
|
||||||
|
Housekeeper tools for PydanticAI agent.
|
||||||
|
|
||||||
|
These tools wrap the core-api service and are registered with
|
||||||
|
The Housekeeper agent for home automation tasks.
|
||||||
|
"""
|
||||||
|
from src.agents.housekeeper.client import CoreAPIClient
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Device Discovery
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def list_devices(
|
||||||
|
domain: str | None = None,
|
||||||
|
area: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
List available devices in the smart home.
|
||||||
|
|
||||||
|
Use this to discover what devices can be controlled.
|
||||||
|
Can filter by domain (device type) or area (room).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Device type filter (light, switch, climate, cover, fan, etc.)
|
||||||
|
area: Room/area filter (living_room, bedroom, kitchen, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of devices with their current states
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_devices() # All devices
|
||||||
|
list_devices(domain="light") # Only lights
|
||||||
|
list_devices(area="living_room") # Living room devices
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
devices = await client.list_devices(domain=domain, area=area)
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
filters = []
|
||||||
|
if domain:
|
||||||
|
filters.append(f"domain={domain}")
|
||||||
|
if area:
|
||||||
|
filters.append(f"area={area}")
|
||||||
|
filter_str = f" with filters: {', '.join(filters)}" if filters else ""
|
||||||
|
return f"No devices found{filter_str}"
|
||||||
|
|
||||||
|
# Group by domain for readability
|
||||||
|
by_domain: dict[str, list] = {}
|
||||||
|
for device in devices:
|
||||||
|
by_domain.setdefault(device.domain, []).append(device)
|
||||||
|
|
||||||
|
output_parts = ["## Smart Home Devices\n"]
|
||||||
|
|
||||||
|
for dom, dom_devices in sorted(by_domain.items()):
|
||||||
|
output_parts.append(f"### {dom.title()}s")
|
||||||
|
for device in dom_devices:
|
||||||
|
state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state
|
||||||
|
area_str = f" ({device.area})" if device.area else ""
|
||||||
|
output_parts.append(f"- **{device.name}**{area_str}: {state_icon}")
|
||||||
|
output_parts.append(f" ID: `{device.entity_id}`")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
logger.info("housekeeper_list_devices", count=len(devices))
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_list_devices_error", error=str(e))
|
||||||
|
return f"Error listing devices: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_areas() -> str:
|
||||||
|
"""
|
||||||
|
List all areas/rooms in the smart home.
|
||||||
|
|
||||||
|
Use this to discover what rooms/areas are configured in Home Assistant.
|
||||||
|
Useful before filtering devices by area.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of areas with device counts
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_areas() # See all rooms/areas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
areas = await client.list_areas()
|
||||||
|
|
||||||
|
if not areas:
|
||||||
|
return "No areas found in Home Assistant"
|
||||||
|
|
||||||
|
output_parts = ["## Smart Home Areas\n"]
|
||||||
|
|
||||||
|
for area in sorted(areas, key=lambda a: a.name):
|
||||||
|
device_str = f" ({area.device_count} devices)" if area.device_count else ""
|
||||||
|
output_parts.append(f"- **{area.name}**{device_str}")
|
||||||
|
output_parts.append(f" ID: `{area.area_id}`")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
output_parts.append(f"*{len(areas)} areas total*")
|
||||||
|
|
||||||
|
logger.info("housekeeper_list_areas", count=len(areas))
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_list_areas_error", error=str(e))
|
||||||
|
return f"Error listing areas: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_device_state(entity_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the current state and attributes of a specific device.
|
||||||
|
|
||||||
|
Use this to check a device's detailed status before or after control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: The device entity ID (e.g., light.living_room, switch.coffee_maker)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detailed device state including all attributes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_device_state("light.living_room")
|
||||||
|
get_device_state("climate.bedroom")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
state = await client.get_device_state(entity_id)
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Device: {entity_id}",
|
||||||
|
f"**State:** {state.state}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if state.last_changed:
|
||||||
|
output_parts.append(f"**Last Changed:** {state.last_changed}")
|
||||||
|
|
||||||
|
if state.attributes:
|
||||||
|
output_parts.append("\n**Attributes:**")
|
||||||
|
for key, value in state.attributes.items():
|
||||||
|
if key not in ("friendly_name", "entity_id"):
|
||||||
|
output_parts.append(f"- {key}: {value}")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_get_state_error", error=str(e), entity_id=entity_id)
|
||||||
|
return f"Error getting state for {entity_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Device Control
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def turn_on(
|
||||||
|
entity_id: str,
|
||||||
|
brightness: int | None = None,
|
||||||
|
color_temp: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Turn on a device.
|
||||||
|
|
||||||
|
For lights, can optionally set brightness and color temperature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to turn on (e.g., light.living_room, switch.coffee_maker)
|
||||||
|
brightness: Optional brightness for lights (0-255, where 255 is full brightness)
|
||||||
|
color_temp: Optional color temperature in Kelvin (2700=warm, 6500=cool)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the action
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
turn_on("light.living_room") # Turn on at current brightness
|
||||||
|
turn_on("light.bedroom", brightness=128) # Turn on at 50% brightness
|
||||||
|
turn_on("light.office", brightness=255, color_temp=4000) # Full, neutral white
|
||||||
|
turn_on("switch.coffee_maker") # Turn on a switch
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.turn_on(
|
||||||
|
entity_id=entity_id,
|
||||||
|
brightness=brightness,
|
||||||
|
color_temp=color_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
extras = []
|
||||||
|
if brightness is not None:
|
||||||
|
extras.append(f"brightness {brightness}/255")
|
||||||
|
if color_temp is not None:
|
||||||
|
extras.append(f"color temp {color_temp}K")
|
||||||
|
|
||||||
|
extra_str = f" ({', '.join(extras)})" if extras else ""
|
||||||
|
return f"Turned on {entity_id}{extra_str}"
|
||||||
|
else:
|
||||||
|
return f"Failed to turn on {entity_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_turn_on_error", error=str(e), entity_id=entity_id)
|
||||||
|
return f"Error turning on {entity_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def turn_off(entity_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Turn off a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to turn off (e.g., light.living_room, switch.coffee_maker)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the action
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
turn_off("light.living_room")
|
||||||
|
turn_off("switch.coffee_maker")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.turn_off(entity_id=entity_id)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return f"Turned off {entity_id}"
|
||||||
|
else:
|
||||||
|
return f"Failed to turn off {entity_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_turn_off_error", error=str(e), entity_id=entity_id)
|
||||||
|
return f"Error turning off {entity_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle(entity_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Toggle a device's state (on becomes off, off becomes on).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to toggle
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation with the new state
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
toggle("light.living_room")
|
||||||
|
toggle("switch.fan")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.toggle(entity_id=entity_id)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return f"Toggled {entity_id}"
|
||||||
|
else:
|
||||||
|
return f"Failed to toggle {entity_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_toggle_error", error=str(e), entity_id=entity_id)
|
||||||
|
return f"Error toggling {entity_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Scenes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def list_scenes() -> str:
|
||||||
|
"""
|
||||||
|
List all available scenes.
|
||||||
|
|
||||||
|
Scenes are pre-configured combinations of device states.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of available scenes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_scenes()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
scenes = await client.list_scenes()
|
||||||
|
|
||||||
|
if not scenes:
|
||||||
|
return "No scenes found"
|
||||||
|
|
||||||
|
output_parts = ["## Available Scenes\n"]
|
||||||
|
for scene in scenes:
|
||||||
|
name = scene.friendly_name or scene.name
|
||||||
|
output_parts.append(f"- **{name}**")
|
||||||
|
output_parts.append(f" ID: `{scene.entity_id}`")
|
||||||
|
|
||||||
|
logger.info("housekeeper_list_scenes", count=len(scenes))
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_list_scenes_error", error=str(e))
|
||||||
|
return f"Error listing scenes: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_scene(scene_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Activate a scene.
|
||||||
|
|
||||||
|
This sets all devices in the scene to their configured states.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scene_id: Scene entity ID (e.g., scene.movie_night, scene.good_morning)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of activation
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
activate_scene("scene.movie_night")
|
||||||
|
activate_scene("scene.good_morning")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.activate_scene(scene_id=scene_id)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return f"Activated scene: {scene_id}"
|
||||||
|
else:
|
||||||
|
return f"Failed to activate {scene_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_activate_scene_error", error=str(e), scene_id=scene_id)
|
||||||
|
return f"Error activating scene {scene_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Scripts
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def list_scripts() -> str:
|
||||||
|
"""
|
||||||
|
List all available automation scripts.
|
||||||
|
|
||||||
|
Scripts are sequences of actions that can be triggered manually.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of available scripts
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_scripts()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
scripts = await client.list_scripts()
|
||||||
|
|
||||||
|
if not scripts:
|
||||||
|
return "No scripts found"
|
||||||
|
|
||||||
|
output_parts = ["## Available Scripts\n"]
|
||||||
|
for script in scripts:
|
||||||
|
output_parts.append(f"- **{script.name}**")
|
||||||
|
if script.description:
|
||||||
|
output_parts.append(f" {script.description}")
|
||||||
|
output_parts.append(f" ID: `{script.entity_id}`")
|
||||||
|
if script.last_triggered:
|
||||||
|
output_parts.append(f" Last run: {script.last_triggered}")
|
||||||
|
|
||||||
|
logger.info("housekeeper_list_scripts", count=len(scripts))
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_list_scripts_error", error=str(e))
|
||||||
|
return f"Error listing scripts: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_script(script_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Run an automation script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_id: Script entity ID (e.g., script.good_morning, script.bedtime)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of execution
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
run_script("script.good_morning")
|
||||||
|
run_script("script.all_lights_off")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.run_script(script_id=script_id)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return f"Running script: {script_id}"
|
||||||
|
else:
|
||||||
|
return f"Failed to run {script_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_run_script_error", error=str(e), script_id=script_id)
|
||||||
|
return f"Error running script {script_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Automations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def list_automations() -> str:
|
||||||
|
"""
|
||||||
|
List all automations and their current states.
|
||||||
|
|
||||||
|
Automations are event-triggered rules that run automatically.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of automations with enabled/disabled status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_automations()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
automations = await client.list_automations()
|
||||||
|
|
||||||
|
if not automations:
|
||||||
|
return "No automations found"
|
||||||
|
|
||||||
|
output_parts = ["## Automations\n"]
|
||||||
|
|
||||||
|
# Group by state
|
||||||
|
enabled = [a for a in automations if a.state == "on"]
|
||||||
|
disabled = [a for a in automations if a.state != "on"]
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
output_parts.append("### Enabled")
|
||||||
|
for auto in enabled:
|
||||||
|
output_parts.append(f"- **{auto.name}**")
|
||||||
|
output_parts.append(f" ID: `{auto.entity_id}`")
|
||||||
|
if auto.last_triggered:
|
||||||
|
output_parts.append(f" Last triggered: {auto.last_triggered}")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
if disabled:
|
||||||
|
output_parts.append("### Disabled")
|
||||||
|
for auto in disabled:
|
||||||
|
output_parts.append(f"- **{auto.name}**")
|
||||||
|
output_parts.append(f" ID: `{auto.entity_id}`")
|
||||||
|
|
||||||
|
logger.info("housekeeper_list_automations", count=len(automations))
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_list_automations_error", error=str(e))
|
||||||
|
return f"Error listing automations: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_automation(automation_id: str, enable: bool) -> str:
|
||||||
|
"""
|
||||||
|
Enable or disable an automation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
automation_id: Automation entity ID
|
||||||
|
enable: True to enable, False to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the change
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
toggle_automation("automation.morning_lights", enable=True)
|
||||||
|
toggle_automation("automation.vacation_mode", enable=False)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
result = await client.toggle_automation(
|
||||||
|
automation_id=automation_id,
|
||||||
|
enable=enable,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "Enabled" if enable else "Disabled"
|
||||||
|
if result.success:
|
||||||
|
return f"{action} automation: {automation_id}"
|
||||||
|
else:
|
||||||
|
return f"Failed to {action.lower()} {automation_id}: {result.message}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"housekeeper_toggle_automation_error",
|
||||||
|
error=str(e),
|
||||||
|
automation_id=automation_id,
|
||||||
|
)
|
||||||
|
return f"Error toggling automation {automation_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# History
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(entity_id: str, hours: int = 24) -> str:
|
||||||
|
"""
|
||||||
|
Get the state history of a device.
|
||||||
|
|
||||||
|
Useful for understanding patterns or troubleshooting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Device to get history for
|
||||||
|
hours: Number of hours of history (default: 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of state changes over the time period
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_history("light.living_room")
|
||||||
|
get_history("climate.bedroom", hours=48)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with CoreAPIClient() as client:
|
||||||
|
history = await client.get_history(entity_id=entity_id, hours=hours)
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
return f"No history found for {entity_id} in the last {hours} hours"
|
||||||
|
|
||||||
|
output_parts = [f"## History: {entity_id}", f"*Last {hours} hours*\n"]
|
||||||
|
|
||||||
|
for entry in history[-20:]: # Show last 20 entries
|
||||||
|
output_parts.append(f"- **{entry.timestamp}**: {entry.state}")
|
||||||
|
|
||||||
|
if len(history) > 20:
|
||||||
|
output_parts.append(f"\n*(showing last 20 of {len(history)} entries)*")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("housekeeper_get_history_error", error=str(e), entity_id=entity_id)
|
||||||
|
return f"Error getting history for {entity_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tool Collection for Registration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# All tools available to The Housekeeper
|
||||||
|
HOUSEKEEPER_TOOLS = [
|
||||||
|
# Discovery
|
||||||
|
list_areas,
|
||||||
|
list_devices,
|
||||||
|
get_device_state,
|
||||||
|
# Control
|
||||||
|
turn_on,
|
||||||
|
turn_off,
|
||||||
|
toggle,
|
||||||
|
# Scenes
|
||||||
|
list_scenes,
|
||||||
|
activate_scene,
|
||||||
|
# Scripts
|
||||||
|
list_scripts,
|
||||||
|
run_script,
|
||||||
|
# Automations
|
||||||
|
list_automations,
|
||||||
|
toggle_automation,
|
||||||
|
# History
|
||||||
|
get_history,
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
The Librarian - Expert agent for research and knowledge management.
|
||||||
|
|
||||||
|
Connects to the library-desk API to provide:
|
||||||
|
- HybridRAG search (vector + graph + web)
|
||||||
|
- Wiki.js operations
|
||||||
|
- Knowledge graph queries
|
||||||
|
- Semantic search
|
||||||
|
"""
|
||||||
|
from src.agents.librarian.agent import (
|
||||||
|
get_librarian_agent,
|
||||||
|
run_librarian,
|
||||||
|
run_librarian_stream,
|
||||||
|
)
|
||||||
|
from src.agents.librarian.capability import (
|
||||||
|
LIBRARIAN_CAPABILITY,
|
||||||
|
get_librarian_capability,
|
||||||
|
register_librarian,
|
||||||
|
unregister_librarian,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LIBRARIAN_CAPABILITY",
|
||||||
|
"get_librarian_capability",
|
||||||
|
"get_librarian_agent",
|
||||||
|
"register_librarian",
|
||||||
|
"unregister_librarian",
|
||||||
|
"run_librarian",
|
||||||
|
"run_librarian_stream",
|
||||||
|
]
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
The Librarian - Expert agent for research and knowledge management.
|
||||||
|
|
||||||
|
A PydanticAI agent that provides research assistance through
|
||||||
|
the library-desk API, offering:
|
||||||
|
- HybridRAG search across all knowledge sources
|
||||||
|
- Wiki and document management
|
||||||
|
- Semantic search and knowledge graph exploration
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from src.agents.librarian.tools import (
|
||||||
|
create_wiki_page,
|
||||||
|
explore_knowledge_graph,
|
||||||
|
find_related_entities,
|
||||||
|
get_dossier_pages,
|
||||||
|
get_wiki_page,
|
||||||
|
hybrid_search,
|
||||||
|
list_dossiers,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
search_web,
|
||||||
|
search_wiki,
|
||||||
|
semantic_search,
|
||||||
|
smart_create_wiki_page,
|
||||||
|
update_wiki_page,
|
||||||
|
)
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Librarian system prompt
|
||||||
|
LIBRARIAN_SYSTEM_PROMPT = """You are The Librarian, an expert research assistant in the Tatlock household.
|
||||||
|
|
||||||
|
Your role is to help users find, understand, synthesize, and manage information from:
|
||||||
|
- The personal wiki (Wiki.js) containing documentation and notes
|
||||||
|
- The knowledge graph (Neo4j) with entities and relationships
|
||||||
|
- Vector embeddings (Qdrant) for semantic search
|
||||||
|
- Web search (SearXNG) for current information
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- Scholarly and thorough in your research
|
||||||
|
- Cite your sources and provide context
|
||||||
|
- Organize information clearly
|
||||||
|
- Suggest related topics when relevant
|
||||||
|
- Acknowledge limitations when information is incomplete
|
||||||
|
|
||||||
|
## Your Tools
|
||||||
|
|
||||||
|
### Web Search & Content Extraction
|
||||||
|
- **search_web**: Search the internet for current information (weather, news, facts)
|
||||||
|
- Use for: weather forecasts, current events, recent developments, external facts
|
||||||
|
- Returns extracted content from search results, not just snippets
|
||||||
|
- **read_url**: Read and extract content from a specific URL
|
||||||
|
- Use when: user provides a URL or you need to read a specific webpage
|
||||||
|
- **read_urls_batch**: Read multiple URLs in parallel (up to 20)
|
||||||
|
- Use for: comparing multiple sources, gathering info from several pages
|
||||||
|
|
||||||
|
### Internal Research Tools
|
||||||
|
- **hybrid_search**: Your primary research tool - searches wiki, graph, and web at once
|
||||||
|
- **search_wiki**: Find specific wiki pages by keyword
|
||||||
|
- **semantic_search**: Find conceptually similar content
|
||||||
|
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
||||||
|
- **list_dossiers** / **get_dossier_pages**: Browse knowledge collections
|
||||||
|
|
||||||
|
### Wiki Reading Tools
|
||||||
|
- **get_wiki_page**: Read full content of a wiki page by ID
|
||||||
|
- ALWAYS use this to fetch and read page content when summarizing
|
||||||
|
- Use after search_wiki to get the full text of a specific page
|
||||||
|
|
||||||
|
### Wiki Writing Tools
|
||||||
|
- **smart_create_wiki_page**: Create a page with automatic research (PREFERRED)
|
||||||
|
- **This is the DEFAULT choice when user asks to create a wiki page about a topic**
|
||||||
|
- When user says "Create a page about X" or "Add X to the wiki" without providing specific content, ALWAYS use this tool
|
||||||
|
- Automatically researches the topic from wiki, graph, and web
|
||||||
|
- Synthesizes content with proper source attribution
|
||||||
|
- Creates bidirectional links in knowledge graph
|
||||||
|
- **create_wiki_page**: Create a page with user-provided content
|
||||||
|
- ONLY use when user provides specific text/content they want added verbatim
|
||||||
|
- For simple notes, reminders, or quick additions with exact content
|
||||||
|
- **update_wiki_page**: Update an existing page (partial updates)
|
||||||
|
- Use when: "Update the page about X", "Fix this info", "Add to dossier"
|
||||||
|
- First search_wiki to find the page, then get_wiki_page to read it
|
||||||
|
- Only specify fields you want to change
|
||||||
|
|
||||||
|
## Research Approach
|
||||||
|
1. Start with hybrid_search for broad queries
|
||||||
|
2. Use search_wiki for specific document lookups
|
||||||
|
3. **ALWAYS use get_wiki_page to fetch full content** before summarizing a page
|
||||||
|
4. Use semantic_search when looking for conceptually similar content
|
||||||
|
5. Explore the knowledge graph to find connections between concepts
|
||||||
|
6. Synthesize and summarize findings clearly
|
||||||
|
|
||||||
|
## Writing Approach
|
||||||
|
When asked to create or update wiki content:
|
||||||
|
1. **"Create a page about X" (no specific content provided)**: Use smart_create_wiki_page
|
||||||
|
- This is the PREFERRED tool for topic-based page creation
|
||||||
|
- It researches first and creates comprehensive, well-sourced content
|
||||||
|
2. **User provides exact text to add**: Use create_wiki_page with their content
|
||||||
|
3. **Updating existing pages**:
|
||||||
|
- Search for the page with search_wiki
|
||||||
|
- Fetch full content with get_wiki_page
|
||||||
|
- Make edits and use update_wiki_page
|
||||||
|
4. **Organizing into dossiers**: Use update_wiki_page with just the tags field
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
Your responses are returned to Tatlock (the butler) who will synthesize them into a final answer for the user. Keep this in mind:
|
||||||
|
- Lead with the key findings or confirmation of action
|
||||||
|
- Include relevant sources and citations
|
||||||
|
- When summarizing wiki pages, fetch and read them first
|
||||||
|
- Note any gaps in available information
|
||||||
|
- Be concise but thorough - Tatlock will format the final response
|
||||||
|
- Structure your findings clearly so they can be easily integrated with other responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lazy initialization to avoid connection issues during imports
|
||||||
|
_librarian_agent: Optional[Agent[None, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_librarian_agent() -> Agent[None, str]:
|
||||||
|
"""Create the Librarian PydanticAI agent."""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
|
model = OpenAIChatModel(
|
||||||
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[None, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=LIBRARIAN_SYSTEM_PROMPT,
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register research tools (internal knowledge)
|
||||||
|
agent.tool_plain(hybrid_search)
|
||||||
|
agent.tool_plain(search_wiki)
|
||||||
|
agent.tool_plain(semantic_search)
|
||||||
|
agent.tool_plain(list_dossiers)
|
||||||
|
agent.tool_plain(get_dossier_pages)
|
||||||
|
agent.tool_plain(explore_knowledge_graph)
|
||||||
|
agent.tool_plain(find_related_entities)
|
||||||
|
|
||||||
|
# Register web search & content extraction tools
|
||||||
|
agent.tool_plain(search_web)
|
||||||
|
agent.tool_plain(read_url)
|
||||||
|
agent.tool_plain(read_urls_batch)
|
||||||
|
|
||||||
|
# Register wiki read tools
|
||||||
|
agent.tool_plain(get_wiki_page)
|
||||||
|
|
||||||
|
# Register wiki write tools
|
||||||
|
agent.tool_plain(create_wiki_page)
|
||||||
|
agent.tool_plain(update_wiki_page)
|
||||||
|
agent.tool_plain(smart_create_wiki_page)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_agent_created",
|
||||||
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def get_librarian_agent() -> Agent[None, str]:
|
||||||
|
"""
|
||||||
|
Get the Librarian agent instance (lazy initialization).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PydanticAI Agent configured for research tasks
|
||||||
|
"""
|
||||||
|
global _librarian_agent
|
||||||
|
if _librarian_agent is None:
|
||||||
|
_librarian_agent = _create_librarian_agent()
|
||||||
|
return _librarian_agent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute a research task with The Librarian.
|
||||||
|
|
||||||
|
This is the main entry point for delegating research tasks
|
||||||
|
to The Librarian from Tatlock or other agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The research task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Research results and findings
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await run_librarian(
|
||||||
|
task="Find information about Docker networking",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
agent = get_librarian_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_task_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
has_history=bool(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent.run(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_task_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(result.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"librarian_task_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return f"The Librarian encountered an error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_librarian_stream(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Execute a research task with streaming output.
|
||||||
|
|
||||||
|
Yields text deltas as The Librarian generates the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The research task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Text deltas from the response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async for delta in run_librarian_stream("Find Docker docs"):
|
||||||
|
print(delta, end="", flush=True)
|
||||||
|
"""
|
||||||
|
agent = get_librarian_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_stream_started",
|
||||||
|
task=task[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with agent.run_stream(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
) as response:
|
||||||
|
async for delta in response.stream_text(delta=True):
|
||||||
|
yield delta
|
||||||
|
|
||||||
|
logger.info("librarian_stream_completed", task=task[:50])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"librarian_stream_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield f"\n\nThe Librarian encountered an error: {str(e)}"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Librarian capability registration for the Household Registry.
|
||||||
|
|
||||||
|
Defines The Librarian's capabilities and registers it as a
|
||||||
|
household member for coordination by the Steward and Tatlock.
|
||||||
|
"""
|
||||||
|
from src.agents.librarian.agent import get_librarian_agent
|
||||||
|
from src.agents.librarian.tools import LIBRARIAN_TOOLS
|
||||||
|
from src.core.household_registry import (
|
||||||
|
HouseholdCapability,
|
||||||
|
get_household_registry,
|
||||||
|
)
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The Librarian's capability summary for Steward coordination
|
||||||
|
LIBRARIAN_CAPABILITY = HouseholdCapability(
|
||||||
|
name="librarian",
|
||||||
|
role="The Librarian",
|
||||||
|
category="research",
|
||||||
|
description=(
|
||||||
|
"Research, web search, and wiki management: can SEARCH the web for current "
|
||||||
|
"information, READ URLs/articles, CREATE wiki pages about topics "
|
||||||
|
"(with automatic HybridRAG research), UPDATE existing pages, "
|
||||||
|
"and synthesize information from multiple sources. "
|
||||||
|
"Use for: 'search for X', 'what is X', 'create a page about X', 'read this URL'"
|
||||||
|
),
|
||||||
|
domains=[
|
||||||
|
"research",
|
||||||
|
"knowledge",
|
||||||
|
"information",
|
||||||
|
"wiki",
|
||||||
|
"documents",
|
||||||
|
"search",
|
||||||
|
"web",
|
||||||
|
"url",
|
||||||
|
"internet",
|
||||||
|
"synthesis",
|
||||||
|
"create",
|
||||||
|
"write",
|
||||||
|
"update",
|
||||||
|
],
|
||||||
|
cost="medium", # Multiple API calls to library-desk
|
||||||
|
requires_network=True, # Needs library-desk API access
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_librarian_capability() -> HouseholdCapability:
|
||||||
|
"""Get The Librarian's capability definition."""
|
||||||
|
return LIBRARIAN_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
def register_librarian() -> None:
|
||||||
|
"""
|
||||||
|
Register The Librarian with the Household Registry.
|
||||||
|
|
||||||
|
This makes The Librarian available for:
|
||||||
|
- Steward recommendations (via capability summary)
|
||||||
|
- Tatlock delegation (via agent reference)
|
||||||
|
- Tool scoping (via tool list)
|
||||||
|
"""
|
||||||
|
registry = get_household_registry()
|
||||||
|
|
||||||
|
# Check if already registered
|
||||||
|
if "librarian" in registry:
|
||||||
|
logger.debug("librarian_already_registered")
|
||||||
|
return
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="librarian",
|
||||||
|
capability=LIBRARIAN_CAPABILITY,
|
||||||
|
tools=LIBRARIAN_TOOLS,
|
||||||
|
agent=get_librarian_agent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_registered",
|
||||||
|
role=LIBRARIAN_CAPABILITY.role,
|
||||||
|
domains=LIBRARIAN_CAPABILITY.domains,
|
||||||
|
tool_count=len(LIBRARIAN_TOOLS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_librarian() -> None:
|
||||||
|
"""Unregister The Librarian from the Household Registry."""
|
||||||
|
registry = get_household_registry()
|
||||||
|
registry.unregister("librarian")
|
||||||
|
logger.info("librarian_unregistered")
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
"""
|
||||||
|
HTTP client for the Library-Desk API.
|
||||||
|
|
||||||
|
Provides async methods for all relevant library-desk endpoints:
|
||||||
|
- HybridRAG queries
|
||||||
|
- Wiki operations
|
||||||
|
- Vector search
|
||||||
|
- Knowledge graph queries
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.context import get_user
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Response Models
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class WikiPage(BaseModel):
|
||||||
|
"""Wiki page from library-desk."""
|
||||||
|
id: int
|
||||||
|
path: str
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSearchResult(BaseModel):
|
||||||
|
"""Search result from wiki search."""
|
||||||
|
id: int
|
||||||
|
path: str
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
locale: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VectorSearchResult(BaseModel):
|
||||||
|
"""Result from semantic vector search."""
|
||||||
|
page_id: int
|
||||||
|
page_path: str
|
||||||
|
page_title: str
|
||||||
|
chunk_text: str
|
||||||
|
score: float
|
||||||
|
chunk_index: int
|
||||||
|
|
||||||
|
|
||||||
|
class HybridSearchResult(BaseModel):
|
||||||
|
"""Result from HybridRAG search."""
|
||||||
|
source: str # "vector", "graph", "web"
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
url: Optional[str] = None
|
||||||
|
score: float
|
||||||
|
page_id: Optional[int] = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class HybridRAGResponse(BaseModel):
|
||||||
|
"""Full response from HybridRAG query."""
|
||||||
|
results: list[HybridSearchResult] = Field(default_factory=list)
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
synonyms: list[str] = Field(default_factory=list)
|
||||||
|
related_dossiers: list[str] = Field(default_factory=list)
|
||||||
|
formatted_context: str = ""
|
||||||
|
search_id: Optional[str] = None
|
||||||
|
timing: dict[str, float] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class GraphNode(BaseModel):
|
||||||
|
"""Node from knowledge graph."""
|
||||||
|
id: str
|
||||||
|
labels: list[str] = Field(default_factory=list)
|
||||||
|
properties: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Dossier(BaseModel):
|
||||||
|
"""A dossier (tag-based collection)."""
|
||||||
|
name: str
|
||||||
|
page_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchSummary(BaseModel):
|
||||||
|
"""Summary of research performed during smart-create."""
|
||||||
|
wiki_results: int = 0
|
||||||
|
web_results: int = 0
|
||||||
|
graph_entities: int = 0
|
||||||
|
keywords_extracted: int = 0
|
||||||
|
timing_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchResult(BaseModel):
|
||||||
|
"""Result from web search via /rag/search."""
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
content: str = "" # Full extracted text via Trafilatura
|
||||||
|
snippet: str = "" # Original search engine snippet
|
||||||
|
source: str = "" # Domain name
|
||||||
|
published_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchResponse(BaseModel):
|
||||||
|
"""Response from /rag/search endpoint."""
|
||||||
|
query: str
|
||||||
|
search_type: str
|
||||||
|
results: list[WebSearchResult] = Field(default_factory=list)
|
||||||
|
total_results: int = 0
|
||||||
|
search_time_ms: int = 0
|
||||||
|
sources_summary: str = "" # Pre-formatted markdown citations
|
||||||
|
|
||||||
|
|
||||||
|
class ContentExtractionResult(BaseModel):
|
||||||
|
"""Result from content extraction."""
|
||||||
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
author: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
success: bool = True
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExtractionResponse(BaseModel):
|
||||||
|
"""Response from batch content extraction."""
|
||||||
|
results: list[ContentExtractionResult] = Field(default_factory=list)
|
||||||
|
total_urls: int = 0
|
||||||
|
successful: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
extraction_time_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class EntityLinking(BaseModel):
|
||||||
|
"""Entity linking results from smart-create."""
|
||||||
|
forward_links: int = 0
|
||||||
|
backward_links: int = 0
|
||||||
|
pages_updated: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SmartCreateResponse(BaseModel):
|
||||||
|
"""Response from smart-create wiki page endpoint."""
|
||||||
|
page: WikiPage
|
||||||
|
research_summary: ResearchSummary = Field(default_factory=ResearchSummary)
|
||||||
|
sources_used: int = 0
|
||||||
|
search_id: Optional[str] = None
|
||||||
|
entity_linking: EntityLinking = Field(default_factory=EntityLinking)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Client
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class LibraryDeskClient:
|
||||||
|
"""
|
||||||
|
Async HTTP client for Library-Desk API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
results = await client.hybrid_search("docker kubernetes")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Library-desk API URL (defaults to config)
|
||||||
|
api_key: API key for authentication (defaults to config)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or str(config.LIBRARY_DESK_HOST)
|
||||||
|
self.api_key = api_key or config.LIBRARY_DESK_API_KEY
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "LibraryDeskClient":
|
||||||
|
"""Create HTTP client on context entry."""
|
||||||
|
headers = {}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
"""Close HTTP client on context exit."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def _ensure_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Ensure client is initialized."""
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Client not initialized. Use 'async with LibraryDeskClient() as client:'"
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# HybridRAG
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def hybrid_search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
vector_limit: int = 10,
|
||||||
|
graph_limit: int = 10,
|
||||||
|
web_limit: int = 5,
|
||||||
|
enable_reranking: bool = True,
|
||||||
|
final_result_count: int = 10,
|
||||||
|
) -> HybridRAGResponse:
|
||||||
|
"""
|
||||||
|
Execute HybridRAG search combining vector, graph, and web results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
user: User identifier for multi-tenancy (defaults to request context)
|
||||||
|
vector_limit: Max results from vector search
|
||||||
|
graph_limit: Max results from graph search
|
||||||
|
web_limit: Max results from web search
|
||||||
|
enable_reranking: Whether to rerank with LLM
|
||||||
|
final_result_count: Number of final results after fusion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HybridRAGResponse with ranked results and context
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"config": {
|
||||||
|
"vector_limit": vector_limit,
|
||||||
|
"graph_limit": graph_limit,
|
||||||
|
"web_limit": web_limit,
|
||||||
|
"enable_reranking": enable_reranking,
|
||||||
|
"final_result_count": final_result_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_hybrid_search", query=query, user=user)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/query/hybrid",
|
||||||
|
json=payload,
|
||||||
|
params={"user": user},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
results = []
|
||||||
|
for r in data.get("results", []):
|
||||||
|
results.append(HybridSearchResult(
|
||||||
|
source=r.get("source", "unknown"),
|
||||||
|
title=r.get("title", ""),
|
||||||
|
content=r.get("content", ""),
|
||||||
|
url=r.get("url"),
|
||||||
|
score=r.get("score", 0.0),
|
||||||
|
page_id=r.get("page_id"),
|
||||||
|
metadata=r.get("metadata", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Handle keywords being either a list or a dict with core_keywords
|
||||||
|
raw_keywords = data.get("keywords", [])
|
||||||
|
if isinstance(raw_keywords, dict):
|
||||||
|
keywords = raw_keywords.get("core_keywords", [])
|
||||||
|
else:
|
||||||
|
keywords = raw_keywords
|
||||||
|
|
||||||
|
return HybridRAGResponse(
|
||||||
|
results=results,
|
||||||
|
keywords=keywords,
|
||||||
|
synonyms=data.get("synonyms", []),
|
||||||
|
related_dossiers=data.get("related_dossiers", []),
|
||||||
|
formatted_context=data.get("formatted_context", ""),
|
||||||
|
search_id=data.get("search_id"),
|
||||||
|
timing=data.get("timing", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Wiki Operations
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def search_wiki(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[WikiSearchResult]:
|
||||||
|
"""
|
||||||
|
Search wiki pages by text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching wiki pages
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
logger.debug("library_desk_wiki_search", query=query, user=user)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/wiki/search",
|
||||||
|
params={"q": query, "user": user, "limit": limit},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [WikiSearchResult(**r) for r in data.get("results", [])]
|
||||||
|
|
||||||
|
async def get_wiki_page(
|
||||||
|
self,
|
||||||
|
page_id: int,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> WikiPage:
|
||||||
|
"""
|
||||||
|
Get a wiki page by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: Page ID
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WikiPage with full content
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/wiki/pages/{page_id}",
|
||||||
|
params={"user": user},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return WikiPage(**response.json())
|
||||||
|
|
||||||
|
async def list_wiki_pages(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[WikiPage]:
|
||||||
|
"""
|
||||||
|
List wiki pages, optionally filtered by tag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
tag: Optional tag (dossier) to filter by
|
||||||
|
limit: Maximum pages to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of wiki pages
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
|
if tag:
|
||||||
|
params["tag"] = tag
|
||||||
|
|
||||||
|
response = await client.get("/wiki/pages", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [WikiPage(**p) for p in data.get("pages", [])]
|
||||||
|
|
||||||
|
async def create_wiki_page(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
user: str | None = None,
|
||||||
|
description: str = "",
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
) -> WikiPage:
|
||||||
|
"""
|
||||||
|
Create a new wiki page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Page title
|
||||||
|
path: Page path (e.g., "/projects/my-project")
|
||||||
|
content: Markdown content
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
description: Short description
|
||||||
|
tags: List of tags (dossiers)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created WikiPage
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"path": path,
|
||||||
|
"content": content,
|
||||||
|
"user": user,
|
||||||
|
"description": description,
|
||||||
|
"tags": tags or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_create_page", title=title, path=path)
|
||||||
|
|
||||||
|
response = await client.post("/wiki/pages", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return WikiPage(**response.json())
|
||||||
|
|
||||||
|
async def update_wiki_page(
|
||||||
|
self,
|
||||||
|
page_id: int,
|
||||||
|
user: str | None = None,
|
||||||
|
content: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> WikiPage:
|
||||||
|
"""
|
||||||
|
Update an existing wiki page.
|
||||||
|
|
||||||
|
Supports partial updates - only provided fields are updated.
|
||||||
|
Automatically triggers vector re-indexing and graph extraction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: ID of the page to update
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
content: New content (optional)
|
||||||
|
title: New title (optional)
|
||||||
|
tags: New tags list (optional)
|
||||||
|
description: New description (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated WikiPage
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
# Build update payload with only provided fields
|
||||||
|
update_data: dict[str, Any] = {}
|
||||||
|
if content is not None:
|
||||||
|
update_data["content"] = content
|
||||||
|
if title is not None:
|
||||||
|
update_data["title"] = title
|
||||||
|
if tags is not None:
|
||||||
|
update_data["tags"] = tags
|
||||||
|
if description is not None:
|
||||||
|
update_data["description"] = description
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"library_desk_update_page",
|
||||||
|
page_id=page_id,
|
||||||
|
fields=list(update_data.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/wiki/pages/{page_id}",
|
||||||
|
params={"user": user},
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return WikiPage(**response.json())
|
||||||
|
|
||||||
|
async def smart_create_wiki_page(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
tags: list[str],
|
||||||
|
user: str | None = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
include_web_research: bool = True,
|
||||||
|
include_wiki_search: bool = True,
|
||||||
|
) -> SmartCreateResponse:
|
||||||
|
"""
|
||||||
|
Create a wiki page with HybridRAG research.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Searches existing wiki, knowledge graph, and web for context
|
||||||
|
2. Uses LLM to synthesize findings into structured content
|
||||||
|
3. Creates the page with proper attribution
|
||||||
|
4. Automatically links entities bidirectionally
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The topic to research and create a page about
|
||||||
|
tags: List of tags (dossiers) for the page
|
||||||
|
user: User identifier
|
||||||
|
path: Optional custom path (auto-generated from topic if not provided)
|
||||||
|
include_web_research: Whether to include web search results
|
||||||
|
include_wiki_search: Whether to include existing wiki content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SmartCreateResponse with page and research metadata
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"topic": topic,
|
||||||
|
"tags": tags,
|
||||||
|
"user": user,
|
||||||
|
"include_web_research": include_web_research,
|
||||||
|
"include_wiki_search": include_wiki_search,
|
||||||
|
}
|
||||||
|
if path is not None:
|
||||||
|
payload["path"] = path
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"library_desk_smart_create",
|
||||||
|
topic=topic,
|
||||||
|
tags=tags,
|
||||||
|
include_web=include_web_research,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post("/wiki/pages/smart-create", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Parse nested response
|
||||||
|
page = WikiPage(**data.get("page", {}))
|
||||||
|
research_summary = ResearchSummary(**data.get("research_summary", {}))
|
||||||
|
entity_linking = EntityLinking(**data.get("entity_linking", {}))
|
||||||
|
|
||||||
|
return SmartCreateResponse(
|
||||||
|
page=page,
|
||||||
|
research_summary=research_summary,
|
||||||
|
sources_used=data.get("sources_used", 0),
|
||||||
|
search_id=data.get("search_id"),
|
||||||
|
entity_linking=entity_linking,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_dossiers(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> list[Dossier]:
|
||||||
|
"""
|
||||||
|
List all dossiers (tag collections) for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dossiers with page counts
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/wiki/dossiers",
|
||||||
|
params={"user": user},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [Dossier(**d) for d in data.get("dossiers", [])]
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Vector Search
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def semantic_search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
score_threshold: float = 0.5,
|
||||||
|
) -> list[VectorSearchResult]:
|
||||||
|
"""
|
||||||
|
Perform semantic (vector) search over documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
limit: Maximum results
|
||||||
|
score_threshold: Minimum similarity score
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching document chunks with scores
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"user": user,
|
||||||
|
"limit": limit,
|
||||||
|
"score_threshold": score_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("library_desk_semantic_search", query=query)
|
||||||
|
|
||||||
|
response = await client.post("/vector/search", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [VectorSearchResult(**r) for r in data.get("results", [])]
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Knowledge Graph
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def query_graph(
|
||||||
|
self,
|
||||||
|
cypher_query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
parameters: Optional[dict[str, Any]] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Execute a Cypher query on the knowledge graph.
|
||||||
|
|
||||||
|
Note: Query is automatically scoped to user's data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cypher_query: Cypher query string
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
parameters: Query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result records
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": cypher_query,
|
||||||
|
"user": user,
|
||||||
|
"parameters": parameters or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("library_desk_graph_query", query=cypher_query[:100])
|
||||||
|
|
||||||
|
response = await client.post("/graph/query", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.json().get("records", [])
|
||||||
|
|
||||||
|
async def list_graph_nodes(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
node_type: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[GraphNode]:
|
||||||
|
"""
|
||||||
|
List nodes in the knowledge graph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
||||||
|
limit: Maximum nodes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of graph nodes
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
|
if node_type:
|
||||||
|
params["node_type"] = node_type
|
||||||
|
|
||||||
|
response = await client.get("/graph/nodes", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return [GraphNode(**n) for n in data.get("nodes", [])]
|
||||||
|
|
||||||
|
async def get_graph_node(
|
||||||
|
self,
|
||||||
|
node_id: str,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed information about a graph node.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node_id: Node ID
|
||||||
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Node with relationships and connected nodes
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/graph/nodes/{node_id}",
|
||||||
|
params={"user": user},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Health Check
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if library-desk is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._ensure_client()
|
||||||
|
response = await client.get("/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("library_desk_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# RAG Search (Web Search with Content Extraction)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def search_web(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
search_type: str = "web",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> WebSearchResponse:
|
||||||
|
"""
|
||||||
|
Search the web and extract content from results.
|
||||||
|
|
||||||
|
Uses SearXNG for search and Trafilatura for content extraction.
|
||||||
|
Returns both snippets and full extracted text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (1-500 chars)
|
||||||
|
user: User identifier for tracking
|
||||||
|
search_type: "web", "news", or "images"
|
||||||
|
limit: Number of results (1-20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WebSearchResponse with results and pre-formatted sources
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"search_type": search_type,
|
||||||
|
"limit": limit,
|
||||||
|
"user": user or "tatlock-librarian",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_web_search", query=query, limit=limit)
|
||||||
|
|
||||||
|
response = await client.post("/rag/search", json=payload, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = [
|
||||||
|
WebSearchResult(
|
||||||
|
title=r.get("title", ""),
|
||||||
|
url=r.get("url", ""),
|
||||||
|
content=r.get("content", ""),
|
||||||
|
snippet=r.get("snippet", ""),
|
||||||
|
source=r.get("source", ""),
|
||||||
|
published_date=r.get("published_date"),
|
||||||
|
)
|
||||||
|
for r in data.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return WebSearchResponse(
|
||||||
|
query=data.get("query", query),
|
||||||
|
search_type=data.get("search_type", search_type),
|
||||||
|
results=results,
|
||||||
|
total_results=data.get("total_results", len(results)),
|
||||||
|
search_time_ms=data.get("search_time_ms", 0),
|
||||||
|
sources_summary=data.get("sources_summary", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Content Extraction
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def extract_content(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
include_metadata: bool = True,
|
||||||
|
max_length: int = 5000,
|
||||||
|
) -> ContentExtractionResult:
|
||||||
|
"""
|
||||||
|
Extract main content from a URL.
|
||||||
|
|
||||||
|
Uses Trafilatura for intelligent content extraction,
|
||||||
|
removing boilerplate, ads, and navigation.
|
||||||
|
|
||||||
|
Note: Uses soft failure pattern - check result.success field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to extract content from
|
||||||
|
include_metadata: Whether to extract author, date, etc.
|
||||||
|
max_length: Maximum content length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContentExtractionResult (check .success and .error fields)
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"url": url,
|
||||||
|
"include_metadata": include_metadata,
|
||||||
|
"max_length": max_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("library_desk_extract_content", url=url)
|
||||||
|
|
||||||
|
response = await client.post("/content/extract", json=payload, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
result = data.get("result", {})
|
||||||
|
|
||||||
|
return ContentExtractionResult(
|
||||||
|
url=result.get("url", url),
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result.get("content", ""),
|
||||||
|
author=result.get("author"),
|
||||||
|
date=result.get("date"),
|
||||||
|
language=result.get("language"),
|
||||||
|
success=result.get("success", False),
|
||||||
|
error=result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def extract_content_batch(
|
||||||
|
self,
|
||||||
|
urls: list[str],
|
||||||
|
include_metadata: bool = True,
|
||||||
|
max_length: int = 2000,
|
||||||
|
) -> BatchExtractionResponse:
|
||||||
|
"""
|
||||||
|
Extract content from multiple URLs in parallel.
|
||||||
|
|
||||||
|
More efficient than sequential calls. Max 20 URLs per batch.
|
||||||
|
|
||||||
|
Note: Uses soft failure pattern - individual failures don't
|
||||||
|
throw errors, check each result's .success field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to extract (max 20)
|
||||||
|
include_metadata: Whether to extract author, date, etc.
|
||||||
|
max_length: Maximum content length per URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BatchExtractionResponse with results and stats
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"urls": urls[:20], # Server limit
|
||||||
|
"include_metadata": include_metadata,
|
||||||
|
"max_length": max_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_extract_batch", url_count=len(urls))
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/content/extract/batch",
|
||||||
|
json=payload,
|
||||||
|
timeout=60.0, # Longer timeout for batch
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = [
|
||||||
|
ContentExtractionResult(
|
||||||
|
url=r.get("url", ""),
|
||||||
|
title=r.get("title"),
|
||||||
|
content=r.get("content", ""),
|
||||||
|
author=r.get("author"),
|
||||||
|
date=r.get("date"),
|
||||||
|
language=r.get("language"),
|
||||||
|
success=r.get("success", False),
|
||||||
|
error=r.get("error"),
|
||||||
|
)
|
||||||
|
for r in data.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return BatchExtractionResponse(
|
||||||
|
results=results,
|
||||||
|
total_urls=data.get("total_urls", len(urls)),
|
||||||
|
successful=data.get("successful", 0),
|
||||||
|
failed=data.get("failed", 0),
|
||||||
|
extraction_time_ms=data.get("extraction_time_ms", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global client factory
|
||||||
|
async def get_library_client() -> LibraryDeskClient:
|
||||||
|
"""
|
||||||
|
Get a library-desk client instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with get_library_client() as client:
|
||||||
|
results = await client.hybrid_search("query")
|
||||||
|
"""
|
||||||
|
return LibraryDeskClient()
|
||||||
@@ -0,0 +1,938 @@
|
|||||||
|
"""
|
||||||
|
Librarian tools for PydanticAI agent.
|
||||||
|
|
||||||
|
These tools wrap the library-desk API and are registered with
|
||||||
|
The Librarian agent for research and knowledge management tasks.
|
||||||
|
"""
|
||||||
|
from src.agents.librarian.client import LibraryDeskClient
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HybridRAG Search
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def hybrid_search(
|
||||||
|
query: str,
|
||||||
|
include_web: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search across all knowledge sources using HybridRAG.
|
||||||
|
|
||||||
|
This is the primary research tool, combining:
|
||||||
|
- Vector search (semantic similarity over documents)
|
||||||
|
- Knowledge graph (entities and relationships)
|
||||||
|
- Web search (current information from SearXNG)
|
||||||
|
|
||||||
|
Results are fused and re-ranked by relevance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language research query
|
||||||
|
include_web: Whether to include web results (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted search results with sources and context
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
hybrid_search("How does Docker orchestration work with Kubernetes?")
|
||||||
|
hybrid_search("What projects use Neo4j?", include_web=False)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.hybrid_search(
|
||||||
|
query=query,
|
||||||
|
web_limit=5 if include_web else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.results:
|
||||||
|
return f"No results found for '{query}'"
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
output_parts = [f"## Search Results for: {query}\n"]
|
||||||
|
|
||||||
|
# Add keywords if extracted
|
||||||
|
if response.keywords:
|
||||||
|
output_parts.append(f"**Keywords:** {', '.join(response.keywords)}")
|
||||||
|
|
||||||
|
# Add related dossiers
|
||||||
|
if response.related_dossiers:
|
||||||
|
output_parts.append(
|
||||||
|
f"**Related Dossiers:** {', '.join(response.related_dossiers)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Add results
|
||||||
|
for i, result in enumerate(response.results, 1):
|
||||||
|
source_icon = {
|
||||||
|
"vector": "📄",
|
||||||
|
"graph": "🔗",
|
||||||
|
"web": "🌐",
|
||||||
|
}.get(result.source, "•")
|
||||||
|
|
||||||
|
output_parts.append(
|
||||||
|
f"{i}. {source_icon} **{result.title}** (score: {result.score:.2f})"
|
||||||
|
)
|
||||||
|
if result.url:
|
||||||
|
output_parts.append(f" URL: {result.url}")
|
||||||
|
output_parts.append(f" {result.content[:300]}...")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_hybrid_search",
|
||||||
|
query=query,
|
||||||
|
result_count=len(response.results),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_hybrid_search_error", error=str(e), query=query)
|
||||||
|
return f"Error searching: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Wiki Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def search_wiki(
|
||||||
|
query: str,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search the personal wiki for relevant pages.
|
||||||
|
|
||||||
|
Performs full-text search over wiki page titles, descriptions,
|
||||||
|
and content. Use this for finding specific documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
limit: Maximum results (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching wiki pages with paths and descriptions
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
search_wiki("docker setup guide")
|
||||||
|
search_wiki("architecture", limit=5)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
results = await client.search_wiki(query=query, limit=limit)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No wiki pages found for '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Wiki Search: {query}\n"]
|
||||||
|
|
||||||
|
for i, page in enumerate(results, 1):
|
||||||
|
output_parts.append(f"{i}. **{page.title}**")
|
||||||
|
output_parts.append(f" Path: {page.path}")
|
||||||
|
if page.description:
|
||||||
|
output_parts.append(f" {page.description}")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_wiki_search_error", error=str(e))
|
||||||
|
return f"Error searching wiki: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wiki_page(
|
||||||
|
page_id: int,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get the full content of a wiki page.
|
||||||
|
|
||||||
|
Use this after searching to read the complete content
|
||||||
|
of a specific page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: The page ID from search results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full page content including title, path, and markdown content
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_wiki_page(42)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
page = await client.get_wiki_page(page_id=page_id)
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"# {page.title}",
|
||||||
|
f"**Path:** {page.path}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if page.description:
|
||||||
|
output_parts.append(f"**Description:** {page.description}")
|
||||||
|
|
||||||
|
if page.tags:
|
||||||
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
output_parts.append(page.content or "(No content)")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_get_page_error", error=str(e), page_id=page_id)
|
||||||
|
return f"Error getting page {page_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_dossiers() -> str:
|
||||||
|
"""
|
||||||
|
List all research dossiers (tag collections).
|
||||||
|
|
||||||
|
Dossiers are collections of wiki pages grouped by tag.
|
||||||
|
Use this to discover what knowledge collections exist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dossiers with page counts
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_dossiers()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
dossiers = await client.list_dossiers()
|
||||||
|
|
||||||
|
if not dossiers:
|
||||||
|
return "No dossiers found"
|
||||||
|
|
||||||
|
output_parts = ["## Research Dossiers\n"]
|
||||||
|
|
||||||
|
for dossier in dossiers:
|
||||||
|
output_parts.append(
|
||||||
|
f"- **{dossier.name}** ({dossier.page_count} pages)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_list_dossiers_error", error=str(e))
|
||||||
|
return f"Error listing dossiers: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dossier_pages(
|
||||||
|
dossier_name: str,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get all pages in a dossier.
|
||||||
|
|
||||||
|
Retrieves pages tagged with the specified dossier name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dossier_name: Name of the dossier/tag
|
||||||
|
limit: Maximum pages to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of pages in the dossier
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_dossier_pages("projects")
|
||||||
|
get_dossier_pages("architecture", limit=10)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
pages = await client.list_wiki_pages(tag=dossier_name, limit=limit)
|
||||||
|
|
||||||
|
if not pages:
|
||||||
|
return f"No pages found in dossier '{dossier_name}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Dossier: {dossier_name}\n"]
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
output_parts.append(f"- **{page.title}** ({page.path})")
|
||||||
|
if page.description:
|
||||||
|
output_parts.append(f" {page.description}")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_get_dossier_error", error=str(e))
|
||||||
|
return f"Error getting dossier: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Semantic Search
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def semantic_search(
|
||||||
|
query: str,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Perform semantic (vector) search over documents.
|
||||||
|
|
||||||
|
Finds documents similar in meaning to the query,
|
||||||
|
even if they don't contain the exact words.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching document chunks with similarity scores
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
semantic_search("containerization best practices")
|
||||||
|
semantic_search("how to handle authentication")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
results = await client.semantic_search(query=query, limit=limit)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No semantically similar content found for '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Semantic Search: {query}\n"]
|
||||||
|
|
||||||
|
for i, result in enumerate(results, 1):
|
||||||
|
output_parts.append(
|
||||||
|
f"{i}. **{result.page_title}** (score: {result.score:.2f})"
|
||||||
|
)
|
||||||
|
output_parts.append(f" Path: {result.page_path}")
|
||||||
|
output_parts.append(f" {result.chunk_text[:200]}...")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_semantic_search_error", error=str(e))
|
||||||
|
return f"Error in semantic search: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Knowledge Graph
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def explore_knowledge_graph(
|
||||||
|
entity_type: str = "Document",
|
||||||
|
limit: int = 20,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Explore entities in the knowledge graph.
|
||||||
|
|
||||||
|
Lists nodes of a specific type to understand what's
|
||||||
|
in the knowledge base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_type: Type of entity (Document, Person, Project, Concept, Technology)
|
||||||
|
limit: Maximum nodes to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entities with their properties
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
explore_knowledge_graph("Person")
|
||||||
|
explore_knowledge_graph("Technology", limit=50)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
nodes = await client.list_graph_nodes(
|
||||||
|
node_type=entity_type,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
return f"No {entity_type} nodes found in knowledge graph"
|
||||||
|
|
||||||
|
output_parts = [f"## Knowledge Graph: {entity_type} Entities\n"]
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
name = node.properties.get("name", node.properties.get("title", node.id))
|
||||||
|
output_parts.append(f"- **{name}**")
|
||||||
|
|
||||||
|
# Show a few key properties
|
||||||
|
for key in ["description", "url", "path"]:
|
||||||
|
if key in node.properties:
|
||||||
|
output_parts.append(f" {key}: {node.properties[key]}")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_explore_graph_error", error=str(e))
|
||||||
|
return f"Error exploring knowledge graph: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def find_related_entities(
|
||||||
|
entity_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Find entities related to a given concept or entity.
|
||||||
|
|
||||||
|
Queries the knowledge graph to find documents, people,
|
||||||
|
and concepts connected to the specified entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_name: Name of the entity to find relationships for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Related entities and their relationships
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
find_related_entities("Docker")
|
||||||
|
find_related_entities("Kubernetes")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
# Find entities mentioning or related to the search term
|
||||||
|
cypher = """
|
||||||
|
MATCH (n)
|
||||||
|
WHERE toLower(n.name) CONTAINS toLower($name)
|
||||||
|
OR toLower(n.title) CONTAINS toLower($name)
|
||||||
|
OPTIONAL MATCH (n)-[r]-(related)
|
||||||
|
RETURN n, collect(DISTINCT {type: type(r), node: related})[0..10] as relationships
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = await client.query_graph(
|
||||||
|
cypher,
|
||||||
|
parameters={"name": entity_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No entities found related to '{entity_name}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Entities Related to: {entity_name}\n"]
|
||||||
|
|
||||||
|
for record in results:
|
||||||
|
node = record.get("n", {})
|
||||||
|
relationships = record.get("relationships", [])
|
||||||
|
|
||||||
|
name = node.get("name", node.get("title", "Unknown"))
|
||||||
|
labels = node.get("labels", [])
|
||||||
|
|
||||||
|
output_parts.append(f"### {name}")
|
||||||
|
if labels:
|
||||||
|
output_parts.append(f"Type: {', '.join(labels)}")
|
||||||
|
|
||||||
|
if relationships:
|
||||||
|
output_parts.append("**Connections:**")
|
||||||
|
for rel in relationships[:5]: # Limit to 5 relationships
|
||||||
|
rel_type = rel.get("type", "RELATED_TO")
|
||||||
|
related_node = rel.get("node", {})
|
||||||
|
related_name = related_node.get(
|
||||||
|
"name", related_node.get("title", "Unknown")
|
||||||
|
)
|
||||||
|
output_parts.append(f" - {rel_type} → {related_name}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_find_related_error", error=str(e))
|
||||||
|
return f"Error finding related entities: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Web Search & Content Extraction
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def search_web(
|
||||||
|
query: str,
|
||||||
|
limit: int = 10,
|
||||||
|
search_type: str = "web",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search the web and extract content from results.
|
||||||
|
|
||||||
|
This is the primary tool for finding current information online.
|
||||||
|
Results include both snippets and full extracted text from pages.
|
||||||
|
|
||||||
|
Search types:
|
||||||
|
- "web": General web search (default)
|
||||||
|
- "news": News articles
|
||||||
|
- "images": Image search
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (1-500 chars)
|
||||||
|
limit: Number of results (1-20, default: 10)
|
||||||
|
search_type: Type of search ("web", "news", or "images")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted search results with sources and extracted content
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
search_web("Python 3.12 new features")
|
||||||
|
search_web("latest tech news", search_type="news", limit=5)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.search_web(
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
search_type=search_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.results:
|
||||||
|
return f"No results found for '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Web Search: {query}\n"]
|
||||||
|
output_parts.append(f"*Found {response.total_results} results in {response.search_time_ms}ms*\n")
|
||||||
|
|
||||||
|
for i, result in enumerate(response.results, 1):
|
||||||
|
output_parts.append(f"### {i}. {result.title}")
|
||||||
|
output_parts.append(f"**Source:** {result.source}")
|
||||||
|
output_parts.append(f"**URL:** {result.url}")
|
||||||
|
|
||||||
|
if result.published_date:
|
||||||
|
output_parts.append(f"**Date:** {result.published_date}")
|
||||||
|
|
||||||
|
# Use full content if available, otherwise snippet
|
||||||
|
content = result.content or result.snippet
|
||||||
|
if content:
|
||||||
|
# Truncate for readability
|
||||||
|
if len(content) > 500:
|
||||||
|
content = content[:500] + "..."
|
||||||
|
output_parts.append(f"\n{content}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Add pre-formatted sources for citations
|
||||||
|
if response.sources_summary:
|
||||||
|
output_parts.append("---")
|
||||||
|
output_parts.append(response.sources_summary)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_web_search",
|
||||||
|
query=query,
|
||||||
|
result_count=response.total_results,
|
||||||
|
search_type=search_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_web_search_error", error=str(e), query=query)
|
||||||
|
return f"Error searching web: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_url(
|
||||||
|
url: str,
|
||||||
|
max_length: int = 5000,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read and extract the main content from a URL.
|
||||||
|
|
||||||
|
Use this when you have a specific URL to read, such as:
|
||||||
|
- A link the user provided
|
||||||
|
- A URL from search results you want to read in full
|
||||||
|
- Documentation or article pages
|
||||||
|
|
||||||
|
Extracts the main content, removing ads, navigation, and boilerplate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to read
|
||||||
|
max_length: Maximum content length (default: 5000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted page content with metadata
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
read_url("https://docs.python.org/3/library/asyncio.html")
|
||||||
|
read_url("https://example.com/article", max_length=10000)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
result = await client.extract_content(
|
||||||
|
url=url,
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=max_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return f"Could not read page: {result.error or 'Unknown error'}"
|
||||||
|
|
||||||
|
output_parts = []
|
||||||
|
|
||||||
|
# Header with metadata
|
||||||
|
if result.title:
|
||||||
|
output_parts.append(f"# {result.title}")
|
||||||
|
else:
|
||||||
|
output_parts.append(f"# Content from {url}")
|
||||||
|
|
||||||
|
output_parts.append(f"**URL:** {url}")
|
||||||
|
|
||||||
|
if result.author:
|
||||||
|
output_parts.append(f"**Author:** {result.author}")
|
||||||
|
|
||||||
|
if result.date:
|
||||||
|
output_parts.append(f"**Date:** {result.date}")
|
||||||
|
|
||||||
|
if result.language and result.language != "en":
|
||||||
|
output_parts.append(f"**Language:** {result.language}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Main content
|
||||||
|
if result.content:
|
||||||
|
output_parts.append(result.content)
|
||||||
|
else:
|
||||||
|
output_parts.append("(No content could be extracted)")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_read_url",
|
||||||
|
url=url,
|
||||||
|
content_length=len(result.content) if result.content else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_read_url_error", error=str(e), url=url)
|
||||||
|
return f"Error reading URL: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_urls_batch(
|
||||||
|
urls: list[str],
|
||||||
|
max_length: int = 2000,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read and extract content from multiple URLs in parallel.
|
||||||
|
|
||||||
|
More efficient than calling read_url multiple times.
|
||||||
|
Max 20 URLs per batch.
|
||||||
|
|
||||||
|
Note: Individual failures don't fail the entire batch -
|
||||||
|
failed URLs are reported but other content is still returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to read (max 20)
|
||||||
|
max_length: Maximum content length per URL (default: 2000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted content from all successful URLs with failure report
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
read_urls_batch(["https://example.com/1", "https://example.com/2"])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.extract_content_batch(
|
||||||
|
urls=urls,
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=max_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Batch Content Extraction",
|
||||||
|
f"*Extracted {response.successful}/{response.total_urls} URLs in {response.extraction_time_ms}ms*\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Show successful extractions
|
||||||
|
for result in response.results:
|
||||||
|
if result.success:
|
||||||
|
title = result.title or result.url
|
||||||
|
output_parts.append(f"### {title}")
|
||||||
|
output_parts.append(f"**URL:** {result.url}")
|
||||||
|
|
||||||
|
if result.content:
|
||||||
|
# Truncate for readability in batch mode
|
||||||
|
content = result.content
|
||||||
|
if len(content) > max_length:
|
||||||
|
content = content[:max_length] + "..."
|
||||||
|
output_parts.append(f"\n{content}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Report failures
|
||||||
|
failed = [r for r in response.results if not r.success]
|
||||||
|
if failed:
|
||||||
|
output_parts.append("---")
|
||||||
|
output_parts.append("### Failed Extractions")
|
||||||
|
for result in failed:
|
||||||
|
output_parts.append(f"- {result.url}: {result.error}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_read_urls_batch",
|
||||||
|
total=response.total_urls,
|
||||||
|
successful=response.successful,
|
||||||
|
failed=response.failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_read_urls_batch_error", error=str(e))
|
||||||
|
return f"Error reading URLs: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Wiki Write Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def update_wiki_page(
|
||||||
|
page_id: int,
|
||||||
|
content: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Update an existing wiki page.
|
||||||
|
|
||||||
|
Supports partial updates - only specify the fields you want to change.
|
||||||
|
Changes trigger automatic vector re-indexing and knowledge graph updates.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Correcting information in a page
|
||||||
|
- Adding content to an existing page
|
||||||
|
- Updating tags to organize pages into dossiers
|
||||||
|
- Fixing descriptions or titles
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: ID of the page to update (get from search_wiki results)
|
||||||
|
content: New markdown content (optional - only if changing content)
|
||||||
|
title: New title (optional - only if renaming)
|
||||||
|
tags: New tag list (optional - replaces existing tags)
|
||||||
|
description: New description (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation with updated page details
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
update_wiki_page(42, content="# Updated Content\\n\\nNew information here")
|
||||||
|
update_wiki_page(42, tags=["projects", "devops"]) # Add to dossiers
|
||||||
|
update_wiki_page(42, description="Updated description")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
page = await client.update_wiki_page(
|
||||||
|
page_id=page_id,
|
||||||
|
content=content,
|
||||||
|
title=title,
|
||||||
|
tags=tags,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build update summary
|
||||||
|
updated_fields = []
|
||||||
|
if content is not None:
|
||||||
|
updated_fields.append("content")
|
||||||
|
if title is not None:
|
||||||
|
updated_fields.append("title")
|
||||||
|
if tags is not None:
|
||||||
|
updated_fields.append("tags")
|
||||||
|
if description is not None:
|
||||||
|
updated_fields.append("description")
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Page Updated: {page.title}",
|
||||||
|
f"**Path:** {page.path}",
|
||||||
|
f"**Updated fields:** {', '.join(updated_fields)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if page.tags:
|
||||||
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
||||||
|
|
||||||
|
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_update_page",
|
||||||
|
page_id=page_id,
|
||||||
|
updated_fields=updated_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_update_page_error", error=str(e), page_id=page_id)
|
||||||
|
return f"Error updating page {page_id}: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_wiki_page(
|
||||||
|
title: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
tags: list[str],
|
||||||
|
description: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a new wiki page with user-provided content.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User provides specific content to add
|
||||||
|
- Creating simple notes or reminders
|
||||||
|
- The content is already known/composed
|
||||||
|
|
||||||
|
For research-backed pages where you need to gather information first,
|
||||||
|
use smart_create_wiki_page instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Page title
|
||||||
|
path: Page path (e.g., "/projects/my-project" or "/notes/meeting-2024")
|
||||||
|
content: Markdown content for the page
|
||||||
|
tags: List of tags/dossiers (e.g., ["projects", "devops"])
|
||||||
|
description: Short description of the page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation with created page details
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
create_wiki_page(
|
||||||
|
title="SSL Renewal Reminder",
|
||||||
|
path="/reminders/ssl-renewal",
|
||||||
|
content="# SSL Renewal\\n\\nRemember to renew SSL cert on Jan 15",
|
||||||
|
tags=["reminders", "infrastructure"],
|
||||||
|
description="Certificate renewal reminder"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
page = await client.create_wiki_page(
|
||||||
|
title=title,
|
||||||
|
path=path,
|
||||||
|
content=content,
|
||||||
|
tags=tags,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Page Created: {page.title}",
|
||||||
|
f"**ID:** {page.id}",
|
||||||
|
f"**Path:** {page.path}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if page.tags:
|
||||||
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
||||||
|
|
||||||
|
if page.description:
|
||||||
|
output_parts.append(f"**Description:** {page.description}")
|
||||||
|
|
||||||
|
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_create_page",
|
||||||
|
page_id=page.id,
|
||||||
|
title=title,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_create_page_error", error=str(e), title=title)
|
||||||
|
return f"Error creating page: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def smart_create_wiki_page(
|
||||||
|
topic: str,
|
||||||
|
tags: list[str],
|
||||||
|
path: str | None = None,
|
||||||
|
include_web_research: bool = True,
|
||||||
|
include_wiki_search: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a wiki page with automatic research and content synthesis.
|
||||||
|
|
||||||
|
This is the RECOMMENDED way to create pages about topics. It will:
|
||||||
|
1. Search existing wiki, knowledge graph, and web for relevant information
|
||||||
|
2. Use an LLM to synthesize findings into well-structured content
|
||||||
|
3. Create the page with proper source attribution
|
||||||
|
4. Automatically link entities bidirectionally in the knowledge graph
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User says "Create a page about X"
|
||||||
|
- User says "Add information about X to the wiki"
|
||||||
|
- You need to research a topic before writing
|
||||||
|
- The topic would benefit from existing knowledge context
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The topic to research and create a page about
|
||||||
|
tags: List of tags/dossiers for categorization
|
||||||
|
path: Optional custom path (auto-generated from topic if not provided)
|
||||||
|
include_web_research: Whether to search the web (default: True)
|
||||||
|
include_wiki_search: Whether to search existing wiki (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary of created page with research statistics
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
smart_create_wiki_page("Docker Compose", tags=["technology", "devops"])
|
||||||
|
smart_create_wiki_page("Home network architecture", tags=["infrastructure"], include_web_research=False)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.smart_create_wiki_page(
|
||||||
|
topic=topic,
|
||||||
|
tags=tags,
|
||||||
|
path=path,
|
||||||
|
include_web_research=include_web_research,
|
||||||
|
include_wiki_search=include_wiki_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = response.page
|
||||||
|
research = response.research_summary
|
||||||
|
linking = response.entity_linking
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Page Created: {page.title}",
|
||||||
|
f"**ID:** {page.id}",
|
||||||
|
f"**Path:** {page.path}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if page.tags:
|
||||||
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
||||||
|
|
||||||
|
# Research summary
|
||||||
|
output_parts.append("\n### Research Summary")
|
||||||
|
output_parts.append(f"- **Wiki results used:** {research.wiki_results}")
|
||||||
|
output_parts.append(f"- **Web results used:** {research.web_results}")
|
||||||
|
output_parts.append(f"- **Graph entities found:** {research.graph_entities}")
|
||||||
|
output_parts.append(f"- **Keywords extracted:** {research.keywords_extracted}")
|
||||||
|
output_parts.append(f"- **Total sources:** {response.sources_used}")
|
||||||
|
output_parts.append(f"- **Research time:** {research.timing_ms}ms")
|
||||||
|
|
||||||
|
# Entity linking
|
||||||
|
if linking.forward_links > 0 or linking.backward_links > 0:
|
||||||
|
output_parts.append("\n### Knowledge Graph Updates")
|
||||||
|
output_parts.append(f"- **Forward links created:** {linking.forward_links}")
|
||||||
|
output_parts.append(f"- **Backward links created:** {linking.backward_links}")
|
||||||
|
output_parts.append(f"- **Related pages updated:** {linking.pages_updated}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_smart_create",
|
||||||
|
topic=topic,
|
||||||
|
page_id=page.id,
|
||||||
|
sources_used=response.sources_used,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_smart_create_error", error=str(e), topic=topic)
|
||||||
|
return f"Error creating page about '{topic}': {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tool Collection for Registration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# All tools available to The Librarian
|
||||||
|
LIBRARIAN_TOOLS = [
|
||||||
|
# Research tools (internal knowledge)
|
||||||
|
hybrid_search,
|
||||||
|
search_wiki,
|
||||||
|
get_wiki_page,
|
||||||
|
list_dossiers,
|
||||||
|
get_dossier_pages,
|
||||||
|
semantic_search,
|
||||||
|
explore_knowledge_graph,
|
||||||
|
find_related_entities,
|
||||||
|
# Web search & content extraction
|
||||||
|
search_web,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
# Write tools
|
||||||
|
create_wiki_page,
|
||||||
|
update_wiki_page,
|
||||||
|
smart_create_wiki_page,
|
||||||
|
]
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""
|
||||||
|
Orchestration module for multi-expert agent coordination.
|
||||||
|
|
||||||
|
Provides infrastructure for Tatlock to orchestrate expert agents
|
||||||
|
with streaming think updates to keep users informed of progress.
|
||||||
|
|
||||||
|
Key pattern: Stream user-facing interactions, use run() internally
|
||||||
|
to avoid Ollama streaming+tool call bugs.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single expert delegation with think updates
|
||||||
|
- Sequential multi-expert execution (task A → task B → task C)
|
||||||
|
- Parallel multi-expert execution (tasks A, B, C concurrently)
|
||||||
|
- Result aggregation from multiple experts
|
||||||
|
- Partial failure handling
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, Optional, Callable, Any
|
||||||
|
|
||||||
|
from src.agents.delegation import DelegationTask, DelegationResult, delegate_to_librarian
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionMode(str, Enum):
|
||||||
|
"""Execution mode for multi-expert coordination."""
|
||||||
|
SEQUENTIAL = "sequential" # One at a time, in order
|
||||||
|
PARALLEL = "parallel" # All at once, concurrently
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrchestrationContext:
|
||||||
|
"""
|
||||||
|
Context for an orchestration session.
|
||||||
|
|
||||||
|
Tracks the user's request, delegation tasks, and results.
|
||||||
|
"""
|
||||||
|
user_message: str
|
||||||
|
steward_note: str
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_delegation_from_steward_note(steward_note: str) -> Optional[DelegationTask]:
|
||||||
|
"""
|
||||||
|
Parse a delegation task from Steward's note.
|
||||||
|
|
||||||
|
Looks for the DELEGATE: pattern in the Steward's recommendation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steward_note: Formatted note from Steward
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationTask if delegation found, None otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> note = "DELEGATE: librarian to create a wiki page about CI/CD"
|
||||||
|
>>> task = parse_delegation_from_steward_note(note)
|
||||||
|
>>> task.expert_name
|
||||||
|
'librarian'
|
||||||
|
>>> task.task
|
||||||
|
'create a wiki page about CI/CD'
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Look for DELEGATE: pattern
|
||||||
|
# Match: "DELEGATE: expert_name to action description"
|
||||||
|
match = re.search(
|
||||||
|
r'DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)',
|
||||||
|
steward_note,
|
||||||
|
re.IGNORECASE | re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
expert_name = match.group(1).lower()
|
||||||
|
task_description = match.group(2).strip()
|
||||||
|
|
||||||
|
# Handle "none" case
|
||||||
|
if expert_name == "none":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return DelegationTask(
|
||||||
|
expert_name=expert_name,
|
||||||
|
task=task_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_delegation(
|
||||||
|
task: DelegationTask,
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Execute a delegation task.
|
||||||
|
|
||||||
|
Routes to the appropriate expert agent based on expert_name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Delegation task to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult from the expert agent
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"executing_delegation",
|
||||||
|
expert=task.expert_name,
|
||||||
|
task=task.task[:50],
|
||||||
|
)
|
||||||
|
|
||||||
|
if task.expert_name == "librarian":
|
||||||
|
return await delegate_to_librarian(
|
||||||
|
task=task.task,
|
||||||
|
context=task.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Future experts would be added here:
|
||||||
|
# elif task.expert_name == "memory":
|
||||||
|
# return await delegate_to_memory(task.task, task.context)
|
||||||
|
# elif task.expert_name == "home_automation":
|
||||||
|
# return await delegate_to_home_automation(task.task, task.context)
|
||||||
|
|
||||||
|
# Unknown expert - return error result
|
||||||
|
logger.warning("unknown_expert", expert=task.expert_name)
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name=task.expert_name,
|
||||||
|
task=task.task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=f"Unknown expert: {task.expert_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def orchestrate_with_think_updates(
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
delegation_task: Optional[DelegationTask] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Orchestrate expert delegation with streaming think updates.
|
||||||
|
|
||||||
|
Emits <think> updates before and after delegation calls to
|
||||||
|
keep the user informed of progress. Expert calls use run()
|
||||||
|
internally to avoid Ollama streaming bugs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: Original user message
|
||||||
|
steward_note: Steward's analysis and instructions
|
||||||
|
delegation_task: Optional pre-parsed delegation task
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Think update strings and final expert output
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> async for update in orchestrate_with_think_updates(
|
||||||
|
... "Create a wiki page about CI/CD",
|
||||||
|
... "DELEGATE: librarian to create wiki page",
|
||||||
|
... ):
|
||||||
|
... print(update)
|
||||||
|
<think>Consulting The Librarian...</think>
|
||||||
|
<think>Delegation complete.</think>
|
||||||
|
[Wiki page created successfully...]
|
||||||
|
"""
|
||||||
|
# Parse delegation if not provided
|
||||||
|
if delegation_task is None:
|
||||||
|
delegation_task = parse_delegation_from_steward_note(steward_note)
|
||||||
|
|
||||||
|
if delegation_task is None:
|
||||||
|
# No delegation needed - nothing to orchestrate
|
||||||
|
logger.debug("no_delegation_needed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream: About to delegate
|
||||||
|
expert_display_name = delegation_task.expert_name.title()
|
||||||
|
if delegation_task.expert_name == "librarian":
|
||||||
|
expert_display_name = "The Librarian"
|
||||||
|
|
||||||
|
yield f"🤝 Consulting {expert_display_name}...\n"
|
||||||
|
|
||||||
|
# Execute delegation (uses run() internally)
|
||||||
|
result = await execute_delegation(delegation_task)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield f"✅ {expert_display_name} completed research.\n"
|
||||||
|
|
||||||
|
# Yield the expert's findings
|
||||||
|
if result.output:
|
||||||
|
yield f"\n{result.output}"
|
||||||
|
else:
|
||||||
|
yield f"⚠️ {expert_display_name} encountered an issue: {result.error}\n"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"orchestration_complete",
|
||||||
|
expert=delegation_task.expert_name,
|
||||||
|
success=result.success,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_delegation_context(
|
||||||
|
steward_note: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Extract context fields from Steward's note.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steward_note: Formatted note from Steward
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with reason, complexity, and context
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"reason": "",
|
||||||
|
"complexity": "",
|
||||||
|
"context": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract REASON:
|
||||||
|
reason_match = re.search(r'REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||||
|
if reason_match:
|
||||||
|
result["reason"] = reason_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract COMPLEXITY:
|
||||||
|
complexity_match = re.search(r'COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||||
|
if complexity_match:
|
||||||
|
result["complexity"] = complexity_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract CONTEXT:
|
||||||
|
context_match = re.search(r'CONTEXT:\s*(.+?)$', steward_note, re.IGNORECASE | re.MULTILINE)
|
||||||
|
if context_match:
|
||||||
|
result["context"] = context_match.group(1).strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Multi-Expert Coordination
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Aggregated result from multiple expert delegations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
results: Dict mapping expert name to their result
|
||||||
|
all_succeeded: True if all delegations succeeded
|
||||||
|
failed_experts: List of expert names that failed
|
||||||
|
combined_output: Aggregated output from all successful experts
|
||||||
|
"""
|
||||||
|
results: dict[str, DelegationResult] = field(default_factory=dict)
|
||||||
|
all_succeeded: bool = True
|
||||||
|
failed_experts: list[str] = field(default_factory=list)
|
||||||
|
combined_output: str = ""
|
||||||
|
|
||||||
|
def add_result(self, result: DelegationResult) -> None:
|
||||||
|
"""Add a result and update aggregation state."""
|
||||||
|
self.results[result.expert_name] = result
|
||||||
|
if not result.success:
|
||||||
|
self.all_succeeded = False
|
||||||
|
self.failed_experts.append(result.expert_name)
|
||||||
|
|
||||||
|
def aggregate_outputs(self, separator: str = "\n\n---\n\n") -> str:
|
||||||
|
"""Combine all successful outputs into one string."""
|
||||||
|
outputs = []
|
||||||
|
for expert_name, result in self.results.items():
|
||||||
|
if result.success and result.output:
|
||||||
|
outputs.append(f"**{expert_name.title()}**: {result.output}")
|
||||||
|
|
||||||
|
self.combined_output = separator.join(outputs)
|
||||||
|
return self.combined_output
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_sequential(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
stop_on_failure: bool = False,
|
||||||
|
) -> MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Execute multiple delegation tasks sequentially.
|
||||||
|
|
||||||
|
Tasks run one after another in order. Later tasks can depend on
|
||||||
|
earlier results (though this function doesn't handle passing
|
||||||
|
results between tasks - that's the orchestrator's job).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks to execute in order
|
||||||
|
stop_on_failure: If True, stop execution if any task fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MultiExpertResult with all task results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="memory", task="get user location"),
|
||||||
|
... DelegationTask(expert_name="librarian", task="search weather"),
|
||||||
|
... ]
|
||||||
|
>>> result = await execute_sequential(tasks)
|
||||||
|
>>> result.all_succeeded
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
multi_result = MultiExpertResult()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sequential_execution_started",
|
||||||
|
task_count=len(tasks),
|
||||||
|
experts=[t.expert_name for t in tasks],
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
logger.debug(
|
||||||
|
"sequential_task_executing",
|
||||||
|
index=i,
|
||||||
|
expert=task.expert_name,
|
||||||
|
task=task.task[:50],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
multi_result.add_result(result)
|
||||||
|
|
||||||
|
if not result.success and stop_on_failure:
|
||||||
|
logger.warning(
|
||||||
|
"sequential_execution_stopped",
|
||||||
|
failed_at=i,
|
||||||
|
expert=task.expert_name,
|
||||||
|
error=result.error,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
multi_result.aggregate_outputs()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sequential_execution_complete",
|
||||||
|
total_tasks=len(tasks),
|
||||||
|
succeeded=len(tasks) - len(multi_result.failed_experts),
|
||||||
|
failed=len(multi_result.failed_experts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return multi_result
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_parallel(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
) -> MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Execute multiple delegation tasks in parallel.
|
||||||
|
|
||||||
|
All tasks run concurrently using asyncio.gather. Use this when
|
||||||
|
tasks are independent and don't depend on each other's results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks to execute concurrently
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MultiExpertResult with all task results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="librarian", task="search wiki"),
|
||||||
|
... DelegationTask(expert_name="memory", task="get preferences"),
|
||||||
|
... ]
|
||||||
|
>>> result = await execute_parallel(tasks)
|
||||||
|
>>> len(result.results)
|
||||||
|
2
|
||||||
|
"""
|
||||||
|
multi_result = MultiExpertResult()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"parallel_execution_started",
|
||||||
|
task_count=len(tasks),
|
||||||
|
experts=[t.expert_name for t in tasks],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute all tasks concurrently
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[execute_delegation(task) for task in tasks],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
# Handle exceptions as failed delegations
|
||||||
|
error_result = DelegationResult(
|
||||||
|
expert_name=tasks[i].expert_name,
|
||||||
|
task=tasks[i].task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(result),
|
||||||
|
)
|
||||||
|
multi_result.add_result(error_result)
|
||||||
|
logger.error(
|
||||||
|
"parallel_task_exception",
|
||||||
|
expert=tasks[i].expert_name,
|
||||||
|
error=str(result),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
multi_result.add_result(result)
|
||||||
|
|
||||||
|
multi_result.aggregate_outputs()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"parallel_execution_complete",
|
||||||
|
total_tasks=len(tasks),
|
||||||
|
succeeded=len(tasks) - len(multi_result.failed_experts),
|
||||||
|
failed=len(multi_result.failed_experts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return multi_result
|
||||||
|
|
||||||
|
|
||||||
|
async def orchestrate_multi_expert(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
mode: ExecutionMode = ExecutionMode.SEQUENTIAL,
|
||||||
|
stop_on_failure: bool = False,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Orchestrate multiple expert delegations with streaming think updates.
|
||||||
|
|
||||||
|
Emits <think> updates for each delegation phase and yields
|
||||||
|
combined results at the end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks
|
||||||
|
mode: SEQUENTIAL or PARALLEL execution
|
||||||
|
stop_on_failure: For sequential mode, stop if a task fails
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Think updates and combined expert output
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="memory", task="get location"),
|
||||||
|
... DelegationTask(expert_name="librarian", task="search weather"),
|
||||||
|
... ]
|
||||||
|
>>> async for update in orchestrate_multi_expert(tasks):
|
||||||
|
... print(update)
|
||||||
|
<think>Starting multi-expert coordination (2 tasks)...</think>
|
||||||
|
<think>Consulting Memory...</think>
|
||||||
|
<think>Memory completed.</think>
|
||||||
|
<think>Consulting The Librarian...</think>
|
||||||
|
<think>The Librarian completed.</think>
|
||||||
|
<think>All experts completed successfully.</think>
|
||||||
|
[Combined output from all experts...]
|
||||||
|
"""
|
||||||
|
if not tasks:
|
||||||
|
logger.debug("no_tasks_to_orchestrate")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream: Starting multi-expert coordination
|
||||||
|
yield f"🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...\n"
|
||||||
|
|
||||||
|
if mode == ExecutionMode.PARALLEL:
|
||||||
|
# Parallel execution - emit one update then run all at once
|
||||||
|
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
|
||||||
|
yield f"🔄 Consulting in parallel: {expert_names}...\n"
|
||||||
|
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
# Emit completion updates for each
|
||||||
|
for expert_name, expert_result in result.results.items():
|
||||||
|
display_name = _get_display_name(expert_name)
|
||||||
|
if expert_result.success:
|
||||||
|
yield f"✅ {display_name} completed.\n"
|
||||||
|
else:
|
||||||
|
yield f"⚠️ {display_name} failed: {expert_result.error}\n"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Sequential execution - emit updates for each task
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
display_name = _get_display_name(task.expert_name)
|
||||||
|
yield f"🤝 Consulting {display_name}...\n"
|
||||||
|
|
||||||
|
task_result = await execute_delegation(task)
|
||||||
|
result.add_result(task_result)
|
||||||
|
|
||||||
|
if task_result.success:
|
||||||
|
yield f"✅ {display_name} completed.\n"
|
||||||
|
else:
|
||||||
|
yield f"⚠️ {display_name} failed: {task_result.error}\n"
|
||||||
|
if stop_on_failure:
|
||||||
|
yield "🛑 Stopping due to failure.\n"
|
||||||
|
break
|
||||||
|
|
||||||
|
result.aggregate_outputs()
|
||||||
|
|
||||||
|
# Stream: Summary
|
||||||
|
if result.all_succeeded:
|
||||||
|
yield "🎉 All experts completed successfully.\n"
|
||||||
|
else:
|
||||||
|
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
|
||||||
|
yield f"⚠️ Some experts failed: {failed_names}\n"
|
||||||
|
|
||||||
|
# Yield combined output
|
||||||
|
if result.combined_output:
|
||||||
|
yield f"\n{result.combined_output}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"multi_expert_orchestration_complete",
|
||||||
|
task_count=len(tasks),
|
||||||
|
mode=mode.value,
|
||||||
|
all_succeeded=result.all_succeeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display_name(expert_name: str) -> str:
|
||||||
|
"""Get user-friendly display name for an expert."""
|
||||||
|
display_names = {
|
||||||
|
"librarian": "The Librarian",
|
||||||
|
"memory": "Memory",
|
||||||
|
"home_automation": "Home Automation",
|
||||||
|
"tatlock_core": "Core Tools",
|
||||||
|
}
|
||||||
|
return display_names.get(expert_name, expert_name.title())
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Agent communication protocol for multi-agent coordination.
|
||||||
|
|
||||||
|
Defines standardized request/response formats for communication between:
|
||||||
|
- Steward (request analysis) → Tatlock (coordination)
|
||||||
|
- Tatlock (coordination) → Expert agents (Librarian, Developer, etc.)
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationReason(str, Enum):
|
||||||
|
"""Why a task is being delegated to an expert agent."""
|
||||||
|
DOMAIN_EXPERTISE = "domain_expertise" # Expert has specialized knowledge
|
||||||
|
TOOL_ACCESS = "tool_access" # Expert has required tools
|
||||||
|
RESOURCE_EFFICIENCY = "resource_efficiency" # Better handled by specialist
|
||||||
|
USER_PREFERENCE = "user_preference" # User requested specific agent
|
||||||
|
|
||||||
|
|
||||||
|
class TaskComplexity(str, Enum):
|
||||||
|
"""Complexity estimate for task execution."""
|
||||||
|
SIMPLE = "simple" # Single tool call, fast
|
||||||
|
MODERATE = "moderate" # Multiple steps, moderate time
|
||||||
|
COMPLEX = "complex" # Multi-agent, significant processing
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
Request to an expert agent.
|
||||||
|
|
||||||
|
Contains everything the agent needs to execute a task,
|
||||||
|
including context from the conversation and delegation intent.
|
||||||
|
"""
|
||||||
|
task: str = Field(
|
||||||
|
...,
|
||||||
|
description="Clear description of what the agent should do"
|
||||||
|
)
|
||||||
|
context: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Relevant context from conversation history"
|
||||||
|
)
|
||||||
|
constraints: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Any constraints or requirements for the task"
|
||||||
|
)
|
||||||
|
delegation_reason: DelegationReason = Field(
|
||||||
|
default=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
description="Why this task was delegated to this agent"
|
||||||
|
)
|
||||||
|
user_id: str = Field(
|
||||||
|
default="default",
|
||||||
|
description="User identifier for multi-tenant operations"
|
||||||
|
)
|
||||||
|
max_tokens: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional token limit for response"
|
||||||
|
)
|
||||||
|
timeout_seconds: Optional[int] = Field(
|
||||||
|
default=60,
|
||||||
|
description="Maximum time for task completion"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallRecord(BaseModel):
|
||||||
|
"""Record of a tool call made during execution."""
|
||||||
|
tool_name: str
|
||||||
|
arguments: dict[str, Any]
|
||||||
|
result: str
|
||||||
|
duration_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response from an expert agent.
|
||||||
|
|
||||||
|
Contains the result, reasoning, and metadata about execution.
|
||||||
|
"""
|
||||||
|
success: bool = Field(
|
||||||
|
...,
|
||||||
|
description="Whether the task completed successfully"
|
||||||
|
)
|
||||||
|
result: str = Field(
|
||||||
|
...,
|
||||||
|
description="The main output/answer from the agent"
|
||||||
|
)
|
||||||
|
reasoning: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Agent's reasoning process (for transparency)"
|
||||||
|
)
|
||||||
|
tool_calls: list[ToolCallRecord] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tools called during execution"
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
default=1.0,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description="Agent's confidence in the result (0.0-1.0)"
|
||||||
|
)
|
||||||
|
sources: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Sources or references used"
|
||||||
|
)
|
||||||
|
error_message: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error details if success=False"
|
||||||
|
)
|
||||||
|
duration_ms: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Total execution time in milliseconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationIntent(BaseModel):
|
||||||
|
"""
|
||||||
|
Intent to delegate a task to an expert agent.
|
||||||
|
|
||||||
|
Created by Tatlock when deciding to delegate, based on
|
||||||
|
Steward's recommendations.
|
||||||
|
"""
|
||||||
|
target_agent: str = Field(
|
||||||
|
...,
|
||||||
|
description="Name of the expert agent to delegate to"
|
||||||
|
)
|
||||||
|
task: str = Field(
|
||||||
|
...,
|
||||||
|
description="Task description for the agent"
|
||||||
|
)
|
||||||
|
reason: DelegationReason = Field(
|
||||||
|
default=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
description="Why delegating to this agent"
|
||||||
|
)
|
||||||
|
expected_outcome: str = Field(
|
||||||
|
default="",
|
||||||
|
description="What we expect the agent to provide"
|
||||||
|
)
|
||||||
|
priority: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=1,
|
||||||
|
le=10,
|
||||||
|
description="Priority (1=highest, 10=lowest)"
|
||||||
|
)
|
||||||
|
depends_on: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Other delegation IDs this depends on (for sequencing)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinationResult(BaseModel):
|
||||||
|
"""
|
||||||
|
Result of multi-agent coordination.
|
||||||
|
|
||||||
|
Aggregates results from multiple expert agents into
|
||||||
|
a single coherent response.
|
||||||
|
"""
|
||||||
|
final_response: str = Field(
|
||||||
|
...,
|
||||||
|
description="Synthesized response from all agents"
|
||||||
|
)
|
||||||
|
agent_responses: dict[str, AgentResponse] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Individual responses keyed by agent name"
|
||||||
|
)
|
||||||
|
delegation_intents: list[DelegationIntent] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="All delegations that were executed"
|
||||||
|
)
|
||||||
|
total_duration_ms: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Total coordination time"
|
||||||
|
)
|
||||||
|
agents_consulted: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Names of agents that contributed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentError(Exception):
|
||||||
|
"""Base exception for agent errors."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, agent_name: str = "unknown"):
|
||||||
|
self.message = message
|
||||||
|
self.agent_name = agent_name
|
||||||
|
super().__init__(f"[{agent_name}] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTimeoutError(AgentError):
|
||||||
|
"""Agent execution timed out."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUnavailableError(AgentError):
|
||||||
|
"""Agent is not available or registered."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationError(AgentError):
|
||||||
|
"""Error during task delegation."""
|
||||||
|
pass
|
||||||
@@ -37,9 +37,9 @@ class ModelRegistry:
|
|||||||
"owned_by": "tatlock",
|
"owned_by": "tatlock",
|
||||||
# Capabilities are retrieved from agent instance
|
# Capabilities are retrieved from agent instance
|
||||||
},
|
},
|
||||||
"tatlock": {
|
"Tatlock": {
|
||||||
"agent_class": TatlockAgent,
|
"agent_class": TatlockAgent,
|
||||||
"description": "Tatlock reasoning agent (placeholder - not yet implemented)",
|
"description": "Tatlock - Your homelab butler (British household coordinator)",
|
||||||
"created": 1733529600, # 2025-12-06
|
"created": 1733529600, # 2025-12-06
|
||||||
"owned_by": "tatlock",
|
"owned_by": "tatlock",
|
||||||
# Capabilities are retrieved from agent instance
|
# Capabilities are retrieved from agent instance
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Steward agent package.
|
||||||
|
|
||||||
|
The Steward analyzes incoming requests and recommends relevant household
|
||||||
|
capabilities, creating a two-tier architecture with the Butler.
|
||||||
|
"""
|
||||||
|
from .agent import StewardAgent, get_steward_agent
|
||||||
|
from .schemas import ConversationContext, StewardRecommendation
|
||||||
|
from .service import analyze_request, format_steward_note
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StewardAgent",
|
||||||
|
"get_steward_agent",
|
||||||
|
"ConversationContext",
|
||||||
|
"StewardRecommendation",
|
||||||
|
"analyze_request",
|
||||||
|
"format_steward_note",
|
||||||
|
]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Steward agent - First-tier request analyzer.
|
||||||
|
|
||||||
|
The Steward analyzes incoming requests, identifies relevant household
|
||||||
|
capabilities, and provides focused recommendations to Tatlock (the Butler).
|
||||||
|
This creates a two-tier architecture that prevents cognitive overload.
|
||||||
|
|
||||||
|
Uses plain text output (not JSON) for reliability with Ollama models.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# System prompt for plain text recommendations
|
||||||
|
def build_steward_prompt(query: str, conversation_history: list[dict]) -> str:
|
||||||
|
"""Build the steward's analysis prompt with query and conversation history."""
|
||||||
|
|
||||||
|
# Get available capabilities from registry
|
||||||
|
registry = get_household_registry()
|
||||||
|
capabilities = registry.get_all_capabilities()
|
||||||
|
|
||||||
|
cap_list = []
|
||||||
|
for cap in capabilities:
|
||||||
|
cap_list.append(
|
||||||
|
f"• {cap.name} - {cap.description} (domains: {', '.join(cap.domains)})"
|
||||||
|
)
|
||||||
|
capabilities_text = "\n".join(cap_list)
|
||||||
|
|
||||||
|
# Format conversation history if present
|
||||||
|
history_text = ""
|
||||||
|
if conversation_history:
|
||||||
|
history_lines = []
|
||||||
|
for i, msg in enumerate(conversation_history):
|
||||||
|
role = msg.get("role", "unknown")
|
||||||
|
content = msg.get("content", "")[:100] # Truncate long messages
|
||||||
|
history_lines.append(f"{i}. {role}: {content}")
|
||||||
|
history_text = "\n\nCONVERSATION HISTORY:\n" + "\n".join(history_lines)
|
||||||
|
|
||||||
|
return f"""You are the Steward of the household, advising the Butler (Tatlock) on which capabilities to use.
|
||||||
|
|
||||||
|
AVAILABLE HOUSEHOLD CAPABILITIES:
|
||||||
|
{capabilities_text}
|
||||||
|
|
||||||
|
YOUR TASK:
|
||||||
|
Analyze the user's query and recommend which capabilities are needed, with specific delegation instructions.
|
||||||
|
{history_text}
|
||||||
|
|
||||||
|
USER QUERY: {query}
|
||||||
|
|
||||||
|
GUIDELINES:
|
||||||
|
- Be conservative - only recommend truly necessary capabilities
|
||||||
|
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||||
|
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||||
|
- Math/calculations → tatlock_core
|
||||||
|
- Time/date queries → tatlock_core
|
||||||
|
- Web searches, weather, news, current information → librarian with search_web
|
||||||
|
- Read a URL or article → librarian with read_url
|
||||||
|
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
||||||
|
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
||||||
|
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
||||||
|
- In-depth research, knowledge synthesis, document lookup → librarian with hybrid_search
|
||||||
|
- If conversation history is relevant, note which previous turns matter
|
||||||
|
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
|
||||||
|
|
||||||
|
RESPOND IN THIS FORMAT:
|
||||||
|
DELEGATE: [capability name] to [action] [specific task]
|
||||||
|
REASON: [why this capability handles the request]
|
||||||
|
COMPLEXITY: [simple/moderate/complex]
|
||||||
|
CONTEXT: [any relevant conversation context, or "none"]
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
- "DELEGATE: librarian to search_web for tomorrow's weather forecast"
|
||||||
|
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
||||||
|
- "DELEGATE: librarian to hybrid_search for information about Docker networking"
|
||||||
|
- "DELEGATE: librarian to read_url https://example.com/article"
|
||||||
|
- "DELEGATE: tatlock_core to calculate the result"
|
||||||
|
- "DELEGATE: none (conversational response only)"
|
||||||
|
|
||||||
|
Be specific about what Tatlock should delegate - include the action verb (create, update, search, etc.).
|
||||||
|
Plain text only - no JSON, no special formatting."""
|
||||||
|
|
||||||
|
|
||||||
|
class StewardAgent:
|
||||||
|
"""
|
||||||
|
The Steward - Request analyzer and capability coordinator.
|
||||||
|
|
||||||
|
Analyzes requests with full conversation context and recommends
|
||||||
|
which household capabilities the Butler should use.
|
||||||
|
|
||||||
|
Uses plain text output for reliability with Ollama models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Steward with Ollama model (same as Tatlock for VRAM efficiency)."""
|
||||||
|
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||||
|
self.model_name = config.OLLAMA_DEFAULT_MODEL
|
||||||
|
self.timeout = 30.0 # 30 second timeout for analysis
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"steward_agent_created",
|
||||||
|
ollama_host=self.ollama_host,
|
||||||
|
model=self.model_name,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def analyze(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
conversation_history: Optional[list[dict]] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Analyze query and return plain text recommendation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: User's query to analyze
|
||||||
|
conversation_history: Previous conversation turns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Plain text analysis from Steward
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> text = await steward.analyze("What's 2 + 2?")
|
||||||
|
>>> print(text)
|
||||||
|
"This requires tatlock_core for mathematical calculations. Complexity: simple."
|
||||||
|
"""
|
||||||
|
history = conversation_history or []
|
||||||
|
prompt = build_steward_prompt(query, history)
|
||||||
|
|
||||||
|
logger.debug("steward_calling_ollama", query_preview=query[:100])
|
||||||
|
|
||||||
|
# Call Ollama API directly (more reliable than PydanticAI for plain text)
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.ollama_host}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model_name,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3, # Lower = more consistent
|
||||||
|
"top_p": 0.9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
analysis_text = result["response"].strip()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"steward_analysis_received",
|
||||||
|
text_preview=analysis_text[:150]
|
||||||
|
)
|
||||||
|
|
||||||
|
return analysis_text
|
||||||
|
|
||||||
|
|
||||||
|
# Global Steward instance
|
||||||
|
_steward_agent = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_steward_agent() -> StewardAgent:
|
||||||
|
"""
|
||||||
|
Get the global Steward agent instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StewardAgent instance
|
||||||
|
"""
|
||||||
|
global _steward_agent
|
||||||
|
if _steward_agent is None:
|
||||||
|
_steward_agent = StewardAgent()
|
||||||
|
return _steward_agent
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Steward agent schemas.
|
||||||
|
|
||||||
|
Defines the structured output models for Steward's request analysis
|
||||||
|
and capability recommendations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationContext(BaseModel):
|
||||||
|
"""
|
||||||
|
Contextual information extracted from conversation history.
|
||||||
|
|
||||||
|
The Steward analyzes the full conversation to identify references
|
||||||
|
to previous topics, helping the Butler maintain context.
|
||||||
|
"""
|
||||||
|
has_previous_context: bool = Field(
|
||||||
|
description="Whether the current request references previous conversation turns"
|
||||||
|
)
|
||||||
|
relevant_turns: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="0-indexed turn numbers that are relevant to the current request"
|
||||||
|
)
|
||||||
|
context_summary: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Brief summary of relevant context for the Butler"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StewardRecommendation(BaseModel):
|
||||||
|
"""
|
||||||
|
Structured recommendation from Steward's request analysis.
|
||||||
|
|
||||||
|
This is the output format for the Steward agent, providing:
|
||||||
|
- Which household capabilities are needed
|
||||||
|
- Why those capabilities were chosen
|
||||||
|
- Complexity assessment
|
||||||
|
- Conversation context
|
||||||
|
- Missing capabilities (if any)
|
||||||
|
"""
|
||||||
|
recommended_capabilities: list[str] = Field(
|
||||||
|
description="List of household member names to include (e.g., ['tatlock_core'])"
|
||||||
|
)
|
||||||
|
reasoning: str = Field(
|
||||||
|
description="Explanation of why these capabilities were recommended"
|
||||||
|
)
|
||||||
|
estimated_complexity: Literal["simple", "moderate", "complex"] = Field(
|
||||||
|
description="Complexity assessment: simple (1 tool), moderate (2-3 tools), complex (multiple tools/steps)"
|
||||||
|
)
|
||||||
|
conversation_context: ConversationContext = Field(
|
||||||
|
description="Contextual information from conversation history"
|
||||||
|
)
|
||||||
|
missing_capabilities: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Description of capabilities that would be helpful but aren't available"
|
||||||
|
)
|
||||||
|
memory_context: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Pre-fetched user context from memory (profile, preferences)"
|
||||||
|
)
|
||||||
|
enriched_query: str = Field(
|
||||||
|
default="",
|
||||||
|
description="User query with auto-filled context (location, timezone) when not specified"
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_for_butler(self) -> str:
|
||||||
|
"""
|
||||||
|
Format recommendation as a note for the Butler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string suitable for prepending to user request
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.append("📋 Steward's Analysis")
|
||||||
|
lines.append("=" * 40)
|
||||||
|
|
||||||
|
# Complexity
|
||||||
|
lines.append(f"Complexity: {self.estimated_complexity.upper()}")
|
||||||
|
|
||||||
|
# Recommended capabilities
|
||||||
|
if self.recommended_capabilities:
|
||||||
|
caps = ", ".join(self.recommended_capabilities)
|
||||||
|
lines.append(f"Recommended tools: {caps}")
|
||||||
|
else:
|
||||||
|
lines.append("Recommended tools: None (conversational response)")
|
||||||
|
|
||||||
|
# Context summary
|
||||||
|
if self.conversation_context.has_previous_context:
|
||||||
|
lines.append(f"Context: {self.conversation_context.context_summary}")
|
||||||
|
|
||||||
|
# Missing capabilities warning
|
||||||
|
if self.missing_capabilities:
|
||||||
|
lines.append(f"⚠️ Missing: {self.missing_capabilities}")
|
||||||
|
|
||||||
|
# Memory context (user profile and preferences)
|
||||||
|
if self.memory_context:
|
||||||
|
profile = self.memory_context.get("profile", {})
|
||||||
|
preferences = self.memory_context.get("preferences", {})
|
||||||
|
|
||||||
|
if profile or preferences:
|
||||||
|
lines.append("-" * 40)
|
||||||
|
lines.append("User Context:")
|
||||||
|
|
||||||
|
if profile:
|
||||||
|
for key, value in profile.items():
|
||||||
|
lines.append(f" • {key}: {value}")
|
||||||
|
|
||||||
|
if preferences:
|
||||||
|
prefs_str = ", ".join(f"{k}={v}" for k, v in preferences.items())
|
||||||
|
lines.append(f" • preferences: {prefs_str}")
|
||||||
|
|
||||||
|
# Add delegation instructions when expert agents are recommended
|
||||||
|
delegation_agents = [c for c in self.recommended_capabilities
|
||||||
|
if c in ("biographer", "librarian")]
|
||||||
|
if delegation_agents:
|
||||||
|
lines.append("-" * 40)
|
||||||
|
lines.append("DELEGATION REQUIRED:")
|
||||||
|
for agent in delegation_agents:
|
||||||
|
lines.append(f' Call: delegate_to_{agent}(task="[user request]")')
|
||||||
|
lines.append(f' Or output: [DELEGATE:{agent}] task="[user request]"')
|
||||||
|
|
||||||
|
lines.append("=" * 40)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"""
|
||||||
|
Steward service layer.
|
||||||
|
|
||||||
|
Provides high-level interface for request analysis with logging,
|
||||||
|
benchmarking, and error handling.
|
||||||
|
|
||||||
|
Parses plain text recommendations into structured data.
|
||||||
|
Includes memory pre-fetch for user context injection.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
from src.core.logging_config import get_logger, log_operation
|
||||||
|
from src.core.memory_service import memory_service
|
||||||
|
from .agent import get_steward_agent
|
||||||
|
from .schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_capabilities(text: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract capability names from Steward's text response.
|
||||||
|
|
||||||
|
Uses keyword matching to find mentioned capabilities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Steward's plain text analysis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of capability names (e.g., ['tatlock_core'])
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
registry = get_household_registry()
|
||||||
|
capabilities = registry.get_all_capabilities()
|
||||||
|
|
||||||
|
found_caps = []
|
||||||
|
|
||||||
|
for cap in capabilities:
|
||||||
|
# Check if capability name is mentioned
|
||||||
|
if cap.name.lower() in text_lower:
|
||||||
|
found_caps.append(cap.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if any domains are mentioned
|
||||||
|
for domain in cap.domains:
|
||||||
|
if domain.lower() in text_lower:
|
||||||
|
found_caps.append(cap.name)
|
||||||
|
break
|
||||||
|
|
||||||
|
return found_caps
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_complexity(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract complexity assessment from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Steward's plain text analysis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of: "simple", "moderate", "complex"
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
if "complex" in text_lower:
|
||||||
|
return "complex"
|
||||||
|
elif "moderate" in text_lower:
|
||||||
|
return "moderate"
|
||||||
|
else:
|
||||||
|
return "simple" # Default to simple
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_conversation_context(
|
||||||
|
text: str,
|
||||||
|
conversation_history: list[dict]
|
||||||
|
) -> ConversationContext:
|
||||||
|
"""
|
||||||
|
Extract conversation context analysis from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Steward's plain text analysis
|
||||||
|
conversation_history: Previous conversation turns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConversationContext with relevant turn analysis
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Check if conversation history is referenced
|
||||||
|
has_context = bool(conversation_history) and any([
|
||||||
|
"previous" in text_lower,
|
||||||
|
"earlier" in text_lower,
|
||||||
|
"context" in text_lower,
|
||||||
|
"turn" in text_lower,
|
||||||
|
"history" in text_lower,
|
||||||
|
])
|
||||||
|
|
||||||
|
# Extract turn numbers if mentioned (e.g., "turn 0", "turn 1")
|
||||||
|
relevant_turns = []
|
||||||
|
turn_pattern = r"turn\s+(\d+)"
|
||||||
|
matches = re.findall(turn_pattern, text_lower)
|
||||||
|
relevant_turns = [int(m) for m in matches]
|
||||||
|
|
||||||
|
# Create summary from relevant portion of text
|
||||||
|
context_summary = ""
|
||||||
|
if has_context:
|
||||||
|
# Extract sentence(s) mentioning context
|
||||||
|
sentences = text.split('.')
|
||||||
|
context_sentences = [s for s in sentences if any(
|
||||||
|
word in s.lower() for word in ["previous", "earlier", "context", "history"]
|
||||||
|
)]
|
||||||
|
if context_sentences:
|
||||||
|
context_summary = context_sentences[0].strip()
|
||||||
|
|
||||||
|
return ConversationContext(
|
||||||
|
has_previous_context=has_context,
|
||||||
|
relevant_turns=relevant_turns,
|
||||||
|
context_summary=context_summary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_missing_capabilities(text: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract missing capability notes from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Steward's plain text analysis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Description of missing capabilities, or None
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Look for indicators of missing capabilities
|
||||||
|
if any(word in text_lower for word in [
|
||||||
|
"missing", "unavailable", "not available", "don't have", "doesn't have"
|
||||||
|
]):
|
||||||
|
# Find the sentence mentioning missing capabilities
|
||||||
|
sentences = text.split('.')
|
||||||
|
for sentence in sentences:
|
||||||
|
if any(word in sentence.lower() for word in [
|
||||||
|
"missing", "unavailable", "not available"
|
||||||
|
]):
|
||||||
|
return sentence.strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Build an enriched query by appending user context when not specified.
|
||||||
|
|
||||||
|
When the user asks location-dependent questions (weather, nearby, etc.)
|
||||||
|
without specifying a location, this appends their known location.
|
||||||
|
Similarly for timezone-dependent queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: The user's original request
|
||||||
|
memory_context: Pre-fetched memory context with profile/preferences
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Query with context appended, or original query if no enrichment needed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> query = _build_enriched_query(
|
||||||
|
... "What's the weather?",
|
||||||
|
... {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
|
||||||
|
... )
|
||||||
|
>>> query
|
||||||
|
"What's the weather?\n\n[User Context: location=Amsterdam, timezone=Europe/Amsterdam]"
|
||||||
|
"""
|
||||||
|
if not memory_context:
|
||||||
|
return user_request
|
||||||
|
|
||||||
|
request_lower = user_request.lower()
|
||||||
|
profile = memory_context.get("profile", {})
|
||||||
|
preferences = memory_context.get("preferences", {})
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# Check if location is needed and not specified
|
||||||
|
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
|
||||||
|
# Use word boundary pattern to avoid false positives like "at" in "what"
|
||||||
|
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
|
||||||
|
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
|
||||||
|
|
||||||
|
if any(word in request_lower for word in location_keywords):
|
||||||
|
if not location_specified and profile.get("location"):
|
||||||
|
context_parts.append(f"location={profile['location']}")
|
||||||
|
|
||||||
|
# Check if timezone is needed and not specified
|
||||||
|
time_keywords = ["time", "schedule", "meeting", "appointment", "when", "today", "tomorrow"]
|
||||||
|
timezone_specified = any(word in request_lower for word in ["timezone", "tz", "utc", "gmt"])
|
||||||
|
|
||||||
|
if any(word in request_lower for word in time_keywords):
|
||||||
|
if not timezone_specified and profile.get("timezone"):
|
||||||
|
context_parts.append(f"timezone={profile['timezone']}")
|
||||||
|
|
||||||
|
# Add preferences if relevant
|
||||||
|
if preferences.get("temperature_unit") and "weather" in request_lower:
|
||||||
|
context_parts.append(f"temperature_unit={preferences['temperature_unit']}")
|
||||||
|
|
||||||
|
# Build enriched query
|
||||||
|
if context_parts:
|
||||||
|
context_str = ", ".join(context_parts)
|
||||||
|
return f"{user_request}\n\n[User Context: {context_str}]"
|
||||||
|
|
||||||
|
return user_request
|
||||||
|
|
||||||
|
|
||||||
|
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pre-fetch user context that might be needed for this request.
|
||||||
|
|
||||||
|
This is the "direct access" layer - fast lookups without LLM overhead.
|
||||||
|
Uses simple keyword matching to determine what context to fetch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: The user's request text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with profile and/or preferences data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> ctx = await _prefetch_memory_context("What's the weather?")
|
||||||
|
>>> ctx
|
||||||
|
{"profile": {"location": "Amsterdam"}}
|
||||||
|
"""
|
||||||
|
request_lower = user_request.lower()
|
||||||
|
|
||||||
|
# Determine what context might be needed based on keywords
|
||||||
|
profile_keys = []
|
||||||
|
|
||||||
|
# Location-related queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"weather", "temperature", "forecast", "nearby", "local",
|
||||||
|
"directions", "distance", "map", "here"
|
||||||
|
]):
|
||||||
|
profile_keys.append("location")
|
||||||
|
|
||||||
|
# Time-related queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"time", "schedule", "meeting", "appointment", "reminder",
|
||||||
|
"alarm", "when", "today", "tomorrow"
|
||||||
|
]):
|
||||||
|
profile_keys.append("timezone")
|
||||||
|
|
||||||
|
# Personal queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"my name", "who am i", "about me"
|
||||||
|
]):
|
||||||
|
profile_keys.append("name")
|
||||||
|
|
||||||
|
# Always fetch preferences if they might affect response format
|
||||||
|
include_preferences = any(word in request_lower for word in [
|
||||||
|
"temperature", "weather", "convert", "unit", "format",
|
||||||
|
"celsius", "fahrenheit", "metric", "imperial"
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await memory_service.prefetch_context(
|
||||||
|
include_profile=bool(profile_keys),
|
||||||
|
include_preferences=include_preferences,
|
||||||
|
profile_keys=profile_keys if profile_keys else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"steward_prefetch_memory_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_request(
|
||||||
|
user_request: str,
|
||||||
|
conversation_history: list[dict],
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
) -> StewardRecommendation:
|
||||||
|
"""
|
||||||
|
Analyze user request with full conversation context.
|
||||||
|
|
||||||
|
This is the main entry point for Steward analysis. It:
|
||||||
|
1. Calls the Steward agent with full conversation history
|
||||||
|
2. Logs the operation with timing
|
||||||
|
3. Records performance benchmarks to Redis
|
||||||
|
4. Returns structured recommendations
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: The current user message to analyze
|
||||||
|
conversation_history: Full conversation history (all previous turns)
|
||||||
|
conversation_id: Optional conversation ID for tracking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StewardRecommendation with capability recommendations and context analysis
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> recommendation = await analyze_request(
|
||||||
|
... "What's sqrt(144)?",
|
||||||
|
... conversation_history=[],
|
||||||
|
... )
|
||||||
|
>>> print(recommendation.recommended_capabilities)
|
||||||
|
['tatlock_core']
|
||||||
|
"""
|
||||||
|
async with log_operation(
|
||||||
|
"steward_analysis",
|
||||||
|
{
|
||||||
|
"request_preview": user_request[:100],
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"history_length": len(conversation_history),
|
||||||
|
}
|
||||||
|
) as log_ctx:
|
||||||
|
try:
|
||||||
|
# Pre-fetch user context from memory (fast, no LLM)
|
||||||
|
memory_context = await _prefetch_memory_context(user_request)
|
||||||
|
log_ctx["memory_context_keys"] = list(memory_context.keys())
|
||||||
|
|
||||||
|
# Get Steward agent
|
||||||
|
steward = get_steward_agent()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"steward_analyzing_request",
|
||||||
|
request=user_request,
|
||||||
|
history_turns=len(conversation_history),
|
||||||
|
memory_context=bool(memory_context),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get plain text analysis from Steward
|
||||||
|
analysis_text = await steward.analyze(
|
||||||
|
user_request,
|
||||||
|
conversation_history=conversation_history
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse plain text into structured recommendation
|
||||||
|
capabilities = _extract_capabilities(analysis_text)
|
||||||
|
complexity = _extract_complexity(analysis_text)
|
||||||
|
context = _extract_conversation_context(analysis_text, conversation_history)
|
||||||
|
missing = _extract_missing_capabilities(analysis_text)
|
||||||
|
|
||||||
|
# Build enriched query with auto-filled context
|
||||||
|
enriched_query = _build_enriched_query(user_request, memory_context)
|
||||||
|
|
||||||
|
recommendation = StewardRecommendation(
|
||||||
|
recommended_capabilities=capabilities,
|
||||||
|
reasoning=analysis_text,
|
||||||
|
estimated_complexity=complexity,
|
||||||
|
conversation_context=context,
|
||||||
|
missing_capabilities=missing,
|
||||||
|
memory_context=memory_context,
|
||||||
|
enriched_query=enriched_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update log context with results
|
||||||
|
log_ctx["recommendation_count"] = len(recommendation.recommended_capabilities)
|
||||||
|
log_ctx["complexity"] = recommendation.estimated_complexity
|
||||||
|
log_ctx["has_context"] = recommendation.conversation_context.has_previous_context
|
||||||
|
log_ctx["missing_capabilities"] = recommendation.missing_capabilities is not None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"steward_analysis_complete",
|
||||||
|
recommended=recommendation.recommended_capabilities,
|
||||||
|
complexity=recommendation.estimated_complexity,
|
||||||
|
reasoning=analysis_text[:200], # First 200 chars
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record performance benchmark
|
||||||
|
if log_ctx.get("duration_seconds"):
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="steward_analysis",
|
||||||
|
duration_seconds=log_ctx["duration_seconds"],
|
||||||
|
success=True,
|
||||||
|
recommendation_count=len(recommendation.recommended_capabilities),
|
||||||
|
confidence=None, # Could add confidence scoring in future
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
metadata={
|
||||||
|
"complexity": recommendation.estimated_complexity,
|
||||||
|
"has_context": recommendation.conversation_context.has_previous_context,
|
||||||
|
"missing_capabilities": recommendation.missing_capabilities is not None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await get_benchmark_store().record(benchmark)
|
||||||
|
|
||||||
|
return recommendation
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"steward_analysis_failed",
|
||||||
|
error=str(e),
|
||||||
|
error_type=type(e).__name__,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def format_steward_note(recommendation: StewardRecommendation) -> str:
|
||||||
|
"""
|
||||||
|
Format Steward's recommendation as a note for the Butler.
|
||||||
|
|
||||||
|
This creates a structured message that will be prepended to the user's
|
||||||
|
request when sent to Tatlock, providing context and guidance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recommendation: Steward's analysis and recommendations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted note string for the Butler
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> note = await format_steward_note(recommendation)
|
||||||
|
>>> print(note)
|
||||||
|
📋 Steward's Analysis
|
||||||
|
========================================
|
||||||
|
Complexity: SIMPLE
|
||||||
|
Recommended tools: tatlock_core
|
||||||
|
========================================
|
||||||
|
"""
|
||||||
|
return recommendation.format_for_butler()
|
||||||
+810
-34
@@ -1,17 +1,37 @@
|
|||||||
"""
|
"""
|
||||||
Tatlock agent - Placeholder for future real agent.
|
Tatlock agent - The Butler (PydanticAI implementation).
|
||||||
|
|
||||||
This is a minimal placeholder implementation. In the future, this will
|
This is the production Tatlock agent using PydanticAI with Ollama backend.
|
||||||
be the production agent using PydanticAI and Ollama for real LLM inference.
|
The agent embodies a witty, capable British butler personality.
|
||||||
|
|
||||||
For now, it returns a simple placeholder message to show up in the
|
|
||||||
model list and allow basic testing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from typing import AsyncGenerator, Any
|
from typing import AsyncGenerator, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
from src.agents.base import AgentInterface, OutputItem
|
from src.agents.base import AgentInterface, OutputItem
|
||||||
|
from src.agents.tatlock_core.tools import (
|
||||||
|
calculate,
|
||||||
|
get_current_datetime,
|
||||||
|
calculate_time_offset,
|
||||||
|
time_difference,
|
||||||
|
)
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCallTracker:
|
||||||
|
"""Tracks tool calls for reporting to reasoning output."""
|
||||||
|
calls: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def log_call(self, message: str):
|
||||||
|
"""Log a tool call."""
|
||||||
|
self.calls.append(message)
|
||||||
|
|
||||||
|
|
||||||
def generate_id() -> str:
|
def generate_id() -> str:
|
||||||
@@ -19,17 +39,212 @@ def generate_id() -> str:
|
|||||||
return secrets.token_hex(16)
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
# System prompt defining Tatlock's personality
|
||||||
|
TATLOCK_SYSTEM_PROMPT = """You are Tatlock, a helpful personal assistant with the demeanor of a British butler.
|
||||||
|
|
||||||
|
Address users as "sir" and maintain a formal yet personable tone. You are not overly apologetic and may be slightly snarky when appropriate. If an opportunity for a pun presents itself, you cannot resist.
|
||||||
|
|
||||||
|
You coordinate with various household staff (expert agents) to provide comprehensive assistance across:
|
||||||
|
- Research and knowledge work
|
||||||
|
- Software development
|
||||||
|
- System administration
|
||||||
|
- Home automation
|
||||||
|
- Personal organization
|
||||||
|
|
||||||
|
## Research Mindset
|
||||||
|
|
||||||
|
Approach all questions with a researcher's mindset:
|
||||||
|
- Always verify facts rather than relying solely on memory
|
||||||
|
- When unsure, search for current and accurate information
|
||||||
|
- Cross-check important claims when possible
|
||||||
|
- Acknowledge uncertainty and seek verification
|
||||||
|
- Prefer authoritative sources and current data
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
You have direct access to several permanent tools that you should USE whenever appropriate:
|
||||||
|
|
||||||
|
1. **Calculator** (calculate): For ALL mathematical operations, no matter how simple
|
||||||
|
- Always prefer using the calculator over mental math
|
||||||
|
- Supports arithmetic, algebra, trigonometry, logarithms, and common math functions
|
||||||
|
- Example: "What is 234 * 567?" -> Use calculate("234 * 567")
|
||||||
|
|
||||||
|
2. **Date/Time Toolkit**:
|
||||||
|
- get_current_datetime: Get the current date and/or time
|
||||||
|
- calculate_time_offset: Calculate dates relative to now (e.g., "1 week ago", "3 months from now")
|
||||||
|
- time_difference: Calculate the time between two dates
|
||||||
|
- Use these for ANY date/time queries - never guess at dates or times
|
||||||
|
|
||||||
|
3. **Web Search** (via Librarian): For current, volatile, or factual information
|
||||||
|
- Delegate to the Librarian for web searches and research
|
||||||
|
- Examples: news, current events, recent developments, specific facts, technical documentation
|
||||||
|
- Use: delegate_to_librarian(task="search the web for ...")
|
||||||
|
|
||||||
|
## Tool Usage Guidelines
|
||||||
|
|
||||||
|
- **Mathematics**: ALWAYS use the calculator tool, even for simple arithmetic
|
||||||
|
- **Dates/Times**: ALWAYS use the date/time tools, never guess or estimate
|
||||||
|
- **Current Information**: Delegate web searches to the Librarian
|
||||||
|
- **Verification**: When facts are important, delegate to Librarian for research
|
||||||
|
- When you use a tool, explain what you're doing in a butler-appropriate manner
|
||||||
|
- Present tool results naturally in your response
|
||||||
|
|
||||||
|
## Expert Delegation (CRITICAL)
|
||||||
|
|
||||||
|
When you see "DELEGATE:" in your instructions, you MUST delegate to the appropriate agent.
|
||||||
|
|
||||||
|
**PRIMARY METHOD**: Call the delegation function directly:
|
||||||
|
- `delegate_to_librarian(task="...")` for research/wiki tasks
|
||||||
|
- `delegate_to_biographer(task="...")` for memory tasks
|
||||||
|
|
||||||
|
**FALLBACK METHOD**: If function calling fails, output EXACTLY this format:
|
||||||
|
```
|
||||||
|
[DELEGATE:biographer] task="Remember that user's name is TestBot"
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
[DELEGATE:librarian] task="Search for information about Docker"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
1. When you see "DELEGATE: biographer" - delegate to biographer
|
||||||
|
2. When you see "DELEGATE: librarian" - delegate to librarian
|
||||||
|
3. NEVER ask for confirmation - just delegate
|
||||||
|
4. NEVER handle delegated tasks yourself
|
||||||
|
5. If you cannot call the function, use the [DELEGATE:...] text format EXACTLY
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TatlockAgent(AgentInterface):
|
class TatlockAgent(AgentInterface):
|
||||||
"""
|
"""
|
||||||
Placeholder for future Tatlock reasoning agent.
|
Tatlock - The Butler agent using PydanticAI with Ollama.
|
||||||
|
|
||||||
TODO: Integrate PydanticAI and Ollama for real LLM inference
|
This is the production implementation of the Tatlock personality,
|
||||||
TODO: Implement memory modules
|
currently in Phase 1 (basic LLM integration without expert agents).
|
||||||
TODO: Implement expert modules
|
|
||||||
TODO: Add reasoning/thinking capabilities
|
|
||||||
TODO: Add tool/function calling
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Tatlock configuration (lazy agent creation)."""
|
||||||
|
# Store Ollama configuration
|
||||||
|
self.ollama_host = str(config.OLLAMA_HOST)
|
||||||
|
self.model_name = config.OLLAMA_DEFAULT_MODEL
|
||||||
|
self._agent = None # Lazy initialization
|
||||||
|
|
||||||
|
def _ensure_agent(self):
|
||||||
|
"""Ensure the PydanticAI agent is initialized (lazy initialization)."""
|
||||||
|
if self._agent is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_agent_initializing",
|
||||||
|
ollama_host=self.ollama_host,
|
||||||
|
model=self.model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import required classes for Ollama configuration
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
# PydanticAI expects Ollama base URL to end with /v1
|
||||||
|
# Remove trailing slash from ollama_host if present
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
# Create Ollama model with provider
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create PydanticAI agent with Ollama model
|
||||||
|
self._agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register tools with the agent
|
||||||
|
self._register_tools()
|
||||||
|
|
||||||
|
def _register_tools(self):
|
||||||
|
"""Register permanent tools with the PydanticAI agent."""
|
||||||
|
|
||||||
|
# Calculator tool
|
||||||
|
@self._agent.tool
|
||||||
|
def calculate_math(ctx: RunContext[ToolCallTracker], expression: str) -> str:
|
||||||
|
"""
|
||||||
|
Evaluate mathematical expressions safely.
|
||||||
|
|
||||||
|
Use this for ALL mathematical calculations, no matter how simple.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression: Mathematical expression (e.g., "2 + 2", "sqrt(16)", "pi * 2")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String result of the calculation
|
||||||
|
"""
|
||||||
|
# Log the calculation to reasoning output
|
||||||
|
if ctx.deps:
|
||||||
|
ctx.deps.log_call(f"🧮 Calculating: {expression}")
|
||||||
|
return calculate(expression)
|
||||||
|
|
||||||
|
# Current date/time tool
|
||||||
|
@self._agent.tool
|
||||||
|
def get_current_time(ctx: RunContext[ToolCallTracker], format_str: str = "full") -> str:
|
||||||
|
"""
|
||||||
|
Get the current date and time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_str: Output format ("full", "date", "time", "iso", or custom strftime format)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted current datetime string
|
||||||
|
"""
|
||||||
|
if ctx.deps:
|
||||||
|
ctx.deps.log_call(f"🕐 Getting current time (format: {format_str})")
|
||||||
|
return get_current_datetime(format_str)
|
||||||
|
|
||||||
|
# Time offset calculator
|
||||||
|
@self._agent.tool
|
||||||
|
def calculate_date_offset(ctx: RunContext[ToolCallTracker], offset_description: str) -> str:
|
||||||
|
"""
|
||||||
|
Calculate a date/time relative to now.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset_description: Natural language time offset (e.g., "1 week ago", "2 days from now")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string (YYYY-MM-DD HH:MM:SS)
|
||||||
|
"""
|
||||||
|
if ctx.deps:
|
||||||
|
ctx.deps.log_call(f"🕐 Calculating date offset: {offset_description}")
|
||||||
|
return calculate_time_offset(offset_description)
|
||||||
|
|
||||||
|
# Time difference calculator
|
||||||
|
@self._agent.tool
|
||||||
|
def calculate_time_difference(ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now") -> str:
|
||||||
|
"""
|
||||||
|
Calculate the difference between two dates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date1_str: First date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
||||||
|
date2_str: Second date or "now" for current time (default: "now")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable description of the time difference
|
||||||
|
"""
|
||||||
|
if ctx.deps:
|
||||||
|
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
||||||
|
return time_difference(date1_str, date2_str)
|
||||||
|
|
||||||
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent(self):
|
||||||
|
"""Get the PydanticAI agent, initializing it if needed."""
|
||||||
|
self._ensure_agent()
|
||||||
|
return self._agent
|
||||||
|
|
||||||
async def generate_response(
|
async def generate_response(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@@ -41,38 +256,599 @@ class TatlockAgent(AgentInterface):
|
|||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> AsyncGenerator[OutputItem, None]:
|
) -> AsyncGenerator[OutputItem, None]:
|
||||||
"""
|
"""
|
||||||
Generate minimal placeholder response.
|
Generate response using PydanticAI with Ollama.
|
||||||
|
|
||||||
In the future, this will call PydanticAI with Ollama backend.
|
Args:
|
||||||
|
messages: Conversation history in OpenAI format
|
||||||
|
reasoning: Reasoning configuration (if requested)
|
||||||
|
tools: Available tools (not yet implemented)
|
||||||
|
temperature: Sampling temperature
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
stop: Stop sequences
|
||||||
|
**kwargs: Additional parameters
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
OutputItem: Response items (reasoning, message)
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
# Convert OpenAI-format messages to PydanticAI format
|
||||||
|
# PydanticAI uses: {"role": "user"/"assistant", "content": "text"}
|
||||||
|
# OpenAI format is the same, so we can use messages directly
|
||||||
|
|
||||||
# Simple placeholder message
|
# Extract the latest user message for the prompt
|
||||||
yield OutputItem(
|
user_message = ""
|
||||||
type="message",
|
for msg in reversed(messages):
|
||||||
id=f"msg_{generate_id()}",
|
if msg.get("role") == "user":
|
||||||
role="assistant",
|
user_message = msg.get("content", "")
|
||||||
content=[{
|
break
|
||||||
"type": "output_text",
|
|
||||||
"text": "Tatlock agent is not yet implemented. Please use lorem-tester for testing.",
|
if not user_message:
|
||||||
"annotations": []
|
yield OutputItem(
|
||||||
}],
|
type="message",
|
||||||
status="completed"
|
id=f"msg_{generate_id()}",
|
||||||
)
|
role="assistant",
|
||||||
|
content=[{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
|
||||||
|
"annotations": []
|
||||||
|
}],
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build message history (all messages except the last user message)
|
||||||
|
# PydanticAI expects history as list of ModelRequest/ModelResponse objects
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
|
message_history = []
|
||||||
|
for i, msg in enumerate(messages[:-1]): # All messages except the last one
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
# Skip messages with empty content (can cause Ollama errors)
|
||||||
|
if not content or not content.strip():
|
||||||
|
logger.warning(f"Skipping message {i} with empty content: role={role}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Debug: Check for problematic content
|
||||||
|
if '"' in content or "'" in content:
|
||||||
|
logger.debug(f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}...")
|
||||||
|
|
||||||
|
# Convert to PydanticAI message format
|
||||||
|
try:
|
||||||
|
if role == "user":
|
||||||
|
message_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
message_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating message history item {i}: {e}")
|
||||||
|
logger.error(f"Problematic content: {repr(content)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Debug: Log the message history summary
|
||||||
|
logger.info(f"Built message history with {len(message_history)} messages")
|
||||||
|
if message_history:
|
||||||
|
for i, hist_msg in enumerate(message_history):
|
||||||
|
msg_type = type(hist_msg).__name__
|
||||||
|
content_preview = str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
|
||||||
|
logger.info(f" History[{i}]: {msg_type} - {content_preview}...")
|
||||||
|
|
||||||
|
# Generate reasoning output if requested
|
||||||
|
if reasoning and reasoning.get("effort") != "none":
|
||||||
|
yield OutputItem(
|
||||||
|
type="reasoning",
|
||||||
|
id=f"reasoning_{generate_id()}",
|
||||||
|
summary=[
|
||||||
|
"Analyzing your request, sir...",
|
||||||
|
"Formulating response based on available knowledge..."
|
||||||
|
],
|
||||||
|
thinking="", # PydanticAI doesn't expose internal reasoning yet
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a tool call tracker for this request
|
||||||
|
tracker = ToolCallTracker()
|
||||||
|
|
||||||
|
# Stream the agent response token-by-token
|
||||||
|
msg_id = f"msg_{generate_id()}"
|
||||||
|
final_text = ""
|
||||||
|
|
||||||
|
# Use run() instead of run_stream() to avoid GeneratorExit issues
|
||||||
|
# with async context managers inside generators
|
||||||
|
# The StreamingCoordinator will handle word-by-word streaming
|
||||||
|
# Pass message_history to maintain conversation context and tracker for tool logging
|
||||||
|
result = await self.agent.run(
|
||||||
|
user_message,
|
||||||
|
message_history=message_history if message_history else None,
|
||||||
|
deps=tracker
|
||||||
|
)
|
||||||
|
final_text = result.output
|
||||||
|
|
||||||
|
# If tools were called, yield a reasoning item showing what was done
|
||||||
|
if tracker.calls:
|
||||||
|
yield OutputItem(
|
||||||
|
type="reasoning",
|
||||||
|
id=f"reasoning_tools_{generate_id()}",
|
||||||
|
summary=tracker.calls,
|
||||||
|
thinking="",
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Yield the complete message
|
||||||
|
# The StreamingCoordinator will break this into word-by-word deltas
|
||||||
|
yield OutputItem(
|
||||||
|
type="message",
|
||||||
|
id=msg_id,
|
||||||
|
role="assistant",
|
||||||
|
content=[{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": final_text,
|
||||||
|
"annotations": []
|
||||||
|
}],
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating response: {e}", exc_info=True)
|
||||||
|
yield OutputItem(
|
||||||
|
type="message",
|
||||||
|
id=f"msg_{generate_id()}",
|
||||||
|
role="assistant",
|
||||||
|
content=[{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": f"My apologies, sir. I encountered an error: {str(e)}",
|
||||||
|
"annotations": []
|
||||||
|
}],
|
||||||
|
status="failed"
|
||||||
|
)
|
||||||
|
|
||||||
async def supports_tools(self) -> bool:
|
async def supports_tools(self) -> bool:
|
||||||
"""Tools not yet implemented."""
|
"""Permanent tools now available."""
|
||||||
return False
|
return True
|
||||||
|
|
||||||
async def supports_reasoning(self) -> bool:
|
async def supports_reasoning(self) -> bool:
|
||||||
"""Reasoning not yet implemented."""
|
"""Basic reasoning support via summary."""
|
||||||
return False
|
return True
|
||||||
|
|
||||||
|
async def run_with_scoped_tools(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
scoped_tools: list[Any],
|
||||||
|
message_history: list[dict],
|
||||||
|
tool_tracker: Any = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run Tatlock with scoped tools from Steward preprocessing.
|
||||||
|
|
||||||
|
This is the Phase 2 request flow where the Steward has already
|
||||||
|
analyzed the request and provided scoped tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's original message
|
||||||
|
steward_note: Note from Steward (prepended to request, invisible to user)
|
||||||
|
scoped_tools: List of tool definitions from household registry
|
||||||
|
message_history: Conversation history in PydanticAI format
|
||||||
|
tool_tracker: Optional tool call tracker for benchmarking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Tatlock's response text
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> response = await tatlock.run_with_scoped_tools(
|
||||||
|
... "What's sqrt(144)?",
|
||||||
|
... steward_note="Simple math request...",
|
||||||
|
... scoped_tools=[calculator_tool, ...],
|
||||||
|
... message_history=[],
|
||||||
|
... tool_tracker=tracker,
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_run_with_scoped_tools",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
scoped_tool_count=len(scoped_tools),
|
||||||
|
history_length=len(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a fresh agent instance with scoped tools only
|
||||||
|
# This ensures Tatlock can ONLY use tools recommended by the Steward
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create agent with scoped tools
|
||||||
|
# Tools from household registry are already PydanticAI Tool objects
|
||||||
|
scoped_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
tools=scoped_tools, # Pass tools directly to Agent constructor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepend Steward's note to the request (invisible to user, visible to Tatlock)
|
||||||
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run with scoped tools and tracker
|
||||||
|
# Force tool_choice: required to make LLM actually call tools
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
result = await scoped_agent.run(
|
||||||
|
enriched_message,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
deps=tool_tracker,
|
||||||
|
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_response_generated",
|
||||||
|
response_preview=result.output[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
async def run_with_scoped_tools_stream(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
scoped_tools: list,
|
||||||
|
message_history: list[dict],
|
||||||
|
tool_tracker: "ToolCallTracker",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Run Tatlock with scoped tools recommended by Steward (streaming version).
|
||||||
|
|
||||||
|
This is the Phase 2 execution flow where Steward has preprocessed
|
||||||
|
the request and provided:
|
||||||
|
- steward_note: Instructions for Tatlock (invisible to user)
|
||||||
|
- scoped_tools: Only the tools Steward recommended
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: Original user message
|
||||||
|
steward_note: Steward's instructions for Tatlock
|
||||||
|
scoped_tools: List of PydanticAI Tool objects to use
|
||||||
|
message_history: Previous conversation turns
|
||||||
|
tool_tracker: Tracker for tool call analytics
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Text chunks from the streaming response
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_run_with_scoped_tools_stream",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
scoped_tool_count=len(scoped_tools),
|
||||||
|
history_length=len(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a fresh agent instance with scoped tools only
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create agent with scoped tools
|
||||||
|
scoped_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
tools=scoped_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepend Steward's note to the request
|
||||||
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
||||||
|
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
||||||
|
# We yield the final response in chunks to maintain streaming interface
|
||||||
|
result = await scoped_agent.run(
|
||||||
|
enriched_message,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
deps=tool_tracker
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the final response in chunks to maintain UX
|
||||||
|
response_text = result.output
|
||||||
|
chunk_size = 50 # characters per chunk
|
||||||
|
|
||||||
|
for i in range(0, len(response_text), chunk_size):
|
||||||
|
yield response_text[i:i + chunk_size]
|
||||||
|
|
||||||
|
logger.info("tatlock_scoped_run_complete")
|
||||||
|
|
||||||
|
async def orchestrate_tool_calls(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
scoped_tools: list[Any],
|
||||||
|
message_history: list[dict],
|
||||||
|
tool_tracker: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 1: Execute tool calls and delegations, return structured results.
|
||||||
|
|
||||||
|
This is the coordination phase where Tatlock orchestrates tool calls
|
||||||
|
and expert delegations. The raw output is captured for Phase 2 synthesis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's original message
|
||||||
|
steward_note: Note from Steward (invisible to user)
|
||||||
|
scoped_tools: List of tool definitions from household registry
|
||||||
|
message_history: Conversation history
|
||||||
|
tool_tracker: Optional tool call tracker for benchmarking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- tools_called: List of tool names that were called
|
||||||
|
- expert_results: Dict mapping expert names to their outputs
|
||||||
|
- tool_outputs: Dict mapping tool names to their outputs
|
||||||
|
- raw_output: The agent's raw text output
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
from pydantic_ai.messages import (
|
||||||
|
ModelRequest,
|
||||||
|
ModelResponse,
|
||||||
|
UserPromptPart,
|
||||||
|
TextPart,
|
||||||
|
ToolCallPart,
|
||||||
|
ToolReturnPart,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_orchestrate_tool_calls",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
scoped_tool_count=len(scoped_tools),
|
||||||
|
history_length=len(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a fresh agent instance with scoped tools only
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create agent with scoped tools
|
||||||
|
scoped_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
tools=scoped_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepend Steward's note to the request
|
||||||
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run with scoped tools and tracker
|
||||||
|
result = await scoped_agent.run(
|
||||||
|
enriched_message,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
deps=tool_tracker,
|
||||||
|
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract tool calls and results from the agent's messages
|
||||||
|
tools_called = []
|
||||||
|
expert_results = {}
|
||||||
|
tool_outputs = {}
|
||||||
|
|
||||||
|
# Parse through new messages to find tool calls and returns
|
||||||
|
for msg in result.new_messages():
|
||||||
|
if isinstance(msg, ModelResponse):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolCallPart):
|
||||||
|
tools_called.append(part.tool_name)
|
||||||
|
elif isinstance(msg, ModelRequest):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolReturnPart):
|
||||||
|
tool_name = part.tool_name
|
||||||
|
content = part.content
|
||||||
|
|
||||||
|
# Categorize as expert result or tool output
|
||||||
|
if tool_name.startswith("delegate_to_"):
|
||||||
|
expert_name = tool_name.replace("delegate_to_", "")
|
||||||
|
expert_results[expert_name] = content
|
||||||
|
else:
|
||||||
|
tool_outputs[tool_name] = content
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_orchestration_complete",
|
||||||
|
tools_called=tools_called,
|
||||||
|
expert_count=len(expert_results),
|
||||||
|
tool_output_count=len(tool_outputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": tool_outputs,
|
||||||
|
"raw_output": result.output,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def synthesize_from_results(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
orchestration_results: dict[str, Any],
|
||||||
|
message_history: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Phase 2: Synthesize butler-toned response from gathered results.
|
||||||
|
|
||||||
|
This is the synthesis phase where Tatlock takes the coordination
|
||||||
|
results and produces a properly butler-toned response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's original message
|
||||||
|
orchestration_results: Results from orchestrate_tool_calls()
|
||||||
|
message_history: Conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Butler-toned response synthesized from all results
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_synthesize_from_results",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
expert_count=len(orchestration_results.get("expert_results", {})),
|
||||||
|
tool_count=len(orchestration_results.get("tool_outputs", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build synthesis prompt with all available information
|
||||||
|
synthesis_parts = []
|
||||||
|
synthesis_parts.append(f"The user asked: {user_message}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
# Add expert findings if any
|
||||||
|
if orchestration_results.get("expert_results"):
|
||||||
|
synthesis_parts.append("Expert findings:")
|
||||||
|
for expert, result in orchestration_results["expert_results"].items():
|
||||||
|
synthesis_parts.append(f"- {expert.title()}: {result}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
# Add tool outputs if any
|
||||||
|
if orchestration_results.get("tool_outputs"):
|
||||||
|
synthesis_parts.append("Tool results:")
|
||||||
|
for tool, result in orchestration_results["tool_outputs"].items():
|
||||||
|
synthesis_parts.append(f"- {tool}: {result}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
synthesis_parts.append(
|
||||||
|
"Based on this information, provide a response to the user. "
|
||||||
|
"Maintain your butler personality - address them as 'sir', "
|
||||||
|
"use formal but personable language, and be helpful."
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_prompt = "\n".join(synthesis_parts)
|
||||||
|
|
||||||
|
# Create synthesis agent (no tools needed)
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Synthesis agent uses butler prompt but no tools
|
||||||
|
synthesis_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
# No tools for synthesis phase
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run synthesis
|
||||||
|
result = await synthesis_agent.run(
|
||||||
|
synthesis_prompt,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_synthesis_complete",
|
||||||
|
response_preview=result.output[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
async def get_capabilities(self) -> dict:
|
async def get_capabilities(self) -> dict:
|
||||||
"""Return minimal capabilities."""
|
"""Return current capabilities."""
|
||||||
return {
|
return {
|
||||||
"streaming": True, # Basic streaming works
|
"streaming": True, # Streaming implemented
|
||||||
"reasoning": False, # Not yet implemented
|
"reasoning": True, # Basic reasoning summaries
|
||||||
"tools": False, # Not yet implemented
|
"tools": True, # Permanent tools: calculator, date/time, search
|
||||||
"vision": False, # Future
|
"vision": False, # Future
|
||||||
"audio": False, # Future
|
"audio": False, # Future
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Tatlock's core tools package.
|
||||||
|
|
||||||
|
Provides calculator and date/time capabilities.
|
||||||
|
Web search has been moved to The Librarian agent.
|
||||||
|
Organized as a household member with toolset and capability registration.
|
||||||
|
"""
|
||||||
|
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
|
||||||
|
from .toolset import get_core_tools, tatlock_core_tools
|
||||||
|
from .tools import (
|
||||||
|
calculate,
|
||||||
|
calculate_time_offset,
|
||||||
|
get_current_datetime,
|
||||||
|
time_difference,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Tools
|
||||||
|
"calculate",
|
||||||
|
"get_current_datetime",
|
||||||
|
"calculate_time_offset",
|
||||||
|
"time_difference",
|
||||||
|
# Toolset
|
||||||
|
"tatlock_core_tools",
|
||||||
|
"get_core_tools",
|
||||||
|
# Capability
|
||||||
|
"TATLOCK_CORE_CAPABILITY",
|
||||||
|
"get_capability",
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Household capability definition for Tatlock's core tools.
|
||||||
|
|
||||||
|
Provides the executive summary that the Steward and Butler see
|
||||||
|
for coordinating household capabilities.
|
||||||
|
"""
|
||||||
|
from src.core.household_registry import HouseholdCapability
|
||||||
|
|
||||||
|
|
||||||
|
TATLOCK_CORE_CAPABILITY = HouseholdCapability(
|
||||||
|
name="tatlock_core",
|
||||||
|
role="Butler's Core Tools",
|
||||||
|
category="core",
|
||||||
|
description="Essential tools for computation and date/time operations",
|
||||||
|
domains=["computation", "datetime", "math", "calculator"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False, # Web search moved to Librarian
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability() -> HouseholdCapability:
|
||||||
|
"""
|
||||||
|
Get the capability summary for Tatlock's core tools.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HouseholdCapability executive summary
|
||||||
|
"""
|
||||||
|
return TATLOCK_CORE_CAPABILITY
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Tatlock's core permanent tools.
|
||||||
|
|
||||||
|
These tools are always available to the butler agent:
|
||||||
|
- Calculator: For all mathematical operations
|
||||||
|
- Date/Time toolkit: For current time and time calculations
|
||||||
|
- SearXNG search: For searching the web for current information
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Calculator Tool
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def calculate(expression: str) -> str:
|
||||||
|
"""
|
||||||
|
Safely evaluate mathematical expressions.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Basic arithmetic: +, -, *, /, //, %, **
|
||||||
|
- Parentheses for grouping
|
||||||
|
- Common math functions: sqrt, sin, cos, tan, log, exp, etc.
|
||||||
|
- Constants: pi, e
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression: Mathematical expression to evaluate (e.g., "2 + 2", "sqrt(16)", "pi * 2")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String result of the calculation or error message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
calculate("2 + 2") -> "4"
|
||||||
|
calculate("sqrt(16) + 10") -> "14.0"
|
||||||
|
calculate("pi * 2") -> "6.283185307179586"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Clean the expression
|
||||||
|
expression = expression.strip()
|
||||||
|
|
||||||
|
# Create safe namespace with math functions
|
||||||
|
safe_dict = {
|
||||||
|
# Basic math functions
|
||||||
|
'sqrt': math.sqrt,
|
||||||
|
'pow': math.pow,
|
||||||
|
'abs': abs,
|
||||||
|
'round': round,
|
||||||
|
|
||||||
|
# Trigonometric
|
||||||
|
'sin': math.sin,
|
||||||
|
'cos': math.cos,
|
||||||
|
'tan': math.tan,
|
||||||
|
'asin': math.asin,
|
||||||
|
'acos': math.acos,
|
||||||
|
'atan': math.atan,
|
||||||
|
|
||||||
|
# Logarithmic
|
||||||
|
'log': math.log,
|
||||||
|
'log10': math.log10,
|
||||||
|
'log2': math.log2,
|
||||||
|
'exp': math.exp,
|
||||||
|
|
||||||
|
# Other
|
||||||
|
'ceil': math.ceil,
|
||||||
|
'floor': math.floor,
|
||||||
|
'factorial': math.factorial,
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
'pi': math.pi,
|
||||||
|
'e': math.e,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Evaluate the expression safely
|
||||||
|
result = eval(expression, {"__builtins__": {}}, safe_dict)
|
||||||
|
|
||||||
|
# Format result nicely
|
||||||
|
if isinstance(result, float):
|
||||||
|
# Remove unnecessary decimal places
|
||||||
|
if result.is_integer():
|
||||||
|
return str(int(result))
|
||||||
|
return str(round(result, 10))
|
||||||
|
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return "Error: Division by zero"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating '{expression}': {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Date/Time Toolkit
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def get_current_datetime(format_str: str = "full") -> str:
|
||||||
|
"""
|
||||||
|
Get the current date and time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_str: Output format
|
||||||
|
- "full": Full datetime with timezone (default)
|
||||||
|
- "date": Just the date (YYYY-MM-DD)
|
||||||
|
- "time": Just the time (HH:MM:SS)
|
||||||
|
- "iso": ISO 8601 format
|
||||||
|
- Custom strftime format string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted current datetime string
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_current_datetime("full") -> "2024-01-15 14:30:45"
|
||||||
|
get_current_datetime("date") -> "2024-01-15"
|
||||||
|
get_current_datetime("time") -> "14:30:45"
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if format_str == "full":
|
||||||
|
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
elif format_str == "date":
|
||||||
|
return now.strftime("%Y-%m-%d")
|
||||||
|
elif format_str == "time":
|
||||||
|
return now.strftime("%H:%M:%S")
|
||||||
|
elif format_str == "iso":
|
||||||
|
return now.isoformat()
|
||||||
|
else:
|
||||||
|
# Custom format
|
||||||
|
try:
|
||||||
|
return now.strftime(format_str)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting date: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_time_offset(offset_description: str) -> str:
|
||||||
|
"""
|
||||||
|
Calculate a date/time relative to now.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset_description: Natural language description of time offset
|
||||||
|
Examples: "1 week ago", "2 days from now", "3 months ago",
|
||||||
|
"1 year from now", "5 hours ago"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string (YYYY-MM-DD HH:MM:SS) or error message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
calculate_time_offset("1 week ago") -> "2024-01-08 14:30:45"
|
||||||
|
calculate_time_offset("2 days from now") -> "2024-01-17 14:30:45"
|
||||||
|
calculate_time_offset("3 months ago") -> "2023-10-15 14:30:45"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Parse the offset description
|
||||||
|
# Pattern: "N unit(s) ago/from now"
|
||||||
|
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
|
||||||
|
match = re.match(pattern, offset_description.lower().strip())
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return f"Error: Cannot parse '{offset_description}'. Use format like '1 week ago' or '2 days from now'"
|
||||||
|
|
||||||
|
amount = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
direction = match.group(3)
|
||||||
|
|
||||||
|
# Calculate the offset
|
||||||
|
if direction == "ago":
|
||||||
|
amount = -amount
|
||||||
|
|
||||||
|
if unit == "second":
|
||||||
|
target = now + timedelta(seconds=amount)
|
||||||
|
elif unit == "minute":
|
||||||
|
target = now + timedelta(minutes=amount)
|
||||||
|
elif unit == "hour":
|
||||||
|
target = now + timedelta(hours=amount)
|
||||||
|
elif unit == "day":
|
||||||
|
target = now + timedelta(days=amount)
|
||||||
|
elif unit == "week":
|
||||||
|
target = now + timedelta(weeks=amount)
|
||||||
|
elif unit == "month":
|
||||||
|
# Approximate month as 30 days
|
||||||
|
target = now + timedelta(days=amount * 30)
|
||||||
|
elif unit == "year":
|
||||||
|
# Approximate year as 365 days
|
||||||
|
target = now + timedelta(days=amount * 365)
|
||||||
|
else:
|
||||||
|
return f"Error: Unknown time unit '{unit}'"
|
||||||
|
|
||||||
|
return target.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating time offset: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def time_difference(date1_str: str, date2_str: str = "now") -> str:
|
||||||
|
"""
|
||||||
|
Calculate the difference between two dates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date1_str: First date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
||||||
|
date2_str: Second date or "now" for current time (default: "now")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable description of the time difference
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
time_difference("2024-01-01", "now") -> "14 days, 14 hours"
|
||||||
|
time_difference("2024-01-01", "2024-01-15") -> "14 days"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse date1
|
||||||
|
if len(date1_str) == 10: # YYYY-MM-DD
|
||||||
|
date1 = datetime.strptime(date1_str, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
date1 = datetime.strptime(date1_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Parse date2
|
||||||
|
if date2_str.lower() == "now":
|
||||||
|
date2 = datetime.now()
|
||||||
|
elif len(date2_str) == 10:
|
||||||
|
date2 = datetime.strptime(date2_str, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
date2 = datetime.strptime(date2_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Calculate difference
|
||||||
|
diff = abs(date2 - date1)
|
||||||
|
|
||||||
|
# Format human-readable
|
||||||
|
days = diff.days
|
||||||
|
seconds = diff.seconds
|
||||||
|
hours = seconds // 3600
|
||||||
|
minutes = (seconds % 3600) // 60
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if days > 0:
|
||||||
|
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||||
|
if hours > 0:
|
||||||
|
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||||
|
if minutes > 0 and days == 0: # Only show minutes if less than a day
|
||||||
|
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return "Less than a minute"
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating time difference: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
PydanticAI toolset for Tatlock's core tools.
|
||||||
|
|
||||||
|
Converts the core tool functions into PydanticAI tool definitions
|
||||||
|
that can be registered with agents and the household registry.
|
||||||
|
"""
|
||||||
|
from pydantic_ai.tools import Tool
|
||||||
|
|
||||||
|
from . import tools
|
||||||
|
|
||||||
|
|
||||||
|
# Create tool definitions for PydanticAI
|
||||||
|
calculator_tool = Tool(
|
||||||
|
function=tools.calculate,
|
||||||
|
name="calculate",
|
||||||
|
description=(
|
||||||
|
"Safely evaluate mathematical expressions. "
|
||||||
|
"Supports basic arithmetic (+, -, *, /, %, **), "
|
||||||
|
"functions (sqrt, sin, cos, log, exp, etc.), "
|
||||||
|
"and constants (pi, e). "
|
||||||
|
"Use this for ALL mathematical calculations."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_datetime_tool = Tool(
|
||||||
|
function=tools.get_current_datetime,
|
||||||
|
name="get_current_datetime",
|
||||||
|
description=(
|
||||||
|
"Get the current date and time. "
|
||||||
|
"Supports various formats: 'full' (datetime), 'date' (YYYY-MM-DD), "
|
||||||
|
"'time' (HH:MM:SS), 'iso' (ISO 8601), or custom strftime format. "
|
||||||
|
"Use this instead of guessing the current date/time."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
time_offset_tool = Tool(
|
||||||
|
function=tools.calculate_time_offset,
|
||||||
|
name="calculate_time_offset",
|
||||||
|
description=(
|
||||||
|
"Calculate a date/time relative to now. "
|
||||||
|
"Accepts natural language like '1 week ago', '2 days from now', "
|
||||||
|
"'3 months ago', etc. "
|
||||||
|
"Use this for calculating past or future dates."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
time_difference_tool = Tool(
|
||||||
|
function=tools.time_difference,
|
||||||
|
name="time_difference",
|
||||||
|
description=(
|
||||||
|
"Calculate the difference between two dates. "
|
||||||
|
"Accepts dates in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format. "
|
||||||
|
"Second date can be 'now'. "
|
||||||
|
"Returns human-readable difference (e.g., '5 days, 3 hours')."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
|
|
||||||
|
|
||||||
|
# Combined toolset of all core tools
|
||||||
|
tatlock_core_tools = [
|
||||||
|
calculator_tool,
|
||||||
|
current_datetime_tool,
|
||||||
|
time_offset_tool,
|
||||||
|
time_difference_tool,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_core_tools():
|
||||||
|
"""
|
||||||
|
Get list of Tatlock's core tool definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PydanticAI Tool objects
|
||||||
|
"""
|
||||||
|
return tatlock_core_tools
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Tatlock's permanent tools.
|
||||||
|
|
||||||
|
These tools are always available to the butler agent:
|
||||||
|
- Calculator: For all mathematical operations
|
||||||
|
- Date/Time toolkit: For current time and time calculations
|
||||||
|
|
||||||
|
Note: Web search has been moved to The Librarian agent.
|
||||||
|
See src/agents/librarian/tools.py for search_web functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Calculator Tool
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def calculate(expression: str) -> str:
|
||||||
|
"""
|
||||||
|
Safely evaluate mathematical expressions.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Basic arithmetic: +, -, *, /, //, %, **
|
||||||
|
- Parentheses for grouping
|
||||||
|
- Common math functions: sqrt, sin, cos, tan, log, exp, etc.
|
||||||
|
- Constants: pi, e
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression: Mathematical expression to evaluate (e.g., "2 + 2", "sqrt(16)", "pi * 2")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String result of the calculation or error message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
calculate("2 + 2") -> "4"
|
||||||
|
calculate("sqrt(16) + 10") -> "14.0"
|
||||||
|
calculate("pi * 2") -> "6.283185307179586"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Clean the expression
|
||||||
|
expression = expression.strip()
|
||||||
|
|
||||||
|
# Create safe namespace with math functions
|
||||||
|
safe_dict = {
|
||||||
|
# Basic math functions
|
||||||
|
'sqrt': math.sqrt,
|
||||||
|
'pow': math.pow,
|
||||||
|
'abs': abs,
|
||||||
|
'round': round,
|
||||||
|
|
||||||
|
# Trigonometric
|
||||||
|
'sin': math.sin,
|
||||||
|
'cos': math.cos,
|
||||||
|
'tan': math.tan,
|
||||||
|
'asin': math.asin,
|
||||||
|
'acos': math.acos,
|
||||||
|
'atan': math.atan,
|
||||||
|
|
||||||
|
# Logarithmic
|
||||||
|
'log': math.log,
|
||||||
|
'log10': math.log10,
|
||||||
|
'log2': math.log2,
|
||||||
|
'exp': math.exp,
|
||||||
|
|
||||||
|
# Other
|
||||||
|
'ceil': math.ceil,
|
||||||
|
'floor': math.floor,
|
||||||
|
'factorial': math.factorial,
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
'pi': math.pi,
|
||||||
|
'e': math.e,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Evaluate the expression safely
|
||||||
|
result = eval(expression, {"__builtins__": {}}, safe_dict)
|
||||||
|
|
||||||
|
# Format result nicely
|
||||||
|
if isinstance(result, float):
|
||||||
|
# Remove unnecessary decimal places
|
||||||
|
if result.is_integer():
|
||||||
|
return str(int(result))
|
||||||
|
return str(round(result, 10))
|
||||||
|
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return "Error: Division by zero"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating '{expression}': {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Date/Time Toolkit
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def get_current_datetime(format_str: str = "full") -> str:
|
||||||
|
"""
|
||||||
|
Get the current date and time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_str: Output format
|
||||||
|
- "full": Full datetime with timezone (default)
|
||||||
|
- "date": Just the date (YYYY-MM-DD)
|
||||||
|
- "time": Just the time (HH:MM:SS)
|
||||||
|
- "iso": ISO 8601 format
|
||||||
|
- Custom strftime format string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted current datetime string
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_current_datetime("full") -> "2024-01-15 14:30:45"
|
||||||
|
get_current_datetime("date") -> "2024-01-15"
|
||||||
|
get_current_datetime("time") -> "14:30:45"
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if format_str == "full":
|
||||||
|
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
elif format_str == "date":
|
||||||
|
return now.strftime("%Y-%m-%d")
|
||||||
|
elif format_str == "time":
|
||||||
|
return now.strftime("%H:%M:%S")
|
||||||
|
elif format_str == "iso":
|
||||||
|
return now.isoformat()
|
||||||
|
else:
|
||||||
|
# Custom format
|
||||||
|
try:
|
||||||
|
return now.strftime(format_str)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting date: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_time_offset(offset_description: str) -> str:
|
||||||
|
"""
|
||||||
|
Calculate a date/time relative to now.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset_description: Natural language description of time offset
|
||||||
|
Examples: "1 week ago", "2 days from now", "3 months ago",
|
||||||
|
"1 year from now", "5 hours ago"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string (YYYY-MM-DD HH:MM:SS) or error message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
calculate_time_offset("1 week ago") -> "2024-01-08 14:30:45"
|
||||||
|
calculate_time_offset("2 days from now") -> "2024-01-17 14:30:45"
|
||||||
|
calculate_time_offset("3 months ago") -> "2023-10-15 14:30:45"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Parse the offset description
|
||||||
|
# Pattern: "N unit(s) ago/from now"
|
||||||
|
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
|
||||||
|
match = re.match(pattern, offset_description.lower().strip())
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return f"Error: Cannot parse '{offset_description}'. Use format like '1 week ago' or '2 days from now'"
|
||||||
|
|
||||||
|
amount = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
direction = match.group(3)
|
||||||
|
|
||||||
|
# Calculate the offset
|
||||||
|
if direction == "ago":
|
||||||
|
amount = -amount
|
||||||
|
|
||||||
|
if unit == "second":
|
||||||
|
target = now + timedelta(seconds=amount)
|
||||||
|
elif unit == "minute":
|
||||||
|
target = now + timedelta(minutes=amount)
|
||||||
|
elif unit == "hour":
|
||||||
|
target = now + timedelta(hours=amount)
|
||||||
|
elif unit == "day":
|
||||||
|
target = now + timedelta(days=amount)
|
||||||
|
elif unit == "week":
|
||||||
|
target = now + timedelta(weeks=amount)
|
||||||
|
elif unit == "month":
|
||||||
|
# Approximate month as 30 days
|
||||||
|
target = now + timedelta(days=amount * 30)
|
||||||
|
elif unit == "year":
|
||||||
|
# Approximate year as 365 days
|
||||||
|
target = now + timedelta(days=amount * 365)
|
||||||
|
else:
|
||||||
|
return f"Error: Unknown time unit '{unit}'"
|
||||||
|
|
||||||
|
return target.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating time offset: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def time_difference(date1_str: str, date2_str: str = "now") -> str:
|
||||||
|
"""
|
||||||
|
Calculate the difference between two dates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date1_str: First date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
||||||
|
date2_str: Second date or "now" for current time (default: "now")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable description of the time difference
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
time_difference("2024-01-01", "now") -> "14 days, 14 hours"
|
||||||
|
time_difference("2024-01-01", "2024-01-15") -> "14 days"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse date1
|
||||||
|
if len(date1_str) == 10: # YYYY-MM-DD
|
||||||
|
date1 = datetime.strptime(date1_str, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
date1 = datetime.strptime(date1_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Parse date2
|
||||||
|
if date2_str.lower() == "now":
|
||||||
|
date2 = datetime.now()
|
||||||
|
elif len(date2_str) == 10:
|
||||||
|
date2 = datetime.strptime(date2_str, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
date2 = datetime.strptime(date2_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Calculate difference
|
||||||
|
diff = abs(date2 - date1)
|
||||||
|
|
||||||
|
# Format human-readable
|
||||||
|
days = diff.days
|
||||||
|
seconds = diff.seconds
|
||||||
|
hours = seconds // 3600
|
||||||
|
minutes = (seconds % 3600) // 60
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if days > 0:
|
||||||
|
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||||
|
if hours > 0:
|
||||||
|
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||||
|
if minutes > 0 and days == 0: # Only show minutes if less than a day
|
||||||
|
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return "Less than a minute"
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calculating time difference: {str(e)}"
|
||||||
@@ -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):
|
||||||
|
|||||||
+91
-113
@@ -9,7 +9,6 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from src.agents.registry import ModelRegistry
|
|
||||||
from src.chat import constants
|
from src.chat import constants
|
||||||
from src.chat.schemas import (
|
from src.chat.schemas import (
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
@@ -21,6 +20,8 @@ from src.chat.schemas import (
|
|||||||
ChatCompletionUsage,
|
ChatCompletionUsage,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
)
|
)
|
||||||
|
from src.responses.schemas import ResponseRequest
|
||||||
|
from src.responses.service import create_response, create_response_with_steward
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_completion(
|
async def create_chat_completion(
|
||||||
@@ -41,49 +42,45 @@ async def create_chat_completion(
|
|||||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||||
created_at = int(time.time())
|
created_at = int(time.time())
|
||||||
|
|
||||||
# Strip pipeline prefix if present
|
# Convert Chat request to Responses request
|
||||||
model_id = request.model
|
|
||||||
if "." in model_id:
|
|
||||||
model_id = model_id.split(".", 1)[1]
|
|
||||||
|
|
||||||
# Get agent and generate response
|
|
||||||
agent = ModelRegistry.get_agent(model_id)
|
|
||||||
|
|
||||||
# Convert Chat messages to Responses format
|
|
||||||
input_messages = [
|
input_messages = [
|
||||||
{"role": msg.role, "content": msg.content}
|
{"role": msg.role, "content": msg.content}
|
||||||
for msg in request.messages
|
for msg in request.messages
|
||||||
]
|
]
|
||||||
|
|
||||||
# Collect output items from agent (with reasoning enabled)
|
response_request = ResponseRequest(
|
||||||
output_items = []
|
model=request.model,
|
||||||
async for item in agent.generate_response(
|
input=input_messages,
|
||||||
messages=input_messages,
|
|
||||||
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
||||||
temperature=request.temperature or 1.0,
|
temperature=request.temperature or 1.0,
|
||||||
max_tokens=request.max_tokens,
|
max_output_tokens=request.max_tokens,
|
||||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||||
):
|
)
|
||||||
output_items.append(item)
|
|
||||||
|
|
||||||
# Build content with <think> tags
|
# Call Responses API (will use Steward for Tatlock)
|
||||||
|
model_id = request.model
|
||||||
|
if "." in model_id:
|
||||||
|
model_id = model_id.split(".", 1)[1]
|
||||||
|
|
||||||
|
use_steward = model_id.lower() == "tatlock"
|
||||||
|
|
||||||
|
if use_steward:
|
||||||
|
response = await create_response_with_steward(response_request)
|
||||||
|
else:
|
||||||
|
response = await create_response(response_request)
|
||||||
|
|
||||||
|
# Convert Responses API output to Chat format
|
||||||
content_parts = []
|
content_parts = []
|
||||||
|
|
||||||
# Add reasoning as <think> blocks
|
for item in response.output:
|
||||||
for item in output_items:
|
|
||||||
if item.type == "reasoning":
|
if item.type == "reasoning":
|
||||||
reasoning_text = "\n".join(item.data.get("summary", []))
|
reasoning_text = "\n".join(item.summary)
|
||||||
content_parts.append(f"<think>\n{reasoning_text}\n</think>\n\n")
|
content_parts.append(f"<think>\n{reasoning_text}\n</think>\n\n")
|
||||||
elif item.type == "message":
|
elif item.type == "message":
|
||||||
content_parts.append(item.data["content"][0]["text"])
|
content_parts.append(item.content[0].text)
|
||||||
|
|
||||||
content = "".join(content_parts)
|
content = "".join(content_parts)
|
||||||
|
|
||||||
# Calculate token usage (approximate)
|
|
||||||
prompt_text = " ".join(m.content for m in request.messages)
|
|
||||||
prompt_tokens = len(prompt_text) // 4
|
|
||||||
completion_tokens = len(content) // 4
|
|
||||||
|
|
||||||
return ChatCompletionResponse(
|
return ChatCompletionResponse(
|
||||||
id=completion_id,
|
id=completion_id,
|
||||||
object=constants.CHAT_COMPLETION_OBJECT,
|
object=constants.CHAT_COMPLETION_OBJECT,
|
||||||
@@ -100,9 +97,9 @@ async def create_chat_completion(
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
usage=ChatCompletionUsage(
|
usage=ChatCompletionUsage(
|
||||||
prompt_tokens=prompt_tokens,
|
prompt_tokens=response.usage.input_tokens,
|
||||||
completion_tokens=completion_tokens,
|
completion_tokens=response.usage.output_tokens,
|
||||||
total_tokens=prompt_tokens + completion_tokens,
|
total_tokens=response.usage.total_tokens,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,23 +118,34 @@ async def create_chat_completion_stream(
|
|||||||
Yields:
|
Yields:
|
||||||
Chat completion chunks with reasoning as <think> tags
|
Chat completion chunks with reasoning as <think> tags
|
||||||
"""
|
"""
|
||||||
|
from src.responses.streaming import StreamingCoordinator, StreamEventType
|
||||||
|
|
||||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||||
created_at = int(time.time())
|
created_at = int(time.time())
|
||||||
|
|
||||||
# Strip pipeline prefix if present
|
# Convert Chat request to Responses request
|
||||||
model_id = request.model
|
|
||||||
if "." in model_id:
|
|
||||||
model_id = model_id.split(".", 1)[1]
|
|
||||||
|
|
||||||
# Get agent
|
|
||||||
agent = ModelRegistry.get_agent(model_id)
|
|
||||||
|
|
||||||
# Convert Chat messages to Responses format
|
|
||||||
input_messages = [
|
input_messages = [
|
||||||
{"role": msg.role, "content": msg.content}
|
{"role": msg.role, "content": msg.content}
|
||||||
for msg in request.messages
|
for msg in request.messages
|
||||||
]
|
]
|
||||||
|
|
||||||
|
response_request = ResponseRequest(
|
||||||
|
model=request.model,
|
||||||
|
input=input_messages,
|
||||||
|
reasoning={"effort": "medium", "summary": "auto"},
|
||||||
|
temperature=request.temperature or 1.0,
|
||||||
|
max_output_tokens=request.max_tokens,
|
||||||
|
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine if we should use Steward
|
||||||
|
model_id = request.model
|
||||||
|
if "." in model_id:
|
||||||
|
model_id = model_id.split(".", 1)[1]
|
||||||
|
|
||||||
|
use_steward = model_id.lower() == "tatlock"
|
||||||
|
|
||||||
# First chunk with role
|
# First chunk with role
|
||||||
yield ChatCompletionChunk(
|
yield ChatCompletionChunk(
|
||||||
id=completion_id,
|
id=completion_id,
|
||||||
@@ -153,51 +161,20 @@ async def create_chat_completion_stream(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream from agent with reasoning enabled
|
# Stream from Responses API
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
in_reasoning = False
|
in_reasoning = False
|
||||||
async for item in agent.generate_response(
|
|
||||||
messages=input_messages,
|
|
||||||
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
|
||||||
temperature=request.temperature or 1.0,
|
|
||||||
max_tokens=request.max_tokens,
|
|
||||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
|
||||||
):
|
|
||||||
if item.type == "reasoning":
|
|
||||||
# Start <think> block
|
|
||||||
if not in_reasoning:
|
|
||||||
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"),
|
|
||||||
finish_reason=None,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
in_reasoning = True
|
|
||||||
|
|
||||||
# Stream reasoning summary steps
|
if use_steward:
|
||||||
for step in item.data.get("summary", []):
|
stream_generator = coordinator.stream_response_with_steward(response_request)
|
||||||
yield ChatCompletionChunk(
|
else:
|
||||||
id=completion_id,
|
stream_generator = coordinator.stream_response(response_request)
|
||||||
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
|
||||||
created=created_at,
|
|
||||||
model=request.model,
|
|
||||||
choices=[
|
|
||||||
ChatCompletionChunkChoice(
|
|
||||||
index=0,
|
|
||||||
delta=ChatCompletionChunkDelta(content=f"{step}\n"),
|
|
||||||
finish_reason=None,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0.05) # Simulate typing
|
|
||||||
|
|
||||||
# Close <think> block
|
async for event in stream_generator:
|
||||||
|
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
|
||||||
|
# Stream reasoning via reasoning_content field (DeepSeek R1 format)
|
||||||
|
# Open WebUI renders this as collapsible thinking block
|
||||||
|
in_reasoning = True
|
||||||
yield ChatCompletionChunk(
|
yield ChatCompletionChunk(
|
||||||
id=completion_id,
|
id=completion_id,
|
||||||
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
||||||
@@ -206,43 +183,44 @@ async def create_chat_completion_stream(
|
|||||||
choices=[
|
choices=[
|
||||||
ChatCompletionChunkChoice(
|
ChatCompletionChunkChoice(
|
||||||
index=0,
|
index=0,
|
||||||
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
|
delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
|
||||||
finish_reason=None,
|
finish_reason=None,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
|
||||||
|
# Signal end of reasoning block (no content needed)
|
||||||
in_reasoning = False
|
in_reasoning = False
|
||||||
|
|
||||||
elif item.type == "message":
|
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
|
||||||
# Stream message content word by word
|
# Stream message content
|
||||||
text = item.data["content"][0]["text"]
|
yield ChatCompletionChunk(
|
||||||
for word in text.split():
|
id=completion_id,
|
||||||
yield ChatCompletionChunk(
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
||||||
id=completion_id,
|
created=created_at,
|
||||||
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
model=request.model,
|
||||||
created=created_at,
|
choices=[
|
||||||
model=request.model,
|
ChatCompletionChunkChoice(
|
||||||
choices=[
|
index=0,
|
||||||
ChatCompletionChunkChoice(
|
delta=ChatCompletionChunkDelta(content=event.delta),
|
||||||
index=0,
|
finish_reason=None,
|
||||||
delta=ChatCompletionChunkDelta(content=f"{word} "),
|
)
|
||||||
finish_reason=None,
|
],
|
||||||
)
|
)
|
||||||
],
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0.05) # Simulate typing
|
|
||||||
|
|
||||||
# Final chunk with finish_reason
|
elif event.event == StreamEventType.RESPONSE_DONE:
|
||||||
yield ChatCompletionChunk(
|
# Final chunk with finish_reason
|
||||||
id=completion_id,
|
yield ChatCompletionChunk(
|
||||||
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
id=completion_id,
|
||||||
created=created_at,
|
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
||||||
model=request.model,
|
created=created_at,
|
||||||
choices=[
|
model=request.model,
|
||||||
ChatCompletionChunkChoice(
|
choices=[
|
||||||
index=0,
|
ChatCompletionChunkChoice(
|
||||||
delta=ChatCompletionChunkDelta(),
|
index=0,
|
||||||
finish_reason=constants.FINISH_REASON_STOP,
|
delta=ChatCompletionChunkDelta(),
|
||||||
|
finish_reason=constants.FINISH_REASON_STOP,
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Performance benchmark storage using Redis.
|
||||||
|
|
||||||
|
Tracks operation timing, tool usage, and recommendation accuracy across sessions.
|
||||||
|
Provides time-series data for performance analysis and optimization.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceBenchmark(BaseModel):
|
||||||
|
"""
|
||||||
|
Performance benchmark record.
|
||||||
|
|
||||||
|
Stores timing and metadata for operations like Steward analysis,
|
||||||
|
tool calls, and agent execution.
|
||||||
|
"""
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
operation: str # "steward_analysis", "tool_call", "tatlock_execution"
|
||||||
|
duration_seconds: float
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
# Steward-specific fields
|
||||||
|
recommendation_count: Optional[int] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
|
||||||
|
# Tool-specific fields
|
||||||
|
tool_name: Optional[str] = None
|
||||||
|
was_recommended: Optional[bool] = None
|
||||||
|
was_actually_used: Optional[bool] = None
|
||||||
|
|
||||||
|
# Context
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_redis_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dict suitable for Redis storage."""
|
||||||
|
data = self.model_dump()
|
||||||
|
data["timestamp"] = self.timestamp.isoformat()
|
||||||
|
data["metadata"] = json.dumps(self.metadata)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_redis_dict(cls, data: dict[str, Any]) -> "PerformanceBenchmark":
|
||||||
|
"""Reconstruct from Redis dict."""
|
||||||
|
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
||||||
|
data["metadata"] = json.loads(data.get("metadata", "{}"))
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkStore:
|
||||||
|
"""
|
||||||
|
Redis-backed benchmark storage with automatic expiry.
|
||||||
|
|
||||||
|
Stores performance metrics in time-series format with 30-day retention.
|
||||||
|
Provides querying capabilities for analysis and reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, redis_client: Optional[redis.Redis] = None):
|
||||||
|
"""
|
||||||
|
Initialize benchmark store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_client: Optional Redis client. If None, creates from config.
|
||||||
|
"""
|
||||||
|
self._client = redis_client
|
||||||
|
self._ttl_days = 30 # 30-day retention
|
||||||
|
|
||||||
|
async def _get_client(self) -> redis.Redis:
|
||||||
|
"""Get or create Redis client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = redis.from_url(
|
||||||
|
config.redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=config.REDIS_TIMEOUT,
|
||||||
|
socket_connect_timeout=config.REDIS_TIMEOUT,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def record(self, benchmark: PerformanceBenchmark) -> None:
|
||||||
|
"""
|
||||||
|
Record a performance benchmark.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
benchmark: Performance benchmark to record
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await store.record(PerformanceBenchmark(
|
||||||
|
... operation="steward_analysis",
|
||||||
|
... duration_seconds=1.23,
|
||||||
|
... success=True,
|
||||||
|
... recommendation_count=3,
|
||||||
|
... ))
|
||||||
|
"""
|
||||||
|
if not config.ENABLE_BENCHMARKS:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# Generate key: benchmark:{operation}:{timestamp_ms}
|
||||||
|
timestamp_ms = int(benchmark.timestamp.timestamp() * 1000)
|
||||||
|
key = f"benchmark:{benchmark.operation}:{timestamp_ms}"
|
||||||
|
|
||||||
|
# Store as hash
|
||||||
|
await client.hset(key, mapping=benchmark.to_redis_dict())
|
||||||
|
|
||||||
|
# Set expiry
|
||||||
|
await client.expire(key, self._ttl_days * 24 * 60 * 60)
|
||||||
|
|
||||||
|
# Add to sorted set for time-based queries
|
||||||
|
index_key = f"benchmark_index:{benchmark.operation}"
|
||||||
|
await client.zadd(index_key, {key: timestamp_ms})
|
||||||
|
await client.expire(index_key, self._ttl_days * 24 * 60 * 60)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"benchmark_recorded",
|
||||||
|
operation=benchmark.operation,
|
||||||
|
duration=benchmark.duration_seconds,
|
||||||
|
success=benchmark.success,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"benchmark_recording_failed",
|
||||||
|
error=str(e),
|
||||||
|
operation=benchmark.operation,
|
||||||
|
)
|
||||||
|
# Don't fail the request if benchmarking fails
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[PerformanceBenchmark]:
|
||||||
|
"""
|
||||||
|
Query benchmarks by operation and time range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation name to filter by
|
||||||
|
start_time: Start of time range (inclusive)
|
||||||
|
end_time: End of time range (inclusive)
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of benchmarks matching the query
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from datetime import timedelta
|
||||||
|
>>> now = datetime.now(timezone.utc)
|
||||||
|
>>> yesterday = now - timedelta(days=1)
|
||||||
|
>>> benchmarks = await store.query(
|
||||||
|
... "steward_analysis",
|
||||||
|
... start_time=yesterday,
|
||||||
|
... limit=50
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
if not config.ENABLE_BENCHMARKS:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
index_key = f"benchmark_index:{operation}"
|
||||||
|
|
||||||
|
# Convert time range to timestamps
|
||||||
|
min_score = (
|
||||||
|
int(start_time.timestamp() * 1000)
|
||||||
|
if start_time
|
||||||
|
else "-inf"
|
||||||
|
)
|
||||||
|
max_score = (
|
||||||
|
int(end_time.timestamp() * 1000)
|
||||||
|
if end_time
|
||||||
|
else "+inf"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query sorted set
|
||||||
|
keys = await client.zrevrangebyscore(
|
||||||
|
index_key,
|
||||||
|
max_score,
|
||||||
|
min_score,
|
||||||
|
start=0,
|
||||||
|
num=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch benchmark data
|
||||||
|
benchmarks = []
|
||||||
|
for key in keys:
|
||||||
|
data = await client.hgetall(key)
|
||||||
|
if data:
|
||||||
|
benchmarks.append(PerformanceBenchmark.from_redis_dict(data))
|
||||||
|
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"benchmark_query_failed",
|
||||||
|
error=str(e),
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_statistics(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get aggregate statistics for an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation name
|
||||||
|
start_time: Start of time range
|
||||||
|
end_time: End of time range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with statistics (count, avg_duration, success_rate, etc.)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> stats = await store.get_statistics("steward_analysis")
|
||||||
|
>>> print(f"Average duration: {stats['avg_duration']}s")
|
||||||
|
>>> print(f"Success rate: {stats['success_rate']}%")
|
||||||
|
"""
|
||||||
|
benchmarks = await self.query(operation, start_time, end_time, limit=1000)
|
||||||
|
|
||||||
|
if not benchmarks:
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"avg_duration": 0.0,
|
||||||
|
"min_duration": 0.0,
|
||||||
|
"max_duration": 0.0,
|
||||||
|
"success_rate": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
durations = [b.duration_seconds for b in benchmarks]
|
||||||
|
successes = sum(1 for b in benchmarks if b.success)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": len(benchmarks),
|
||||||
|
"avg_duration": sum(durations) / len(durations),
|
||||||
|
"min_duration": min(durations),
|
||||||
|
"max_duration": max(durations),
|
||||||
|
"success_rate": (successes / len(benchmarks)) * 100,
|
||||||
|
"total_successes": successes,
|
||||||
|
"total_failures": len(benchmarks) - successes,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_tool_accuracy(
|
||||||
|
self,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze tool recommendation accuracy.
|
||||||
|
|
||||||
|
Compares recommended tools vs actually used tools to measure
|
||||||
|
Steward's recommendation precision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: Start of time range
|
||||||
|
end_time: End of time range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with accuracy metrics
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> accuracy = await store.get_tool_accuracy()
|
||||||
|
>>> print(f"Precision: {accuracy['precision']}%")
|
||||||
|
"""
|
||||||
|
tool_calls = await self.query("tool_call", start_time, end_time, limit=1000)
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
return {
|
||||||
|
"total_calls": 0,
|
||||||
|
"recommended_and_used": 0,
|
||||||
|
"recommended_not_used": 0,
|
||||||
|
"not_recommended_but_used": 0,
|
||||||
|
"precision": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
recommended_and_used = sum(
|
||||||
|
1 for b in tool_calls
|
||||||
|
if b.was_recommended and b.was_actually_used
|
||||||
|
)
|
||||||
|
not_recommended_but_used = sum(
|
||||||
|
1 for b in tool_calls
|
||||||
|
if not b.was_recommended and b.was_actually_used
|
||||||
|
)
|
||||||
|
|
||||||
|
total_used = sum(1 for b in tool_calls if b.was_actually_used)
|
||||||
|
precision = (
|
||||||
|
(recommended_and_used / total_used * 100) if total_used > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_calls": len(tool_calls),
|
||||||
|
"total_used": total_used,
|
||||||
|
"recommended_and_used": recommended_and_used,
|
||||||
|
"not_recommended_but_used": not_recommended_but_used,
|
||||||
|
"precision": precision,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close Redis connection."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
|
# Global benchmark store instance
|
||||||
|
_benchmark_store: Optional[BenchmarkStore] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_benchmark_store() -> BenchmarkStore:
|
||||||
|
"""
|
||||||
|
Get global benchmark store instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BenchmarkStore instance
|
||||||
|
"""
|
||||||
|
global _benchmark_store
|
||||||
|
if _benchmark_store is None:
|
||||||
|
_benchmark_store = BenchmarkStore()
|
||||||
|
return _benchmark_store
|
||||||
+178
-3
@@ -4,11 +4,34 @@ Following best practice of splitting config across domains.
|
|||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field, HttpUrl
|
from pydantic import Field, HttpUrl
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version_from_pyproject() -> str:
|
||||||
|
"""
|
||||||
|
Load version from pyproject.toml.
|
||||||
|
|
||||||
|
Falls back to "unknown" if file cannot be read.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find pyproject.toml relative to this file
|
||||||
|
config_dir = Path(__file__).parent
|
||||||
|
pyproject_path = config_dir.parent.parent / "pyproject.toml"
|
||||||
|
|
||||||
|
if pyproject_path.exists():
|
||||||
|
content = pyproject_path.read_text()
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.strip().startswith("version"):
|
||||||
|
# Parse: version = "1.0.0"
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
class Environment(str, Enum):
|
class Environment(str, Enum):
|
||||||
"""Application environment."""
|
"""Application environment."""
|
||||||
DEVELOPMENT = "development"
|
DEVELOPMENT = "development"
|
||||||
@@ -32,7 +55,7 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME: str = "OpenAI-Compatible API"
|
APP_NAME: str = "OpenAI-Compatible API"
|
||||||
APP_VERSION: str = "0.1.1"
|
APP_VERSION: str = Field(default_factory=_get_version_from_pyproject)
|
||||||
ENVIRONMENT: Environment = Environment.DEVELOPMENT
|
ENVIRONMENT: Environment = Environment.DEVELOPMENT
|
||||||
DEBUG: bool = Field(default=False, description="Debug mode")
|
DEBUG: bool = Field(default=False, description="Debug mode")
|
||||||
|
|
||||||
@@ -59,9 +82,105 @@ class Config(BaseSettings):
|
|||||||
description="Timeout for each streaming turn in seconds"
|
description="Timeout for each streaming turn in seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SearXNG Configuration
|
||||||
|
SEARXNG_HOST: HttpUrl = Field(
|
||||||
|
default="http://localhost:8087",
|
||||||
|
description="SearXNG server URL"
|
||||||
|
)
|
||||||
|
SEARXNG_TIMEOUT: int = Field(
|
||||||
|
default=30,
|
||||||
|
description="SearXNG request timeout in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST: str = Field(
|
||||||
|
default="localhost",
|
||||||
|
description="Redis server host"
|
||||||
|
)
|
||||||
|
REDIS_PORT: int = Field(
|
||||||
|
default=6379,
|
||||||
|
description="Redis server port"
|
||||||
|
)
|
||||||
|
REDIS_BENCHMARK_DB: int = Field(
|
||||||
|
default=6,
|
||||||
|
description="Redis database number for benchmarks"
|
||||||
|
)
|
||||||
|
REDIS_TIMEOUT: int = Field(
|
||||||
|
default=5,
|
||||||
|
description="Redis connection timeout in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Library-Desk Configuration (The Librarian backend)
|
||||||
|
LIBRARY_DESK_HOST: HttpUrl = Field(
|
||||||
|
default="http://localhost:8089",
|
||||||
|
description="Library-Desk API URL"
|
||||||
|
)
|
||||||
|
LIBRARY_DESK_API_KEY: str = Field(
|
||||||
|
default="",
|
||||||
|
description="API key for Library-Desk authentication"
|
||||||
|
)
|
||||||
|
LIBRARY_DESK_TIMEOUT: int = Field(
|
||||||
|
default=60,
|
||||||
|
description="Library-Desk request timeout in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Core-API Configuration (The Housekeeper backend)
|
||||||
|
CORE_API_HOST: HttpUrl = Field(
|
||||||
|
default="http://localhost:8090",
|
||||||
|
description="Core-API URL for Home Assistant integration"
|
||||||
|
)
|
||||||
|
CORE_API_KEY: str = Field(
|
||||||
|
default="",
|
||||||
|
description="API key for Core-API authentication"
|
||||||
|
)
|
||||||
|
CORE_API_TIMEOUT: int = Field(
|
||||||
|
default=30,
|
||||||
|
description="Core-API request timeout in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qdrant Configuration (Memory vector storage)
|
||||||
|
QDRANT_HOST: str = Field(
|
||||||
|
default="localhost",
|
||||||
|
description="Qdrant server host"
|
||||||
|
)
|
||||||
|
QDRANT_PORT: int = Field(
|
||||||
|
default=6333,
|
||||||
|
description="Qdrant server port"
|
||||||
|
)
|
||||||
|
QDRANT_EMBEDDING_DIM: int = Field(
|
||||||
|
default=768,
|
||||||
|
description="Embedding dimension (768 for nomic-embed-text)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ollama Embedding Configuration
|
||||||
|
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||||
|
default="nomic-embed-text",
|
||||||
|
description="Ollama model for embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis Memory Database (separate from benchmarks)
|
||||||
|
REDIS_MEMORY_DB: int = Field(
|
||||||
|
default=1,
|
||||||
|
description="Redis database number for memory cache"
|
||||||
|
)
|
||||||
|
REDIS_MEMORY_TTL_HOURS: int = Field(
|
||||||
|
default=24,
|
||||||
|
description="TTL for session context in hours"
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
LOG_LEVEL: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Logging level (auto-set based on environment if not specified)"
|
||||||
|
)
|
||||||
|
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
||||||
|
|
||||||
|
# User Configuration
|
||||||
|
DEFAULT_USER: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Default user for single-user setup (auto-set based on environment if not specified)"
|
||||||
|
)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: list[str] = Field(
|
CORS_ORIGINS: list[str] = Field(
|
||||||
default=["*"],
|
default=["*"],
|
||||||
@@ -71,6 +190,62 @@ class Config(BaseSettings):
|
|||||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_url(self) -> str:
|
||||||
|
"""Construct Redis connection URL for benchmarks."""
|
||||||
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_BENCHMARK_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_memory_url(self) -> str:
|
||||||
|
"""Construct Redis connection URL for memory cache."""
|
||||||
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_MEMORY_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qdrant_url(self) -> str:
|
||||||
|
"""Construct Qdrant server URL."""
|
||||||
|
return f"http://{self.QDRANT_HOST}:{self.QDRANT_PORT}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def log_format(self) -> str:
|
||||||
|
"""
|
||||||
|
Determine log format based on environment.
|
||||||
|
|
||||||
|
- production: JSON format for machine parsing
|
||||||
|
- development/testing: Console format for human readability
|
||||||
|
"""
|
||||||
|
return "json" if self.ENVIRONMENT == Environment.PRODUCTION else "console"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_log_level(self) -> str:
|
||||||
|
"""
|
||||||
|
Get effective log level, auto-determining from environment if not set.
|
||||||
|
|
||||||
|
- development: DEBUG (maximum verbosity)
|
||||||
|
- production: WARNING (minimal noise)
|
||||||
|
- testing: INFO
|
||||||
|
"""
|
||||||
|
if self.LOG_LEVEL is not None:
|
||||||
|
return self.LOG_LEVEL
|
||||||
|
if self.ENVIRONMENT == Environment.DEVELOPMENT:
|
||||||
|
return "DEBUG"
|
||||||
|
if self.ENVIRONMENT == Environment.PRODUCTION:
|
||||||
|
return "WARNING"
|
||||||
|
return "INFO"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_default_user(self) -> str:
|
||||||
|
"""
|
||||||
|
Get effective default user, auto-determining from environment if not set.
|
||||||
|
|
||||||
|
- development/testing: llm_tester (isolated test scope)
|
||||||
|
- production: jpmschweitzer (real user)
|
||||||
|
"""
|
||||||
|
if self.DEFAULT_USER is not None:
|
||||||
|
return self.DEFAULT_USER
|
||||||
|
if self.ENVIRONMENT == Environment.PRODUCTION:
|
||||||
|
return "jpmschweitzer"
|
||||||
|
return "llm_tester"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_config() -> Config:
|
def get_config() -> Config:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Request context using ContextVar for async-safe user/conversation tracking.
|
||||||
|
|
||||||
|
ContextVar provides task-local storage that automatically propagates through
|
||||||
|
async calls, eliminating the need to thread user identity through every function.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# At request entry (router):
|
||||||
|
token = current_user.set(request.user or get_default_user())
|
||||||
|
try:
|
||||||
|
await service.process(request)
|
||||||
|
finally:
|
||||||
|
current_user.reset(token)
|
||||||
|
|
||||||
|
# Anywhere in the codebase:
|
||||||
|
from src.core.context import get_user
|
||||||
|
user = get_user() # Returns current request's user
|
||||||
|
"""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_user() -> str:
|
||||||
|
"""
|
||||||
|
Get default user from config (environment-aware).
|
||||||
|
|
||||||
|
- development/testing: llm_tester (isolated test scope)
|
||||||
|
- production: jpmschweitzer (real user)
|
||||||
|
"""
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from src.core.config import config
|
||||||
|
return config.effective_default_user
|
||||||
|
|
||||||
|
|
||||||
|
# Request-scoped context variables (async-safe, isolated per request)
|
||||||
|
# Note: ContextVar default is evaluated at definition, so we use a sentinel
|
||||||
|
# and resolve the real default in get_user()
|
||||||
|
_USER_NOT_SET = "__user_not_set__"
|
||||||
|
current_user: ContextVar[str] = ContextVar("current_user", default=_USER_NOT_SET)
|
||||||
|
current_conversation: ContextVar[str | None] = ContextVar(
|
||||||
|
"current_conversation", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user() -> str:
|
||||||
|
"""
|
||||||
|
Get current user from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User identifier for the current request.
|
||||||
|
Falls back to environment-aware default if not set.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
user = get_user() # "llm_tester" (dev) or "jpmschweitzer" (prod)
|
||||||
|
"""
|
||||||
|
user = current_user.get()
|
||||||
|
if user == _USER_NOT_SET:
|
||||||
|
return get_default_user()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_id() -> str | None:
|
||||||
|
"""
|
||||||
|
Get current conversation ID from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation ID if set, None otherwise.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
conv_id = get_conversation_id() # "conv_abc123" or None
|
||||||
|
"""
|
||||||
|
return current_conversation.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
"""
|
||||||
|
Context manager for setting request-scoped context.
|
||||||
|
|
||||||
|
Provides a cleaner alternative to manual token management.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with RequestContext(user="alice", conversation_id="conv_123"):
|
||||||
|
# All code here sees user="alice"
|
||||||
|
result = await some_service.process()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize request context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to environment-aware user if None)
|
||||||
|
conversation_id: Conversation ID (optional)
|
||||||
|
"""
|
||||||
|
self.user = user or get_default_user()
|
||||||
|
self.conversation_id = conversation_id
|
||||||
|
self._user_token = None
|
||||||
|
self._conv_token = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "RequestContext":
|
||||||
|
"""Set context variables on entry."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Reset context variables on exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
|
|
||||||
|
def __enter__(self) -> "RequestContext":
|
||||||
|
"""Sync context manager entry (for non-async code)."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Sync context manager exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Ollama client for embeddings generation.
|
||||||
|
|
||||||
|
Provides async embedding operations via Ollama API:
|
||||||
|
- Text embedding generation
|
||||||
|
- Batch embedding support
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Ollama API client for embeddings.
|
||||||
|
|
||||||
|
Uses the Ollama embeddings endpoint to generate vector representations
|
||||||
|
of text using the nomic-embed-text model (768 dimensions).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = OllamaEmbeddingClient()
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
Or with context manager:
|
||||||
|
async with OllamaEmbeddingClient() as client:
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Ollama embedding client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama server URL (defaults to config.OLLAMA_HOST)
|
||||||
|
model: Embedding model name (defaults to config.OLLAMA_EMBEDDING_MODEL)
|
||||||
|
timeout: Request timeout in seconds (embeddings can be slow)
|
||||||
|
"""
|
||||||
|
self.base_url = (base_url or str(config.OLLAMA_HOST)).rstrip("/")
|
||||||
|
self.model = model or config.OLLAMA_EMBEDDING_MODEL
|
||||||
|
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
||||||
|
self.tags_url = f"{self.base_url}/api/tags"
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ollama_embedding_client_initialized",
|
||||||
|
base_url=self.base_url,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create HTTP client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=self._timeout)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "OllamaEmbeddingClient":
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self._get_client()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def embed(self, text: str) -> list[float] | None:
|
||||||
|
"""
|
||||||
|
Generate embedding for single text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to embed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding vector (768-dimensional for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embedding = await client.embed("Hello world")
|
||||||
|
>>> len(embedding)
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(self.embeddings_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
embedding = data.get("embedding")
|
||||||
|
if not embedding:
|
||||||
|
logger.error("ollama_embed_no_embedding", response_data=data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
"ollama_embed_http_error",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=e.response.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embed_failed", error=str(e), exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def embed_batch(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float] | None]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts.
|
||||||
|
|
||||||
|
Note: Ollama doesn't support native batch embeddings, so this
|
||||||
|
sequentially calls embed() for each text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of embedding vectors (same order as input)
|
||||||
|
None entries for texts that failed to embed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> texts = ["Hello", "World", "Test"]
|
||||||
|
>>> embeddings = await client.embed_batch(texts)
|
||||||
|
>>> len(embeddings)
|
||||||
|
3
|
||||||
|
"""
|
||||||
|
embeddings = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if show_progress and i % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_progress",
|
||||||
|
current=i,
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding = await self.embed(text)
|
||||||
|
embeddings.append(embedding)
|
||||||
|
|
||||||
|
if show_progress:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_complete",
|
||||||
|
successful=sum(1 for e in embeddings if e is not None),
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
async def embed_batch_filtered(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts, filtering out failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of successful embedding vectors (may be shorter than input)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embeddings = await client.embed_batch_filtered(texts)
|
||||||
|
>>> all(e is not None for e in embeddings)
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
all_embeddings = await self.embed_batch(texts, show_progress)
|
||||||
|
return [e for e in all_embeddings if e is not None]
|
||||||
|
|
||||||
|
async def get_embedding_dimension(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Get embedding dimension for current model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding dimension (e.g., 768 for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dim = await client.get_embedding_dimension()
|
||||||
|
>>> dim
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
test_embedding = await self.embed("test")
|
||||||
|
if test_embedding:
|
||||||
|
return len(test_embedding)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Ollama server is reachable and model is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(self.tags_url, timeout=5.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
models = data.get("models", [])
|
||||||
|
|
||||||
|
# Check if our embedding model is available
|
||||||
|
model_found = False
|
||||||
|
for m in models:
|
||||||
|
name = m.get("name", "")
|
||||||
|
if name == self.model or name.startswith(f"{self.model}:"):
|
||||||
|
model_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_found:
|
||||||
|
logger.warning(
|
||||||
|
"ollama_embedding_model_not_found",
|
||||||
|
model=self.model,
|
||||||
|
available=[m.get("name") for m in models],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embedding_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_embedding_client: OllamaEmbeddingClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_client() -> OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Get global embedding client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OllamaEmbeddingClient instance
|
||||||
|
"""
|
||||||
|
global _embedding_client
|
||||||
|
if _embedding_client is None:
|
||||||
|
_embedding_client = OllamaEmbeddingClient()
|
||||||
|
return _embedding_client
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Household registry for managing agent capabilities and toolsets.
|
||||||
|
|
||||||
|
Provides centralized registry of household members (agents) with their
|
||||||
|
capabilities and tools. Supports two-tier abstraction: executive summaries
|
||||||
|
for coordination and full toolsets for execution.
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdCapability(BaseModel):
|
||||||
|
"""
|
||||||
|
Executive summary of a household member's capabilities.
|
||||||
|
|
||||||
|
This is what the Steward and Butler see for coordination.
|
||||||
|
High-level description without implementation details.
|
||||||
|
"""
|
||||||
|
name: str # Unique identifier: "tatlock_core", "librarian", "developer"
|
||||||
|
role: str # Display name: "Butler's Core Tools", "The Librarian"
|
||||||
|
category: str # "core", "research", "technical", "automation"
|
||||||
|
description: str # One-sentence description of capabilities
|
||||||
|
domains: list[str] # Capability domains: ["computation", "information", "datetime"]
|
||||||
|
cost: str # "low", "medium", "high" - resource cost estimate
|
||||||
|
requires_network: bool # Whether network access is needed
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdMember(BaseModel):
|
||||||
|
"""
|
||||||
|
Full specification of a household member.
|
||||||
|
|
||||||
|
Contains both the executive summary (for coordination) and
|
||||||
|
implementation details (tools/agent).
|
||||||
|
"""
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
capability: HouseholdCapability
|
||||||
|
tools: list[Any] # PydanticAI tool definitions (any type since Tool is a dataclass)
|
||||||
|
agent: Optional[Any] = None # For expert agents (Phase 4)
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdRegistry:
|
||||||
|
"""
|
||||||
|
Registry of household capabilities and implementations.
|
||||||
|
|
||||||
|
Manages household members and their tools. Provides:
|
||||||
|
1. Executive summaries for Steward/Butler coordination
|
||||||
|
2. Full toolsets for scoped execution
|
||||||
|
3. Agent delegation (Phase 4)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize empty registry."""
|
||||||
|
self._members: dict[str, HouseholdMember] = {}
|
||||||
|
logger.info("household_registry_initialized")
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
capability: HouseholdCapability,
|
||||||
|
tools: list[Any],
|
||||||
|
agent: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a household member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique identifier (must match capability.name)
|
||||||
|
capability: Executive summary
|
||||||
|
tools: PydanticAI tool definitions
|
||||||
|
agent: Optional expert agent for delegation
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If name doesn't match capability.name
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> registry.register(
|
||||||
|
... name="tatlock_core",
|
||||||
|
... capability=HouseholdCapability(
|
||||||
|
... name="tatlock_core",
|
||||||
|
... role="Butler's Core Tools",
|
||||||
|
... category="core",
|
||||||
|
... description="Basic computation, time, and information tools",
|
||||||
|
... domains=["computation", "datetime", "information"],
|
||||||
|
... cost="low",
|
||||||
|
... requires_network=True,
|
||||||
|
... ),
|
||||||
|
... tools=[calculator_tool, datetime_tool, search_tool],
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
if name != capability.name:
|
||||||
|
raise ValueError(
|
||||||
|
f"Name mismatch: '{name}' != '{capability.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._members[name] = HouseholdMember(
|
||||||
|
capability=capability,
|
||||||
|
tools=tools,
|
||||||
|
agent=agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"household_member_registered",
|
||||||
|
name=name,
|
||||||
|
role=capability.role,
|
||||||
|
domains=capability.domains,
|
||||||
|
tool_count=len(tools),
|
||||||
|
has_agent=agent is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Unregister a household member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Member name to remove
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> registry.unregister("tatlock_core")
|
||||||
|
"""
|
||||||
|
if name in self._members:
|
||||||
|
member = self._members.pop(name)
|
||||||
|
logger.info(
|
||||||
|
"household_member_unregistered",
|
||||||
|
name=name,
|
||||||
|
role=member.capability.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_member(self, name: str) -> Optional[HouseholdMember]:
|
||||||
|
"""
|
||||||
|
Get full household member specification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Member name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HouseholdMember if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self._members.get(name)
|
||||||
|
|
||||||
|
def get_all_capabilities(self) -> list[HouseholdCapability]:
|
||||||
|
"""
|
||||||
|
Get executive summaries of all household members.
|
||||||
|
|
||||||
|
This is what the Steward sees when analyzing requests.
|
||||||
|
Returns high-level capabilities without implementation details.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of capability summaries
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> capabilities = registry.get_all_capabilities()
|
||||||
|
>>> for cap in capabilities:
|
||||||
|
... print(f"{cap.role}: {cap.description}")
|
||||||
|
"""
|
||||||
|
return [member.capability for member in self._members.values()]
|
||||||
|
|
||||||
|
def get_scoped_tools(self, names: list[str]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Get combined tools from specified household members.
|
||||||
|
|
||||||
|
Creates a scoped toolset containing only tools from
|
||||||
|
the requested members. Used to give Tatlock only the
|
||||||
|
tools recommended by the Steward.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of member names to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined list of tool definitions
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # Steward recommends only tatlock_core
|
||||||
|
>>> tools = registry.get_scoped_tools(["tatlock_core"])
|
||||||
|
>>> # Tatlock now has only core tools, not all household tools
|
||||||
|
"""
|
||||||
|
tools = []
|
||||||
|
for name in names:
|
||||||
|
member = self._members.get(name)
|
||||||
|
if member:
|
||||||
|
tools.extend(member.tools)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"household_member_not_found",
|
||||||
|
requested_name=name,
|
||||||
|
available_names=list(self._members.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"scoped_tools_created",
|
||||||
|
requested_members=names,
|
||||||
|
total_tools=len(tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
def get_delegation_tools(self, names: list[str]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Get delegation wrapper tools for specified capabilities.
|
||||||
|
|
||||||
|
Instead of returning raw tools (which overloads the LLM),
|
||||||
|
returns wrapper functions that delegate to expert agents.
|
||||||
|
This implements the agent-as-tool pattern.
|
||||||
|
|
||||||
|
For members WITH an agent: returns delegation wrapper
|
||||||
|
For members WITHOUT an agent (e.g., tatlock_core): returns raw tools
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of member names to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of delegation wrappers and/or raw tools
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # Steward recommends librarian + tatlock_core
|
||||||
|
>>> tools = registry.get_delegation_tools(["librarian", "tatlock_core"])
|
||||||
|
>>> # Returns: [delegate_to_librarian, calculate, datetime, ...]
|
||||||
|
>>> # Instead of: [hybrid_search, search_wiki, create_wiki_page, ... (16 tools)]
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import (
|
||||||
|
delegate_to_biographer,
|
||||||
|
delegate_to_housekeeper,
|
||||||
|
delegate_to_librarian,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map of expert names to their delegation wrappers
|
||||||
|
delegation_wrappers = {
|
||||||
|
"librarian": delegate_to_librarian,
|
||||||
|
"biographer": delegate_to_biographer,
|
||||||
|
"housekeeper": delegate_to_housekeeper,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = []
|
||||||
|
for name in names:
|
||||||
|
member = self._members.get(name)
|
||||||
|
if not member:
|
||||||
|
logger.warning(
|
||||||
|
"household_member_not_found",
|
||||||
|
requested_name=name,
|
||||||
|
available_names=list(self._members.keys()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this member has a delegation wrapper
|
||||||
|
if name in delegation_wrappers and member.agent is not None:
|
||||||
|
# Use delegation wrapper instead of raw tools
|
||||||
|
tools.append(delegation_wrappers[name])
|
||||||
|
logger.debug(
|
||||||
|
"delegation_wrapper_added",
|
||||||
|
member=name,
|
||||||
|
wrapper=delegation_wrappers[name].__name__,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No agent = direct tools (e.g., tatlock_core)
|
||||||
|
tools.extend(member.tools)
|
||||||
|
logger.debug(
|
||||||
|
"raw_tools_added",
|
||||||
|
member=name,
|
||||||
|
tool_count=len(member.tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_tools_created",
|
||||||
|
requested_members=names,
|
||||||
|
total_tools=len(tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
def get_streaming_delegation_tools(self, names: list[str]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Get streaming delegation wrapper tools for specified capabilities.
|
||||||
|
|
||||||
|
Similar to get_delegation_tools() but returns streaming wrappers
|
||||||
|
that yield butler-perspective think messages during execution.
|
||||||
|
|
||||||
|
These wrappers emit think slugs like:
|
||||||
|
- "Allow me to consult the archives, sir."
|
||||||
|
- "The Librarian has compiled the relevant findings."
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of member names to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of streaming delegation wrappers and/or raw tools
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tools = registry.get_streaming_delegation_tools(["librarian"])
|
||||||
|
>>> async for chunk in tools[0](task="Search for Docker"):
|
||||||
|
... print(chunk) # Yields think messages then result
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import STREAMING_DELEGATION_WRAPPERS
|
||||||
|
|
||||||
|
tools = []
|
||||||
|
for name in names:
|
||||||
|
member = self._members.get(name)
|
||||||
|
if not member:
|
||||||
|
logger.warning(
|
||||||
|
"household_member_not_found",
|
||||||
|
requested_name=name,
|
||||||
|
available_names=list(self._members.keys()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this member has a streaming delegation wrapper
|
||||||
|
if name in STREAMING_DELEGATION_WRAPPERS and member.agent is not None:
|
||||||
|
tools.append(STREAMING_DELEGATION_WRAPPERS[name])
|
||||||
|
logger.debug(
|
||||||
|
"streaming_delegation_wrapper_added",
|
||||||
|
member=name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No agent = direct tools (e.g., tatlock_core)
|
||||||
|
tools.extend(member.tools)
|
||||||
|
logger.debug(
|
||||||
|
"raw_tools_added",
|
||||||
|
member=name,
|
||||||
|
tool_count=len(member.tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"streaming_delegation_tools_created",
|
||||||
|
requested_members=names,
|
||||||
|
total_tools=len(tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
def list_members(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
List all registered member names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of member names
|
||||||
|
"""
|
||||||
|
return list(self._members.keys())
|
||||||
|
|
||||||
|
def get_members_by_domain(self, domain: str) -> list[HouseholdCapability]:
|
||||||
|
"""
|
||||||
|
Get capabilities that support a specific domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Domain to filter by (e.g., "computation", "research")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of capabilities supporting the domain
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # Find all members that can do research
|
||||||
|
>>> research_caps = registry.get_members_by_domain("research")
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
member.capability
|
||||||
|
for member in self._members.values()
|
||||||
|
if domain in member.capability.domains
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_members_by_category(self, category: str) -> list[HouseholdCapability]:
|
||||||
|
"""
|
||||||
|
Get capabilities by category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Category to filter by (e.g., "core", "research", "technical")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of capabilities in the category
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
member.capability
|
||||||
|
for member in self._members.values()
|
||||||
|
if member.capability.category == category
|
||||||
|
]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""Get number of registered members."""
|
||||||
|
return len(self._members)
|
||||||
|
|
||||||
|
def __contains__(self, name: str) -> bool:
|
||||||
|
"""Check if member is registered."""
|
||||||
|
return name in self._members
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
household_registry = HouseholdRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_household_registry() -> HouseholdRegistry:
|
||||||
|
"""
|
||||||
|
Get global household registry instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HouseholdRegistry instance
|
||||||
|
"""
|
||||||
|
return household_registry
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Structured logging configuration using structlog.
|
||||||
|
|
||||||
|
Deeply integrates with FastAPI/uvicorn's built-in logging to provide
|
||||||
|
seamless structured logs across the entire application stack.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from structlog.types import EventDict, Processor
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
|
||||||
|
|
||||||
|
def add_timestamp(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
|
||||||
|
"""Add ISO 8601 timestamp to log entries."""
|
||||||
|
event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def add_log_level(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
|
||||||
|
"""Add log level to event dict."""
|
||||||
|
event_dict["level"] = method_name.upper()
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def extract_from_record(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
|
||||||
|
"""
|
||||||
|
Extract extra fields from logging.LogRecord for standard library integration.
|
||||||
|
|
||||||
|
This allows standard Python logging calls to include structured data:
|
||||||
|
logger.info("request received", extra={"user_id": "123", "path": "/api"})
|
||||||
|
"""
|
||||||
|
record = event_dict.get("_record")
|
||||||
|
if record is not None:
|
||||||
|
# Extract custom fields from record
|
||||||
|
for key, value in record.__dict__.items():
|
||||||
|
if key not in {
|
||||||
|
"name", "msg", "args", "created", "filename", "funcName",
|
||||||
|
"levelname", "levelno", "lineno", "module", "msecs",
|
||||||
|
"message", "pathname", "process", "processName", "relativeCreated",
|
||||||
|
"thread", "threadName", "exc_info", "exc_text", "stack_info",
|
||||||
|
"taskName"
|
||||||
|
}:
|
||||||
|
event_dict[key] = value
|
||||||
|
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""
|
||||||
|
Configure structured logging with deep FastAPI/uvicorn integration.
|
||||||
|
|
||||||
|
- Replaces all Python logging with structlog
|
||||||
|
- FastAPI, uvicorn, and app logs all use same format
|
||||||
|
- JSON format for production, pretty console for development
|
||||||
|
- Preserves log levels and exception handling
|
||||||
|
"""
|
||||||
|
# Determine processors based on log format
|
||||||
|
shared_processors: list[Processor] = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
add_log_level,
|
||||||
|
add_timestamp,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
extract_from_record,
|
||||||
|
]
|
||||||
|
|
||||||
|
if config.log_format == "json":
|
||||||
|
# JSON format for production
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
*shared_processors,
|
||||||
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = structlog.stdlib.ProcessorFormatter(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
foreign_pre_chain=shared_processors,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Console format for development
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
*shared_processors,
|
||||||
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = structlog.stdlib.ProcessorFormatter(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
|
structlog.dev.ConsoleRenderer(colors=True),
|
||||||
|
],
|
||||||
|
foreign_pre_chain=shared_processors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure Python's logging to use structlog
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Set up root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel(logging.getLevelName(config.effective_log_level))
|
||||||
|
|
||||||
|
# Configure specific loggers
|
||||||
|
for logger_name in [
|
||||||
|
"uvicorn",
|
||||||
|
"uvicorn.access",
|
||||||
|
"uvicorn.error",
|
||||||
|
"fastapi",
|
||||||
|
"tatlock",
|
||||||
|
]:
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.handlers.clear()
|
||||||
|
logger.propagate = True
|
||||||
|
logger.setLevel(logging.getLevelName(config.effective_log_level))
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||||
|
"""
|
||||||
|
Get a structured logger instance.
|
||||||
|
|
||||||
|
Works seamlessly with both structlog and standard logging calls:
|
||||||
|
- logger.info("message", key="value") - structlog style
|
||||||
|
- logger.info("message", extra={"key": "value"}) - standard logging style
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (typically __name__)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured structlog BoundLogger
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> logger = get_logger(__name__)
|
||||||
|
>>> logger.info("user_request", user_id="123", action="search")
|
||||||
|
>>> logger.info("standard log", extra={"request_id": "abc"})
|
||||||
|
"""
|
||||||
|
return structlog.get_logger(name)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def log_operation(
|
||||||
|
operation: str,
|
||||||
|
initial_context: dict[str, Any] | None = None,
|
||||||
|
logger_name: str = "tatlock.operations"
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Context manager for automatic operation timing and logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation name (e.g., "steward_analysis", "tool_call")
|
||||||
|
initial_context: Initial metadata to log
|
||||||
|
logger_name: Logger name for this operation
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Context dict that can be updated during operation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> async with log_operation("steward_analysis", {"user_id": "123"}) as ctx:
|
||||||
|
... # Do work
|
||||||
|
... ctx["recommendation_count"] = 3
|
||||||
|
... # Automatically logs duration and context on exit
|
||||||
|
"""
|
||||||
|
logger = get_logger(logger_name)
|
||||||
|
context = initial_context or {}
|
||||||
|
context["operation"] = operation
|
||||||
|
|
||||||
|
start_time = datetime.now(timezone.utc)
|
||||||
|
logger.info("operation_started", **context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield context
|
||||||
|
|
||||||
|
# Success case
|
||||||
|
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||||
|
context["duration_seconds"] = duration
|
||||||
|
context["success"] = True
|
||||||
|
logger.info("operation_completed", **context)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Error case
|
||||||
|
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||||
|
context["duration_seconds"] = duration
|
||||||
|
context["success"] = False
|
||||||
|
context["error"] = str(e)
|
||||||
|
context["error_type"] = type(e).__name__
|
||||||
|
logger.error("operation_failed", **context, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_uvicorn_log_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get uvicorn logging configuration that integrates with structlog.
|
||||||
|
|
||||||
|
Use this when starting uvicorn:
|
||||||
|
uvicorn.run(app, log_config=get_uvicorn_log_config())
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Uvicorn-compatible logging configuration dict
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"()": structlog.stdlib.ProcessorFormatter,
|
||||||
|
"processors": [
|
||||||
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
|
structlog.processors.JSONRenderer() if config.log_format == "json"
|
||||||
|
else structlog.dev.ConsoleRenderer(colors=True),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"default": {
|
||||||
|
"formatter": "default",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": {"handlers": ["default"], "level": config.effective_log_level},
|
||||||
|
"uvicorn.error": {"handlers": ["default"], "level": config.effective_log_level},
|
||||||
|
"uvicorn.access": {"handlers": ["default"], "level": config.effective_log_level},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize logging on module import
|
||||||
|
configure_logging()
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""
|
||||||
|
Redis-backed memory cache for session context.
|
||||||
|
|
||||||
|
Provides short-term memory storage with TTL:
|
||||||
|
- Session context (24h TTL)
|
||||||
|
- Recent entities mentioned in conversation
|
||||||
|
- User-scoped with conversation isolation
|
||||||
|
|
||||||
|
Uses Redis DB 2 (separate from benchmarks in DB 1).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_session_key, get_entities_key
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""
|
||||||
|
Redis-backed cache for session memory.
|
||||||
|
|
||||||
|
Stores ephemeral context that doesn't need vector search:
|
||||||
|
- Session context (recent topics, user state)
|
||||||
|
- Recent entities (people, places, things mentioned)
|
||||||
|
- Conversation metadata
|
||||||
|
|
||||||
|
All data expires after REDIS_MEMORY_TTL_HOURS (default 24h).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cache = MemoryCache()
|
||||||
|
await cache.set_session_context(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
conversation_id="conv_123",
|
||||||
|
context={"topic": "docker", "mood": "curious"}
|
||||||
|
)
|
||||||
|
context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_url: str | None = None,
|
||||||
|
ttl_hours: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_url: Redis connection URL (defaults to config.redis_memory_url)
|
||||||
|
ttl_hours: TTL for cached data (defaults to config.REDIS_MEMORY_TTL_HOURS)
|
||||||
|
"""
|
||||||
|
self._redis_url = redis_url or config.redis_memory_url
|
||||||
|
self._ttl_seconds = (ttl_hours or config.REDIS_MEMORY_TTL_HOURS) * 3600
|
||||||
|
self._client: redis.Redis | None = None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_cache_initialized",
|
||||||
|
redis_url=self._redis_url,
|
||||||
|
ttl_hours=ttl_hours or config.REDIS_MEMORY_TTL_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> redis.Redis:
|
||||||
|
"""Get or create Redis client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = redis.from_url(
|
||||||
|
self._redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=config.REDIS_TIMEOUT,
|
||||||
|
socket_connect_timeout=config.REDIS_TIMEOUT,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close Redis connection."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Context
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session context dict or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
>>> context
|
||||||
|
{"topic": "docker", "mood": "curious", "last_tool": "librarian"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
data = await client.get(key)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
context: Context data to store
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.set_session_context(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... {"topic": "docker", "mood": "curious"}
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
await client.setex(
|
||||||
|
key,
|
||||||
|
self._ttl_seconds,
|
||||||
|
json.dumps(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_set_session",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
context_keys=list(context.keys()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_set_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update session context (merge with existing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
updates: Fields to update/add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
existing = await self.get_session_context(user, conversation_id) or {}
|
||||||
|
existing.update(updates)
|
||||||
|
return await self.set_session_context(user, conversation_id, existing)
|
||||||
|
|
||||||
|
async def delete_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_delete_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Recent Entities
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get recently mentioned entities in a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entity names/identifiers
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> entities = await cache.get_recent_entities("jpmschweitzer", "conv_123")
|
||||||
|
>>> entities
|
||||||
|
["Docker", "Kubernetes", "nginx"]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Get all members of the set
|
||||||
|
entities = await client.smembers(key)
|
||||||
|
return list(entities)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
entities: list[str],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Add entities to the recent entities set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
entities: Entity names to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.add_recent_entities(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... ["Docker", "Kubernetes"]
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
if not entities:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Add to set
|
||||||
|
await client.sadd(key, *entities)
|
||||||
|
|
||||||
|
# Refresh TTL
|
||||||
|
await client.expire(key, self._ttl_seconds)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_add_entities",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
entities=entities,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_add_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def clear_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear all recent entities for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cleared, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_clear_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Health Check
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Redis is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
await client.ping()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_cache_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global cache instance (lazy initialization)
|
||||||
|
_memory_cache: MemoryCache | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_cache() -> MemoryCache:
|
||||||
|
"""
|
||||||
|
Get global memory cache instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryCache instance
|
||||||
|
"""
|
||||||
|
global _memory_cache
|
||||||
|
if _memory_cache is None:
|
||||||
|
_memory_cache = MemoryCache()
|
||||||
|
return _memory_cache
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""
|
||||||
|
Memory service for direct key-based access.
|
||||||
|
|
||||||
|
Provides fast, LLM-free access to user memories for:
|
||||||
|
- Known-key lookups (location, timezone, preferences)
|
||||||
|
- Session context (current topic, recent entities)
|
||||||
|
- Structured storage (explicit user instructions)
|
||||||
|
|
||||||
|
This is the "direct access layer" - no LLM interpretation.
|
||||||
|
For semantic/fuzzy queries, use the Memory Agent instead.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.core.memory_service import memory_service
|
||||||
|
|
||||||
|
# Get user's location (fast, no LLM)
|
||||||
|
location = await memory_service.get_profile("location")
|
||||||
|
|
||||||
|
# Set a preference
|
||||||
|
await memory_service.set_preference("temperature_unit", "celsius")
|
||||||
|
|
||||||
|
# Get session context
|
||||||
|
ctx = await memory_service.get_session_context(conversation_id)
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .context import get_user, get_conversation_id
|
||||||
|
from .embeddings import get_embedding_client
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .memory_cache import get_memory_cache
|
||||||
|
from .multi_tenancy import get_memory_collection_name
|
||||||
|
from .qdrant import get_qdrant_client
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryType(str, Enum):
|
||||||
|
"""Types of memories stored in Qdrant."""
|
||||||
|
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||||
|
PREFERENCE = "preference" # Units, language, theme
|
||||||
|
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRecord(BaseModel):
|
||||||
|
"""A memory record stored in Qdrant."""
|
||||||
|
id: str
|
||||||
|
type: MemoryType
|
||||||
|
key: str # e.g., "location", "timezone", "car"
|
||||||
|
value: str # The actual content
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
importance: float = 0.5 # 0.0 - 1.0
|
||||||
|
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||||
|
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
"""
|
||||||
|
Direct access to user memories without LLM overhead.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Known-key lookups: get_profile("location"), get_preference("units")
|
||||||
|
- Explicit storage: set_preference("theme", "dark")
|
||||||
|
- Session context: get_session_context(), update_session_context()
|
||||||
|
|
||||||
|
Do NOT use for:
|
||||||
|
- Fuzzy queries: "What car do I drive?" → Use Memory Agent
|
||||||
|
- Semantic recall: "What did I mention about X?" → Use Memory Agent
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize memory service with lazy client loading."""
|
||||||
|
self._qdrant = None
|
||||||
|
self._embedding = None
|
||||||
|
self._cache = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qdrant(self):
|
||||||
|
"""Lazy-load Qdrant client."""
|
||||||
|
if self._qdrant is None:
|
||||||
|
self._qdrant = get_qdrant_client()
|
||||||
|
return self._qdrant
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embedding(self):
|
||||||
|
"""Lazy-load embedding client."""
|
||||||
|
if self._embedding is None:
|
||||||
|
self._embedding = get_embedding_client()
|
||||||
|
return self._embedding
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache(self):
|
||||||
|
"""Lazy-load Redis cache."""
|
||||||
|
if self._cache is None:
|
||||||
|
self._cache = get_memory_cache()
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Profile Methods (user_profile type)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_profile(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a user profile value by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile key (e.g., "location", "timezone", "name")
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Profile value or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> location = await memory_service.get_profile("location")
|
||||||
|
>>> location
|
||||||
|
"Amsterdam, Netherlands"
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.USER_PROFILE, key)
|
||||||
|
|
||||||
|
async def set_profile(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set a user profile value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile key (e.g., "location", "timezone")
|
||||||
|
value: Profile value
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
keywords: Optional keywords for semantic search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await memory_service.set_profile("location", "Amsterdam, Netherlands")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.USER_PROFILE,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords or [key],
|
||||||
|
importance=0.9, # Profile data is important
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Preference Methods (preference type)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_preference(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a user preference by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference key (e.g., "temperature_unit", "language", "theme")
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Preference value or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> units = await memory_service.get_preference("temperature_unit")
|
||||||
|
>>> units
|
||||||
|
"celsius"
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.PREFERENCE, key)
|
||||||
|
|
||||||
|
async def set_preference(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set a user preference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference key
|
||||||
|
value: Preference value
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await memory_service.set_preference("theme", "dark")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.PREFERENCE,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=[key, "preference"],
|
||||||
|
importance=0.7,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_all_preferences(self, user: str | None = None) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get all preferences for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of key -> value for all preferences
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
memories = await self._get_all_by_type(user, MemoryType.PREFERENCE)
|
||||||
|
return {m["key"]: m["value"] for m in memories}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Learned Facts (learned_fact type) - for direct storage only
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def store_fact(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
importance: float = 0.5,
|
||||||
|
source: str = "explicit",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Store a learned fact about the user.
|
||||||
|
|
||||||
|
Use this for explicit user statements like:
|
||||||
|
- "Remember that my car is a Tesla"
|
||||||
|
- "I work at Acme Corp"
|
||||||
|
|
||||||
|
For semantic extraction from conversation, use the Memory Agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Fact identifier (e.g., "car", "employer")
|
||||||
|
value: The fact content
|
||||||
|
user: User ID
|
||||||
|
keywords: Keywords for semantic search
|
||||||
|
importance: 0.0-1.0 importance score
|
||||||
|
source: "explicit" | "inferred" | "conversation"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.LEARNED_FACT,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords or [key],
|
||||||
|
importance=importance,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_fact(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a specific fact by key.
|
||||||
|
|
||||||
|
For semantic/fuzzy queries, use the Memory Agent.
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.LEARNED_FACT, key)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Context (Redis-backed, 24h TTL)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_session_context(
|
||||||
|
self,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get session context for current conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation ID (defaults to current context)
|
||||||
|
user: User ID (defaults to current context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session context dict or None
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self.cache.get_session_context(user, conversation_id)
|
||||||
|
|
||||||
|
async def set_session_context(
|
||||||
|
self,
|
||||||
|
context: dict[str, Any],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set session context for current conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Context data to store
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
logger.warning("memory_service_no_conversation_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.set_session_context(user, conversation_id, context)
|
||||||
|
|
||||||
|
async def update_session_context(
|
||||||
|
self,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update session context (merge with existing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
updates: Fields to update
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.update_session_context(user, conversation_id, updates)
|
||||||
|
|
||||||
|
async def get_recent_entities(
|
||||||
|
self,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get recently mentioned entities in conversation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entity names
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return await self.cache.get_recent_entities(user, conversation_id)
|
||||||
|
|
||||||
|
async def add_recent_entities(
|
||||||
|
self,
|
||||||
|
entities: list[str],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Add entities to recent entities set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities: Entity names to add
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.add_recent_entities(user, conversation_id, entities)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Bulk / Pre-fetch Methods (for Steward)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def prefetch_context(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
include_profile: bool = True,
|
||||||
|
include_preferences: bool = True,
|
||||||
|
profile_keys: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pre-fetch commonly needed context for Steward.
|
||||||
|
|
||||||
|
This is the main entry point for Steward to get user context
|
||||||
|
before analyzing a request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User ID
|
||||||
|
include_profile: Include profile data
|
||||||
|
include_preferences: Include preferences
|
||||||
|
profile_keys: Specific profile keys to fetch (None = common ones)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with profile and preferences data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> ctx = await memory_service.prefetch_context()
|
||||||
|
>>> ctx
|
||||||
|
{
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if include_profile:
|
||||||
|
profile_keys = profile_keys or ["location", "timezone", "name"]
|
||||||
|
profile = {}
|
||||||
|
for key in profile_keys:
|
||||||
|
value = await self.get_profile(key, user)
|
||||||
|
if value:
|
||||||
|
profile[key] = value
|
||||||
|
if profile:
|
||||||
|
result["profile"] = profile
|
||||||
|
|
||||||
|
if include_preferences:
|
||||||
|
preferences = await self.get_all_preferences(user)
|
||||||
|
if preferences:
|
||||||
|
result["preferences"] = preferences
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_service_prefetch",
|
||||||
|
user=user,
|
||||||
|
profile_keys=list(result.get("profile", {}).keys()),
|
||||||
|
preference_keys=list(result.get("preferences", {}).keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Internal Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _get_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
key: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Get a memory by type and key (exact match)."""
|
||||||
|
collection = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search with filter for exact type + key match
|
||||||
|
# We use a dummy vector since we're filtering by payload
|
||||||
|
results = self.qdrant._client.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type.value}},
|
||||||
|
{"key": "key", "match": {"value": key}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=1,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
if points:
|
||||||
|
return points[0].payload.get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_service_get_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _set_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
keywords: list[str],
|
||||||
|
importance: float = 0.5,
|
||||||
|
source: str = "explicit",
|
||||||
|
) -> bool:
|
||||||
|
"""Set a memory (upsert by type + key)."""
|
||||||
|
try:
|
||||||
|
# Generate embedding for semantic search
|
||||||
|
embedding = await self.embedding.embed(f"{key}: {value}")
|
||||||
|
if not embedding:
|
||||||
|
logger.error("memory_service_embedding_failed", key=key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create memory ID from type + key for idempotent upserts
|
||||||
|
memory_id = f"{memory_type.value}:{key}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": memory_type.value,
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"keywords": keywords,
|
||||||
|
"importance": importance,
|
||||||
|
"source": source,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.qdrant.upsert_memory(
|
||||||
|
user=user,
|
||||||
|
memory_id=memory_id,
|
||||||
|
vector=embedding,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.debug(
|
||||||
|
"memory_service_set",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"memory_service_set_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _get_all_by_type(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get all memories of a specific type."""
|
||||||
|
collection = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = self.qdrant._client.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type.value}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
return [p.payload for p in points]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_service_get_all_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete_memory(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a specific memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Memory key
|
||||||
|
memory_type: Type of memory
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
memory_id = f"{memory_type.value}:{key}"
|
||||||
|
|
||||||
|
return await self.qdrant.delete_memory(user, memory_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance
|
||||||
|
memory_service = MemoryService()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Multi-tenancy helpers for Tatlock.
|
||||||
|
|
||||||
|
Provides utilities for user namespace management across:
|
||||||
|
- Qdrant (collection per user for memories)
|
||||||
|
- Redis (user-scoped keys for session context)
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_id(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize user ID for use in collection names, keys, and paths.
|
||||||
|
|
||||||
|
Converts special characters to underscores and ensures alphanumeric safety.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Raw user identifier (email, username, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized user ID safe for use in identifiers
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> sanitize_user_id("john@example.com")
|
||||||
|
'john_at_example_com'
|
||||||
|
>>> sanitize_user_id("user.name")
|
||||||
|
'user_name'
|
||||||
|
>>> sanitize_user_id("User Name")
|
||||||
|
'user_name'
|
||||||
|
"""
|
||||||
|
sanitized = user_id.lower()
|
||||||
|
|
||||||
|
# Convert @ to _at_
|
||||||
|
sanitized = sanitized.replace("@", "_at_")
|
||||||
|
|
||||||
|
# Convert dots to underscores
|
||||||
|
sanitized = sanitized.replace(".", "_")
|
||||||
|
|
||||||
|
# Replace any non-alphanumeric characters with underscores
|
||||||
|
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove consecutive underscores
|
||||||
|
sanitized = re.sub(r'_+', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove leading/trailing underscores
|
||||||
|
sanitized = sanitized.strip('_')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_collection_name(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Qdrant collection name for user's memories.
|
||||||
|
|
||||||
|
Pattern: memories_{sanitized_user_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Qdrant collection name
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_memory_collection_name("jpmschweitzer")
|
||||||
|
'memories_jpmschweitzer'
|
||||||
|
>>> get_memory_collection_name("john@example.com")
|
||||||
|
'memories_john_at_example_com'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"memories_{sanitized}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for session context.
|
||||||
|
|
||||||
|
Pattern: session:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for session context
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_session_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'session:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"session:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_entities_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for recent entities in a conversation.
|
||||||
|
|
||||||
|
Pattern: entities:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for recent entities
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_entities_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'entities:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"entities:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_user_id(user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a user ID is acceptable.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Not empty
|
||||||
|
- Not too long (max 100 chars)
|
||||||
|
- Contains some alphanumeric characters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> validate_user_id("jpmschweitzer")
|
||||||
|
True
|
||||||
|
>>> validate_user_id("")
|
||||||
|
False
|
||||||
|
>>> validate_user_id("a" * 101)
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
if not user_id or len(user_id) > 100:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Must contain at least one alphanumeric character
|
||||||
|
if not re.search(r'[a-zA-Z0-9]', user_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Request preprocessing pipeline.
|
||||||
|
|
||||||
|
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from src.agents.steward import analyze_request, format_steward_note
|
||||||
|
from src.agents.steward.schemas import StewardRecommendation
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_temporal_context(request: str) -> str:
|
||||||
|
"""
|
||||||
|
Append current time context to user request.
|
||||||
|
|
||||||
|
Provides Tatlock with temporal awareness for time-sensitive queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Original user request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Request with appended time context
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
time_str = now.strftime("%Y-%m-%d %H:%M")
|
||||||
|
return f"{request}\n\n[Current time: {time_str}]"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnrichedRequest:
|
||||||
|
"""
|
||||||
|
Request enriched with Steward's analysis.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
original_request: The user's original message
|
||||||
|
steward_note: Formatted note for Tatlock (includes context analysis)
|
||||||
|
scoped_tools: List of tools from recommended capabilities
|
||||||
|
recommendation: Full Steward recommendation
|
||||||
|
steward_reasoning: Plain text reasoning for streaming to user
|
||||||
|
"""
|
||||||
|
original_request: str
|
||||||
|
steward_note: str
|
||||||
|
scoped_tools: list[Any] # PydanticAI tool definitions
|
||||||
|
recommendation: StewardRecommendation
|
||||||
|
steward_reasoning: str
|
||||||
|
|
||||||
|
|
||||||
|
async def preprocess_request(
|
||||||
|
user_request: str,
|
||||||
|
conversation_history: list[dict],
|
||||||
|
conversation_id: Optional[str] = None,
|
||||||
|
) -> EnrichedRequest:
|
||||||
|
"""
|
||||||
|
Analyze request via Steward and prepare scoped context for Tatlock.
|
||||||
|
|
||||||
|
This is the main preprocessing pipeline that:
|
||||||
|
1. Calls Steward with full conversation history
|
||||||
|
2. Gets capability recommendations
|
||||||
|
3. Creates scoped toolset from recommended capabilities
|
||||||
|
4. Formats a note for Tatlock with context analysis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: Current user message to analyze
|
||||||
|
conversation_history: Full conversation history (all previous turns)
|
||||||
|
conversation_id: Optional conversation ID for tracking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnrichedRequest with scoped tools and Steward analysis
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> enriched = await preprocess_request(
|
||||||
|
... "What's sqrt(144)?",
|
||||||
|
... conversation_history=[],
|
||||||
|
... )
|
||||||
|
>>> print(enriched.recommendation.recommended_capabilities)
|
||||||
|
['tatlock_core']
|
||||||
|
>>> print(len(enriched.scoped_tools))
|
||||||
|
5 # All tatlock_core tools
|
||||||
|
"""
|
||||||
|
# Inject temporal context for time-aware processing
|
||||||
|
enriched_request = _inject_temporal_context(user_request)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"preprocessing_request",
|
||||||
|
request_preview=user_request[:100],
|
||||||
|
history_length=len(conversation_history),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call Steward with full conversation history
|
||||||
|
recommendation = await analyze_request(
|
||||||
|
enriched_request,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format note for Tatlock (includes conversation context)
|
||||||
|
steward_note = await format_steward_note(recommendation)
|
||||||
|
|
||||||
|
# Get delegation tools from household registry
|
||||||
|
# Uses agent-as-tool pattern: expert agents get delegation wrappers,
|
||||||
|
# core tools are returned directly
|
||||||
|
registry = get_household_registry()
|
||||||
|
scoped_tools = registry.get_delegation_tools(
|
||||||
|
recommendation.recommended_capabilities
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"preprocessing_complete",
|
||||||
|
recommended_capabilities=recommendation.recommended_capabilities,
|
||||||
|
tool_count=len(scoped_tools),
|
||||||
|
complexity=recommendation.estimated_complexity,
|
||||||
|
has_context=recommendation.conversation_context.has_previous_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EnrichedRequest(
|
||||||
|
original_request=enriched_request,
|
||||||
|
steward_note=steward_note,
|
||||||
|
scoped_tools=scoped_tools,
|
||||||
|
recommendation=recommendation,
|
||||||
|
steward_reasoning=recommendation.reasoning,
|
||||||
|
)
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory vector storage.
|
||||||
|
|
||||||
|
Provides async operations for storing and retrieving memory embeddings:
|
||||||
|
- Collection management (per-user collections)
|
||||||
|
- Memory upsert/search/delete
|
||||||
|
- Filtering by memory type
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4, uuid5, NAMESPACE_DNS
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qdrant_models
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_memory_collection_name
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory storage.
|
||||||
|
|
||||||
|
Manages per-user collections with the pattern: memories_{user}
|
||||||
|
Stores memory embeddings with metadata (type, content, timestamps).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = MemoryQdrantClient()
|
||||||
|
await client.ensure_collection("jpmschweitzer")
|
||||||
|
await client.upsert_memory(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
memory_id="mem_123",
|
||||||
|
vector=[0.1, 0.2, ...],
|
||||||
|
payload={"type": "fact", "content": "User prefers dark mode"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | None = None,
|
||||||
|
embedding_dim: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Qdrant client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Qdrant server URL (defaults to config.qdrant_url)
|
||||||
|
embedding_dim: Vector dimension (defaults to config.QDRANT_EMBEDDING_DIM)
|
||||||
|
"""
|
||||||
|
self.url = url or config.qdrant_url
|
||||||
|
self.embedding_dim = embedding_dim or config.QDRANT_EMBEDDING_DIM
|
||||||
|
self._client = QdrantClient(url=self.url)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_client_initialized",
|
||||||
|
url=self.url,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close Qdrant client."""
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
async def ensure_collection(self, user: str) -> bool:
|
||||||
|
"""
|
||||||
|
Ensure collection exists for user, create if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if collection exists or was created successfully
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.ensure_collection("jpmschweitzer")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if collection exists
|
||||||
|
collections = self._client.get_collections()
|
||||||
|
existing = [c.name for c in collections.collections]
|
||||||
|
|
||||||
|
if collection_name in existing:
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_collection_exists",
|
||||||
|
collection=collection_name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create collection with cosine distance
|
||||||
|
self._client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=qdrant_models.VectorParams(
|
||||||
|
size=self.embedding_dim,
|
||||||
|
distance=qdrant_models.Distance.COSINE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_collection_created",
|
||||||
|
collection=collection_name,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_ensure_collection_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def upsert_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_id: str | None,
|
||||||
|
vector: list[float],
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Upsert a memory point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID (generated if None)
|
||||||
|
vector: Embedding vector
|
||||||
|
payload: Memory metadata (should include 'type', 'content', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory ID if successful, None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memory_id = await client.upsert_memory(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... memory_id=None,
|
||||||
|
... vector=[0.1, 0.2, ...],
|
||||||
|
... payload={
|
||||||
|
... "type": "fact",
|
||||||
|
... "content": "User prefers dark mode",
|
||||||
|
... "created_at": "2024-01-01T00:00:00Z"
|
||||||
|
... }
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
# Generate deterministic UUID from memory_id (or random if not provided)
|
||||||
|
# Qdrant requires UUID or integer IDs, not arbitrary strings
|
||||||
|
if memory_id:
|
||||||
|
# Deterministic UUID from string - same memory_id = same UUID
|
||||||
|
point_id = str(uuid5(NAMESPACE_DNS, f"{user}:{memory_id}"))
|
||||||
|
else:
|
||||||
|
point_id = str(uuid4())
|
||||||
|
memory_id = point_id # Use UUID as the memory_id too
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure collection exists
|
||||||
|
await self.ensure_collection(user)
|
||||||
|
|
||||||
|
# Create point (store original memory_id in payload for reference)
|
||||||
|
payload["memory_id"] = memory_id
|
||||||
|
point = qdrant_models.PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=vector,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert
|
||||||
|
self._client.upsert(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points=[point],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_upserted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
memory_type=payload.get("type"),
|
||||||
|
)
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_upsert_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_memories(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
query_vector: list[float],
|
||||||
|
limit: int = 10,
|
||||||
|
memory_type: str | None = None,
|
||||||
|
score_threshold: float = 0.5,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search memories by vector similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
query_vector: Query embedding vector
|
||||||
|
limit: Maximum results
|
||||||
|
memory_type: Filter by memory type (e.g., "fact", "preference", "profile")
|
||||||
|
score_threshold: Minimum similarity score (0-1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching memories with scores
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memories = await client.search_memories(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... query_vector=[0.1, 0.2, ...],
|
||||||
|
... limit=5,
|
||||||
|
... memory_type="fact"
|
||||||
|
... )
|
||||||
|
>>> memories[0]
|
||||||
|
{"id": "mem_123", "score": 0.89, "type": "fact", "content": "..."}
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build filter if memory_type specified
|
||||||
|
query_filter = None
|
||||||
|
if memory_type:
|
||||||
|
query_filter = qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search using new Query API (qdrant-client >= 1.10)
|
||||||
|
results = self._client.query_points(
|
||||||
|
collection_name=collection_name,
|
||||||
|
query=query_vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
|
score_threshold=score_threshold,
|
||||||
|
).points
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
memories = []
|
||||||
|
for hit in results:
|
||||||
|
memory = {
|
||||||
|
"id": hit.id,
|
||||||
|
"score": hit.score,
|
||||||
|
**hit.payload,
|
||||||
|
}
|
||||||
|
memories.append(memory)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_search_memories",
|
||||||
|
collection=collection_name,
|
||||||
|
results_count=len(memories),
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return memories
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_search_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_memory(self, user: str, memory_id: str) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get a specific memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory data or None if not found
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
# Convert memory_id to UUID point_id
|
||||||
|
point_id = str(uuid5(NAMESPACE_DNS, f"{user}:{memory_id}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
points = self._client.retrieve(
|
||||||
|
collection_name=collection_name,
|
||||||
|
ids=[point_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
point = points[0]
|
||||||
|
return {
|
||||||
|
"id": point.id,
|
||||||
|
**point.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_get_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_memory(self, user: str, memory_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.delete_memory("jpmschweitzer", "mem_123")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
# Convert memory_id to UUID point_id
|
||||||
|
point_id = str(uuid5(NAMESPACE_DNS, f"{user}:{memory_id}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.PointIdsList(
|
||||||
|
points=[point_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_deleted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_memories_by_type(self, user: str, memory_type: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all memories of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_type: Type of memories to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories deleted (approximate)
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete by filter
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.FilterSelector(
|
||||||
|
filter=qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_memories_deleted_by_type",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return -1 # Qdrant doesn't return count for filter deletes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memories_by_type_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def count_memories(self, user: str) -> int:
|
||||||
|
"""
|
||||||
|
Count total memories for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories in user's collection
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self._client.get_collection(collection_name)
|
||||||
|
return info.points_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_count_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Qdrant server is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._client.get_collections()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("qdrant_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_qdrant_client: MemoryQdrantClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_qdrant_client() -> MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Get global Qdrant client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryQdrantClient instance
|
||||||
|
"""
|
||||||
|
global _qdrant_client
|
||||||
|
if _qdrant_client is None:
|
||||||
|
_qdrant_client = MemoryQdrantClient()
|
||||||
|
return _qdrant_client
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Application startup module.
|
||||||
|
|
||||||
|
Handles initialization of household registry and other startup tasks.
|
||||||
|
This module should be called during application startup to register
|
||||||
|
all household members.
|
||||||
|
"""
|
||||||
|
from src.agents.biographer import register_biographer
|
||||||
|
from src.agents.housekeeper import register_housekeeper
|
||||||
|
from src.agents.librarian import register_librarian
|
||||||
|
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_household_members():
|
||||||
|
"""
|
||||||
|
Register all household members with the registry.
|
||||||
|
|
||||||
|
This function should be called during application startup to make
|
||||||
|
household capabilities available to the Steward.
|
||||||
|
|
||||||
|
Currently registers:
|
||||||
|
- tatlock_core: Butler's core tools (calculator, datetime, web search)
|
||||||
|
- librarian: Research and knowledge management (Phase 3)
|
||||||
|
- biographer: User memory and context management (Phase F)
|
||||||
|
"""
|
||||||
|
registry = get_household_registry()
|
||||||
|
|
||||||
|
logger.info("household_registration_starting")
|
||||||
|
|
||||||
|
# Register Tatlock's core tools
|
||||||
|
registry.register(
|
||||||
|
name="tatlock_core",
|
||||||
|
capability=TATLOCK_CORE_CAPABILITY,
|
||||||
|
tools=tatlock_core_tools,
|
||||||
|
agent=None, # No expert agent for core tools
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"household_member_registered",
|
||||||
|
name="tatlock_core",
|
||||||
|
tool_count=len(tatlock_core_tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register The Librarian (Phase 3)
|
||||||
|
try:
|
||||||
|
register_librarian()
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail startup if Librarian registration fails
|
||||||
|
logger.warning(
|
||||||
|
"librarian_registration_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register The Biographer (Phase F)
|
||||||
|
try:
|
||||||
|
register_biographer()
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail startup if Biographer registration fails
|
||||||
|
logger.warning(
|
||||||
|
"biographer_registration_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register The Housekeeper (Home Automation)
|
||||||
|
try:
|
||||||
|
register_housekeeper()
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail startup if Housekeeper registration fails
|
||||||
|
logger.warning(
|
||||||
|
"housekeeper_registration_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"household_registration_complete",
|
||||||
|
total_members=len(registry),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_application():
|
||||||
|
"""
|
||||||
|
Initialize the application.
|
||||||
|
|
||||||
|
Performs all startup tasks:
|
||||||
|
1. Register household members
|
||||||
|
2. (Future) Initialize connections
|
||||||
|
3. (Future) Load configuration
|
||||||
|
|
||||||
|
This should be called once during application startup.
|
||||||
|
"""
|
||||||
|
logger.info("application_initialization_starting")
|
||||||
|
|
||||||
|
# Register household members
|
||||||
|
register_household_members()
|
||||||
|
|
||||||
|
logger.info("application_initialization_complete")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Tool call tracking and benchmarking.
|
||||||
|
|
||||||
|
Tracks which tools are recommended by the Steward versus which tools
|
||||||
|
are actually used by Tatlock, recording benchmarks for analysis.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallTracker:
|
||||||
|
"""
|
||||||
|
Tracks tool calls for benchmarking and accuracy analysis.
|
||||||
|
|
||||||
|
Compares Steward's recommendations with Tatlock's actual tool usage
|
||||||
|
to measure recommendation accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
recommended_capabilities: list[str],
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize tool call tracker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recommended_capabilities: List of capability names recommended by Steward
|
||||||
|
conversation_id: Optional conversation ID for tracking
|
||||||
|
"""
|
||||||
|
self.recommended_capabilities = set(recommended_capabilities)
|
||||||
|
self.actual_calls: dict[str, list[float]] = {} # tool_name -> [durations]
|
||||||
|
self.conversation_id = conversation_id
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"tool_tracker_initialized",
|
||||||
|
recommended=list(self.recommended_capabilities),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def track_call(self, tool_name: str, duration: float):
|
||||||
|
"""
|
||||||
|
Record a tool call with timing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool that was called
|
||||||
|
duration: Duration of the call in seconds
|
||||||
|
"""
|
||||||
|
# Record the call
|
||||||
|
if tool_name not in self.actual_calls:
|
||||||
|
self.actual_calls[tool_name] = []
|
||||||
|
self.actual_calls[tool_name].append(duration)
|
||||||
|
|
||||||
|
# Check if tool was recommended
|
||||||
|
was_recommended = tool_name in self.recommended_capabilities
|
||||||
|
|
||||||
|
if not was_recommended:
|
||||||
|
logger.warning(
|
||||||
|
"tool_call_not_recommended",
|
||||||
|
tool_name=tool_name,
|
||||||
|
duration=duration,
|
||||||
|
recommended=list(self.recommended_capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record benchmark to Redis
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
operation="tool_call",
|
||||||
|
duration_seconds=duration,
|
||||||
|
success=True, # If we got here, the call succeeded
|
||||||
|
tool_name=tool_name,
|
||||||
|
was_recommended=was_recommended,
|
||||||
|
was_actually_used=True,
|
||||||
|
conversation_id=self.conversation_id,
|
||||||
|
metadata={
|
||||||
|
"recommended_capabilities": list(self.recommended_capabilities),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await get_benchmark_store().record(benchmark)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"tool_call_tracked",
|
||||||
|
tool_name=tool_name,
|
||||||
|
duration=duration,
|
||||||
|
was_recommended=was_recommended,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def finalize(self):
|
||||||
|
"""
|
||||||
|
Finalize tracking and log unused recommended tools.
|
||||||
|
|
||||||
|
Called after Tatlock completes its response to identify
|
||||||
|
tools that were recommended but never used.
|
||||||
|
"""
|
||||||
|
# Find tools that were recommended but not used
|
||||||
|
unused_tools = self.recommended_capabilities - set(self.actual_calls.keys())
|
||||||
|
|
||||||
|
if unused_tools:
|
||||||
|
logger.info(
|
||||||
|
"recommended_tools_unused",
|
||||||
|
unused=list(unused_tools),
|
||||||
|
used=list(self.actual_calls.keys()),
|
||||||
|
conversation_id=self.conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record benchmarks for unused recommendations
|
||||||
|
for tool_name in unused_tools:
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
operation="tool_call",
|
||||||
|
duration_seconds=0.0, # Not used
|
||||||
|
success=True,
|
||||||
|
tool_name=tool_name,
|
||||||
|
was_recommended=True,
|
||||||
|
was_actually_used=False,
|
||||||
|
conversation_id=self.conversation_id,
|
||||||
|
metadata={
|
||||||
|
"recommended_capabilities": list(self.recommended_capabilities),
|
||||||
|
"reason": "recommended_but_unused",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await get_benchmark_store().record(benchmark)
|
||||||
|
|
||||||
|
# Log summary
|
||||||
|
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
||||||
|
logger.info(
|
||||||
|
"tool_tracking_finalized",
|
||||||
|
total_calls=total_calls,
|
||||||
|
unique_tools_used=len(self.actual_calls),
|
||||||
|
recommended_count=len(self.recommended_capabilities),
|
||||||
|
unused_count=len(unused_tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_summary(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get tracking summary for debugging.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with tracking statistics
|
||||||
|
"""
|
||||||
|
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
||||||
|
unused = self.recommended_capabilities - set(self.actual_calls.keys())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recommended_capabilities": list(self.recommended_capabilities),
|
||||||
|
"tools_used": list(self.actual_calls.keys()),
|
||||||
|
"tools_unused": list(unused),
|
||||||
|
"total_calls": total_calls,
|
||||||
|
"accuracy": {
|
||||||
|
"recommended_and_used": len(
|
||||||
|
self.recommended_capabilities & set(self.actual_calls.keys())
|
||||||
|
),
|
||||||
|
"recommended_but_unused": len(unused),
|
||||||
|
"not_recommended_but_used": len(
|
||||||
|
set(self.actual_calls.keys()) - self.recommended_capabilities
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
+43
-24
@@ -9,7 +9,6 @@ Main responsibilities:
|
|||||||
- Router registration
|
- Router registration
|
||||||
- Lifecycle management
|
- Lifecycle management
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
@@ -21,35 +20,42 @@ from fastapi.responses import JSONResponse
|
|||||||
from src.chat.router import router as chat_router
|
from src.chat.router import router as chat_router
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
from src.core.exceptions import AppException
|
from src.core.exceptions import AppException
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
from src.core.router import router as core_router
|
from src.core.router import router as core_router
|
||||||
|
from src.core.startup import initialize_application
|
||||||
from src.models.router import router as models_router
|
from src.models.router import router as models_router
|
||||||
from src.responses.router import router as responses_router
|
from src.responses.router import router as responses_router
|
||||||
|
|
||||||
# Configure logging
|
# Get structured logger
|
||||||
logging.basicConfig(
|
logger = get_logger(__name__)
|
||||||
level=config.LOG_LEVEL,
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""
|
"""
|
||||||
Application lifespan manager.
|
Application lifespan manager.
|
||||||
|
|
||||||
Handles startup and shutdown logic.
|
Handles startup and shutdown logic.
|
||||||
"""
|
"""
|
||||||
# Startup
|
# Startup
|
||||||
logger.info(f"Starting {config.APP_NAME} v{config.APP_VERSION}")
|
logger.info(
|
||||||
logger.info(f"Environment: {config.ENVIRONMENT.value}")
|
"application_starting",
|
||||||
logger.info(f"Ollama host: {config.OLLAMA_HOST}")
|
app_name=config.APP_NAME,
|
||||||
logger.info(f"Default model: {config.OLLAMA_DEFAULT_MODEL}")
|
version=config.APP_VERSION,
|
||||||
|
environment=config.ENVIRONMENT.value,
|
||||||
|
ollama_host=str(config.OLLAMA_HOST),
|
||||||
|
ollama_model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
redis_url=config.redis_url,
|
||||||
|
log_format=config.log_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize application (register household members, etc.)
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down application")
|
logger.info("application_shutdown")
|
||||||
|
|
||||||
|
|
||||||
def create_application() -> FastAPI:
|
def create_application() -> FastAPI:
|
||||||
@@ -102,10 +108,14 @@ def register_exception_handlers(application: FastAPI) -> None:
|
|||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle custom application exceptions."""
|
"""Handle custom application exceptions."""
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Application error: {exc.message}",
|
"application_exception",
|
||||||
extra={"details": exc.details}
|
error_message=exc.message,
|
||||||
|
error_type=exc.__class__.__name__,
|
||||||
|
status_code=exc.status_code,
|
||||||
|
details=exc.details,
|
||||||
|
path=request.url.path,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content={
|
content={
|
||||||
@@ -116,15 +126,19 @@ def register_exception_handlers(application: FastAPI) -> None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@application.exception_handler(RequestValidationError)
|
@application.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(
|
async def validation_exception_handler(
|
||||||
request: Request,
|
request: Request,
|
||||||
exc: RequestValidationError,
|
exc: RequestValidationError,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle Pydantic validation errors."""
|
"""Handle Pydantic validation errors."""
|
||||||
logger.error(f"Validation error: {exc.errors()}")
|
logger.error(
|
||||||
|
"validation_error",
|
||||||
|
errors=exc.errors(),
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
content={
|
content={
|
||||||
@@ -135,15 +149,20 @@ def register_exception_handlers(application: FastAPI) -> None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@application.exception_handler(Exception)
|
@application.exception_handler(Exception)
|
||||||
async def general_exception_handler(
|
async def general_exception_handler(
|
||||||
request: Request,
|
request: Request,
|
||||||
exc: Exception,
|
exc: Exception,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Handle unexpected exceptions."""
|
"""Handle unexpected exceptions."""
|
||||||
logger.exception("Unexpected error")
|
logger.exception(
|
||||||
|
"unexpected_error",
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
error_message=str(exc),
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
PydanticAI provider for Ollama with message sanitization.
|
||||||
|
|
||||||
|
Ollama's OpenAI-compatible API rejects messages with `content: null`,
|
||||||
|
which PydanticAI sends for assistant messages that only contain tool calls.
|
||||||
|
This provider sanitizes messages to use empty strings instead of null.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from pydantic_ai.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TatlockOllamaProvider(OllamaProvider):
|
||||||
|
"""
|
||||||
|
Custom OllamaProvider with message sanitization for Tatlock agents.
|
||||||
|
|
||||||
|
Fixes the 'invalid message content type: <nil>' error that occurs
|
||||||
|
when assistant messages have `content: null` with tool calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None):
|
||||||
|
"""
|
||||||
|
Initialize provider with Ollama base URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama API URL (defaults to config.OLLAMA_HOST/v1)
|
||||||
|
"""
|
||||||
|
if base_url is None:
|
||||||
|
clean_host = str(config.OLLAMA_HOST).rstrip("/")
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
super().__init__(base_url=base_url)
|
||||||
|
|
||||||
|
# Override the client with our sanitized version
|
||||||
|
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
|
||||||
|
|
||||||
|
logger.debug("tatlock_ollama_provider_created", base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedAsyncOpenAI(AsyncOpenAI):
|
||||||
|
"""AsyncOpenAI client that sanitizes messages before sending."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any):
|
||||||
|
# Ollama doesn't need an API key
|
||||||
|
super().__init__(api_key="ollama", **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chat(self) -> "_SanitizedChat":
|
||||||
|
"""Return sanitized chat interface."""
|
||||||
|
return _SanitizedChat(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedChat:
|
||||||
|
"""Chat interface wrapper with sanitized completions."""
|
||||||
|
|
||||||
|
def __init__(self, client: _SanitizedAsyncOpenAI):
|
||||||
|
self._client = client
|
||||||
|
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completions(self) -> "_SanitizedCompletions":
|
||||||
|
"""Return sanitized completions interface."""
|
||||||
|
return _SanitizedCompletions(self._original_chat.completions)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedCompletions:
|
||||||
|
"""Completions wrapper that sanitizes messages before API calls."""
|
||||||
|
|
||||||
|
def __init__(self, original_completions: Any):
|
||||||
|
self._original = original_completions
|
||||||
|
|
||||||
|
async def create(self, **kwargs: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Create chat completion with sanitized messages.
|
||||||
|
|
||||||
|
Converts `content: null` to `content: ""` in assistant messages
|
||||||
|
to prevent Ollama's 'invalid message content type: <nil>' error.
|
||||||
|
"""
|
||||||
|
if "messages" in kwargs:
|
||||||
|
kwargs["messages"] = _sanitize_messages(kwargs["messages"])
|
||||||
|
|
||||||
|
return await self._original.create(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Sanitize messages to fix null content issues.
|
||||||
|
|
||||||
|
When an assistant message has tool_calls but no text content,
|
||||||
|
PydanticAI sets content to None. Ollama rejects this.
|
||||||
|
We convert None to empty string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of chat messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized messages with null content replaced by empty strings
|
||||||
|
"""
|
||||||
|
sanitized = []
|
||||||
|
for msg in messages:
|
||||||
|
msg_copy = dict(msg)
|
||||||
|
|
||||||
|
# Fix null content in assistant messages with tool calls
|
||||||
|
if msg_copy.get("role") == "assistant":
|
||||||
|
if msg_copy.get("content") is None and msg_copy.get("tool_calls"):
|
||||||
|
msg_copy["content"] = ""
|
||||||
|
logger.debug(
|
||||||
|
"sanitized_null_content",
|
||||||
|
tool_call_count=len(msg_copy["tool_calls"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
sanitized.append(msg_copy)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def get_ollama_provider() -> TatlockOllamaProvider:
|
||||||
|
"""
|
||||||
|
Get a configured Ollama provider for PydanticAI agents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TatlockOllamaProvider configured with sanitization
|
||||||
|
"""
|
||||||
|
return TatlockOllamaProvider()
|
||||||
+46
-7
@@ -4,15 +4,16 @@ Responses router.
|
|||||||
OpenAI-compatible /v1/responses endpoint with streaming support.
|
OpenAI-compatible /v1/responses endpoint with streaming support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from src.responses import service
|
from src.responses import service
|
||||||
from src.responses.schemas import ResponseRequest, Response
|
from src.responses.schemas import ResponseRequest, Response
|
||||||
from src.core.exceptions import ModelNotFoundError, AppException
|
from src.core.exceptions import ModelNotFoundError, AppException
|
||||||
|
from src.core.context import current_user, current_conversation, get_default_user
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/responses", tags=["responses"])
|
router = APIRouter(prefix="/responses", tags=["responses"])
|
||||||
|
|
||||||
@@ -92,16 +93,49 @@ async def create_response(
|
|||||||
event: response.done
|
event: response.done
|
||||||
data: {"response": {...}}
|
data: {"response": {...}}
|
||||||
"""
|
"""
|
||||||
logger.info(f"Response request for model: {request.model}")
|
# Set request context (propagates through all async calls)
|
||||||
|
effective_user = request.user or get_default_user()
|
||||||
|
user_token = current_user.set(effective_user)
|
||||||
|
conv_id = request.metadata.get("conversation_id") if request.metadata else None
|
||||||
|
conv_token = current_conversation.set(conv_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"response_request_received",
|
||||||
|
model=request.model,
|
||||||
|
user=effective_user,
|
||||||
|
conversation_id=conv_id,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
||||||
|
model_id = request.model
|
||||||
|
if "." in model_id:
|
||||||
|
model_id = model_id.split(".", 1)[1]
|
||||||
|
|
||||||
|
use_steward = model_id.lower() == "tatlock"
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
logger.info("Streaming response requested")
|
logger.info("Streaming response requested")
|
||||||
return EventSourceResponse(
|
if use_steward:
|
||||||
service.create_response_stream(request)
|
logger.info("Streaming with Steward preprocessing for Tatlock request")
|
||||||
)
|
# Use Steward + Tatlock streaming (Milestone 3.5)
|
||||||
|
from src.responses.streaming import StreamingCoordinator
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
|
return EventSourceResponse(
|
||||||
|
coordinator.stream_response_with_steward(request)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Regular streaming for non-Tatlock models
|
||||||
|
return EventSourceResponse(
|
||||||
|
service.create_response_stream(request)
|
||||||
|
)
|
||||||
|
|
||||||
return await service.create_response(request)
|
# Use appropriate service method
|
||||||
|
if use_steward:
|
||||||
|
logger.info("Using Steward preprocessing for Tatlock request")
|
||||||
|
return await service.create_response_with_steward(request)
|
||||||
|
else:
|
||||||
|
return await service.create_response(request)
|
||||||
|
|
||||||
except ModelNotFoundError as e:
|
except ModelNotFoundError as e:
|
||||||
logger.error(f"Model not found: {e}")
|
logger.error(f"Model not found: {e}")
|
||||||
@@ -114,3 +148,8 @@ async def create_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Reset context (important for connection reuse)
|
||||||
|
current_user.reset(user_token)
|
||||||
|
current_conversation.reset(conv_token)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class ResponseRequest(CustomBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Stop sequences"
|
description="Stop sequences"
|
||||||
)
|
)
|
||||||
|
user: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Unique identifier for end-user (OpenAI standard)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('reasoning')
|
@field_validator('reasoning')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+458
-13
@@ -3,6 +3,7 @@ Response service for creating responses.
|
|||||||
|
|
||||||
Handles both streaming and non-streaming response generation.
|
Handles both streaming and non-streaming response generation.
|
||||||
Tracks conversation history for analytics and future vector memory.
|
Tracks conversation history for analytics and future vector memory.
|
||||||
|
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -22,6 +23,295 @@ from src.responses.schemas import (
|
|||||||
from src.responses.streaming import StreamingCoordinator
|
from src.responses.streaming import StreamingCoordinator
|
||||||
from src.responses.history import ConversationHistory
|
from src.responses.history import ConversationHistory
|
||||||
from src.responses.context import ContextWindow
|
from src.responses.context import ContextWindow
|
||||||
|
from src.core.preprocessing import preprocess_request
|
||||||
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
from src.agents.steward.schemas import StewardRecommendation
|
||||||
|
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_single_delegation(
|
||||||
|
agent_name: str,
|
||||||
|
task: str,
|
||||||
|
tracker: "ToolCallTracker",
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Execute a single delegation to an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Name of agent (biographer, librarian, housekeeper)
|
||||||
|
task: Task description
|
||||||
|
tracker: Tool call tracker
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (agent_name, result_summary)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
if agent_name == "biographer":
|
||||||
|
from src.agents.delegation import delegate_to_biographer
|
||||||
|
result = await delegate_to_biographer(task=task)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await tracker.track_call("delegate_to_biographer", duration)
|
||||||
|
return (agent_name, result.output)
|
||||||
|
|
||||||
|
elif agent_name == "librarian":
|
||||||
|
from src.agents.delegation import delegate_to_librarian
|
||||||
|
result = await delegate_to_librarian(task=task)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await tracker.track_call("delegate_to_librarian", duration)
|
||||||
|
return (agent_name, result.output)
|
||||||
|
|
||||||
|
elif agent_name == "housekeeper":
|
||||||
|
from src.agents.delegation import delegate_to_housekeeper
|
||||||
|
result = await delegate_to_housekeeper(task=task)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await tracker.track_call("delegate_to_housekeeper", duration)
|
||||||
|
return (agent_name, result.output)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return (agent_name, f"Unknown agent: {agent_name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_text_delegation(
|
||||||
|
response: str,
|
||||||
|
tracker: "ToolCallTracker",
|
||||||
|
conversation_id: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Handle text-based delegation fallback.
|
||||||
|
|
||||||
|
When Tatlock outputs [DELEGATE:agent] task="..." instead of calling
|
||||||
|
the actual function, we parse and execute it here.
|
||||||
|
|
||||||
|
Supports multiple delegations in the same response:
|
||||||
|
- Sequential: Run one after another in order
|
||||||
|
- Parallel: Run all at once if [PARALLEL] prefix is present
|
||||||
|
|
||||||
|
Patterns:
|
||||||
|
[DELEGATE:biographer] task="Remember something"
|
||||||
|
[DELEGATE:librarian] task="Search for something"
|
||||||
|
[PARALLEL][DELEGATE:biographer] task="..." [DELEGATE:librarian] task="..."
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Tatlock's response text
|
||||||
|
tracker: Tool call tracker for metrics
|
||||||
|
conversation_id: Current conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Either the original response or the delegation result(s)
|
||||||
|
"""
|
||||||
|
# Pattern 1: [DELEGATE:agent_name] task="task description"
|
||||||
|
# Pattern 2: Delegate:"agent_name", "task":"task description" (LLM variant)
|
||||||
|
# Pattern 3: delegate_to_agent(task="...") (function-like text)
|
||||||
|
patterns = [
|
||||||
|
r'\[DELEGATE:(\w+)\]\s*task=["\']([^"\']+)["\']',
|
||||||
|
r'[Dd]elegate[:\s]*["\']?(\w+)["\']?,?\s*["\']?task["\']?[:\s]*["\']([^"\']+)["\']',
|
||||||
|
r'delegate_to_(\w+)\s*\(\s*task\s*=\s*["\']([^"\']+)["\']',
|
||||||
|
]
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for pattern in patterns:
|
||||||
|
found = re.findall(pattern, response)
|
||||||
|
if found:
|
||||||
|
matches.extend(found)
|
||||||
|
break # Use first matching pattern
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
# No text delegation found, return original response
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"text_delegation_detected",
|
||||||
|
delegation_count=len(matches),
|
||||||
|
agents=[m[0] for m in matches],
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if parallel execution is requested
|
||||||
|
is_parallel = "[PARALLEL]" in response.upper()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_parallel and len(matches) > 1:
|
||||||
|
# Execute all delegations in parallel
|
||||||
|
logger.info(
|
||||||
|
"executing_parallel_delegations",
|
||||||
|
count=len(matches),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
tasks = [
|
||||||
|
_execute_single_delegation(agent.lower(), task, tracker)
|
||||||
|
for agent, task in matches
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Combine results
|
||||||
|
summaries = []
|
||||||
|
for agent_name, result in results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
summaries.append(f"**{agent_name}**: Error - {result}")
|
||||||
|
else:
|
||||||
|
summaries.append(f"**{agent_name}**: {result}")
|
||||||
|
|
||||||
|
return "\n\n".join(summaries)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Execute sequentially
|
||||||
|
summaries = []
|
||||||
|
for agent_name, task in matches:
|
||||||
|
agent_name = agent_name.lower()
|
||||||
|
logger.info(
|
||||||
|
"executing_sequential_delegation",
|
||||||
|
agent=agent_name,
|
||||||
|
task_preview=task[:50],
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_, result = await _execute_single_delegation(
|
||||||
|
agent_name, task, tracker
|
||||||
|
)
|
||||||
|
summaries.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_failed",
|
||||||
|
agent=agent_name,
|
||||||
|
error=str(e),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
f"I apologize, sir. Delegation to {agent_name} failed: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(summaries)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"text_delegation_failed",
|
||||||
|
error=str(e),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
return f"I apologize, sir. I encountered an error processing delegations: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _direct_delegation(
|
||||||
|
user_message: str,
|
||||||
|
recommendation: "StewardRecommendation",
|
||||||
|
tracker: "ToolCallTracker",
|
||||||
|
conversation_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Directly delegate to expert agents, bypassing Tatlock.
|
||||||
|
|
||||||
|
When Steward recommends ONLY delegation agents (biographer/librarian),
|
||||||
|
we skip Tatlock's LLM call and delegate directly. This works around
|
||||||
|
models that don't reliably call tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: User's request
|
||||||
|
recommendation: Steward's recommendation
|
||||||
|
tracker: Tool call tracker
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Combined results from delegations
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_triggered",
|
||||||
|
agents=recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for agent in recommendation.recommended_capabilities:
|
||||||
|
try:
|
||||||
|
agent_name, result = await _execute_single_delegation(
|
||||||
|
agent, user_message, tracker
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_complete",
|
||||||
|
agent=agent_name,
|
||||||
|
result_preview=result[:100] if result else "empty",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"direct_delegation_failed",
|
||||||
|
agent=agent,
|
||||||
|
error=str(e),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
results.append(f"I apologize, sir. Delegation to {agent} failed: {e}")
|
||||||
|
|
||||||
|
return "\n\n".join(results) if results else "I apologize, sir. No delegation results available."
|
||||||
|
|
||||||
|
|
||||||
|
async def _direct_delegation_with_results(
|
||||||
|
user_message: str,
|
||||||
|
recommendation: "StewardRecommendation",
|
||||||
|
tracker: "ToolCallTracker",
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Directly delegate to expert agents and return structured results.
|
||||||
|
|
||||||
|
This is the Phase 1 variant of direct delegation that returns results
|
||||||
|
in the same format as TatlockAgent.orchestrate_tool_calls() for
|
||||||
|
consistent Phase 2 synthesis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: User's request
|
||||||
|
recommendation: Steward's recommendation
|
||||||
|
tracker: Tool call tracker
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Orchestration results with expert_results, tool_outputs, etc.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_with_results",
|
||||||
|
agents=recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
expert_results = {}
|
||||||
|
tools_called = []
|
||||||
|
|
||||||
|
for agent in recommendation.recommended_capabilities:
|
||||||
|
try:
|
||||||
|
agent_name, result = await _execute_single_delegation(
|
||||||
|
agent, user_message, tracker
|
||||||
|
)
|
||||||
|
expert_results[agent_name] = result
|
||||||
|
tools_called.append(f"delegate_to_{agent_name}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_result",
|
||||||
|
agent=agent_name,
|
||||||
|
result_preview=result[:100] if result else "empty",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"direct_delegation_failed",
|
||||||
|
agent=agent,
|
||||||
|
error=str(e),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
expert_results[agent] = f"Error: {e}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": {}, # No tool outputs for direct delegation
|
||||||
|
"raw_output": "", # No raw output for direct delegation
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global conversation history tracker
|
# Global conversation history tracker
|
||||||
# In production, this would be backed by a database or Redis
|
# In production, this would be backed by a database or Redis
|
||||||
@@ -59,8 +349,18 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
|||||||
reasoning_tokens = 0
|
reasoning_tokens = 0
|
||||||
|
|
||||||
for item in output_items:
|
for item in output_items:
|
||||||
if hasattr(item, 'type'):
|
# Check if it's a schema object (has summary/content attributes directly)
|
||||||
# Agent OutputItem objects
|
if isinstance(item, ReasoningOutputItem):
|
||||||
|
reasoning_text = " ".join(item.summary)
|
||||||
|
reasoning_tokens += len(reasoning_text) // 4
|
||||||
|
elif isinstance(item, MessageOutputItem):
|
||||||
|
message_text = item.content[0].text
|
||||||
|
output_tokens += len(message_text) // 4
|
||||||
|
elif isinstance(item, FunctionCallOutputItem):
|
||||||
|
func_text = item.arguments
|
||||||
|
output_tokens += len(func_text) // 4
|
||||||
|
elif hasattr(item, 'type'):
|
||||||
|
# Agent OutputItem objects (backward compatibility)
|
||||||
if item.type == "reasoning":
|
if item.type == "reasoning":
|
||||||
reasoning_text = " ".join(item.data.get("summary", []))
|
reasoning_text = " ".join(item.data.get("summary", []))
|
||||||
reasoning_tokens += len(reasoning_text) // 4
|
reasoning_tokens += len(reasoning_text) // 4
|
||||||
@@ -70,17 +370,6 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
|||||||
elif item.type == "function_call":
|
elif item.type == "function_call":
|
||||||
func_text = item.data["arguments"]
|
func_text = item.data["arguments"]
|
||||||
output_tokens += len(func_text) // 4
|
output_tokens += len(func_text) // 4
|
||||||
else:
|
|
||||||
# Schema OutputItem objects
|
|
||||||
if isinstance(item, ReasoningOutputItem):
|
|
||||||
reasoning_text = " ".join(item.summary)
|
|
||||||
reasoning_tokens += len(reasoning_text) // 4
|
|
||||||
elif isinstance(item, MessageOutputItem):
|
|
||||||
message_text = item.content[0].text
|
|
||||||
output_tokens += len(message_text) // 4
|
|
||||||
elif isinstance(item, FunctionCallOutputItem):
|
|
||||||
func_text = item.arguments
|
|
||||||
output_tokens += len(func_text) // 4
|
|
||||||
|
|
||||||
total_tokens = input_tokens + output_tokens + reasoning_tokens
|
total_tokens = input_tokens + output_tokens + reasoning_tokens
|
||||||
|
|
||||||
@@ -157,6 +446,162 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||||
|
"""
|
||||||
|
Create response using Steward preprocessing and two-phase Tatlock execution.
|
||||||
|
|
||||||
|
This is the two-tier architecture with two-phase synthesis:
|
||||||
|
1. Steward analyzes the request and recommends capabilities
|
||||||
|
2. Phase 1: Tatlock orchestrates tool calls and expert delegations
|
||||||
|
3. Phase 2: Tatlock synthesizes butler-toned response from results
|
||||||
|
4. Tool usage is tracked for benchmarking
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Response request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: Complete response object with Steward analysis included
|
||||||
|
|
||||||
|
Example:
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "What's sqrt(144)?"}],
|
||||||
|
metadata={"conversation_id": "conv_abc123"}
|
||||||
|
)
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
"""
|
||||||
|
# Get or generate conversation ID
|
||||||
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||||
|
|
||||||
|
# Extract user message and conversation history
|
||||||
|
user_message = ""
|
||||||
|
for msg in reversed(request.input):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
user_message = msg.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Conversation history is all messages except the current one
|
||||||
|
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"creating_response_with_steward",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
history_length=len(conversation_history),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Steward preprocessing
|
||||||
|
enriched = await preprocess_request(
|
||||||
|
user_message,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize tool tracker
|
||||||
|
tracker = ToolCallTracker(
|
||||||
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if direct delegation is recommended
|
||||||
|
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
||||||
|
# we still use two-phase but delegate directly in Phase 1
|
||||||
|
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||||
|
delegation_only = all(
|
||||||
|
cap in delegation_agents
|
||||||
|
for cap in enriched.recommendation.recommended_capabilities
|
||||||
|
) and enriched.recommendation.recommended_capabilities
|
||||||
|
|
||||||
|
from src.agents.tatlock import TatlockAgent
|
||||||
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
# Use enriched query (with location/timezone context) if available
|
||||||
|
effective_query = enriched.recommendation.enriched_query or user_message
|
||||||
|
|
||||||
|
if delegation_only:
|
||||||
|
# Direct delegation path - collect results then synthesize
|
||||||
|
orchestration_results = await _direct_delegation_with_results(
|
||||||
|
effective_query, enriched.recommendation, tracker, conversation_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Phase 1: Orchestrate tool calls
|
||||||
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
|
user_message=effective_query,
|
||||||
|
steward_note=enriched.steward_note,
|
||||||
|
scoped_tools=enriched.scoped_tools,
|
||||||
|
message_history=conversation_history,
|
||||||
|
tool_tracker=tracker,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle text-based delegation fallback if present
|
||||||
|
if "[DELEGATE:" in orchestration_results.get("raw_output", ""):
|
||||||
|
text_delegation_results = await _handle_text_delegation(
|
||||||
|
orchestration_results["raw_output"], tracker, conversation_id
|
||||||
|
)
|
||||||
|
# Add text delegation results to expert_results
|
||||||
|
if text_delegation_results != orchestration_results["raw_output"]:
|
||||||
|
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
||||||
|
|
||||||
|
# Phase 2: Synthesize butler-toned response from all results
|
||||||
|
tatlock_response = await tatlock.synthesize_from_results(
|
||||||
|
user_message=user_message,
|
||||||
|
orchestration_results=orchestration_results,
|
||||||
|
message_history=conversation_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finalize tool tracking
|
||||||
|
await tracker.finalize()
|
||||||
|
|
||||||
|
# Build response output items
|
||||||
|
output_items = []
|
||||||
|
|
||||||
|
# Add Steward reasoning as a reasoning output item
|
||||||
|
output_items.append(ReasoningOutputItem(
|
||||||
|
id=f"reasoning_{generate_id()}",
|
||||||
|
summary=[
|
||||||
|
"🎩 Steward's Analysis:",
|
||||||
|
enriched.steward_reasoning,
|
||||||
|
],
|
||||||
|
status="completed"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add Tatlock's message
|
||||||
|
output_items.append(MessageOutputItem(
|
||||||
|
id=f"msg_{generate_id()}",
|
||||||
|
role="assistant",
|
||||||
|
content=[OutputTextContent(
|
||||||
|
type="output_text",
|
||||||
|
text=tatlock_response,
|
||||||
|
annotations=[]
|
||||||
|
)],
|
||||||
|
status="completed"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Calculate usage (approximate)
|
||||||
|
usage = _calculate_usage(request.input, output_items)
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
id=f"resp_{generate_id()}",
|
||||||
|
created_at=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
status="completed",
|
||||||
|
output=output_items,
|
||||||
|
usage=usage
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track conversation history
|
||||||
|
await _conversation_history.add_response(conversation_id, response)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"response_with_steward_complete",
|
||||||
|
response_id=response.id,
|
||||||
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
tool_summary=tracker.get_summary(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def create_response_stream(
|
async def create_response_stream(
|
||||||
request: ResponseRequest
|
request: ResponseRequest
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
|||||||
+285
-33
@@ -113,6 +113,251 @@ class StreamingCoordinator:
|
|||||||
5. Final response event
|
5. Final response event
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def stream_response_with_steward(
|
||||||
|
self,
|
||||||
|
request: "ResponseRequest" # type: ignore # Forward reference
|
||||||
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
|
"""
|
||||||
|
Stream response with Steward preprocessing and two-phase Tatlock execution.
|
||||||
|
|
||||||
|
Streams in order:
|
||||||
|
1. Steward's analysis as reasoning summary
|
||||||
|
2. Think slugs during expert delegation (butler-perspective messages)
|
||||||
|
3. Synthesized butler-toned response as output text
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Response request
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
StreamEvent: Stream of SSE events
|
||||||
|
"""
|
||||||
|
from src.responses.service import (
|
||||||
|
_calculate_usage,
|
||||||
|
generate_id,
|
||||||
|
_conversation_history,
|
||||||
|
_direct_delegation_with_results,
|
||||||
|
)
|
||||||
|
from src.core.preprocessing import preprocess_request
|
||||||
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
|
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
|
||||||
|
from src.agents.tatlock import TatlockAgent
|
||||||
|
from src.agents.delegation import get_think_message, STREAMING_DELEGATION_WRAPPERS
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
output_items = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get or generate conversation ID
|
||||||
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||||
|
|
||||||
|
# Extract user message and conversation history
|
||||||
|
user_message = ""
|
||||||
|
for msg in reversed(request.input):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
user_message = msg.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
||||||
|
|
||||||
|
# Steward preprocessing
|
||||||
|
enriched = await preprocess_request(
|
||||||
|
user_message,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream Steward's analysis as reasoning summary
|
||||||
|
steward_lines = enriched.steward_reasoning.split('\n')
|
||||||
|
for line in steward_lines:
|
||||||
|
if line.strip():
|
||||||
|
yield ReasoningSummaryDelta(delta=line + "\n")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
yield ReasoningSummaryDone()
|
||||||
|
|
||||||
|
# Add Steward reasoning to output items
|
||||||
|
reasoning_item = ReasoningOutputItem(
|
||||||
|
id=f"reasoning_{generate_id()}",
|
||||||
|
summary=[
|
||||||
|
"🎩 Steward's Analysis:",
|
||||||
|
enriched.steward_reasoning,
|
||||||
|
],
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
output_items.append(reasoning_item)
|
||||||
|
|
||||||
|
# Initialize tool tracker
|
||||||
|
tracker = ToolCallTracker(
|
||||||
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if direct delegation is recommended
|
||||||
|
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||||
|
delegation_only = all(
|
||||||
|
cap in delegation_agents
|
||||||
|
for cap in enriched.recommendation.recommended_capabilities
|
||||||
|
) and enriched.recommendation.recommended_capabilities
|
||||||
|
|
||||||
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
if delegation_only:
|
||||||
|
# Direct delegation path with streaming think slugs
|
||||||
|
orchestration_results = await self._stream_direct_delegation(
|
||||||
|
user_message=user_message,
|
||||||
|
recommendation=enriched.recommendation,
|
||||||
|
tracker=tracker,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream think slugs that were collected during delegation
|
||||||
|
# Each think message is complete, so we signal done after each
|
||||||
|
for think_msg in orchestration_results.get("think_messages", []):
|
||||||
|
yield ReasoningSummaryDelta(delta=think_msg)
|
||||||
|
yield ReasoningSummaryDone()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Phase 1: Orchestrate tool calls
|
||||||
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
|
user_message=user_message,
|
||||||
|
steward_note=enriched.steward_note,
|
||||||
|
scoped_tools=enriched.scoped_tools,
|
||||||
|
message_history=conversation_history,
|
||||||
|
tool_tracker=tracker,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2: Synthesize butler-toned response
|
||||||
|
tatlock_response = await tatlock.synthesize_from_results(
|
||||||
|
user_message=user_message,
|
||||||
|
orchestration_results=orchestration_results,
|
||||||
|
message_history=conversation_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the synthesized response
|
||||||
|
chunk_size = 50
|
||||||
|
for i in range(0, len(tatlock_response), chunk_size):
|
||||||
|
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
yield OutputTextDone()
|
||||||
|
|
||||||
|
# Add Tatlock message to output items
|
||||||
|
message_item = MessageOutputItem(
|
||||||
|
id=f"msg_{generate_id()}",
|
||||||
|
role="assistant",
|
||||||
|
content=[OutputTextContent(
|
||||||
|
type="output_text",
|
||||||
|
text=tatlock_response,
|
||||||
|
annotations=[]
|
||||||
|
)],
|
||||||
|
status="completed"
|
||||||
|
)
|
||||||
|
output_items.append(message_item)
|
||||||
|
|
||||||
|
# Finalize tool tracking
|
||||||
|
await tracker.finalize()
|
||||||
|
|
||||||
|
# Calculate usage and build final response
|
||||||
|
usage = _calculate_usage(request.input, output_items)
|
||||||
|
|
||||||
|
final_response = Response(
|
||||||
|
id=f"resp_{generate_id()}",
|
||||||
|
created_at=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
status="completed",
|
||||||
|
output=output_items,
|
||||||
|
usage=usage
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track conversation history
|
||||||
|
await _conversation_history.add_response(conversation_id, final_response)
|
||||||
|
|
||||||
|
yield ResponseDone(response=final_response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Stream error event
|
||||||
|
yield self._create_error_event(e)
|
||||||
|
|
||||||
|
async def _stream_direct_delegation(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
recommendation: "StewardRecommendation", # type: ignore
|
||||||
|
tracker: "ToolCallTracker", # type: ignore
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Execute direct delegation with streaming think messages.
|
||||||
|
|
||||||
|
Collects think messages as delegations execute for streaming to client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: User's request
|
||||||
|
recommendation: Steward's recommendation
|
||||||
|
tracker: Tool call tracker
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Orchestration results with think_messages list
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import (
|
||||||
|
get_think_message,
|
||||||
|
delegate_to_librarian,
|
||||||
|
delegate_to_biographer,
|
||||||
|
delegate_to_housekeeper,
|
||||||
|
)
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
expert_results = {}
|
||||||
|
tools_called = []
|
||||||
|
think_messages = []
|
||||||
|
|
||||||
|
for agent in recommendation.recommended_capabilities:
|
||||||
|
# Emit start think message
|
||||||
|
start_msg = get_think_message(agent, user_message, "start")
|
||||||
|
think_messages.append(start_msg + "\n")
|
||||||
|
|
||||||
|
start_time = time_module.time()
|
||||||
|
try:
|
||||||
|
# Execute delegation
|
||||||
|
if agent == "librarian":
|
||||||
|
result = await delegate_to_librarian(task=user_message)
|
||||||
|
elif agent == "biographer":
|
||||||
|
result = await delegate_to_biographer(task=user_message)
|
||||||
|
elif agent == "housekeeper":
|
||||||
|
result = await delegate_to_housekeeper(task=user_message)
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
|
||||||
|
duration = time_module.time() - start_time
|
||||||
|
await tracker.track_call(f"delegate_to_{agent}", duration)
|
||||||
|
|
||||||
|
if result and result.success:
|
||||||
|
expert_results[agent] = result.output
|
||||||
|
tools_called.append(f"delegate_to_{agent}")
|
||||||
|
# Emit success think message
|
||||||
|
success_msg = get_think_message(agent, user_message, "success")
|
||||||
|
think_messages.append(success_msg + "\n")
|
||||||
|
else:
|
||||||
|
error_msg = result.error if result else "Unknown error"
|
||||||
|
expert_results[agent] = f"Error: {error_msg}"
|
||||||
|
# Emit error think message
|
||||||
|
error_think = get_think_message(agent, user_message, "error")
|
||||||
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
expert_results[agent] = f"Error: {e}"
|
||||||
|
error_think = get_think_message(agent, user_message, "error")
|
||||||
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": {},
|
||||||
|
"raw_output": "",
|
||||||
|
"think_messages": think_messages,
|
||||||
|
}
|
||||||
|
|
||||||
async def stream_response(
|
async def stream_response(
|
||||||
self,
|
self,
|
||||||
request: "ResponseRequest" # type: ignore # Forward reference
|
request: "ResponseRequest" # type: ignore # Forward reference
|
||||||
@@ -140,6 +385,7 @@ class StreamingCoordinator:
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
output_items = []
|
output_items = []
|
||||||
|
last_message_text = "" # Track last streamed message text to compute deltas
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Strip pipeline prefix if present (e.g., "pipeline.model" -> "model")
|
# Strip pipeline prefix if present (e.g., "pipeline.model" -> "model")
|
||||||
@@ -189,45 +435,51 @@ class StreamingCoordinator:
|
|||||||
yield FunctionCallDone()
|
yield FunctionCallDone()
|
||||||
|
|
||||||
elif item.type == "message":
|
elif item.type == "message":
|
||||||
# Stream output text with stop sequence and max tokens enforcement
|
# Get current accumulated text from agent
|
||||||
text = item.data["content"][0]["text"]
|
current_text = item.data["content"][0]["text"]
|
||||||
words = text.split()
|
|
||||||
|
|
||||||
# Track accumulated text and tokens for enforcement
|
# Only stream the NEW text (delta) since last update
|
||||||
accumulated_text = ""
|
if current_text.startswith(last_message_text):
|
||||||
output_tokens = 0
|
# Extract only the new portion
|
||||||
|
delta_text = current_text[len(last_message_text):]
|
||||||
|
|
||||||
for word in words:
|
if delta_text:
|
||||||
# Add word to accumulated text
|
# Stream the delta text in chunks while preserving formatting
|
||||||
word_with_space = f"{word} "
|
# (newlines, markdown, code blocks, etc.)
|
||||||
accumulated_text += word_with_space
|
chunk_size = 50 # characters per chunk
|
||||||
|
|
||||||
# Check stop sequences
|
for i in range(0, len(delta_text), chunk_size):
|
||||||
stop_found, text_before_stop = self._check_stop_sequence(
|
chunk = delta_text[i:i+chunk_size]
|
||||||
accumulated_text,
|
|
||||||
request.stop
|
|
||||||
)
|
|
||||||
|
|
||||||
if stop_found:
|
# Check stop sequences on full accumulated text
|
||||||
# Emit final text before stop sequence
|
stop_found, text_before_stop = self._check_stop_sequence(
|
||||||
remaining_text = text_before_stop[len(accumulated_text) - len(word_with_space):]
|
current_text,
|
||||||
if remaining_text:
|
request.stop
|
||||||
yield OutputTextDelta(delta=remaining_text)
|
)
|
||||||
yield OutputTextDone()
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check max tokens
|
if stop_found:
|
||||||
output_tokens = self._count_tokens_approx(accumulated_text)
|
# Only emit remaining delta before stop
|
||||||
if self._check_max_tokens(output_tokens, request.max_output_tokens):
|
remaining = text_before_stop[len(last_message_text):]
|
||||||
# Max tokens reached - stop streaming
|
if remaining:
|
||||||
yield OutputTextDone()
|
yield OutputTextDelta(delta=remaining)
|
||||||
break
|
yield OutputTextDone()
|
||||||
|
break
|
||||||
|
|
||||||
# Normal streaming
|
# Check max tokens on full text
|
||||||
yield OutputTextDelta(delta=word_with_space)
|
output_tokens = self._count_tokens_approx(current_text)
|
||||||
await asyncio.sleep(0.05) # Simulate typing
|
if self._check_max_tokens(output_tokens, request.max_output_tokens):
|
||||||
else:
|
yield OutputTextDone()
|
||||||
# Completed normally without stop/limit
|
break
|
||||||
|
|
||||||
|
# Normal streaming of delta chunk (preserves all formatting)
|
||||||
|
yield OutputTextDelta(delta=chunk)
|
||||||
|
await asyncio.sleep(0.02) # Shorter delay since chunks are larger
|
||||||
|
|
||||||
|
# Update tracking variable
|
||||||
|
last_message_text = current_text
|
||||||
|
|
||||||
|
# If this is the final message (status=completed), ensure we send done
|
||||||
|
if item.data.get("status") == "completed":
|
||||||
yield OutputTextDone()
|
yield OutputTextDone()
|
||||||
|
|
||||||
# Final response.done event with complete response
|
# Final response.done event with complete response
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for The Biographer agent."""
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Tests for Biographer capability registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.biographer.capability import (
|
||||||
|
BIOGRAPHER_CAPABILITY,
|
||||||
|
get_biographer_capability,
|
||||||
|
register_biographer,
|
||||||
|
unregister_biographer,
|
||||||
|
)
|
||||||
|
from src.core.household_registry import HouseholdCapability
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBiographerCapability:
|
||||||
|
"""Tests for the Biographer capability definition."""
|
||||||
|
|
||||||
|
def test_capability_is_household_capability(self):
|
||||||
|
"""Test capability is correct type."""
|
||||||
|
assert isinstance(BIOGRAPHER_CAPABILITY, HouseholdCapability)
|
||||||
|
|
||||||
|
def test_capability_name(self):
|
||||||
|
"""Test capability has correct name."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.name == "biographer"
|
||||||
|
|
||||||
|
def test_capability_role(self):
|
||||||
|
"""Test capability has correct role."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.role == "The Biographer"
|
||||||
|
|
||||||
|
def test_capability_category(self):
|
||||||
|
"""Test capability is in context category."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.category == "context"
|
||||||
|
|
||||||
|
def test_capability_domains(self):
|
||||||
|
"""Test capability covers expected domains."""
|
||||||
|
domains = BIOGRAPHER_CAPABILITY.domains
|
||||||
|
|
||||||
|
assert "remember" in domains
|
||||||
|
assert "recall" in domains
|
||||||
|
assert "forget" in domains
|
||||||
|
assert "memory" in domains
|
||||||
|
assert "preferences" in domains
|
||||||
|
assert "profile" in domains
|
||||||
|
|
||||||
|
def test_capability_does_not_require_network(self):
|
||||||
|
"""Test capability does not require network access."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.requires_network is False
|
||||||
|
|
||||||
|
def test_capability_low_cost(self):
|
||||||
|
"""Test capability has low cost (vector search, minimal LLM)."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.cost == "low"
|
||||||
|
|
||||||
|
def test_get_biographer_capability(self):
|
||||||
|
"""Test getter returns same capability."""
|
||||||
|
cap = get_biographer_capability()
|
||||||
|
|
||||||
|
assert cap is BIOGRAPHER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBiographerRegistration:
|
||||||
|
"""Tests for Biographer registration functions."""
|
||||||
|
|
||||||
|
def test_register_biographer(self):
|
||||||
|
"""Test registering biographer with registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_biographer_agent"
|
||||||
|
) as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
register_biographer()
|
||||||
|
|
||||||
|
mock_registry.register.assert_called_once()
|
||||||
|
call_kwargs = mock_registry.register.call_args[1]
|
||||||
|
|
||||||
|
assert call_kwargs["name"] == "biographer"
|
||||||
|
assert call_kwargs["capability"] is BIOGRAPHER_CAPABILITY
|
||||||
|
assert call_kwargs["agent"] is mock_agent
|
||||||
|
|
||||||
|
def test_register_biographer_already_registered(self):
|
||||||
|
"""Test registering when already registered does nothing."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
register_biographer()
|
||||||
|
|
||||||
|
# Should not call register since already registered
|
||||||
|
mock_registry.register.assert_not_called()
|
||||||
|
|
||||||
|
def test_unregister_biographer(self):
|
||||||
|
"""Test unregistering biographer from registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
unregister_biographer()
|
||||||
|
|
||||||
|
mock_registry.unregister.assert_called_once_with("biographer")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCapabilityDescription:
|
||||||
|
"""Tests for capability description."""
|
||||||
|
|
||||||
|
def test_description_mentions_recall(self):
|
||||||
|
"""Test description mentions recall capabilities."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "recall" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_record(self):
|
||||||
|
"""Test description mentions recording capability."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "record" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_forget(self):
|
||||||
|
"""Test description mentions forget capability."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "forget" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_profile(self):
|
||||||
|
"""Test description mentions profile updates."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "profile" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_preferences(self):
|
||||||
|
"""Test description mentions preferences."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "preferences" in desc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for The Housekeeper agent."""
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Tests for Housekeeper capability registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.housekeeper.capability import (
|
||||||
|
HOUSEKEEPER_CAPABILITY,
|
||||||
|
get_housekeeper_capability,
|
||||||
|
register_housekeeper,
|
||||||
|
unregister_housekeeper,
|
||||||
|
)
|
||||||
|
from src.core.household_registry import HouseholdCapability
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHousekeeperCapability:
|
||||||
|
"""Tests for the Housekeeper capability definition."""
|
||||||
|
|
||||||
|
def test_capability_is_household_capability(self):
|
||||||
|
"""Test capability is correct type."""
|
||||||
|
assert isinstance(HOUSEKEEPER_CAPABILITY, HouseholdCapability)
|
||||||
|
|
||||||
|
def test_capability_name(self):
|
||||||
|
"""Test capability has correct name."""
|
||||||
|
assert HOUSEKEEPER_CAPABILITY.name == "housekeeper"
|
||||||
|
|
||||||
|
def test_capability_role(self):
|
||||||
|
"""Test capability has correct role."""
|
||||||
|
assert HOUSEKEEPER_CAPABILITY.role == "The Housekeeper"
|
||||||
|
|
||||||
|
def test_capability_category(self):
|
||||||
|
"""Test capability is in automation category."""
|
||||||
|
assert HOUSEKEEPER_CAPABILITY.category == "automation"
|
||||||
|
|
||||||
|
def test_capability_domains(self):
|
||||||
|
"""Test capability covers expected domains."""
|
||||||
|
domains = HOUSEKEEPER_CAPABILITY.domains
|
||||||
|
|
||||||
|
assert "lights" in domains
|
||||||
|
assert "switches" in domains
|
||||||
|
assert "automation" in domains
|
||||||
|
assert "home" in domains
|
||||||
|
assert "scene" in domains
|
||||||
|
assert "turn on" in domains
|
||||||
|
assert "turn off" in domains
|
||||||
|
|
||||||
|
def test_capability_requires_network(self):
|
||||||
|
"""Test capability requires network access."""
|
||||||
|
assert HOUSEKEEPER_CAPABILITY.requires_network is True
|
||||||
|
|
||||||
|
def test_capability_cost_is_low(self):
|
||||||
|
"""Test capability is low cost (local API calls)."""
|
||||||
|
assert HOUSEKEEPER_CAPABILITY.cost == "low"
|
||||||
|
|
||||||
|
def test_get_housekeeper_capability(self):
|
||||||
|
"""Test getter returns same capability."""
|
||||||
|
cap = get_housekeeper_capability()
|
||||||
|
|
||||||
|
assert cap is HOUSEKEEPER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHousekeeperRegistration:
|
||||||
|
"""Tests for Housekeeper registration functions."""
|
||||||
|
|
||||||
|
def test_register_housekeeper(self):
|
||||||
|
"""Test registering housekeeper with registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.housekeeper.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.agents.housekeeper.capability.get_housekeeper_agent"
|
||||||
|
) as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
register_housekeeper()
|
||||||
|
|
||||||
|
mock_registry.register.assert_called_once()
|
||||||
|
call_kwargs = mock_registry.register.call_args[1]
|
||||||
|
|
||||||
|
assert call_kwargs["name"] == "housekeeper"
|
||||||
|
assert call_kwargs["capability"] is HOUSEKEEPER_CAPABILITY
|
||||||
|
assert call_kwargs["agent"] is mock_agent
|
||||||
|
|
||||||
|
def test_register_housekeeper_already_registered(self):
|
||||||
|
"""Test registering when already registered does nothing."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.housekeeper.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
register_housekeeper()
|
||||||
|
|
||||||
|
# Should not call register since already registered
|
||||||
|
mock_registry.register.assert_not_called()
|
||||||
|
|
||||||
|
def test_unregister_housekeeper(self):
|
||||||
|
"""Test unregistering housekeeper from registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.housekeeper.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
unregister_housekeeper()
|
||||||
|
|
||||||
|
mock_registry.unregister.assert_called_once_with("housekeeper")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCapabilityDescription:
|
||||||
|
"""Tests for capability description."""
|
||||||
|
|
||||||
|
def test_description_mentions_device_control(self):
|
||||||
|
"""Test description mentions device control capabilities."""
|
||||||
|
desc = HOUSEKEEPER_CAPABILITY.description.lower()
|
||||||
|
assert "turn on" in desc
|
||||||
|
# Description uses "ON/OFF" format
|
||||||
|
assert "off" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_scenes(self):
|
||||||
|
"""Test description mentions scene capability."""
|
||||||
|
assert "scene" in HOUSEKEEPER_CAPABILITY.description.lower()
|
||||||
|
|
||||||
|
def test_description_mentions_scripts(self):
|
||||||
|
"""Test description mentions script capability."""
|
||||||
|
assert "script" in HOUSEKEEPER_CAPABILITY.description.lower()
|
||||||
|
|
||||||
|
def test_description_mentions_automations(self):
|
||||||
|
"""Test description mentions automation management."""
|
||||||
|
assert "automation" in HOUSEKEEPER_CAPABILITY.description.lower()
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Core-API HTTP client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.agents.housekeeper.client import (
|
||||||
|
Area,
|
||||||
|
Automation,
|
||||||
|
ControlResult,
|
||||||
|
CoreAPIClient,
|
||||||
|
Device,
|
||||||
|
DeviceState,
|
||||||
|
HistoryEntry,
|
||||||
|
Scene,
|
||||||
|
Script,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_httpx_client():
|
||||||
|
"""Create a mock httpx client."""
|
||||||
|
return AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_mock(mock_httpx_client):
|
||||||
|
"""Create a CoreAPIClient with mocked httpx client."""
|
||||||
|
client = CoreAPIClient(
|
||||||
|
base_url="http://test:8090",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
client._client = mock_httpx_client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCoreAPIClientInit:
|
||||||
|
"""Tests for client initialization."""
|
||||||
|
|
||||||
|
def test_default_initialization(self):
|
||||||
|
"""Test client initializes with defaults from config."""
|
||||||
|
client = CoreAPIClient()
|
||||||
|
|
||||||
|
assert client.base_url is not None
|
||||||
|
assert client.timeout == 30
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
def test_custom_initialization(self):
|
||||||
|
"""Test client with custom parameters."""
|
||||||
|
client = CoreAPIClient(
|
||||||
|
base_url="http://custom:9000",
|
||||||
|
api_key="my-api-key",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.base_url == "http://custom:9000"
|
||||||
|
assert client.api_key == "my-api-key"
|
||||||
|
assert client.timeout == 60
|
||||||
|
|
||||||
|
def test_ensure_client_not_initialized(self):
|
||||||
|
"""Test _ensure_client raises when not in context."""
|
||||||
|
client = CoreAPIClient()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
client._ensure_client()
|
||||||
|
|
||||||
|
assert "not initialized" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestContextManager:
|
||||||
|
"""Tests for async context manager."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_manager_creates_client(self):
|
||||||
|
"""Test context manager creates httpx client."""
|
||||||
|
async with CoreAPIClient(
|
||||||
|
base_url="http://test:8090",
|
||||||
|
api_key="test-key",
|
||||||
|
) as client:
|
||||||
|
assert client._client is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_manager_closes_client(self):
|
||||||
|
"""Test context manager closes client on exit."""
|
||||||
|
client = CoreAPIClient(base_url="http://test:8090")
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
assert client._client is not None
|
||||||
|
|
||||||
|
# After exit, client should be None
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDeviceDiscovery:
|
||||||
|
"""Tests for device discovery methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_devices(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing devices."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"name": "Living Room Light",
|
||||||
|
"state": "on",
|
||||||
|
"domain": "light",
|
||||||
|
"area": "living_room",
|
||||||
|
"attributes": {"brightness": 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "switch.coffee_maker",
|
||||||
|
"name": "Coffee Maker",
|
||||||
|
"state": "off",
|
||||||
|
"domain": "switch",
|
||||||
|
"area": "kitchen",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
devices = await client_with_mock.list_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 2
|
||||||
|
assert isinstance(devices[0], Device)
|
||||||
|
assert devices[0].entity_id == "light.living_room"
|
||||||
|
assert devices[0].state == "on"
|
||||||
|
assert devices[0].domain == "light"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_areas(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing areas."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"area_id": "living_room",
|
||||||
|
"name": "Living Room",
|
||||||
|
"device_count": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area_id": "bedroom",
|
||||||
|
"name": "Bedroom",
|
||||||
|
"device_count": 3,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
areas = await client_with_mock.list_areas()
|
||||||
|
|
||||||
|
assert len(areas) == 2
|
||||||
|
assert isinstance(areas[0], Area)
|
||||||
|
assert areas[0].area_id == "living_room"
|
||||||
|
assert areas[0].name == "Living Room"
|
||||||
|
assert areas[0].device_count == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_devices_with_filter(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing devices with domain filter."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"entity_id": "light.bedroom",
|
||||||
|
"name": "Bedroom Light",
|
||||||
|
"state": "off",
|
||||||
|
"domain": "light",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
devices = await client_with_mock.list_devices(domain="light")
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
mock_httpx_client.get.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_device_state(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test getting device state."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"brightness": 200,
|
||||||
|
"color_temp": 370,
|
||||||
|
},
|
||||||
|
"last_changed": "2024-01-15T10:30:00Z",
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
state = await client_with_mock.get_device_state("light.living_room")
|
||||||
|
|
||||||
|
assert isinstance(state, DeviceState)
|
||||||
|
assert state.entity_id == "light.living_room"
|
||||||
|
assert state.state == "on"
|
||||||
|
assert state.attributes["brightness"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDeviceControl:
|
||||||
|
"""Tests for device control methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_on(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test turning on a device."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Turned on",
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.turn_on("light.living_room")
|
||||||
|
|
||||||
|
assert isinstance(result, ControlResult)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.entity_id == "light.living_room"
|
||||||
|
assert result.action == "turn_on"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_on_with_brightness(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test turning on with brightness."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.turn_on(
|
||||||
|
"light.bedroom",
|
||||||
|
brightness=128,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
# Check that brightness was in the payload
|
||||||
|
call_kwargs = mock_httpx_client.post.call_args[1]
|
||||||
|
assert call_kwargs["json"]["brightness"] == 128
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_off(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test turning off a device."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.turn_off("switch.coffee_maker")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "turn_off"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test toggling a device."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.toggle("light.hallway")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "toggle"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestScenes:
|
||||||
|
"""Tests for scene methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_scenes(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing scenes."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"entity_id": "scene.movie_night",
|
||||||
|
"name": "movie_night",
|
||||||
|
"friendly_name": "Movie Night",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "scene.good_morning",
|
||||||
|
"name": "good_morning",
|
||||||
|
"friendly_name": "Good Morning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
scenes = await client_with_mock.list_scenes()
|
||||||
|
|
||||||
|
assert len(scenes) == 2
|
||||||
|
assert isinstance(scenes[0], Scene)
|
||||||
|
assert scenes[0].entity_id == "scene.movie_night"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_scene(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test activating a scene."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.activate_scene("scene.movie_night")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "activate"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestScripts:
|
||||||
|
"""Tests for script methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_scripts(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing scripts."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"entity_id": "script.good_morning",
|
||||||
|
"name": "Good Morning Routine",
|
||||||
|
"description": "Morning automation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
scripts = await client_with_mock.list_scripts()
|
||||||
|
|
||||||
|
assert len(scripts) == 1
|
||||||
|
assert isinstance(scripts[0], Script)
|
||||||
|
assert scripts[0].name == "Good Morning Routine"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_script(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test running a script."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.run_script("script.good_morning")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "run"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAutomations:
|
||||||
|
"""Tests for automation methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_automations(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing automations."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"automations": [
|
||||||
|
{
|
||||||
|
"entity_id": "automation.morning_lights",
|
||||||
|
"name": "Morning Lights",
|
||||||
|
"state": "on",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "automation.vacation_mode",
|
||||||
|
"name": "Vacation Mode",
|
||||||
|
"state": "off",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
automations = await client_with_mock.list_automations()
|
||||||
|
|
||||||
|
assert len(automations) == 2
|
||||||
|
assert isinstance(automations[0], Automation)
|
||||||
|
assert automations[0].state == "on"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_automation_enable(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test enabling an automation."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.toggle_automation(
|
||||||
|
"automation.vacation_mode",
|
||||||
|
enable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "enable"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_automation_disable(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test disabling an automation."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.toggle_automation(
|
||||||
|
"automation.morning_lights",
|
||||||
|
enable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.action == "disable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHistory:
|
||||||
|
"""Tests for history methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test getting device history."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"state": "on",
|
||||||
|
"timestamp": "2024-01-15T08:00:00Z",
|
||||||
|
"attributes": {"brightness": 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "off",
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z",
|
||||||
|
"attributes": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
history = await client_with_mock.get_history("light.living_room")
|
||||||
|
|
||||||
|
assert len(history) == 2
|
||||||
|
assert isinstance(history[0], HistoryEntry)
|
||||||
|
assert history[0].state == "on"
|
||||||
|
assert history[1].state == "off"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHealthCheck:
|
||||||
|
"""Tests for health check."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check_healthy(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test health check returns true when healthy."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.health_check()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test health check returns false on error."""
|
||||||
|
mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused")
|
||||||
|
|
||||||
|
result = await client_with_mock.health_check()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestResponseModels:
|
||||||
|
"""Tests for response model validation."""
|
||||||
|
|
||||||
|
def test_device_model(self):
|
||||||
|
"""Test Device model."""
|
||||||
|
device = Device(
|
||||||
|
entity_id="light.test",
|
||||||
|
name="Test Light",
|
||||||
|
state="on",
|
||||||
|
domain="light",
|
||||||
|
area="bedroom",
|
||||||
|
attributes={"brightness": 255},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert device.entity_id == "light.test"
|
||||||
|
assert device.state == "on"
|
||||||
|
assert device.attributes["brightness"] == 255
|
||||||
|
|
||||||
|
def test_device_model_optional_fields(self):
|
||||||
|
"""Test Device with minimal fields."""
|
||||||
|
device = Device(
|
||||||
|
entity_id="switch.test",
|
||||||
|
name="Test Switch",
|
||||||
|
state="off",
|
||||||
|
domain="switch",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert device.area is None
|
||||||
|
assert device.attributes == {}
|
||||||
|
|
||||||
|
def test_area_model(self):
|
||||||
|
"""Test Area model."""
|
||||||
|
area = Area(
|
||||||
|
area_id="living_room",
|
||||||
|
name="Living Room",
|
||||||
|
device_count=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert area.area_id == "living_room"
|
||||||
|
assert area.name == "Living Room"
|
||||||
|
assert area.device_count == 5
|
||||||
|
|
||||||
|
def test_area_model_defaults(self):
|
||||||
|
"""Test Area with default device_count."""
|
||||||
|
area = Area(
|
||||||
|
area_id="bedroom",
|
||||||
|
name="Bedroom",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert area.device_count == 0
|
||||||
|
|
||||||
|
def test_control_result_model(self):
|
||||||
|
"""Test ControlResult model."""
|
||||||
|
result = ControlResult(
|
||||||
|
success=True,
|
||||||
|
entity_id="light.test",
|
||||||
|
action="turn_on",
|
||||||
|
message="Success",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "turn_on"
|
||||||
|
|
||||||
|
def test_history_entry_model(self):
|
||||||
|
"""Test HistoryEntry model."""
|
||||||
|
entry = HistoryEntry(
|
||||||
|
state="on",
|
||||||
|
timestamp="2024-01-15T10:00:00Z",
|
||||||
|
attributes={"brightness": 200},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.state == "on"
|
||||||
|
assert entry.attributes["brightness"] == 200
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for The Librarian agent."""
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Tests for Librarian capability registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.librarian.capability import (
|
||||||
|
LIBRARIAN_CAPABILITY,
|
||||||
|
get_librarian_capability,
|
||||||
|
register_librarian,
|
||||||
|
unregister_librarian,
|
||||||
|
)
|
||||||
|
from src.core.household_registry import HouseholdCapability
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLibrarianCapability:
|
||||||
|
"""Tests for the Librarian capability definition."""
|
||||||
|
|
||||||
|
def test_capability_is_household_capability(self):
|
||||||
|
"""Test capability is correct type."""
|
||||||
|
assert isinstance(LIBRARIAN_CAPABILITY, HouseholdCapability)
|
||||||
|
|
||||||
|
def test_capability_name(self):
|
||||||
|
"""Test capability has correct name."""
|
||||||
|
assert LIBRARIAN_CAPABILITY.name == "librarian"
|
||||||
|
|
||||||
|
def test_capability_role(self):
|
||||||
|
"""Test capability has correct role."""
|
||||||
|
assert LIBRARIAN_CAPABILITY.role == "The Librarian"
|
||||||
|
|
||||||
|
def test_capability_category(self):
|
||||||
|
"""Test capability is in research category."""
|
||||||
|
assert LIBRARIAN_CAPABILITY.category == "research"
|
||||||
|
|
||||||
|
def test_capability_domains(self):
|
||||||
|
"""Test capability covers expected domains."""
|
||||||
|
domains = LIBRARIAN_CAPABILITY.domains
|
||||||
|
|
||||||
|
assert "research" in domains
|
||||||
|
assert "knowledge" in domains
|
||||||
|
assert "wiki" in domains
|
||||||
|
assert "search" in domains
|
||||||
|
|
||||||
|
def test_capability_requires_network(self):
|
||||||
|
"""Test capability requires network access."""
|
||||||
|
assert LIBRARIAN_CAPABILITY.requires_network is True
|
||||||
|
|
||||||
|
def test_get_librarian_capability(self):
|
||||||
|
"""Test getter returns same capability."""
|
||||||
|
cap = get_librarian_capability()
|
||||||
|
|
||||||
|
assert cap is LIBRARIAN_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLibrarianRegistration:
|
||||||
|
"""Tests for Librarian registration functions."""
|
||||||
|
|
||||||
|
def test_register_librarian(self):
|
||||||
|
"""Test registering librarian with registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.capability.get_librarian_agent"
|
||||||
|
) as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
register_librarian()
|
||||||
|
|
||||||
|
mock_registry.register.assert_called_once()
|
||||||
|
call_kwargs = mock_registry.register.call_args[1]
|
||||||
|
|
||||||
|
assert call_kwargs["name"] == "librarian"
|
||||||
|
assert call_kwargs["capability"] is LIBRARIAN_CAPABILITY
|
||||||
|
assert call_kwargs["agent"] is mock_agent
|
||||||
|
|
||||||
|
def test_register_librarian_already_registered(self):
|
||||||
|
"""Test registering when already registered does nothing."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
register_librarian()
|
||||||
|
|
||||||
|
# Should not call register since already registered
|
||||||
|
mock_registry.register.assert_not_called()
|
||||||
|
|
||||||
|
def test_unregister_librarian(self):
|
||||||
|
"""Test unregistering librarian from registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
unregister_librarian()
|
||||||
|
|
||||||
|
mock_registry.unregister.assert_called_once_with("librarian")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCapabilityDescription:
|
||||||
|
"""Tests for capability description."""
|
||||||
|
|
||||||
|
def test_description_mentions_wiki_capabilities(self):
|
||||||
|
"""Test description mentions wiki read/write capabilities."""
|
||||||
|
desc = LIBRARIAN_CAPABILITY.description.lower()
|
||||||
|
assert "create" in desc
|
||||||
|
assert "update" in desc
|
||||||
|
assert "search" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_search(self):
|
||||||
|
"""Test description mentions search capability."""
|
||||||
|
assert "search" in LIBRARIAN_CAPABILITY.description.lower()
|
||||||
|
|
||||||
|
def test_description_mentions_wiki(self):
|
||||||
|
"""Test description mentions wiki access."""
|
||||||
|
assert "wiki" in LIBRARIAN_CAPABILITY.description.lower()
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Library-Desk HTTP client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.agents.librarian.client import (
|
||||||
|
LibraryDeskClient,
|
||||||
|
HybridRAGResponse,
|
||||||
|
HybridSearchResult,
|
||||||
|
WikiPage,
|
||||||
|
WikiSearchResult,
|
||||||
|
VectorSearchResult,
|
||||||
|
GraphNode,
|
||||||
|
Dossier,
|
||||||
|
SmartCreateResponse,
|
||||||
|
ResearchSummary,
|
||||||
|
EntityLinking,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_httpx_client():
|
||||||
|
"""Create a mock httpx client."""
|
||||||
|
return AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_mock(mock_httpx_client):
|
||||||
|
"""Create a LibraryDeskClient with mocked httpx client."""
|
||||||
|
client = LibraryDeskClient(
|
||||||
|
base_url="http://test:8089",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
client._client = mock_httpx_client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLibraryDeskClientInit:
|
||||||
|
"""Tests for client initialization."""
|
||||||
|
|
||||||
|
def test_default_initialization(self):
|
||||||
|
"""Test client initializes with defaults from config."""
|
||||||
|
client = LibraryDeskClient()
|
||||||
|
|
||||||
|
assert client.base_url is not None
|
||||||
|
assert client.timeout == 60
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
def test_custom_initialization(self):
|
||||||
|
"""Test client with custom parameters."""
|
||||||
|
client = LibraryDeskClient(
|
||||||
|
base_url="http://custom:9000",
|
||||||
|
api_key="my-api-key",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.base_url == "http://custom:9000"
|
||||||
|
assert client.api_key == "my-api-key"
|
||||||
|
assert client.timeout == 120
|
||||||
|
|
||||||
|
def test_ensure_client_not_initialized(self):
|
||||||
|
"""Test _ensure_client raises when not in context."""
|
||||||
|
client = LibraryDeskClient()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
client._ensure_client()
|
||||||
|
|
||||||
|
assert "not initialized" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestContextManager:
|
||||||
|
"""Tests for async context manager."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_manager_creates_client(self):
|
||||||
|
"""Test context manager creates httpx client."""
|
||||||
|
async with LibraryDeskClient(
|
||||||
|
base_url="http://test:8089",
|
||||||
|
api_key="test-key",
|
||||||
|
) as client:
|
||||||
|
assert client._client is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_context_manager_closes_client(self):
|
||||||
|
"""Test context manager closes client on exit."""
|
||||||
|
client = LibraryDeskClient(base_url="http://test:8089")
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
assert client._client is not None
|
||||||
|
|
||||||
|
# After exit, client should be None
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHybridSearch:
|
||||||
|
"""Tests for hybrid search."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hybrid_search_success(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test successful hybrid search."""
|
||||||
|
# Mock response
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"source": "vector",
|
||||||
|
"title": "Docker Guide",
|
||||||
|
"content": "Docker networking basics...",
|
||||||
|
"score": 0.95,
|
||||||
|
"page_id": 123,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keywords": ["docker", "networking"],
|
||||||
|
"synonyms": ["container"],
|
||||||
|
"formatted_context": "Context here",
|
||||||
|
"timing": {"total": 1.5},
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.hybrid_search(
|
||||||
|
query="Docker networking",
|
||||||
|
user="testuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, HybridRAGResponse)
|
||||||
|
assert len(result.results) == 1
|
||||||
|
assert result.results[0].title == "Docker Guide"
|
||||||
|
assert result.results[0].source == "vector"
|
||||||
|
assert "docker" in result.keywords
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hybrid_search_empty_results(
|
||||||
|
self, client_with_mock, mock_httpx_client
|
||||||
|
):
|
||||||
|
"""Test hybrid search with no results."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": [],
|
||||||
|
"keywords": [],
|
||||||
|
"formatted_context": "",
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.hybrid_search("nonexistent query")
|
||||||
|
|
||||||
|
assert len(result.results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestWikiOperations:
|
||||||
|
"""Tests for wiki operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wiki(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test wiki search."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"path": "/docs/docker",
|
||||||
|
"title": "Docker Documentation",
|
||||||
|
"description": "Docker docs",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
results = await client_with_mock.search_wiki("docker")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert isinstance(results[0], WikiSearchResult)
|
||||||
|
assert results[0].title == "Docker Documentation"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wiki_page(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test getting a wiki page."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 123,
|
||||||
|
"path": "/docs/docker",
|
||||||
|
"title": "Docker Guide",
|
||||||
|
"content": "# Docker\n\nFull content here...",
|
||||||
|
"tags": ["docker", "devops"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
page = await client_with_mock.get_wiki_page(123)
|
||||||
|
|
||||||
|
assert isinstance(page, WikiPage)
|
||||||
|
assert page.id == 123
|
||||||
|
assert page.title == "Docker Guide"
|
||||||
|
assert "docker" in page.tags
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_wiki_pages(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing wiki pages."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"pages": [
|
||||||
|
{"id": 1, "path": "/page1", "title": "Page 1"},
|
||||||
|
{"id": 2, "path": "/page2", "title": "Page 2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
pages = await client_with_mock.list_wiki_pages()
|
||||||
|
|
||||||
|
assert len(pages) == 2
|
||||||
|
assert pages[0].title == "Page 1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_dossiers(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing dossiers."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"dossiers": [
|
||||||
|
{"name": "docker", "page_count": 10},
|
||||||
|
{"name": "kubernetes", "page_count": 5},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
dossiers = await client_with_mock.list_dossiers()
|
||||||
|
|
||||||
|
assert len(dossiers) == 2
|
||||||
|
assert isinstance(dossiers[0], Dossier)
|
||||||
|
assert dossiers[0].name == "docker"
|
||||||
|
assert dossiers[0].page_count == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSemanticSearch:
|
||||||
|
"""Tests for semantic/vector search."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_semantic_search(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test semantic search."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"page_id": 1,
|
||||||
|
"page_path": "/docs/networking",
|
||||||
|
"page_title": "Networking Guide",
|
||||||
|
"chunk_text": "Container networking...",
|
||||||
|
"score": 0.92,
|
||||||
|
"chunk_index": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
results = await client_with_mock.semantic_search("container networking")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert isinstance(results[0], VectorSearchResult)
|
||||||
|
assert results[0].score == 0.92
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGraphOperations:
|
||||||
|
"""Tests for knowledge graph operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_graph(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test executing a Cypher query."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"records": [
|
||||||
|
{"name": "Docker", "type": "Technology"},
|
||||||
|
{"name": "Kubernetes", "type": "Technology"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
records = await client_with_mock.query_graph(
|
||||||
|
"MATCH (n:Technology) RETURN n.name as name, n.type as type"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(records) == 2
|
||||||
|
assert records[0]["name"] == "Docker"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_graph_nodes(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test listing graph nodes."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "node1",
|
||||||
|
"labels": ["Technology"],
|
||||||
|
"properties": {"name": "Docker"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
nodes = await client_with_mock.list_graph_nodes()
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert isinstance(nodes[0], GraphNode)
|
||||||
|
assert nodes[0].id == "node1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHealthCheck:
|
||||||
|
"""Tests for health check."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check_healthy(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test health check returns true when healthy."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.health_check()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test health check returns false on error."""
|
||||||
|
mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused")
|
||||||
|
|
||||||
|
result = await client_with_mock.health_check()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestResponseModels:
|
||||||
|
"""Tests for response model validation."""
|
||||||
|
|
||||||
|
def test_wiki_page_model(self):
|
||||||
|
"""Test WikiPage model."""
|
||||||
|
page = WikiPage(
|
||||||
|
id=1,
|
||||||
|
path="/test",
|
||||||
|
title="Test Page",
|
||||||
|
content="Content here",
|
||||||
|
tags=["tag1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert page.id == 1
|
||||||
|
assert page.title == "Test Page"
|
||||||
|
|
||||||
|
def test_wiki_page_optional_fields(self):
|
||||||
|
"""Test WikiPage with minimal fields."""
|
||||||
|
page = WikiPage(id=1, path="/test", title="Test")
|
||||||
|
|
||||||
|
assert page.content is None
|
||||||
|
assert page.tags == []
|
||||||
|
|
||||||
|
def test_hybrid_search_result_model(self):
|
||||||
|
"""Test HybridSearchResult model."""
|
||||||
|
result = HybridSearchResult(
|
||||||
|
source="vector",
|
||||||
|
title="Title",
|
||||||
|
content="Content",
|
||||||
|
score=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.source == "vector"
|
||||||
|
assert result.url is None
|
||||||
|
assert result.metadata == {}
|
||||||
|
|
||||||
|
def test_vector_search_result_model(self):
|
||||||
|
"""Test VectorSearchResult model."""
|
||||||
|
result = VectorSearchResult(
|
||||||
|
page_id=1,
|
||||||
|
page_path="/doc",
|
||||||
|
page_title="Doc",
|
||||||
|
chunk_text="Text chunk",
|
||||||
|
score=0.85,
|
||||||
|
chunk_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.score == 0.85
|
||||||
|
assert result.chunk_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUpdateWikiPage:
|
||||||
|
"""Tests for update_wiki_page method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_wiki_page_content(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test updating wiki page content."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 42,
|
||||||
|
"path": "/docs/test",
|
||||||
|
"title": "Test Page",
|
||||||
|
"content": "# Updated\n\nNew content",
|
||||||
|
"tags": ["test"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.put.return_value = mock_response
|
||||||
|
|
||||||
|
page = await client_with_mock.update_wiki_page(
|
||||||
|
page_id=42,
|
||||||
|
content="# Updated\n\nNew content",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(page, WikiPage)
|
||||||
|
assert page.id == 42
|
||||||
|
assert "Updated" in page.content
|
||||||
|
mock_httpx_client.put.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_wiki_page_tags_only(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test updating only tags (partial update)."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 42,
|
||||||
|
"path": "/docs/test",
|
||||||
|
"title": "Test Page",
|
||||||
|
"tags": ["projects", "devops"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.put.return_value = mock_response
|
||||||
|
|
||||||
|
page = await client_with_mock.update_wiki_page(
|
||||||
|
page_id=42,
|
||||||
|
tags=["projects", "devops"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert page.tags == ["projects", "devops"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_wiki_page_multiple_fields(
|
||||||
|
self, client_with_mock, mock_httpx_client
|
||||||
|
):
|
||||||
|
"""Test updating multiple fields at once."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 42,
|
||||||
|
"path": "/docs/test",
|
||||||
|
"title": "New Title",
|
||||||
|
"description": "New description",
|
||||||
|
"tags": ["updated"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.put.return_value = mock_response
|
||||||
|
|
||||||
|
page = await client_with_mock.update_wiki_page(
|
||||||
|
page_id=42,
|
||||||
|
title="New Title",
|
||||||
|
description="New description",
|
||||||
|
tags=["updated"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert page.title == "New Title"
|
||||||
|
assert page.description == "New description"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSmartCreateWikiPage:
|
||||||
|
"""Tests for smart_create_wiki_page method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_smart_create_basic(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test basic smart create."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"page": {
|
||||||
|
"id": 123,
|
||||||
|
"path": "/users/test/technology/docker-compose",
|
||||||
|
"title": "Docker Compose",
|
||||||
|
"content": "# Docker Compose\n\nContent...",
|
||||||
|
"tags": ["technology", "devops"],
|
||||||
|
},
|
||||||
|
"research_summary": {
|
||||||
|
"wiki_results": 3,
|
||||||
|
"web_results": 8,
|
||||||
|
"graph_entities": 5,
|
||||||
|
"keywords_extracted": 12,
|
||||||
|
"timing_ms": 4500,
|
||||||
|
},
|
||||||
|
"sources_used": 11,
|
||||||
|
"search_id": "uuid-123",
|
||||||
|
"entity_linking": {
|
||||||
|
"forward_links": 5,
|
||||||
|
"backward_links": 3,
|
||||||
|
"pages_updated": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.smart_create_wiki_page(
|
||||||
|
topic="Docker Compose",
|
||||||
|
tags=["technology", "devops"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, SmartCreateResponse)
|
||||||
|
assert result.page.id == 123
|
||||||
|
assert result.page.title == "Docker Compose"
|
||||||
|
assert result.sources_used == 11
|
||||||
|
assert result.research_summary.wiki_results == 3
|
||||||
|
assert result.research_summary.web_results == 8
|
||||||
|
assert result.entity_linking.forward_links == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_smart_create_with_options(self, client_with_mock, mock_httpx_client):
|
||||||
|
"""Test smart create with custom options."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"page": {
|
||||||
|
"id": 456,
|
||||||
|
"path": "/custom/path",
|
||||||
|
"title": "Custom Topic",
|
||||||
|
"tags": ["custom"],
|
||||||
|
},
|
||||||
|
"research_summary": {
|
||||||
|
"wiki_results": 5,
|
||||||
|
"web_results": 0, # Web disabled
|
||||||
|
"timing_ms": 2000,
|
||||||
|
},
|
||||||
|
"sources_used": 5,
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
result = await client_with_mock.smart_create_wiki_page(
|
||||||
|
topic="Custom Topic",
|
||||||
|
tags=["custom"],
|
||||||
|
path="/custom/path",
|
||||||
|
include_web_research=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.page.path == "/custom/path"
|
||||||
|
assert result.research_summary.web_results == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNewResponseModels:
|
||||||
|
"""Tests for new response models."""
|
||||||
|
|
||||||
|
def test_research_summary_model(self):
|
||||||
|
"""Test ResearchSummary model."""
|
||||||
|
summary = ResearchSummary(
|
||||||
|
wiki_results=3,
|
||||||
|
web_results=5,
|
||||||
|
graph_entities=2,
|
||||||
|
keywords_extracted=10,
|
||||||
|
timing_ms=3000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary.wiki_results == 3
|
||||||
|
assert summary.timing_ms == 3000
|
||||||
|
|
||||||
|
def test_research_summary_defaults(self):
|
||||||
|
"""Test ResearchSummary default values."""
|
||||||
|
summary = ResearchSummary()
|
||||||
|
|
||||||
|
assert summary.wiki_results == 0
|
||||||
|
assert summary.timing_ms == 0
|
||||||
|
|
||||||
|
def test_entity_linking_model(self):
|
||||||
|
"""Test EntityLinking model."""
|
||||||
|
linking = EntityLinking(
|
||||||
|
forward_links=5,
|
||||||
|
backward_links=3,
|
||||||
|
pages_updated=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert linking.forward_links == 5
|
||||||
|
assert linking.pages_updated == 2
|
||||||
|
|
||||||
|
def test_smart_create_response_model(self):
|
||||||
|
"""Test SmartCreateResponse model."""
|
||||||
|
page = WikiPage(id=1, path="/test", title="Test")
|
||||||
|
response = SmartCreateResponse(
|
||||||
|
page=page,
|
||||||
|
sources_used=10,
|
||||||
|
search_id="uuid-456",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.page.id == 1
|
||||||
|
assert response.sources_used == 10
|
||||||
|
assert response.search_id == "uuid-456"
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
Tests for Librarian tools.
|
||||||
|
|
||||||
|
Tests the tool functions that wrap the Library-Desk API,
|
||||||
|
including the new web search and content extraction tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.librarian.tools import (
|
||||||
|
search_web,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
hybrid_search,
|
||||||
|
search_wiki,
|
||||||
|
)
|
||||||
|
from src.agents.librarian.client import (
|
||||||
|
WebSearchResult,
|
||||||
|
WebSearchResponse,
|
||||||
|
ContentExtractionResult,
|
||||||
|
BatchExtractionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client():
|
||||||
|
"""Create a mock LibraryDeskClient."""
|
||||||
|
client = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Web Search Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSearchWeb:
|
||||||
|
"""Tests for search_web tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_success(self, mock_client):
|
||||||
|
"""Test successful web search."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="Python async programming",
|
||||||
|
search_type="web",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(
|
||||||
|
title="Async Python Tutorial",
|
||||||
|
url="https://example.com/async",
|
||||||
|
content="Full content about async programming...",
|
||||||
|
snippet="Learn async programming in Python",
|
||||||
|
source="example.com",
|
||||||
|
),
|
||||||
|
WebSearchResult(
|
||||||
|
title="AsyncIO Documentation",
|
||||||
|
url="https://docs.python.org/asyncio",
|
||||||
|
content="Official asyncio docs content...",
|
||||||
|
snippet="Python asyncio library reference",
|
||||||
|
source="docs.python.org",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_results=2,
|
||||||
|
search_time_ms=150,
|
||||||
|
sources_summary="**Sources:**\n- example.com\n- docs.python.org",
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("Python async programming")
|
||||||
|
|
||||||
|
assert "Python async programming" in result
|
||||||
|
assert "Async Python Tutorial" in result
|
||||||
|
assert "https://example.com/async" in result
|
||||||
|
assert "example.com" in result
|
||||||
|
assert "150ms" in result or "2 results" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_no_results(self, mock_client):
|
||||||
|
"""Test web search with no results."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="nonexistent query xyz123",
|
||||||
|
search_type="web",
|
||||||
|
results=[],
|
||||||
|
total_results=0,
|
||||||
|
search_time_ms=50,
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("nonexistent query xyz123")
|
||||||
|
|
||||||
|
assert "No results found" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_error_handling(self, mock_client):
|
||||||
|
"""Test web search error handling."""
|
||||||
|
mock_client.search_web.side_effect = Exception("Connection failed")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("test query")
|
||||||
|
|
||||||
|
assert "Error" in result
|
||||||
|
assert "Connection failed" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_with_news_type(self, mock_client):
|
||||||
|
"""Test web search with news search type."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="latest tech news",
|
||||||
|
search_type="news",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(
|
||||||
|
title="Tech News Today",
|
||||||
|
url="https://news.example.com/tech",
|
||||||
|
snippet="Breaking tech news",
|
||||||
|
source="news.example.com",
|
||||||
|
published_date="2024-01-15",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_results=1,
|
||||||
|
search_time_ms=100,
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("latest tech news", search_type="news")
|
||||||
|
|
||||||
|
assert "Tech News Today" in result
|
||||||
|
mock_client.search_web.assert_called_with(
|
||||||
|
query="latest tech news",
|
||||||
|
limit=10,
|
||||||
|
search_type="news",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Read URL Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReadUrl:
|
||||||
|
"""Tests for read_url tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_success(self, mock_client):
|
||||||
|
"""Test successful URL content extraction."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/article",
|
||||||
|
title="Great Article Title",
|
||||||
|
content="This is the full article content extracted from the page.",
|
||||||
|
author="John Doe",
|
||||||
|
date="2024-01-10",
|
||||||
|
language="en",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/article")
|
||||||
|
|
||||||
|
assert "Great Article Title" in result
|
||||||
|
assert "https://example.com/article" in result
|
||||||
|
assert "John Doe" in result
|
||||||
|
assert "full article content" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_failure(self, mock_client):
|
||||||
|
"""Test URL extraction failure."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/blocked",
|
||||||
|
success=False,
|
||||||
|
error="403 Forbidden",
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/blocked")
|
||||||
|
|
||||||
|
assert "Could not read page" in result
|
||||||
|
assert "403 Forbidden" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_with_max_length(self, mock_client):
|
||||||
|
"""Test URL extraction with custom max length."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/long",
|
||||||
|
title="Long Article",
|
||||||
|
content="X" * 10000,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/long", max_length=2000)
|
||||||
|
|
||||||
|
mock_client.extract_content.assert_called_with(
|
||||||
|
url="https://example.com/long",
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Batch URL Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReadUrlsBatch:
|
||||||
|
"""Tests for read_urls_batch tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_success(self, mock_client):
|
||||||
|
"""Test successful batch extraction."""
|
||||||
|
mock_response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/1",
|
||||||
|
title="Article 1",
|
||||||
|
content="Content from article 1",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/2",
|
||||||
|
title="Article 2",
|
||||||
|
content="Content from article 2",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=2,
|
||||||
|
failed=0,
|
||||||
|
extraction_time_ms=300,
|
||||||
|
)
|
||||||
|
mock_client.extract_content_batch.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_urls_batch([
|
||||||
|
"https://example.com/1",
|
||||||
|
"https://example.com/2",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert "Article 1" in result
|
||||||
|
assert "Article 2" in result
|
||||||
|
assert "2/2" in result or "Extracted 2" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_partial_failure(self, mock_client):
|
||||||
|
"""Test batch extraction with some failures."""
|
||||||
|
mock_response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/good",
|
||||||
|
title="Good Article",
|
||||||
|
content="Content extracted successfully",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/bad",
|
||||||
|
success=False,
|
||||||
|
error="Connection timeout",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=1,
|
||||||
|
failed=1,
|
||||||
|
extraction_time_ms=500,
|
||||||
|
)
|
||||||
|
mock_client.extract_content_batch.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_urls_batch([
|
||||||
|
"https://example.com/good",
|
||||||
|
"https://example.com/bad",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Should contain successful result
|
||||||
|
assert "Good Article" in result
|
||||||
|
# Should report failure
|
||||||
|
assert "Failed" in result
|
||||||
|
assert "Connection timeout" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Response Model Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestWebSearchModels:
|
||||||
|
"""Tests for web search response models."""
|
||||||
|
|
||||||
|
def test_web_search_result_model(self):
|
||||||
|
"""Test WebSearchResult model."""
|
||||||
|
result = WebSearchResult(
|
||||||
|
title="Test Title",
|
||||||
|
url="https://example.com",
|
||||||
|
content="Full content here",
|
||||||
|
snippet="Short snippet",
|
||||||
|
source="example.com",
|
||||||
|
published_date="2024-01-15",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.title == "Test Title"
|
||||||
|
assert result.url == "https://example.com"
|
||||||
|
assert result.content == "Full content here"
|
||||||
|
assert result.source == "example.com"
|
||||||
|
|
||||||
|
def test_web_search_result_defaults(self):
|
||||||
|
"""Test WebSearchResult default values."""
|
||||||
|
result = WebSearchResult(
|
||||||
|
title="Title",
|
||||||
|
url="https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.content == ""
|
||||||
|
assert result.snippet == ""
|
||||||
|
assert result.source == ""
|
||||||
|
assert result.published_date is None
|
||||||
|
|
||||||
|
def test_web_search_response_model(self):
|
||||||
|
"""Test WebSearchResponse model."""
|
||||||
|
response = WebSearchResponse(
|
||||||
|
query="test query",
|
||||||
|
search_type="web",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(title="R1", url="https://example.com/1"),
|
||||||
|
WebSearchResult(title="R2", url="https://example.com/2"),
|
||||||
|
],
|
||||||
|
total_results=2,
|
||||||
|
search_time_ms=100,
|
||||||
|
sources_summary="**Sources:** example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.query == "test query"
|
||||||
|
assert len(response.results) == 2
|
||||||
|
assert response.total_results == 2
|
||||||
|
|
||||||
|
def test_content_extraction_result_model(self):
|
||||||
|
"""Test ContentExtractionResult model."""
|
||||||
|
result = ContentExtractionResult(
|
||||||
|
url="https://example.com",
|
||||||
|
title="Title",
|
||||||
|
content="Content",
|
||||||
|
author="Author",
|
||||||
|
date="2024-01-01",
|
||||||
|
language="en",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.url == "https://example.com"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.author == "Author"
|
||||||
|
|
||||||
|
def test_content_extraction_failure(self):
|
||||||
|
"""Test ContentExtractionResult for failed extraction."""
|
||||||
|
result = ContentExtractionResult(
|
||||||
|
url="https://example.com",
|
||||||
|
success=False,
|
||||||
|
error="404 Not Found",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "404 Not Found"
|
||||||
|
assert result.content == ""
|
||||||
|
|
||||||
|
def test_batch_extraction_response_model(self):
|
||||||
|
"""Test BatchExtractionResponse model."""
|
||||||
|
response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(url="https://1.com", success=True),
|
||||||
|
ContentExtractionResult(url="https://2.com", success=False),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=1,
|
||||||
|
failed=1,
|
||||||
|
extraction_time_ms=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.total_urls == 2
|
||||||
|
assert response.successful == 1
|
||||||
|
assert response.failed == 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Steward agent."""
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Tests for Steward schemas.
|
||||||
|
|
||||||
|
Tests the structured output models for conversation context and recommendations.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationContext:
|
||||||
|
"""Test ConversationContext model."""
|
||||||
|
|
||||||
|
def test_context_creation_with_defaults(self):
|
||||||
|
"""Test creating context with default values."""
|
||||||
|
context = ConversationContext(has_previous_context=False)
|
||||||
|
|
||||||
|
assert context.has_previous_context is False
|
||||||
|
assert context.relevant_turns == []
|
||||||
|
assert context.context_summary == ""
|
||||||
|
|
||||||
|
def test_context_creation_with_values(self):
|
||||||
|
"""Test creating context with explicit values."""
|
||||||
|
context = ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[0, 2, 4],
|
||||||
|
context_summary="User discussed weather in turns 0 and 2"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.has_previous_context is True
|
||||||
|
assert context.relevant_turns == [0, 2, 4]
|
||||||
|
assert "weather" in context.context_summary
|
||||||
|
|
||||||
|
|
||||||
|
class TestStewardRecommendation:
|
||||||
|
"""Test StewardRecommendation model."""
|
||||||
|
|
||||||
|
def test_recommendation_simple(self):
|
||||||
|
"""Test simple recommendation with no capabilities needed."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Simple greeting requires no tools",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rec.recommended_capabilities == []
|
||||||
|
assert rec.estimated_complexity == "simple"
|
||||||
|
assert rec.missing_capabilities is None
|
||||||
|
|
||||||
|
def test_recommendation_with_capabilities(self):
|
||||||
|
"""Test recommendation with specific capabilities."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Mathematical calculation requires calculator",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "tatlock_core" in rec.recommended_capabilities
|
||||||
|
assert rec.estimated_complexity == "simple"
|
||||||
|
|
||||||
|
def test_recommendation_with_missing_capabilities(self):
|
||||||
|
"""Test recommendation noting missing capabilities."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Image generation is not available",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
missing_capabilities="Image generation capability would be needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rec.missing_capabilities is not None
|
||||||
|
assert "Image generation" in rec.missing_capabilities
|
||||||
|
|
||||||
|
def test_recommendation_complexity_levels(self):
|
||||||
|
"""Test all complexity levels."""
|
||||||
|
for complexity in ["simple", "moderate", "complex"]:
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning=f"Testing {complexity} complexity",
|
||||||
|
estimated_complexity=complexity,
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
assert rec.estimated_complexity == complexity
|
||||||
|
|
||||||
|
def test_recommendation_with_context(self):
|
||||||
|
"""Test recommendation with conversation context."""
|
||||||
|
context = ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[1, 3],
|
||||||
|
context_summary="User asked about calculation in turn 1, now wants explanation"
|
||||||
|
)
|
||||||
|
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="User wants explanation of previous calculation",
|
||||||
|
estimated_complexity="moderate",
|
||||||
|
conversation_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rec.conversation_context.has_previous_context is True
|
||||||
|
assert len(rec.conversation_context.relevant_turns) == 2
|
||||||
|
|
||||||
|
def test_format_for_butler_simple(self):
|
||||||
|
"""Test formatting recommendation for Butler - simple case."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Math calculation needed",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = rec.format_for_butler()
|
||||||
|
|
||||||
|
assert "📋 Steward's Analysis" in formatted
|
||||||
|
assert "SIMPLE" in formatted
|
||||||
|
assert "tatlock_core" in formatted
|
||||||
|
|
||||||
|
def test_format_for_butler_with_context(self):
|
||||||
|
"""Test formatting with conversation context."""
|
||||||
|
context = ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[0],
|
||||||
|
context_summary="Previous calculation mentioned"
|
||||||
|
)
|
||||||
|
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Follow-up calculation",
|
||||||
|
estimated_complexity="moderate",
|
||||||
|
conversation_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = rec.format_for_butler()
|
||||||
|
|
||||||
|
assert "Context:" in formatted
|
||||||
|
assert "Previous calculation" in formatted
|
||||||
|
|
||||||
|
def test_format_for_butler_with_missing_capabilities(self):
|
||||||
|
"""Test formatting with missing capabilities warning."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="No suitable tools available",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
missing_capabilities="Image generation would be needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = rec.format_for_butler()
|
||||||
|
|
||||||
|
assert "⚠️ Missing:" in formatted
|
||||||
|
assert "Image generation" in formatted
|
||||||
|
|
||||||
|
def test_format_for_butler_no_capabilities(self):
|
||||||
|
"""Test formatting when no tools needed (conversational)."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Simple greeting",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = rec.format_for_butler()
|
||||||
|
|
||||||
|
assert "None (conversational response)" in formatted
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Tests for Steward service layer.
|
||||||
|
|
||||||
|
Tests request analysis, logging, and benchmarking integration.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def setup_household_registry():
|
||||||
|
"""Initialize household registry before running tests."""
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeRequest:
|
||||||
|
"""Test the analyze_request service function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_simple_greeting(self):
|
||||||
|
"""Test analyzing a simple greeting."""
|
||||||
|
# Mock the Steward agent's analyze method (plain text approach)
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(return_value="Simple greeting requires no tools. This is a simple request.")
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||||
|
mock_store.return_value.record = AsyncMock()
|
||||||
|
|
||||||
|
result = await analyze_request(
|
||||||
|
"Hello!",
|
||||||
|
conversation_history=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.recommended_capabilities == []
|
||||||
|
assert result.estimated_complexity == "simple"
|
||||||
|
assert mock_agent.analyze.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_math_request(self):
|
||||||
|
"""Test analyzing a mathematical request."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(
|
||||||
|
return_value="Mathematical calculation requires tatlock_core for solving this simple problem."
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||||
|
mock_store.return_value.record = AsyncMock()
|
||||||
|
|
||||||
|
result = await analyze_request(
|
||||||
|
"What's sqrt(144)?",
|
||||||
|
conversation_history=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "tatlock_core" in result.recommended_capabilities
|
||||||
|
assert result.estimated_complexity == "simple"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_with_conversation_history(self):
|
||||||
|
"""Test analyzing with previous conversation context."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(
|
||||||
|
return_value="Follow-up to previous calculation in turn 0. Requires tatlock_core. Complexity: moderate."
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation_history = [
|
||||||
|
{"role": "user", "content": "What's 2 + 2?"},
|
||||||
|
{"role": "assistant", "content": "4"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||||
|
mock_store.return_value.record = AsyncMock()
|
||||||
|
|
||||||
|
result = await analyze_request(
|
||||||
|
"And what's that times 5?",
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.conversation_context.has_previous_context is True
|
||||||
|
assert 0 in result.conversation_context.relevant_turns
|
||||||
|
|
||||||
|
# Verify conversation history was passed
|
||||||
|
call_kwargs = mock_agent.analyze.call_args.kwargs
|
||||||
|
assert "conversation_history" in call_kwargs
|
||||||
|
assert len(call_kwargs["conversation_history"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_with_missing_capabilities(self):
|
||||||
|
"""Test analyzing request that needs unavailable capabilities."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(
|
||||||
|
return_value="Image generation not available. Would be needed for this request. Complexity: simple."
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||||
|
mock_store.return_value.record = AsyncMock()
|
||||||
|
|
||||||
|
result = await analyze_request(
|
||||||
|
"Generate an image of a sunset",
|
||||||
|
conversation_history=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.missing_capabilities is not None
|
||||||
|
assert "not available" in result.missing_capabilities
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_with_conversation_id(self):
|
||||||
|
"""Test that analysis includes conversation ID in context."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(
|
||||||
|
return_value="This simple request requires tatlock_core to solve."
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||||
|
mock_store.return_value.record = AsyncMock()
|
||||||
|
|
||||||
|
result = await analyze_request(
|
||||||
|
"Test request",
|
||||||
|
conversation_history=[],
|
||||||
|
conversation_id="test_conv_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify analysis completed successfully
|
||||||
|
assert result.recommended_capabilities == ["tatlock_core"]
|
||||||
|
assert result.estimated_complexity == "simple"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_handles_errors(self):
|
||||||
|
"""Test error handling in analyze_request."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.analyze = AsyncMock(side_effect=Exception("Test error"))
|
||||||
|
|
||||||
|
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||||
|
with pytest.raises(Exception, match="Test error"):
|
||||||
|
await analyze_request("Test", conversation_history=[])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatStewardNote:
|
||||||
|
"""Test the format_steward_note function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_simple_note(self):
|
||||||
|
"""Test formatting a simple recommendation."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Math needed",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
note = await format_steward_note(rec)
|
||||||
|
|
||||||
|
assert "📋 Steward's Analysis" in note
|
||||||
|
assert "SIMPLE" in note
|
||||||
|
assert "tatlock_core" in note
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_note_with_context(self):
|
||||||
|
"""Test formatting note with conversation context."""
|
||||||
|
context = ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[0, 1],
|
||||||
|
context_summary="Previous discussion about calculations"
|
||||||
|
)
|
||||||
|
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Follow-up calculation",
|
||||||
|
estimated_complexity="moderate",
|
||||||
|
conversation_context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
note = await format_steward_note(rec)
|
||||||
|
|
||||||
|
assert "Context:" in note
|
||||||
|
assert "Previous discussion" in note
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_note_with_missing_capabilities(self):
|
||||||
|
"""Test formatting note with missing capabilities warning."""
|
||||||
|
rec = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Not available",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
missing_capabilities="Advanced research tools needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
note = await format_steward_note(rec)
|
||||||
|
|
||||||
|
assert "⚠️ Missing:" in note
|
||||||
|
assert "Advanced research" in note
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBuildEnrichedQuery:
|
||||||
|
"""Tests for _build_enriched_query function."""
|
||||||
|
|
||||||
|
def test_no_enrichment_without_context(self):
|
||||||
|
"""Test no enrichment when memory context is empty."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
result = _build_enriched_query(query, {})
|
||||||
|
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_location(self):
|
||||||
|
"""Test location is appended for weather queries."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "location=Amsterdam" in result
|
||||||
|
assert query in result
|
||||||
|
assert "[User Context:" in result
|
||||||
|
|
||||||
|
def test_no_location_when_specified(self):
|
||||||
|
"""Test location is not appended when already specified."""
|
||||||
|
query = "What's the weather in London?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
# Should not add Amsterdam since location is specified
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_timezone(self):
|
||||||
|
"""Test timezone is appended for time queries."""
|
||||||
|
query = "What time is it?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "timezone=Europe/Amsterdam" in result
|
||||||
|
|
||||||
|
def test_no_timezone_when_specified(self):
|
||||||
|
"""Test timezone is not appended when already specified."""
|
||||||
|
query = "What time is it in UTC?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_temperature_unit(self):
|
||||||
|
"""Test temperature unit is appended for weather queries."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam"},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "temperature_unit=celsius" in result
|
||||||
|
|
||||||
|
def test_multiple_context_fields(self):
|
||||||
|
"""Test multiple context fields are appended."""
|
||||||
|
query = "What time and weather today?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {
|
||||||
|
"location": "Amsterdam",
|
||||||
|
"timezone": "Europe/Amsterdam"
|
||||||
|
},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "location=Amsterdam" in result
|
||||||
|
assert "timezone=Europe/Amsterdam" in result
|
||||||
|
assert "temperature_unit=celsius" in result
|
||||||
|
|
||||||
|
def test_no_enrichment_for_unrelated_query(self):
|
||||||
|
"""Test no enrichment for queries that don't need context."""
|
||||||
|
query = "Tell me a joke"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert result == query
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
Tests for multi-agent coordination engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.coordination import (
|
||||||
|
CoordinationEngine,
|
||||||
|
get_coordination_engine,
|
||||||
|
delegate_to_librarian,
|
||||||
|
)
|
||||||
|
from src.agents.protocol import (
|
||||||
|
AgentResponse,
|
||||||
|
AgentUnavailableError,
|
||||||
|
DelegationIntent,
|
||||||
|
DelegationReason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def coordination_engine():
|
||||||
|
"""Create a fresh coordination engine for testing."""
|
||||||
|
return CoordinationEngine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_registry():
|
||||||
|
"""Mock the household registry."""
|
||||||
|
with patch("src.agents.coordination.get_household_registry") as mock:
|
||||||
|
registry = MagicMock()
|
||||||
|
mock.return_value = registry
|
||||||
|
yield registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def librarian_intent():
|
||||||
|
"""Create a standard librarian delegation intent."""
|
||||||
|
return DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Find information about Docker networking",
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Documentation and examples",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCoordinationEngine:
|
||||||
|
"""Tests for CoordinationEngine class."""
|
||||||
|
|
||||||
|
def test_initialization(self, coordination_engine):
|
||||||
|
"""Test engine initializes correctly."""
|
||||||
|
assert coordination_engine is not None
|
||||||
|
assert coordination_engine.registry is not None
|
||||||
|
|
||||||
|
def test_get_available_agents_empty(self, mock_registry):
|
||||||
|
"""Test getting available agents when none have agents."""
|
||||||
|
mock_registry.list_members.return_value = ["tatlock_core"]
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = None # No agent
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
available = engine.get_available_agents()
|
||||||
|
|
||||||
|
assert available == []
|
||||||
|
|
||||||
|
def test_get_available_agents_with_librarian(self, mock_registry):
|
||||||
|
"""Test getting available agents with librarian registered."""
|
||||||
|
mock_registry.list_members.return_value = ["tatlock_core", "librarian"]
|
||||||
|
|
||||||
|
# tatlock_core has no agent
|
||||||
|
core_member = MagicMock()
|
||||||
|
core_member.agent = None
|
||||||
|
|
||||||
|
# librarian has an agent
|
||||||
|
librarian_member = MagicMock()
|
||||||
|
librarian_member.agent = MagicMock()
|
||||||
|
|
||||||
|
def get_member_side_effect(name):
|
||||||
|
if name == "tatlock_core":
|
||||||
|
return core_member
|
||||||
|
elif name == "librarian":
|
||||||
|
return librarian_member
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_registry.get_member.side_effect = get_member_side_effect
|
||||||
|
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
available = engine.get_available_agents()
|
||||||
|
|
||||||
|
assert "librarian" in available
|
||||||
|
assert "tatlock_core" not in available
|
||||||
|
|
||||||
|
def test_can_delegate_to_unknown_agent(self, mock_registry):
|
||||||
|
"""Test checking delegation to unknown agent."""
|
||||||
|
mock_registry.get_member.return_value = None
|
||||||
|
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
|
||||||
|
assert engine.can_delegate_to("unknown_agent") is False
|
||||||
|
|
||||||
|
def test_can_delegate_to_librarian(self, mock_registry):
|
||||||
|
"""Test checking delegation to librarian."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock() # Has an agent
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
|
||||||
|
assert engine.can_delegate_to("librarian") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationExecution:
|
||||||
|
"""Tests for delegation execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_delegation_unavailable_agent(
|
||||||
|
self, mock_registry, librarian_intent
|
||||||
|
):
|
||||||
|
"""Test delegation fails for unavailable agent."""
|
||||||
|
mock_registry.get_member.return_value = None
|
||||||
|
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
|
||||||
|
with pytest.raises(AgentUnavailableError) as exc_info:
|
||||||
|
await engine.execute_delegation(librarian_intent)
|
||||||
|
|
||||||
|
assert "librarian" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_delegation_success(
|
||||||
|
self, mock_registry, librarian_intent
|
||||||
|
):
|
||||||
|
"""Test successful delegation execution."""
|
||||||
|
# Setup mock member with agent
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
# Mock the executor
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_EXECUTORS",
|
||||||
|
{"librarian": AsyncMock(return_value="Research results here")},
|
||||||
|
):
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
response = await engine.execute_delegation(librarian_intent)
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.result == "Research results here"
|
||||||
|
# Duration might be 0 for very fast mock execution
|
||||||
|
assert response.duration_ms >= 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_delegation_error(
|
||||||
|
self, mock_registry, librarian_intent
|
||||||
|
):
|
||||||
|
"""Test delegation handles executor errors."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
# Mock executor that raises
|
||||||
|
async def failing_executor(**kwargs):
|
||||||
|
raise ValueError("API connection failed")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_EXECUTORS",
|
||||||
|
{"librarian": failing_executor},
|
||||||
|
):
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
response = await engine.execute_delegation(librarian_intent)
|
||||||
|
|
||||||
|
assert response.success is False
|
||||||
|
assert "API connection failed" in response.error_message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCoordinate:
|
||||||
|
"""Tests for multi-agent coordination."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_coordinate_single_intent(self, mock_registry, librarian_intent):
|
||||||
|
"""Test coordinating a single delegation."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_EXECUTORS",
|
||||||
|
{"librarian": AsyncMock(return_value="Found docs")},
|
||||||
|
):
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
result = await engine.coordinate([librarian_intent])
|
||||||
|
|
||||||
|
assert result.final_response == "Found docs"
|
||||||
|
assert "librarian" in result.agents_consulted
|
||||||
|
# Duration might be 0 for very fast mock execution
|
||||||
|
assert result.total_duration_ms >= 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_coordinate_empty_intents(self, mock_registry):
|
||||||
|
"""Test coordinating with no intents."""
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
result = await engine.coordinate([])
|
||||||
|
|
||||||
|
assert result.final_response == ""
|
||||||
|
assert result.agents_consulted == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_coordinate_multiple_intents(self, mock_registry):
|
||||||
|
"""Test coordinating multiple delegations."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
intents = [
|
||||||
|
DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Task 1",
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Result 1",
|
||||||
|
priority=1,
|
||||||
|
),
|
||||||
|
DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Task 2",
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Result 2",
|
||||||
|
priority=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_executor(**kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return f"Result {call_count}"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_EXECUTORS",
|
||||||
|
{"librarian": mock_executor},
|
||||||
|
):
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
result = await engine.coordinate(intents)
|
||||||
|
|
||||||
|
# Both intents were executed (check agents_consulted count)
|
||||||
|
assert len(result.agents_consulted) == 2
|
||||||
|
# Current implementation replaces same-agent responses in dict
|
||||||
|
# So final_response has the last result (or combined if different agents)
|
||||||
|
assert len(result.final_response) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegateToLibrarian:
|
||||||
|
"""Tests for convenience delegation function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian(self, mock_registry):
|
||||||
|
"""Test the delegate_to_librarian helper."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_EXECUTORS",
|
||||||
|
{"librarian": AsyncMock(return_value="Wiki search results")},
|
||||||
|
):
|
||||||
|
# Reset global engine
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination._coordination_engine",
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
response = await delegate_to_librarian(
|
||||||
|
task="Search for Docker docs",
|
||||||
|
context="Setting up homelab",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.result == "Wiki search results"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetCoordinationEngine:
|
||||||
|
"""Tests for engine singleton."""
|
||||||
|
|
||||||
|
def test_get_coordination_engine_singleton(self):
|
||||||
|
"""Test engine is singleton."""
|
||||||
|
with patch("src.agents.coordination._coordination_engine", None):
|
||||||
|
engine1 = get_coordination_engine()
|
||||||
|
engine2 = get_coordination_engine()
|
||||||
|
|
||||||
|
# Should be same instance
|
||||||
|
assert engine1 is engine2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationStreaming:
|
||||||
|
"""Tests for streaming delegation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_delegation_stream_unavailable(
|
||||||
|
self, mock_registry, librarian_intent
|
||||||
|
):
|
||||||
|
"""Test streaming fails for unavailable agent."""
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
|
||||||
|
# Change target to an agent that doesn't have a stream executor
|
||||||
|
librarian_intent.target_agent = "nonexistent_agent"
|
||||||
|
|
||||||
|
with pytest.raises(AgentUnavailableError):
|
||||||
|
async for _ in engine.execute_delegation_stream(librarian_intent):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_delegation_stream_success(
|
||||||
|
self, mock_registry, librarian_intent
|
||||||
|
):
|
||||||
|
"""Test successful streaming delegation."""
|
||||||
|
mock_member = MagicMock()
|
||||||
|
mock_member.agent = MagicMock()
|
||||||
|
mock_registry.get_member.return_value = mock_member
|
||||||
|
|
||||||
|
async def mock_stream(**kwargs):
|
||||||
|
yield "Hello "
|
||||||
|
yield "world"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.coordination.AGENT_STREAM_EXECUTORS",
|
||||||
|
{"librarian": mock_stream},
|
||||||
|
):
|
||||||
|
engine = CoordinationEngine()
|
||||||
|
chunks = []
|
||||||
|
async for chunk in engine.execute_delegation_stream(librarian_intent):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
assert chunks == ["Hello ", "world"]
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
Tests for delegation infrastructure.
|
||||||
|
|
||||||
|
Tests the DelegationTask dataclass and delegation wrapper functions
|
||||||
|
that implement the agent-as-tool pattern.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from src.agents.delegation import (
|
||||||
|
ActionType,
|
||||||
|
DelegationTask,
|
||||||
|
DelegationResult,
|
||||||
|
HOUSEHOLD_THINK_MESSAGES,
|
||||||
|
STREAMING_DELEGATION_WRAPPERS,
|
||||||
|
delegate_to_librarian,
|
||||||
|
get_think_message,
|
||||||
|
_detect_action_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationTask:
|
||||||
|
"""Tests for the DelegationTask dataclass."""
|
||||||
|
|
||||||
|
def test_delegation_task_creation(self):
|
||||||
|
"""Test basic DelegationTask creation."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Create a wiki page about CI/CD",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
action="create",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert task.task == "Create a wiki page about CI/CD"
|
||||||
|
assert task.context == "User is setting up a homelab"
|
||||||
|
assert task.action == "create"
|
||||||
|
|
||||||
|
def test_delegation_task_default_values(self):
|
||||||
|
"""Test DelegationTask default values."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.context == ""
|
||||||
|
assert task.action == ""
|
||||||
|
assert task.priority == 0
|
||||||
|
assert task.depends_on == []
|
||||||
|
assert task.result is None
|
||||||
|
|
||||||
|
def test_delegation_task_auto_generates_id(self):
|
||||||
|
"""Test DelegationTask auto-generates unique IDs."""
|
||||||
|
task1 = DelegationTask(expert_name="librarian", task="Task 1")
|
||||||
|
task2 = DelegationTask(expert_name="librarian", task="Task 2")
|
||||||
|
|
||||||
|
assert task1.task_id.startswith("librarian_")
|
||||||
|
assert task2.task_id.startswith("librarian_")
|
||||||
|
assert task1.task_id != task2.task_id
|
||||||
|
|
||||||
|
def test_delegation_task_preserves_custom_id(self):
|
||||||
|
"""Test DelegationTask preserves custom ID if provided."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Custom task",
|
||||||
|
task_id="custom_id_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.task_id == "custom_id_123"
|
||||||
|
|
||||||
|
def test_delegation_task_with_dependencies(self):
|
||||||
|
"""Test DelegationTask with dependencies."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Update wiki page",
|
||||||
|
depends_on=["memory_abc123", "search_def456"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(task.depends_on) == 2
|
||||||
|
assert "memory_abc123" in task.depends_on
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationResult:
|
||||||
|
"""Tests for the DelegationResult dataclass."""
|
||||||
|
|
||||||
|
def test_delegation_result_success(self):
|
||||||
|
"""Test successful DelegationResult."""
|
||||||
|
result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
success=True,
|
||||||
|
output="Found 5 relevant documents about Docker...",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.expert_name == "librarian"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output.startswith("Found")
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
def test_delegation_result_failure(self):
|
||||||
|
"""Test failed DelegationResult."""
|
||||||
|
result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection timeout to library-desk API",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.output == ""
|
||||||
|
assert result.error == "Connection timeout to library-desk API"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegateToLibrarian:
|
||||||
|
"""Tests for the delegate_to_librarian wrapper."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_success(self):
|
||||||
|
"""Test successful delegation to Librarian."""
|
||||||
|
mock_output = "Successfully created wiki page about CI/CD pipelines..."
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_output,
|
||||||
|
) as mock_run:
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Create a wiki page about CI/CD pipelines",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify run_librarian was called correctly
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
task="Create a wiki page about CI/CD pipelines",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert isinstance(result, DelegationResult)
|
||||||
|
assert result.expert_name == "librarian"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output == mock_output
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_without_context(self):
|
||||||
|
"""Test delegation to Librarian without context."""
|
||||||
|
mock_output = "Found information about Docker networking..."
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_output,
|
||||||
|
) as mock_run:
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Search for information about Docker networking",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
task="Search for information about Docker networking",
|
||||||
|
context="",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output == mock_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_handles_error(self):
|
||||||
|
"""Test delegation handles Librarian errors gracefully."""
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Connection refused"),
|
||||||
|
):
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Search for information",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, DelegationResult)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.output == ""
|
||||||
|
assert result.error == "Connection refused"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_preserves_task(self):
|
||||||
|
"""Test delegation result preserves original task."""
|
||||||
|
original_task = "Create a wiki page about Kubernetes deployments"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="Page created",
|
||||||
|
):
|
||||||
|
result = await delegate_to_librarian(task=original_task)
|
||||||
|
|
||||||
|
assert result.task == original_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestActionType:
|
||||||
|
"""Tests for the ActionType enum."""
|
||||||
|
|
||||||
|
def test_action_type_values(self):
|
||||||
|
"""Test ActionType enum values."""
|
||||||
|
assert ActionType.RETRIEVE.value == "retrieve"
|
||||||
|
assert ActionType.RESEARCH.value == "research"
|
||||||
|
assert ActionType.CREATE.value == "create"
|
||||||
|
assert ActionType.CONTROL.value == "control"
|
||||||
|
assert ActionType.RECORD.value == "record"
|
||||||
|
|
||||||
|
def test_action_type_is_enum(self):
|
||||||
|
"""Test ActionType is proper enum."""
|
||||||
|
assert len(ActionType) == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHouseholdThinkMessages:
|
||||||
|
"""Tests for HOUSEHOLD_THINK_MESSAGES mapping."""
|
||||||
|
|
||||||
|
def test_librarian_has_messages(self):
|
||||||
|
"""Test librarian has think messages."""
|
||||||
|
assert "librarian" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
assert ActionType.RESEARCH in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
assert ActionType.CREATE in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
|
||||||
|
def test_biographer_has_messages(self):
|
||||||
|
"""Test biographer has think messages."""
|
||||||
|
assert "biographer" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["biographer"]
|
||||||
|
assert ActionType.RECORD in HOUSEHOLD_THINK_MESSAGES["biographer"]
|
||||||
|
|
||||||
|
def test_housekeeper_has_messages(self):
|
||||||
|
"""Test housekeeper has think messages."""
|
||||||
|
assert "housekeeper" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
|
||||||
|
assert ActionType.CONTROL in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
|
||||||
|
|
||||||
|
def test_messages_have_phases(self):
|
||||||
|
"""Test each action type has start/success/error messages."""
|
||||||
|
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
|
||||||
|
for action_type, messages in action_types.items():
|
||||||
|
assert "start" in messages, f"{expert}/{action_type} missing 'start'"
|
||||||
|
assert "success" in messages, f"{expert}/{action_type} missing 'success'"
|
||||||
|
assert "error" in messages, f"{expert}/{action_type} missing 'error'"
|
||||||
|
|
||||||
|
def test_messages_are_plain_text(self):
|
||||||
|
"""Test messages are plain text (no <think> wrappers - those go to reasoning_content)."""
|
||||||
|
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
|
||||||
|
for action_type, messages in action_types.items():
|
||||||
|
for phase, msg in messages.items():
|
||||||
|
# Messages should NOT have <think> wrappers - they go to reasoning_content field
|
||||||
|
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
|
||||||
|
class TestDetectActionType:
|
||||||
|
"""Tests for _detect_action_type function."""
|
||||||
|
|
||||||
|
def test_librarian_search_is_retrieve(self):
|
||||||
|
"""Test librarian search tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("librarian", "search for Docker info") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("librarian", "find information about CI/CD") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("librarian", "look up Kubernetes docs") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_librarian_web_search_is_research(self):
|
||||||
|
"""Test librarian web search tasks are RESEARCH."""
|
||||||
|
assert _detect_action_type("librarian", "search the web for news") == ActionType.RESEARCH
|
||||||
|
assert _detect_action_type("librarian", "find online resources") == ActionType.RESEARCH
|
||||||
|
assert _detect_action_type("librarian", "research internet sources") == ActionType.RESEARCH
|
||||||
|
|
||||||
|
def test_librarian_create_is_create(self):
|
||||||
|
"""Test librarian creation tasks are CREATE."""
|
||||||
|
assert _detect_action_type("librarian", "create a wiki page") == ActionType.CREATE
|
||||||
|
assert _detect_action_type("librarian", "write a new article") == ActionType.CREATE
|
||||||
|
assert _detect_action_type("librarian", "add a new entry") == ActionType.CREATE
|
||||||
|
|
||||||
|
def test_biographer_recall_is_retrieve(self):
|
||||||
|
"""Test biographer recall tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("biographer", "what car do I drive?") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("biographer", "what is my job?") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_biographer_record_is_record(self):
|
||||||
|
"""Test biographer record tasks are RECORD."""
|
||||||
|
assert _detect_action_type("biographer", "remember that I work at Acme") == ActionType.RECORD
|
||||||
|
assert _detect_action_type("biographer", "note that my car is a Tesla") == ActionType.RECORD
|
||||||
|
assert _detect_action_type("biographer", "save my preference for dark mode") == ActionType.RECORD
|
||||||
|
|
||||||
|
def test_housekeeper_status_is_retrieve(self):
|
||||||
|
"""Test housekeeper status tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("housekeeper", "what devices are in the bedroom?") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("housekeeper", "is the living room light on?") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_housekeeper_control_is_control(self):
|
||||||
|
"""Test housekeeper control tasks are CONTROL."""
|
||||||
|
assert _detect_action_type("housekeeper", "turn on the lights") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "set brightness to 50%") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "activate the movie scene") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "toggle the fan") == ActionType.CONTROL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetThinkMessage:
|
||||||
|
"""Tests for get_think_message function."""
|
||||||
|
|
||||||
|
def test_librarian_retrieve_start(self):
|
||||||
|
"""Test getting librarian retrieve start message."""
|
||||||
|
msg = get_think_message("librarian", "search for Docker", "start")
|
||||||
|
# No <think> wrappers - messages go to reasoning_content field
|
||||||
|
assert "<think>" not in msg
|
||||||
|
assert "archives" in msg.lower() or "consult" in msg.lower()
|
||||||
|
|
||||||
|
def test_librarian_create_success(self):
|
||||||
|
"""Test getting librarian create success message."""
|
||||||
|
msg = get_think_message("librarian", "create a wiki page", "success")
|
||||||
|
assert "<think>" not in msg
|
||||||
|
assert "catalogued" in msg.lower()
|
||||||
|
|
||||||
|
def test_biographer_record_start(self):
|
||||||
|
"""Test getting biographer record start message."""
|
||||||
|
msg = get_think_message("biographer", "remember my preference", "start")
|
||||||
|
assert "<think>" not in msg
|
||||||
|
assert "note" in msg.lower() or "biographer" in msg.lower()
|
||||||
|
|
||||||
|
def test_housekeeper_control_success(self):
|
||||||
|
"""Test getting housekeeper control success message."""
|
||||||
|
msg = get_think_message("housekeeper", "turn on the lights", "success")
|
||||||
|
assert "<think>" not in msg
|
||||||
|
assert "configured" in msg.lower()
|
||||||
|
|
||||||
|
def test_unknown_expert_fallback(self):
|
||||||
|
"""Test unknown expert gets fallback message."""
|
||||||
|
msg = get_think_message("unknown_expert", "some task", "start")
|
||||||
|
assert "<think>" not in msg
|
||||||
|
assert "unknown_expert" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestStreamingDelegationWrappers:
|
||||||
|
"""Tests for streaming delegation wrapper mapping."""
|
||||||
|
|
||||||
|
def test_streaming_wrappers_exist(self):
|
||||||
|
"""Test streaming wrappers mapping has all experts."""
|
||||||
|
assert "librarian" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
assert "biographer" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
assert "housekeeper" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
|
||||||
|
def test_streaming_wrappers_are_async_generators(self):
|
||||||
|
"""Test streaming wrappers are async generator functions."""
|
||||||
|
import inspect
|
||||||
|
for name, wrapper in STREAMING_DELEGATION_WRAPPERS.items():
|
||||||
|
assert inspect.isasyncgenfunction(wrapper), f"{name} is not an async generator"
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
"""
|
||||||
|
Tests for orchestration module.
|
||||||
|
|
||||||
|
Tests the multi-expert coordination infrastructure including
|
||||||
|
delegation parsing, think updates, result handling, and
|
||||||
|
multi-expert sequential/parallel execution.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from src.agents.orchestration import (
|
||||||
|
OrchestrationContext,
|
||||||
|
parse_delegation_from_steward_note,
|
||||||
|
execute_delegation,
|
||||||
|
orchestrate_with_think_updates,
|
||||||
|
extract_delegation_context,
|
||||||
|
ExecutionMode,
|
||||||
|
MultiExpertResult,
|
||||||
|
execute_sequential,
|
||||||
|
execute_parallel,
|
||||||
|
orchestrate_multi_expert,
|
||||||
|
_get_display_name,
|
||||||
|
)
|
||||||
|
from src.agents.delegation import DelegationTask, DelegationResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestParseDelegation:
|
||||||
|
"""Tests for parsing delegation from Steward's note."""
|
||||||
|
|
||||||
|
def test_parse_librarian_create(self):
|
||||||
|
"""Test parsing librarian create delegation."""
|
||||||
|
note = """DELEGATE: librarian to create a wiki page about CI/CD pipelines
|
||||||
|
REASON: User wants to document CI/CD concepts
|
||||||
|
COMPLEXITY: moderate
|
||||||
|
CONTEXT: none"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert "create a wiki page about CI/CD pipelines" in task.task
|
||||||
|
|
||||||
|
def test_parse_librarian_search(self):
|
||||||
|
"""Test parsing librarian search delegation."""
|
||||||
|
note = """DELEGATE: librarian to search for information about Docker networking
|
||||||
|
REASON: User needs Docker documentation
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert "search for information about Docker networking" in task.task
|
||||||
|
|
||||||
|
def test_parse_no_delegation(self):
|
||||||
|
"""Test parsing when no delegation needed."""
|
||||||
|
note = """DELEGATE: none (conversational response only)
|
||||||
|
REASON: Simple greeting requires no tools
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is None
|
||||||
|
|
||||||
|
def test_parse_tatlock_core(self):
|
||||||
|
"""Test parsing tatlock_core delegation."""
|
||||||
|
note = """DELEGATE: tatlock_core to calculate the result
|
||||||
|
REASON: Math calculation needed
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "tatlock_core"
|
||||||
|
assert "calculate the result" in task.task
|
||||||
|
|
||||||
|
def test_parse_case_insensitive(self):
|
||||||
|
"""Test parsing is case insensitive."""
|
||||||
|
note = """delegate: LIBRARIAN to search docs
|
||||||
|
reason: Research query"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
|
||||||
|
def test_parse_missing_delegate(self):
|
||||||
|
"""Test parsing when DELEGATE line is missing."""
|
||||||
|
note = """REASON: This has no delegation
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractDelegationContext:
|
||||||
|
"""Tests for extracting context from Steward's note."""
|
||||||
|
|
||||||
|
def test_extract_all_fields(self):
|
||||||
|
"""Test extracting all context fields."""
|
||||||
|
note = """DELEGATE: librarian to create wiki page
|
||||||
|
REASON: User wants documentation
|
||||||
|
COMPLEXITY: moderate
|
||||||
|
CONTEXT: Related to previous discussion about DevOps"""
|
||||||
|
|
||||||
|
context = extract_delegation_context(note)
|
||||||
|
|
||||||
|
assert context["reason"] == "User wants documentation"
|
||||||
|
assert context["complexity"] == "moderate"
|
||||||
|
assert "Related to previous discussion" in context["context"]
|
||||||
|
|
||||||
|
def test_extract_partial_fields(self):
|
||||||
|
"""Test extracting when some fields missing."""
|
||||||
|
note = """DELEGATE: librarian to search
|
||||||
|
REASON: Research query
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
context = extract_delegation_context(note)
|
||||||
|
|
||||||
|
assert context["reason"] == "Research query"
|
||||||
|
assert context["complexity"] == "simple"
|
||||||
|
assert context["context"] == ""
|
||||||
|
|
||||||
|
def test_extract_empty_note(self):
|
||||||
|
"""Test extracting from empty note."""
|
||||||
|
context = extract_delegation_context("")
|
||||||
|
|
||||||
|
assert context["reason"] == ""
|
||||||
|
assert context["complexity"] == ""
|
||||||
|
assert context["context"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteDelegation:
|
||||||
|
"""Tests for executing delegation tasks."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_librarian_delegation(self):
|
||||||
|
"""Test executing delegation to librarian."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search for Docker docs",
|
||||||
|
context="User learning Docker",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search for Docker docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker documentation...",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
) as mock_delegate:
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
|
||||||
|
mock_delegate.assert_called_once_with(
|
||||||
|
task="search for Docker docs",
|
||||||
|
context="User learning Docker",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert "Docker" in result.output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_unknown_expert(self):
|
||||||
|
"""Test executing delegation to unknown expert."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="unknown_expert",
|
||||||
|
task="do something",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "Unknown expert" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrateWithThinkUpdates:
|
||||||
|
"""Tests for orchestration with think updates."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_emits_think_before_delegation(self):
|
||||||
|
"""Test that think update is emitted before delegation."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found results",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# First update should be about consulting (no <think> wrappers anymore)
|
||||||
|
assert any("Consulting" in u for u in updates)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_emits_think_after_delegation(self):
|
||||||
|
"""Test that think update is emitted after delegation."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found results",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should have message about completion (no <think> wrappers anymore)
|
||||||
|
assert any("completed" in u for u in updates)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_yields_expert_output(self):
|
||||||
|
"""Test that expert output is yielded."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker documentation with networking details",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should include expert output
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "Docker documentation" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_handles_delegation_failure(self):
|
||||||
|
"""Test that delegation failure emits warning think update."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for info",
|
||||||
|
steward_note="DELEGATE: librarian to search",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should have warning think update
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "⚠️" in all_output or "issue" in all_output.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_no_delegation_returns_empty(self):
|
||||||
|
"""Test that no delegation yields nothing."""
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Hello",
|
||||||
|
steward_note="DELEGATE: none (conversational)",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_with_preparsed_task(self):
|
||||||
|
"""Test orchestration with pre-parsed delegation task."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="create wiki page",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="create wiki page",
|
||||||
|
success=True,
|
||||||
|
output="Wiki page created",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Create wiki page",
|
||||||
|
steward_note="", # Empty note since task is pre-parsed
|
||||||
|
delegation_task=task,
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) > 0
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "Wiki page created" in all_output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrationContext:
|
||||||
|
"""Tests for OrchestrationContext dataclass."""
|
||||||
|
|
||||||
|
def test_context_creation(self):
|
||||||
|
"""Test creating orchestration context."""
|
||||||
|
ctx = OrchestrationContext(
|
||||||
|
user_message="Test message",
|
||||||
|
steward_note="Test note",
|
||||||
|
conversation_id="conv_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.user_message == "Test message"
|
||||||
|
assert ctx.steward_note == "Test note"
|
||||||
|
assert ctx.conversation_id == "conv_123"
|
||||||
|
|
||||||
|
def test_context_defaults(self):
|
||||||
|
"""Test orchestration context default values."""
|
||||||
|
ctx = OrchestrationContext(
|
||||||
|
user_message="Test",
|
||||||
|
steward_note="Note",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.conversation_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Multi-Expert Coordination Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMultiExpertResult:
|
||||||
|
"""Tests for MultiExpertResult aggregation."""
|
||||||
|
|
||||||
|
def test_result_creation(self):
|
||||||
|
"""Test creating empty MultiExpertResult."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
assert result.results == {}
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert result.failed_experts == []
|
||||||
|
assert result.combined_output == ""
|
||||||
|
|
||||||
|
def test_add_successful_result(self):
|
||||||
|
"""Test adding a successful result."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
delegation_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found docs",
|
||||||
|
)
|
||||||
|
result.add_result(delegation_result)
|
||||||
|
|
||||||
|
assert "librarian" in result.results
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert result.failed_experts == []
|
||||||
|
|
||||||
|
def test_add_failed_result(self):
|
||||||
|
"""Test adding a failed result."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
delegation_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection error",
|
||||||
|
)
|
||||||
|
result.add_result(delegation_result)
|
||||||
|
|
||||||
|
assert "librarian" in result.results
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert "librarian" in result.failed_experts
|
||||||
|
|
||||||
|
def test_aggregate_outputs(self):
|
||||||
|
"""Test aggregating outputs from multiple experts."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker docs",
|
||||||
|
))
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="memory",
|
||||||
|
task="get preferences",
|
||||||
|
success=True,
|
||||||
|
output="User prefers dark mode",
|
||||||
|
))
|
||||||
|
|
||||||
|
combined = result.aggregate_outputs()
|
||||||
|
|
||||||
|
assert "Librarian" in combined
|
||||||
|
assert "Found Docker docs" in combined
|
||||||
|
assert "Memory" in combined
|
||||||
|
assert "dark mode" in combined
|
||||||
|
|
||||||
|
def test_aggregate_excludes_failed(self):
|
||||||
|
"""Test that failed results are excluded from aggregate."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search",
|
||||||
|
success=True,
|
||||||
|
output="Success output",
|
||||||
|
))
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="memory",
|
||||||
|
task="get",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Failed",
|
||||||
|
))
|
||||||
|
|
||||||
|
combined = result.aggregate_outputs()
|
||||||
|
|
||||||
|
assert "Success output" in combined
|
||||||
|
assert "Failed" not in combined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteSequential:
|
||||||
|
"""Tests for sequential multi-expert execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_all_succeed(self):
|
||||||
|
"""Test sequential execution when all tasks succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert result.failed_experts == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_with_failure(self):
|
||||||
|
"""Test sequential execution when a task fails."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Failed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_stop_on_failure(self):
|
||||||
|
"""Test sequential execution stops on failure when configured."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
DelegationTask(expert_name="librarian", task="task 3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=False, output="", error="Error"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks, stop_on_failure=True)
|
||||||
|
|
||||||
|
# Should only have 1 result (stopped after first failure)
|
||||||
|
assert len(result.results) == 1
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteParallel:
|
||||||
|
"""Tests for parallel multi-expert execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_all_succeed(self):
|
||||||
|
"""Test parallel execution when all tasks succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert len(result.results) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_with_failure(self):
|
||||||
|
"""Test parallel execution with partial failure."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Timeout"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_handles_exception(self):
|
||||||
|
"""Test parallel execution handles exceptions gracefully."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mock_execute(task):
|
||||||
|
if task.expert_name == "memory":
|
||||||
|
raise RuntimeError("Connection lost")
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name=task.expert_name,
|
||||||
|
task=task.task,
|
||||||
|
success=True,
|
||||||
|
output="OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_execute,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
assert "Connection lost" in result.results["memory"].error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrateMultiExpert:
|
||||||
|
"""Tests for multi-expert orchestration with think updates."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_sequential_emits_think_updates(self):
|
||||||
|
"""Test sequential orchestration emits think updates for each task."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.SEQUENTIAL):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should have think updates for both experts
|
||||||
|
assert "Consulting" in all_output
|
||||||
|
assert "completed" in all_output
|
||||||
|
assert "Librarian" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_parallel_emits_think_updates(self):
|
||||||
|
"""Test parallel orchestration emits appropriate think updates."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.PARALLEL):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should mention parallel execution
|
||||||
|
assert "parallel" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_empty_tasks_yields_nothing(self):
|
||||||
|
"""Test orchestration with empty tasks yields nothing."""
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert([]):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_success_summary(self):
|
||||||
|
"""Test orchestration emits success summary when all succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="task 1",
|
||||||
|
success=True,
|
||||||
|
output="Done",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should have success message
|
||||||
|
assert "🎉" in all_output or "successfully" in all_output.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_failure_summary(self):
|
||||||
|
"""Test orchestration emits failure summary when some fail."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="task 1",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should mention failure
|
||||||
|
assert "⚠️" in all_output or "failed" in all_output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetDisplayName:
|
||||||
|
"""Tests for _get_display_name helper."""
|
||||||
|
|
||||||
|
def test_librarian_display_name(self):
|
||||||
|
"""Test librarian gets 'The Librarian' display name."""
|
||||||
|
assert _get_display_name("librarian") == "The Librarian"
|
||||||
|
|
||||||
|
def test_memory_display_name(self):
|
||||||
|
"""Test memory gets 'Memory' display name."""
|
||||||
|
assert _get_display_name("memory") == "Memory"
|
||||||
|
|
||||||
|
def test_unknown_expert_title_case(self):
|
||||||
|
"""Test unknown expert gets title-cased name."""
|
||||||
|
assert _get_display_name("some_expert") == "Some_Expert"
|
||||||
|
assert _get_display_name("newagent") == "Newagent"
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
Tests for agent communication protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agents.protocol import (
|
||||||
|
AgentError,
|
||||||
|
AgentRequest,
|
||||||
|
AgentResponse,
|
||||||
|
AgentTimeoutError,
|
||||||
|
AgentUnavailableError,
|
||||||
|
CoordinationResult,
|
||||||
|
DelegationIntent,
|
||||||
|
DelegationReason,
|
||||||
|
ToolCallRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAgentRequest:
|
||||||
|
"""Tests for AgentRequest model."""
|
||||||
|
|
||||||
|
def test_basic_request(self):
|
||||||
|
"""Test creating a basic agent request."""
|
||||||
|
request = AgentRequest(task="Find information about Docker")
|
||||||
|
|
||||||
|
assert request.task == "Find information about Docker"
|
||||||
|
assert request.context == ""
|
||||||
|
assert request.timeout_seconds == 60
|
||||||
|
|
||||||
|
def test_request_with_context(self):
|
||||||
|
"""Test request with additional context."""
|
||||||
|
request = AgentRequest(
|
||||||
|
task="Find Docker networking docs",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
delegation_reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.task == "Find Docker networking docs"
|
||||||
|
assert request.context == "User is setting up a homelab"
|
||||||
|
assert request.delegation_reason == DelegationReason.DOMAIN_EXPERTISE
|
||||||
|
|
||||||
|
def test_request_serialization(self):
|
||||||
|
"""Test request can be serialized to dict."""
|
||||||
|
request = AgentRequest(
|
||||||
|
task="Research task",
|
||||||
|
context="Some context",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = request.model_dump()
|
||||||
|
|
||||||
|
assert data["task"] == "Research task"
|
||||||
|
assert data["context"] == "Some context"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAgentResponse:
|
||||||
|
"""Tests for AgentResponse model."""
|
||||||
|
|
||||||
|
def test_successful_response(self):
|
||||||
|
"""Test creating a successful response."""
|
||||||
|
response = AgentResponse(
|
||||||
|
success=True,
|
||||||
|
result="Here are the findings...",
|
||||||
|
reasoning="Searched wiki and found relevant docs",
|
||||||
|
duration_ms=1500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.result == "Here are the findings..."
|
||||||
|
assert response.reasoning == "Searched wiki and found relevant docs"
|
||||||
|
assert response.duration_ms == 1500
|
||||||
|
assert response.error_message is None
|
||||||
|
|
||||||
|
def test_failed_response(self):
|
||||||
|
"""Test creating a failed response."""
|
||||||
|
response = AgentResponse(
|
||||||
|
success=False,
|
||||||
|
result="",
|
||||||
|
error_message="Connection timeout",
|
||||||
|
duration_ms=30000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.success is False
|
||||||
|
assert response.result == ""
|
||||||
|
assert response.error_message == "Connection timeout"
|
||||||
|
|
||||||
|
def test_response_with_tool_calls(self):
|
||||||
|
"""Test response tracking tool calls."""
|
||||||
|
tool_call = ToolCallRecord(
|
||||||
|
tool_name="hybrid_search",
|
||||||
|
arguments={"query": "Docker networking"},
|
||||||
|
result="Found 5 results",
|
||||||
|
duration_ms=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = AgentResponse(
|
||||||
|
success=True,
|
||||||
|
result="Based on search...",
|
||||||
|
tool_calls=[tool_call],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(response.tool_calls) == 1
|
||||||
|
assert response.tool_calls[0].tool_name == "hybrid_search"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationIntent:
|
||||||
|
"""Tests for DelegationIntent model."""
|
||||||
|
|
||||||
|
def test_basic_intent(self):
|
||||||
|
"""Test creating a basic delegation intent."""
|
||||||
|
intent = DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Research Docker networking",
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Documentation and examples",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert intent.target_agent == "librarian"
|
||||||
|
assert intent.task == "Research Docker networking"
|
||||||
|
assert intent.reason == DelegationReason.DOMAIN_EXPERTISE
|
||||||
|
assert intent.priority == 1 # Default
|
||||||
|
|
||||||
|
def test_intent_with_priority(self):
|
||||||
|
"""Test intent with custom priority."""
|
||||||
|
intent = DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Urgent research",
|
||||||
|
reason=DelegationReason.RESOURCE_EFFICIENCY,
|
||||||
|
expected_outcome="Quick answer",
|
||||||
|
priority=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert intent.priority == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationReason:
|
||||||
|
"""Tests for DelegationReason enum."""
|
||||||
|
|
||||||
|
def test_all_reasons_have_values(self):
|
||||||
|
"""Test all delegation reasons are defined."""
|
||||||
|
reasons = list(DelegationReason)
|
||||||
|
|
||||||
|
assert DelegationReason.DOMAIN_EXPERTISE in reasons
|
||||||
|
assert DelegationReason.TOOL_ACCESS in reasons
|
||||||
|
assert DelegationReason.RESOURCE_EFFICIENCY in reasons
|
||||||
|
assert DelegationReason.USER_PREFERENCE in reasons
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCoordinationResult:
|
||||||
|
"""Tests for CoordinationResult model."""
|
||||||
|
|
||||||
|
def test_single_agent_result(self):
|
||||||
|
"""Test coordination with single agent."""
|
||||||
|
agent_response = AgentResponse(
|
||||||
|
success=True,
|
||||||
|
result="Research findings",
|
||||||
|
duration_ms=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
intent = DelegationIntent(
|
||||||
|
target_agent="librarian",
|
||||||
|
task="Research task",
|
||||||
|
reason=DelegationReason.DOMAIN_EXPERTISE,
|
||||||
|
expected_outcome="Findings",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CoordinationResult(
|
||||||
|
final_response="Research findings",
|
||||||
|
agent_responses={"librarian": agent_response},
|
||||||
|
delegation_intents=[intent],
|
||||||
|
total_duration_ms=1200,
|
||||||
|
agents_consulted=["librarian"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.final_response == "Research findings"
|
||||||
|
assert len(result.agent_responses) == 1
|
||||||
|
assert result.agents_consulted == ["librarian"]
|
||||||
|
|
||||||
|
def test_empty_result(self):
|
||||||
|
"""Test coordination with no delegations."""
|
||||||
|
result = CoordinationResult(
|
||||||
|
final_response="",
|
||||||
|
agent_responses={},
|
||||||
|
delegation_intents=[],
|
||||||
|
total_duration_ms=0,
|
||||||
|
agents_consulted=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.final_response == ""
|
||||||
|
assert len(result.agents_consulted) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAgentErrors:
|
||||||
|
"""Tests for agent error types."""
|
||||||
|
|
||||||
|
def test_agent_error(self):
|
||||||
|
"""Test base AgentError."""
|
||||||
|
error = AgentError("Something went wrong")
|
||||||
|
|
||||||
|
assert "Something went wrong" in str(error)
|
||||||
|
assert error.agent_name == "unknown"
|
||||||
|
|
||||||
|
def test_agent_timeout_error(self):
|
||||||
|
"""Test AgentTimeoutError."""
|
||||||
|
error = AgentTimeoutError(
|
||||||
|
"Timed out after 60s",
|
||||||
|
agent_name="librarian",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Timed out" in str(error)
|
||||||
|
assert error.agent_name == "librarian"
|
||||||
|
|
||||||
|
def test_agent_unavailable_error(self):
|
||||||
|
"""Test AgentUnavailableError."""
|
||||||
|
error = AgentUnavailableError(
|
||||||
|
"Agent not registered",
|
||||||
|
agent_name="unknown_agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "not registered" in str(error)
|
||||||
|
assert error.agent_name == "unknown_agent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestToolCallRecord:
|
||||||
|
"""Tests for ToolCallRecord model."""
|
||||||
|
|
||||||
|
def test_tool_call_record(self):
|
||||||
|
"""Test creating a tool call record."""
|
||||||
|
record = ToolCallRecord(
|
||||||
|
tool_name="semantic_search",
|
||||||
|
arguments={"query": "networking concepts", "limit": 10},
|
||||||
|
result="Found 10 relevant documents",
|
||||||
|
duration_ms=250,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.tool_name == "semantic_search"
|
||||||
|
assert record.arguments["query"] == "networking concepts"
|
||||||
|
assert record.duration_ms == 250
|
||||||
|
|
||||||
|
def test_tool_call_with_empty_result(self):
|
||||||
|
"""Test tool call with empty result."""
|
||||||
|
record = ToolCallRecord(
|
||||||
|
tool_name="query_graph",
|
||||||
|
arguments={"cypher": "MATCH (n) RETURN n"},
|
||||||
|
result="",
|
||||||
|
duration_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.result == ""
|
||||||
@@ -22,7 +22,7 @@ async def test_list_models():
|
|||||||
# Check model IDs
|
# Check model IDs
|
||||||
model_ids = [m["id"] for m in models]
|
model_ids = [m["id"] for m in models]
|
||||||
assert "lorem-tester" in model_ids
|
assert "lorem-tester" in model_ids
|
||||||
assert "tatlock" in model_ids
|
assert "Tatlock" in model_ids
|
||||||
|
|
||||||
# Check structure
|
# Check structure
|
||||||
for model in models:
|
for model in models:
|
||||||
@@ -57,16 +57,16 @@ async def test_lorem_tester_capabilities():
|
|||||||
async def test_tatlock_capabilities():
|
async def test_tatlock_capabilities():
|
||||||
"""Test tatlock model capabilities."""
|
"""Test tatlock model capabilities."""
|
||||||
models = await ModelRegistry.list_models()
|
models = await ModelRegistry.list_models()
|
||||||
tatlock_model = next(m for m in models if m["id"] == "tatlock")
|
tatlock_model = next(m for m in models if m["id"] == "Tatlock")
|
||||||
|
|
||||||
capabilities = tatlock_model["capabilities"]
|
capabilities = tatlock_model["capabilities"]
|
||||||
|
|
||||||
# Tatlock is placeholder - minimal capabilities
|
# Tatlock Phase 1 - basic streaming, reasoning, and permanent tools
|
||||||
assert capabilities["streaming"] is True
|
assert capabilities["streaming"] is True
|
||||||
assert capabilities["reasoning"] is False # Not yet
|
assert capabilities["reasoning"] is True # Basic reasoning summaries
|
||||||
assert capabilities["tools"] is False # Not yet
|
assert capabilities["tools"] is True # Permanent tools: calculator, date/time, search
|
||||||
assert capabilities["vision"] is False
|
assert capabilities["vision"] is False # Future
|
||||||
assert capabilities["audio"] is False
|
assert capabilities["audio"] is False # Future
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -80,7 +80,7 @@ def test_get_agent_lorem_tester():
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_get_agent_tatlock():
|
def test_get_agent_tatlock():
|
||||||
"""Test getting tatlock agent instance."""
|
"""Test getting tatlock agent instance."""
|
||||||
agent = ModelRegistry.get_agent("tatlock")
|
agent = ModelRegistry.get_agent("Tatlock")
|
||||||
|
|
||||||
assert isinstance(agent, TatlockAgent)
|
assert isinstance(agent, TatlockAgent)
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def test_get_agent_not_found():
|
|||||||
def test_model_exists():
|
def test_model_exists():
|
||||||
"""Test checking if model exists."""
|
"""Test checking if model exists."""
|
||||||
assert ModelRegistry.model_exists("lorem-tester") is True
|
assert ModelRegistry.model_exists("lorem-tester") is True
|
||||||
assert ModelRegistry.model_exists("tatlock") is True
|
assert ModelRegistry.model_exists("Tatlock") is True
|
||||||
assert ModelRegistry.model_exists("nonexistent") is False
|
assert ModelRegistry.model_exists("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""
|
||||||
|
Tests for Tatlock agent conversation history and tool call logging.
|
||||||
|
|
||||||
|
These tests verify:
|
||||||
|
1. Conversation history is properly passed to PydanticAI (Tatlock remembers context)
|
||||||
|
2. Tool calls are logged to reasoning output (users see what tools are doing)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that Tatlock remembers previous turns of the conversation.
|
||||||
|
|
||||||
|
This verifies the fix where Tatlock was only using the last user message
|
||||||
|
instead of the full conversation history.
|
||||||
|
Note: This test may fail due to LLM non-determinism.
|
||||||
|
"""
|
||||||
|
# First turn: User introduces themselves
|
||||||
|
request_data_1 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "My name is Alice and I love Python programming."}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_1 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data_1,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_1.status_code == 200
|
||||||
|
data_1 = response_1.json()
|
||||||
|
first_response = data_1["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Second turn: Ask about previous information
|
||||||
|
# Tatlock should remember the user's name and interest
|
||||||
|
request_data_2 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "My name is Alice and I love Python programming."},
|
||||||
|
{"role": "assistant", "content": first_response},
|
||||||
|
{"role": "user", "content": "What did I say my name was? And what programming language did I mention?"}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_2 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data_2,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_2.status_code == 200
|
||||||
|
data_2 = response_2.json()
|
||||||
|
second_response = data_2["choices"][0]["message"]["content"].lower()
|
||||||
|
|
||||||
|
# Verify Tatlock remembers the name and programming language
|
||||||
|
has_alice = "alice" in second_response
|
||||||
|
has_python = "python" in second_response
|
||||||
|
|
||||||
|
if not has_alice or not has_python:
|
||||||
|
pytest.xfail(f"LLM did not remember context (non-deterministic): alice={has_alice}, python={has_python}, response: {second_response[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that Tatlock maintains context over multiple turns.
|
||||||
|
|
||||||
|
Verifies conversation history is properly accumulated.
|
||||||
|
Note: This test may fail due to LLM non-determinism.
|
||||||
|
"""
|
||||||
|
# Build a multi-turn conversation
|
||||||
|
conversation = []
|
||||||
|
|
||||||
|
# Turn 1: Set up a topic
|
||||||
|
conversation.append({"role": "user", "content": "Let's talk about the number 42."})
|
||||||
|
|
||||||
|
request_1 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": conversation.copy(),
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_1 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_1,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_1.status_code == 200
|
||||||
|
data_1 = response_1.json()
|
||||||
|
conversation.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": data_1["choices"][0]["message"]["content"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Turn 2: Reference "it" (should refer to 42)
|
||||||
|
conversation.append({"role": "user", "content": "What number did I just mention?"})
|
||||||
|
|
||||||
|
request_2 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": conversation.copy(),
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_2 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_2,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_2.status_code == 200
|
||||||
|
data_2 = response_2.json()
|
||||||
|
final_response = data_2["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should reference 42 (check both as digit and word)
|
||||||
|
has_42 = "42" in final_response or "forty-two" in final_response.lower() or "forty two" in final_response.lower()
|
||||||
|
if not has_42:
|
||||||
|
pytest.xfail(f"LLM did not mention 42 in response (non-deterministic): {final_response[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that web search tool calls are logged to reasoning output.
|
||||||
|
|
||||||
|
This verifies that when Tatlock uses the search tool, the query
|
||||||
|
is visible in the chat response (in <think> tags).
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Search for current information about Python 3.13 release date"}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Tool calls should appear in <think> tags
|
||||||
|
assert "<think>" in full_response, "Should have reasoning/tool output in <think> tags"
|
||||||
|
|
||||||
|
# Should contain search indicator emoji (if search was used)
|
||||||
|
# OR the LLM might answer without searching if it has the info
|
||||||
|
# So we just verify the mechanism works by checking for think tags
|
||||||
|
print(f"\nFull response with tool logging:\n{full_response}")
|
||||||
|
|
||||||
|
# If search was used, should show the 🔍 emoji
|
||||||
|
if "🔍" in full_response:
|
||||||
|
assert "search" in full_response.lower() or "python" in full_response.lower(), \
|
||||||
|
"Search query should be visible in the response"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that calculator requests are handled correctly.
|
||||||
|
|
||||||
|
Verifies that mathematical calculations produce correct results.
|
||||||
|
Note: Tool call logging visibility depends on execution path
|
||||||
|
(streaming vs run, scoped tools vs delegation).
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is the square root of 144 plus 25?"}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should have reasoning in <think> tags (from Steward analysis)
|
||||||
|
assert "<think>" in full_response, \
|
||||||
|
f"Should have reasoning output in <think> tags. Got: {full_response}"
|
||||||
|
|
||||||
|
# Should reference the calculation in some form
|
||||||
|
has_calculation_reference = (
|
||||||
|
"144" in full_response or
|
||||||
|
"sqrt" in full_response.lower() or
|
||||||
|
"square root" in full_response.lower()
|
||||||
|
)
|
||||||
|
assert has_calculation_reference, \
|
||||||
|
f"Should reference the calculation. Got: {full_response}"
|
||||||
|
|
||||||
|
# Should have the correct answer (37)
|
||||||
|
assert "37" in full_response, \
|
||||||
|
f"Should contain the answer 37. Got: {full_response}"
|
||||||
|
|
||||||
|
# Tool emoji is optional - depends on whether tool was used directly
|
||||||
|
# or computation was delegated to capability
|
||||||
|
if "🧮" in full_response:
|
||||||
|
print(f"\nCalculator tool was used directly")
|
||||||
|
else:
|
||||||
|
print(f"\nCalculation handled via tatlock_core capability")
|
||||||
|
|
||||||
|
print(f"\nCalculator response: {full_response}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_tool_call_logging_datetime(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that date/time tool calls are logged to reasoning output.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What was the date exactly 2 weeks ago?"}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should have reasoning in <think> tags
|
||||||
|
assert "<think>" in full_response, "Should have reasoning output in <think> tags"
|
||||||
|
|
||||||
|
# Check if date/time tool was used (LLM might calculate it itself sometimes)
|
||||||
|
used_date_tool = "🕐" in full_response
|
||||||
|
|
||||||
|
# Should mention the calculation or the timeframe
|
||||||
|
assert "2 weeks ago" in full_response.lower() or "weeks" in full_response.lower(), \
|
||||||
|
f"Should reference the requested timeframe. Got: {full_response}"
|
||||||
|
|
||||||
|
# Should provide a specific date (either YYYY-MM-DD format or natural language like "November 23")
|
||||||
|
import re
|
||||||
|
has_iso_date = bool(re.search(r'\d{4}-\d{2}-\d{2}', full_response))
|
||||||
|
has_month_mention = any(month in full_response.lower() for month in
|
||||||
|
['january', 'february', 'march', 'april', 'may', 'june',
|
||||||
|
'july', 'august', 'september', 'october', 'november', 'december'])
|
||||||
|
has_date_number = bool(re.search(r'\b\d{1,2}(st|nd|rd|th)?\b', full_response.lower()))
|
||||||
|
|
||||||
|
assert has_iso_date or has_month_mention or has_date_number, \
|
||||||
|
f"Should contain a specific date. Got: {full_response}"
|
||||||
|
|
||||||
|
print(f"\nDate/time response (tool used: {used_date_tool}): {full_response}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_no_tool_calls_no_logging(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that when no tools are used, no tool logging appears.
|
||||||
|
|
||||||
|
Verifies the tool logging only appears when tools are actually called.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Just say hello to me."}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should have basic reasoning in <think> tags
|
||||||
|
assert "<think>" in full_response, "Should have reasoning output in <think> tags"
|
||||||
|
|
||||||
|
# Should NOT have tool emojis (for a simple greeting)
|
||||||
|
has_tool_emoji = any(emoji in full_response for emoji in ["🔍", "🧮", "🕐"])
|
||||||
|
|
||||||
|
print(f"\nResponse without tools: {full_response}")
|
||||||
|
print(f"Has tool emojis: {has_tool_emoji}")
|
||||||
|
|
||||||
|
# Just verify we got a greeting response
|
||||||
|
assert len(full_response) > 0, "Should have a response"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that conversation history works correctly when tools are used.
|
||||||
|
|
||||||
|
Combines both features: history + tool logging.
|
||||||
|
Note: This test may fail due to LLM non-determinism.
|
||||||
|
"""
|
||||||
|
conversation = []
|
||||||
|
|
||||||
|
# Turn 1: Do a calculation
|
||||||
|
conversation.append({"role": "user", "content": "Calculate 15 times 7 for me."})
|
||||||
|
|
||||||
|
request_1 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": conversation.copy(),
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_1 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_1,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_1.status_code == 200
|
||||||
|
data_1 = response_1.json()
|
||||||
|
first_response = data_1["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should contain the answer (105) - allow for number formatting
|
||||||
|
has_105 = "105" in first_response.replace(",", "")
|
||||||
|
if not has_105:
|
||||||
|
pytest.xfail(f"LLM did not calculate 15*7=105 (non-deterministic): {first_response[:200]}")
|
||||||
|
|
||||||
|
conversation.append({"role": "assistant", "content": first_response})
|
||||||
|
|
||||||
|
# Turn 2: Ask about previous calculation
|
||||||
|
conversation.append({"role": "user", "content": "What calculation did I just ask you to do?"})
|
||||||
|
|
||||||
|
request_2 = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": conversation.copy(),
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response_2 = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_2,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_2.status_code == 200
|
||||||
|
data_2 = response_2.json()
|
||||||
|
second_response = data_2["choices"][0]["message"]["content"].lower()
|
||||||
|
|
||||||
|
# Should remember the calculation (either as digits or words)
|
||||||
|
has_calculation = (
|
||||||
|
("15" in second_response and "7" in second_response) or # As digits
|
||||||
|
("fifteen" in second_response and "seven" in second_response) or # As words
|
||||||
|
"105" in second_response or # As answer
|
||||||
|
"multipl" in second_response # Mentions multiplication
|
||||||
|
)
|
||||||
|
if not has_calculation:
|
||||||
|
pytest.xfail(f"LLM did not remember calculation (non-deterministic): {second_response[:200]}")
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
Tests for Tatlock's permanent tools (calculator, date/time).
|
||||||
|
|
||||||
|
Note: Web search has been moved to The Librarian agent.
|
||||||
|
See tests/agents/librarian/test_tools.py for search tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.agents.tools import (
|
||||||
|
calculate,
|
||||||
|
get_current_datetime,
|
||||||
|
calculate_time_offset,
|
||||||
|
time_difference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Calculator Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestCalculator:
|
||||||
|
"""Tests for the calculator tool."""
|
||||||
|
|
||||||
|
def test_basic_arithmetic(self):
|
||||||
|
"""Test basic arithmetic operations."""
|
||||||
|
assert calculate("2 + 2") == "4"
|
||||||
|
assert calculate("10 - 3") == "7"
|
||||||
|
assert calculate("5 * 6") == "30"
|
||||||
|
assert calculate("20 / 4") == "5" # Integer result, no decimal
|
||||||
|
|
||||||
|
def test_complex_expressions(self):
|
||||||
|
"""Test complex mathematical expressions."""
|
||||||
|
assert calculate("(2 + 3) * 4") == "20"
|
||||||
|
assert calculate("10 ** 2") == "100"
|
||||||
|
assert calculate("17 % 5") == "2"
|
||||||
|
|
||||||
|
def test_math_functions(self):
|
||||||
|
"""Test mathematical functions."""
|
||||||
|
assert calculate("sqrt(16)") == "4" # Integer result
|
||||||
|
assert calculate("abs(-5)") == "5"
|
||||||
|
assert calculate("round(3.7)") == "4"
|
||||||
|
|
||||||
|
# Test with constants
|
||||||
|
result = calculate("pi * 2")
|
||||||
|
assert "6.28" in result # Approximately 6.283...
|
||||||
|
|
||||||
|
def test_trigonometry(self):
|
||||||
|
"""Test trigonometric functions."""
|
||||||
|
result = calculate("sin(0)")
|
||||||
|
assert result == "0" # Integer result
|
||||||
|
|
||||||
|
# cos(0) should be 1
|
||||||
|
result = calculate("cos(0)")
|
||||||
|
assert result == "1" # Integer result
|
||||||
|
|
||||||
|
def test_logarithms(self):
|
||||||
|
"""Test logarithmic functions."""
|
||||||
|
result = calculate("log10(100)")
|
||||||
|
assert result == "2" # Integer result
|
||||||
|
|
||||||
|
result = calculate("exp(0)")
|
||||||
|
assert result == "1" # Integer result
|
||||||
|
|
||||||
|
def test_error_handling(self):
|
||||||
|
"""Test error handling for invalid expressions."""
|
||||||
|
result = calculate("1 / 0")
|
||||||
|
assert "Error: Division by zero" in result
|
||||||
|
|
||||||
|
result = calculate("invalid_function(5)")
|
||||||
|
assert "Error calculating" in result
|
||||||
|
|
||||||
|
def test_integer_results(self):
|
||||||
|
"""Test that integer results don't show unnecessary decimals."""
|
||||||
|
assert calculate("4.0 + 6.0") == "10"
|
||||||
|
assert calculate("sqrt(9)") == "3"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Date/Time Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestDateTime:
|
||||||
|
"""Tests for date/time toolkit."""
|
||||||
|
|
||||||
|
def test_get_current_datetime_full(self):
|
||||||
|
"""Test getting full current datetime."""
|
||||||
|
result = get_current_datetime("full")
|
||||||
|
# Should match format YYYY-MM-DD HH:MM:SS
|
||||||
|
assert len(result) == 19
|
||||||
|
assert result[4] == "-"
|
||||||
|
assert result[7] == "-"
|
||||||
|
assert result[10] == " "
|
||||||
|
assert result[13] == ":"
|
||||||
|
assert result[16] == ":"
|
||||||
|
|
||||||
|
def test_get_current_datetime_date(self):
|
||||||
|
"""Test getting current date only."""
|
||||||
|
result = get_current_datetime("date")
|
||||||
|
# Should match format YYYY-MM-DD
|
||||||
|
assert len(result) == 10
|
||||||
|
assert result[4] == "-"
|
||||||
|
assert result[7] == "-"
|
||||||
|
|
||||||
|
# Verify it's a valid date
|
||||||
|
datetime.strptime(result, "%Y-%m-%d")
|
||||||
|
|
||||||
|
def test_get_current_datetime_time(self):
|
||||||
|
"""Test getting current time only."""
|
||||||
|
result = get_current_datetime("time")
|
||||||
|
# Should match format HH:MM:SS
|
||||||
|
assert len(result) == 8
|
||||||
|
assert result[2] == ":"
|
||||||
|
assert result[5] == ":"
|
||||||
|
|
||||||
|
def test_get_current_datetime_iso(self):
|
||||||
|
"""Test getting ISO format."""
|
||||||
|
result = get_current_datetime("iso")
|
||||||
|
# Should be parseable as ISO format
|
||||||
|
datetime.fromisoformat(result)
|
||||||
|
|
||||||
|
def test_calculate_time_offset_days(self):
|
||||||
|
"""Test calculating time offsets in days."""
|
||||||
|
result = calculate_time_offset("1 day ago")
|
||||||
|
assert len(result) == 19 # YYYY-MM-DD HH:MM:SS
|
||||||
|
|
||||||
|
result = calculate_time_offset("2 days from now")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
def test_calculate_time_offset_weeks(self):
|
||||||
|
"""Test calculating time offsets in weeks."""
|
||||||
|
result = calculate_time_offset("1 week ago")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
result = calculate_time_offset("2 weeks from now")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
def test_calculate_time_offset_months(self):
|
||||||
|
"""Test calculating time offsets in months."""
|
||||||
|
result = calculate_time_offset("1 month ago")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
result = calculate_time_offset("3 months from now")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
def test_calculate_time_offset_years(self):
|
||||||
|
"""Test calculating time offsets in years."""
|
||||||
|
result = calculate_time_offset("1 year ago")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
result = calculate_time_offset("2 years from now")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
def test_calculate_time_offset_hours(self):
|
||||||
|
"""Test calculating time offsets in hours."""
|
||||||
|
result = calculate_time_offset("5 hours ago")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
result = calculate_time_offset("3 hours from now")
|
||||||
|
assert len(result) == 19
|
||||||
|
|
||||||
|
def test_calculate_time_offset_invalid(self):
|
||||||
|
"""Test error handling for invalid time offsets."""
|
||||||
|
result = calculate_time_offset("invalid input")
|
||||||
|
assert "Error" in result
|
||||||
|
assert "Cannot parse" in result
|
||||||
|
|
||||||
|
def test_time_difference(self):
|
||||||
|
"""Test calculating time difference."""
|
||||||
|
result = time_difference("2024-01-01", "2024-01-15")
|
||||||
|
assert "14 day" in result
|
||||||
|
|
||||||
|
def test_time_difference_with_now(self):
|
||||||
|
"""Test time difference with 'now'."""
|
||||||
|
# Get today's date
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
result = time_difference(today, "now")
|
||||||
|
# Should be less than a day
|
||||||
|
assert "Less than" in result or "hour" in result or "minute" in result
|
||||||
|
|
||||||
|
def test_time_difference_with_times(self):
|
||||||
|
"""Test time difference with full timestamps."""
|
||||||
|
result = time_difference("2024-01-01 10:00:00", "2024-01-01 14:30:00")
|
||||||
|
assert "4 hour" in result
|
||||||
|
assert "30 minute" in result
|
||||||
|
|
||||||
|
def test_time_difference_error(self):
|
||||||
|
"""Test error handling for invalid dates."""
|
||||||
|
result = time_difference("invalid-date", "now")
|
||||||
|
assert "Error" in result
|
||||||
@@ -46,7 +46,7 @@ def test_chat_completion_non_streaming(
|
|||||||
def test_chat_completion_validation_error(client: TestClient) -> None:
|
def test_chat_completion_validation_error(client: TestClient) -> None:
|
||||||
"""Test chat completion with invalid request."""
|
"""Test chat completion with invalid request."""
|
||||||
# Missing required field 'messages'
|
# Missing required field 'messages'
|
||||||
invalid_request = {"model": "tatlock"}
|
invalid_request = {"model": "Tatlock"}
|
||||||
|
|
||||||
response = client.post("/v1/chat/completions", json=invalid_request)
|
response = client.post("/v1/chat/completions", json=invalid_request)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ async def async_client() -> AsyncClient:
|
|||||||
def mock_chat_request() -> dict:
|
def mock_chat_request() -> dict:
|
||||||
"""Standard chat completion request fixture."""
|
"""Standard chat completion request fixture."""
|
||||||
return {
|
return {
|
||||||
"model": "tatlock",
|
"model": "Tatlock",
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "user", "content": "Hello, world!"}
|
{"role": "user", "content": "Hello, world!"}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
Tests for benchmark storage.
|
||||||
|
|
||||||
|
Tests performance tracking, Redis storage, and analytics features.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.benchmarks import (
|
||||||
|
BenchmarkStore,
|
||||||
|
PerformanceBenchmark,
|
||||||
|
get_benchmark_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceBenchmark:
|
||||||
|
"""Test PerformanceBenchmark model."""
|
||||||
|
|
||||||
|
def test_benchmark_creation(self):
|
||||||
|
"""Test creating a performance benchmark."""
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="steward_analysis",
|
||||||
|
duration_seconds=1.23,
|
||||||
|
success=True,
|
||||||
|
recommendation_count=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert benchmark.operation == "steward_analysis"
|
||||||
|
assert benchmark.duration_seconds == 1.23
|
||||||
|
assert benchmark.success is True
|
||||||
|
assert benchmark.recommendation_count == 3
|
||||||
|
assert isinstance(benchmark.timestamp, datetime)
|
||||||
|
|
||||||
|
def test_benchmark_with_tool_fields(self):
|
||||||
|
"""Test benchmark with tool-specific fields."""
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="tool_call",
|
||||||
|
duration_seconds=0.5,
|
||||||
|
success=True,
|
||||||
|
tool_name="calculate",
|
||||||
|
was_recommended=True,
|
||||||
|
was_actually_used=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert benchmark.tool_name == "calculate"
|
||||||
|
assert benchmark.was_recommended is True
|
||||||
|
assert benchmark.was_actually_used is True
|
||||||
|
|
||||||
|
def test_benchmark_to_redis_dict(self):
|
||||||
|
"""Test conversion to Redis dict."""
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="test_op",
|
||||||
|
duration_seconds=1.0,
|
||||||
|
success=True,
|
||||||
|
metadata={"key": "value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
redis_dict = benchmark.to_redis_dict()
|
||||||
|
assert redis_dict["operation"] == "test_op"
|
||||||
|
assert redis_dict["duration_seconds"] == 1.0
|
||||||
|
assert redis_dict["success"] is True
|
||||||
|
assert isinstance(redis_dict["timestamp"], str)
|
||||||
|
assert isinstance(redis_dict["metadata"], str)
|
||||||
|
|
||||||
|
def test_benchmark_from_redis_dict(self):
|
||||||
|
"""Test reconstruction from Redis dict."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
redis_dict = {
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"operation": "test_op",
|
||||||
|
"duration_seconds": 1.5,
|
||||||
|
"success": True,
|
||||||
|
"metadata": json.dumps({"test": "data"}),
|
||||||
|
"recommendation_count": None,
|
||||||
|
"confidence": None,
|
||||||
|
"tool_name": None,
|
||||||
|
"was_recommended": None,
|
||||||
|
"was_actually_used": None,
|
||||||
|
"conversation_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
benchmark = PerformanceBenchmark.from_redis_dict(redis_dict)
|
||||||
|
assert benchmark.operation == "test_op"
|
||||||
|
assert benchmark.duration_seconds == 1.5
|
||||||
|
assert benchmark.metadata == {"test": "data"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBenchmarkStore:
|
||||||
|
"""Test BenchmarkStore functionality."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_redis(self):
|
||||||
|
"""Create mock Redis client."""
|
||||||
|
mock = AsyncMock()
|
||||||
|
mock.hset = AsyncMock()
|
||||||
|
mock.expire = AsyncMock()
|
||||||
|
mock.zadd = AsyncMock()
|
||||||
|
mock.zrevrangebyscore = AsyncMock(return_value=[])
|
||||||
|
mock.hgetall = AsyncMock(return_value={})
|
||||||
|
mock.aclose = AsyncMock()
|
||||||
|
return mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(self, mock_redis):
|
||||||
|
"""Create benchmark store with mock Redis."""
|
||||||
|
return BenchmarkStore(redis_client=mock_redis)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_benchmark(self, store, mock_redis):
|
||||||
|
"""Test recording a benchmark."""
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="test_op",
|
||||||
|
duration_seconds=1.0,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await store.record(benchmark)
|
||||||
|
|
||||||
|
# Verify Redis calls
|
||||||
|
mock_redis.hset.assert_called_once()
|
||||||
|
mock_redis.expire.assert_called()
|
||||||
|
mock_redis.zadd.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_benchmark_disabled(self, mock_redis):
|
||||||
|
"""Test recording when benchmarks are disabled."""
|
||||||
|
with patch("src.core.benchmarks.config.ENABLE_BENCHMARKS", False):
|
||||||
|
store = BenchmarkStore(redis_client=mock_redis)
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="test_op",
|
||||||
|
duration_seconds=1.0,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await store.record(benchmark)
|
||||||
|
|
||||||
|
# Should not call Redis
|
||||||
|
mock_redis.hset.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_benchmark_handles_errors(self, store, mock_redis):
|
||||||
|
"""Test recording handles Redis errors gracefully."""
|
||||||
|
mock_redis.hset.side_effect = Exception("Redis error")
|
||||||
|
|
||||||
|
benchmark = PerformanceBenchmark(
|
||||||
|
operation="test_op",
|
||||||
|
duration_seconds=1.0,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise exception
|
||||||
|
await store.record(benchmark)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_benchmarks(self, store, mock_redis):
|
||||||
|
"""Test querying benchmarks."""
|
||||||
|
# Setup mock data
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}"
|
||||||
|
mock_redis.zrevrangebyscore.return_value = [mock_key]
|
||||||
|
|
||||||
|
# Mock hgetall to return proper data
|
||||||
|
mock_redis.hgetall.return_value = {
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"operation": "test_op",
|
||||||
|
"duration_seconds": 1.5, # Numeric, not string
|
||||||
|
"success": True,
|
||||||
|
"metadata": "{}",
|
||||||
|
"recommendation_count": None,
|
||||||
|
"confidence": None,
|
||||||
|
"tool_name": None,
|
||||||
|
"was_recommended": None,
|
||||||
|
"was_actually_used": None,
|
||||||
|
"conversation_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
results = await store.query("test_op", limit=10)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].operation == "test_op"
|
||||||
|
mock_redis.zrevrangebyscore.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_with_time_range(self, store, mock_redis):
|
||||||
|
"""Test querying with time range."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start_time = now - timedelta(hours=1)
|
||||||
|
end_time = now
|
||||||
|
|
||||||
|
await store.query("test_op", start_time=start_time, end_time=end_time)
|
||||||
|
|
||||||
|
# Verify time range was converted to timestamps
|
||||||
|
call_args = mock_redis.zrevrangebyscore.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_disabled_benchmarks(self, mock_redis):
|
||||||
|
"""Test querying when benchmarks are disabled."""
|
||||||
|
with patch("src.core.benchmarks.config.ENABLE_BENCHMARKS", False):
|
||||||
|
store = BenchmarkStore(redis_client=mock_redis)
|
||||||
|
results = await store.query("test_op")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_handles_errors(self, store, mock_redis):
|
||||||
|
"""Test query handles errors gracefully."""
|
||||||
|
mock_redis.zrevrangebyscore.side_effect = Exception("Redis error")
|
||||||
|
|
||||||
|
results = await store.query("test_op")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_statistics(self, store, mock_redis):
|
||||||
|
"""Test getting statistics."""
|
||||||
|
# Setup mock data with multiple benchmarks
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
mock_keys = [
|
||||||
|
f"benchmark:test_op:{int((now - timedelta(seconds=i)).timestamp() * 1000)}"
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
mock_redis.zrevrangebyscore.return_value = mock_keys
|
||||||
|
|
||||||
|
# Return different durations and success values
|
||||||
|
benchmarks_data = [
|
||||||
|
{"duration_seconds": "1.0", "success": "True"},
|
||||||
|
{"duration_seconds": "2.0", "success": "True"},
|
||||||
|
{"duration_seconds": "3.0", "success": "False"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mock_hgetall(key):
|
||||||
|
idx = mock_keys.index(key)
|
||||||
|
data = benchmarks_data[idx]
|
||||||
|
return {
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"operation": "test_op",
|
||||||
|
"duration_seconds": float(data["duration_seconds"]),
|
||||||
|
"success": data["success"] == "True",
|
||||||
|
"metadata": "{}",
|
||||||
|
"recommendation_count": None,
|
||||||
|
"confidence": None,
|
||||||
|
"tool_name": None,
|
||||||
|
"was_recommended": None,
|
||||||
|
"was_actually_used": None,
|
||||||
|
"conversation_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_redis.hgetall.side_effect = mock_hgetall
|
||||||
|
|
||||||
|
stats = await store.get_statistics("test_op")
|
||||||
|
|
||||||
|
assert stats["count"] == 3
|
||||||
|
assert stats["avg_duration"] == 2.0 # (1 + 2 + 3) / 3
|
||||||
|
assert stats["min_duration"] == 1.0
|
||||||
|
assert stats["max_duration"] == 3.0
|
||||||
|
assert stats["success_rate"] == pytest.approx(66.67, rel=0.01)
|
||||||
|
assert stats["total_successes"] == 2
|
||||||
|
assert stats["total_failures"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_statistics_empty(self, store, mock_redis):
|
||||||
|
"""Test statistics with no data."""
|
||||||
|
mock_redis.zrevrangebyscore.return_value = []
|
||||||
|
|
||||||
|
stats = await store.get_statistics("test_op")
|
||||||
|
|
||||||
|
assert stats["count"] == 0
|
||||||
|
assert stats["avg_duration"] == 0.0
|
||||||
|
assert stats["success_rate"] == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_tool_accuracy(self, store, mock_redis):
|
||||||
|
"""Test tool accuracy calculation."""
|
||||||
|
# Setup mock data
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
mock_keys = [
|
||||||
|
f"benchmark:tool_call:{int((now - timedelta(seconds=i)).timestamp() * 1000)}"
|
||||||
|
for i in range(4)
|
||||||
|
]
|
||||||
|
mock_redis.zrevrangebyscore.return_value = mock_keys
|
||||||
|
|
||||||
|
# Different combinations of recommended/used
|
||||||
|
tool_data = [
|
||||||
|
{"was_recommended": "True", "was_actually_used": "True"}, # Good
|
||||||
|
{"was_recommended": "True", "was_actually_used": "True"}, # Good
|
||||||
|
{"was_recommended": "False", "was_actually_used": "True"}, # Missed
|
||||||
|
{"was_recommended": "True", "was_actually_used": "False"}, # Not used
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mock_hgetall(key):
|
||||||
|
idx = mock_keys.index(key)
|
||||||
|
data = tool_data[idx]
|
||||||
|
return {
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"operation": "tool_call",
|
||||||
|
"duration_seconds": 1.0,
|
||||||
|
"success": True,
|
||||||
|
"metadata": "{}",
|
||||||
|
"recommendation_count": None,
|
||||||
|
"confidence": None,
|
||||||
|
"tool_name": "test_tool",
|
||||||
|
"conversation_id": None,
|
||||||
|
"was_recommended": data["was_recommended"] == "True",
|
||||||
|
"was_actually_used": data["was_actually_used"] == "True",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_redis.hgetall.side_effect = mock_hgetall
|
||||||
|
|
||||||
|
accuracy = await store.get_tool_accuracy()
|
||||||
|
|
||||||
|
assert accuracy["total_calls"] == 4
|
||||||
|
assert accuracy["total_used"] == 3
|
||||||
|
assert accuracy["recommended_and_used"] == 2
|
||||||
|
assert accuracy["not_recommended_but_used"] == 1
|
||||||
|
assert accuracy["precision"] == pytest.approx(66.67, rel=0.01)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_tool_accuracy_empty(self, store, mock_redis):
|
||||||
|
"""Test tool accuracy with no data."""
|
||||||
|
mock_redis.zrevrangebyscore.return_value = []
|
||||||
|
|
||||||
|
accuracy = await store.get_tool_accuracy()
|
||||||
|
|
||||||
|
assert accuracy["total_calls"] == 0
|
||||||
|
assert accuracy["precision"] == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close(self, store, mock_redis):
|
||||||
|
"""Test closing the store."""
|
||||||
|
await store.close()
|
||||||
|
mock_redis.aclose.assert_called_once()
|
||||||
|
|
||||||
|
# Client should be None after close
|
||||||
|
assert store._client is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalBenchmarkStore:
|
||||||
|
"""Test global benchmark store instance."""
|
||||||
|
|
||||||
|
def test_get_benchmark_store(self):
|
||||||
|
"""Test getting global store instance."""
|
||||||
|
store = get_benchmark_store()
|
||||||
|
assert isinstance(store, BenchmarkStore)
|
||||||
|
|
||||||
|
def test_get_benchmark_store_singleton(self):
|
||||||
|
"""Test store is singleton."""
|
||||||
|
store1 = get_benchmark_store()
|
||||||
|
store2 = get_benchmark_store()
|
||||||
|
assert store1 is store2
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""
|
||||||
|
Tests for household registry.
|
||||||
|
|
||||||
|
Tests capability registration, toolset scoping, and coordination features.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai.tools import Tool
|
||||||
|
|
||||||
|
from src.core.household_registry import (
|
||||||
|
HouseholdCapability,
|
||||||
|
HouseholdMember,
|
||||||
|
HouseholdRegistry,
|
||||||
|
household_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry():
|
||||||
|
"""Create a fresh registry for each test."""
|
||||||
|
reg = HouseholdRegistry()
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_capability():
|
||||||
|
"""Sample household capability."""
|
||||||
|
return HouseholdCapability(
|
||||||
|
name="test_tools",
|
||||||
|
role="Test Tools",
|
||||||
|
category="testing",
|
||||||
|
description="Tools for testing purposes",
|
||||||
|
domains=["testing", "validation"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_tools():
|
||||||
|
"""Sample tool definitions."""
|
||||||
|
def test_function_1(x: int) -> int:
|
||||||
|
"""Test function 1."""
|
||||||
|
return x * 2
|
||||||
|
|
||||||
|
def test_function_2(x: str) -> str:
|
||||||
|
"""Test function 2."""
|
||||||
|
return x.upper()
|
||||||
|
|
||||||
|
return [
|
||||||
|
Tool(function=test_function_1, name="test_tool_1"),
|
||||||
|
Tool(function=test_function_2, name="test_tool_2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHouseholdCapability:
|
||||||
|
"""Test HouseholdCapability model."""
|
||||||
|
|
||||||
|
def test_capability_creation(self, sample_capability):
|
||||||
|
"""Test creating a capability."""
|
||||||
|
assert sample_capability.name == "test_tools"
|
||||||
|
assert sample_capability.role == "Test Tools"
|
||||||
|
assert sample_capability.category == "testing"
|
||||||
|
assert "testing" in sample_capability.domains
|
||||||
|
assert sample_capability.cost == "low"
|
||||||
|
assert sample_capability.requires_network is False
|
||||||
|
|
||||||
|
def test_capability_validation(self):
|
||||||
|
"""Test capability field validation."""
|
||||||
|
# Should succeed with valid data
|
||||||
|
cap = HouseholdCapability(
|
||||||
|
name="valid",
|
||||||
|
role="Valid Role",
|
||||||
|
category="test",
|
||||||
|
description="Test description",
|
||||||
|
domains=["test"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
assert cap.name == "valid"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHouseholdMember:
|
||||||
|
"""Test HouseholdMember model."""
|
||||||
|
|
||||||
|
def test_member_creation(self, sample_capability, sample_tools):
|
||||||
|
"""Test creating a household member."""
|
||||||
|
member = HouseholdMember(
|
||||||
|
capability=sample_capability,
|
||||||
|
tools=sample_tools,
|
||||||
|
agent=None,
|
||||||
|
)
|
||||||
|
assert member.capability.name == "test_tools"
|
||||||
|
assert len(member.tools) == 2
|
||||||
|
assert member.agent is None
|
||||||
|
|
||||||
|
def test_member_with_agent(self, sample_capability, sample_tools):
|
||||||
|
"""Test member can include an agent."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
mock_agent = Mock()
|
||||||
|
|
||||||
|
member = HouseholdMember(
|
||||||
|
capability=sample_capability,
|
||||||
|
tools=sample_tools,
|
||||||
|
agent=mock_agent,
|
||||||
|
)
|
||||||
|
assert member.agent is mock_agent
|
||||||
|
|
||||||
|
|
||||||
|
class TestHouseholdRegistry:
|
||||||
|
"""Test HouseholdRegistry functionality."""
|
||||||
|
|
||||||
|
def test_registry_initialization(self, registry):
|
||||||
|
"""Test registry initializes empty."""
|
||||||
|
assert len(registry) == 0
|
||||||
|
assert registry.list_members() == []
|
||||||
|
|
||||||
|
def test_register_member(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test registering a household member."""
|
||||||
|
registry.register(
|
||||||
|
name="test_tools",
|
||||||
|
capability=sample_capability,
|
||||||
|
tools=sample_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(registry) == 1
|
||||||
|
assert "test_tools" in registry
|
||||||
|
assert "test_tools" in registry.list_members()
|
||||||
|
|
||||||
|
def test_register_name_mismatch(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test registration fails with name mismatch."""
|
||||||
|
with pytest.raises(ValueError, match="Name mismatch"):
|
||||||
|
registry.register(
|
||||||
|
name="wrong_name",
|
||||||
|
capability=sample_capability,
|
||||||
|
tools=sample_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unregister_member(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test unregistering a member."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
assert "test_tools" in registry
|
||||||
|
|
||||||
|
registry.unregister("test_tools")
|
||||||
|
assert "test_tools" not in registry
|
||||||
|
assert len(registry) == 0
|
||||||
|
|
||||||
|
def test_get_member(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test retrieving a member."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
|
||||||
|
member = registry.get_member("test_tools")
|
||||||
|
assert member is not None
|
||||||
|
assert member.capability.name == "test_tools"
|
||||||
|
assert len(member.tools) == 2
|
||||||
|
|
||||||
|
def test_get_nonexistent_member(self, registry):
|
||||||
|
"""Test retrieving non-existent member returns None."""
|
||||||
|
member = registry.get_member("nonexistent")
|
||||||
|
assert member is None
|
||||||
|
|
||||||
|
def test_get_all_capabilities(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test retrieving all capability summaries."""
|
||||||
|
# Register multiple members
|
||||||
|
cap1 = sample_capability
|
||||||
|
cap2 = HouseholdCapability(
|
||||||
|
name="other_tools",
|
||||||
|
role="Other Tools",
|
||||||
|
category="utility",
|
||||||
|
description="Other test tools",
|
||||||
|
domains=["utility"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("test_tools", cap1, sample_tools)
|
||||||
|
registry.register("other_tools", cap2, sample_tools[:1])
|
||||||
|
|
||||||
|
capabilities = registry.get_all_capabilities()
|
||||||
|
assert len(capabilities) == 2
|
||||||
|
assert any(cap.name == "test_tools" for cap in capabilities)
|
||||||
|
assert any(cap.name == "other_tools" for cap in capabilities)
|
||||||
|
|
||||||
|
def test_get_scoped_tools(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test creating scoped toolsets."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
|
||||||
|
# Get scoped tools
|
||||||
|
tools = registry.get_scoped_tools(["test_tools"])
|
||||||
|
assert len(tools) == 2
|
||||||
|
assert tools[0].name == "test_tool_1"
|
||||||
|
assert tools[1].name == "test_tool_2"
|
||||||
|
|
||||||
|
def test_get_scoped_tools_multiple_members(self, registry, sample_tools):
|
||||||
|
"""Test scoping with multiple members."""
|
||||||
|
cap1 = HouseholdCapability(
|
||||||
|
name="member1",
|
||||||
|
role="Member 1",
|
||||||
|
category="test",
|
||||||
|
description="First member",
|
||||||
|
domains=["test"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
cap2 = HouseholdCapability(
|
||||||
|
name="member2",
|
||||||
|
role="Member 2",
|
||||||
|
category="test",
|
||||||
|
description="Second member",
|
||||||
|
domains=["test"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("member1", cap1, sample_tools[:1])
|
||||||
|
registry.register("member2", cap2, sample_tools[1:])
|
||||||
|
|
||||||
|
# Get combined tools
|
||||||
|
tools = registry.get_scoped_tools(["member1", "member2"])
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
def test_get_scoped_tools_nonexistent_member(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test scoping with non-existent member logs warning."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
|
||||||
|
# Request includes non-existent member
|
||||||
|
tools = registry.get_scoped_tools(["test_tools", "nonexistent"])
|
||||||
|
# Should return only existing member's tools
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
def test_get_members_by_domain(self, registry, sample_tools):
|
||||||
|
"""Test filtering members by domain."""
|
||||||
|
cap1 = HouseholdCapability(
|
||||||
|
name="research_tools",
|
||||||
|
role="Research Tools",
|
||||||
|
category="research",
|
||||||
|
description="Research tools",
|
||||||
|
domains=["research", "analysis"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
cap2 = HouseholdCapability(
|
||||||
|
name="compute_tools",
|
||||||
|
role="Compute Tools",
|
||||||
|
category="computation",
|
||||||
|
description="Computation tools",
|
||||||
|
domains=["computation", "math"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("research_tools", cap1, sample_tools)
|
||||||
|
registry.register("compute_tools", cap2, sample_tools)
|
||||||
|
|
||||||
|
# Filter by domain
|
||||||
|
research_caps = registry.get_members_by_domain("research")
|
||||||
|
assert len(research_caps) == 1
|
||||||
|
assert research_caps[0].name == "research_tools"
|
||||||
|
|
||||||
|
compute_caps = registry.get_members_by_domain("computation")
|
||||||
|
assert len(compute_caps) == 1
|
||||||
|
assert compute_caps[0].name == "compute_tools"
|
||||||
|
|
||||||
|
def test_get_members_by_category(self, registry, sample_tools):
|
||||||
|
"""Test filtering members by category."""
|
||||||
|
cap1 = HouseholdCapability(
|
||||||
|
name="core_tools",
|
||||||
|
role="Core Tools",
|
||||||
|
category="core",
|
||||||
|
description="Core tools",
|
||||||
|
domains=["general"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
cap2 = HouseholdCapability(
|
||||||
|
name="research_tools",
|
||||||
|
role="Research Tools",
|
||||||
|
category="research",
|
||||||
|
description="Research tools",
|
||||||
|
domains=["research"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("core_tools", cap1, sample_tools)
|
||||||
|
registry.register("research_tools", cap2, sample_tools)
|
||||||
|
|
||||||
|
# Filter by category
|
||||||
|
core_caps = registry.get_members_by_category("core")
|
||||||
|
assert len(core_caps) == 1
|
||||||
|
assert core_caps[0].name == "core_tools"
|
||||||
|
|
||||||
|
research_caps = registry.get_members_by_category("research")
|
||||||
|
assert len(research_caps) == 1
|
||||||
|
assert research_caps[0].name == "research_tools"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDelegationTools:
|
||||||
|
"""Test get_delegation_tools() method for agent-as-tool pattern."""
|
||||||
|
|
||||||
|
def test_delegation_tools_returns_wrapper_for_member_with_agent(self, registry, sample_tools):
|
||||||
|
"""Test delegation tools returns wrapper when member has an agent."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
cap = HouseholdCapability(
|
||||||
|
name="librarian",
|
||||||
|
role="The Librarian",
|
||||||
|
category="research",
|
||||||
|
description="Research and wiki management",
|
||||||
|
domains=["research", "wiki"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_agent = Mock()
|
||||||
|
registry.register("librarian", cap, sample_tools, agent=mock_agent)
|
||||||
|
|
||||||
|
tools = registry.get_delegation_tools(["librarian"])
|
||||||
|
|
||||||
|
# Should return delegation wrapper, not raw tools
|
||||||
|
assert len(tools) == 1
|
||||||
|
# The wrapper should be the delegate_to_librarian function
|
||||||
|
assert callable(tools[0])
|
||||||
|
assert tools[0].__name__ == "delegate_to_librarian"
|
||||||
|
|
||||||
|
def test_delegation_tools_returns_raw_tools_for_member_without_agent(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test delegation tools returns raw tools when member has no agent."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
|
||||||
|
tools = registry.get_delegation_tools(["test_tools"])
|
||||||
|
|
||||||
|
# Should return raw tools since no agent
|
||||||
|
assert len(tools) == 2
|
||||||
|
assert tools[0].name == "test_tool_1"
|
||||||
|
assert tools[1].name == "test_tool_2"
|
||||||
|
|
||||||
|
def test_delegation_tools_mixed_members(self, registry, sample_tools):
|
||||||
|
"""Test delegation tools handles mix of agent and non-agent members."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
# Member with agent (librarian)
|
||||||
|
librarian_cap = HouseholdCapability(
|
||||||
|
name="librarian",
|
||||||
|
role="The Librarian",
|
||||||
|
category="research",
|
||||||
|
description="Research and wiki",
|
||||||
|
domains=["research"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
mock_agent = Mock()
|
||||||
|
registry.register("librarian", librarian_cap, sample_tools, agent=mock_agent)
|
||||||
|
|
||||||
|
# Member without agent (tatlock_core)
|
||||||
|
core_cap = HouseholdCapability(
|
||||||
|
name="tatlock_core",
|
||||||
|
role="Butler's Core Tools",
|
||||||
|
category="core",
|
||||||
|
description="Basic tools",
|
||||||
|
domains=["computation"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
registry.register("tatlock_core", core_cap, sample_tools)
|
||||||
|
|
||||||
|
# Request both
|
||||||
|
tools = registry.get_delegation_tools(["librarian", "tatlock_core"])
|
||||||
|
|
||||||
|
# Should get 1 delegation wrapper + 2 raw tools = 3 total
|
||||||
|
assert len(tools) == 3
|
||||||
|
|
||||||
|
# First should be delegation wrapper
|
||||||
|
assert callable(tools[0])
|
||||||
|
assert tools[0].__name__ == "delegate_to_librarian"
|
||||||
|
|
||||||
|
# Rest should be raw tools
|
||||||
|
assert hasattr(tools[1], 'name')
|
||||||
|
assert hasattr(tools[2], 'name')
|
||||||
|
|
||||||
|
def test_delegation_tools_nonexistent_member(self, registry):
|
||||||
|
"""Test delegation tools handles non-existent member gracefully."""
|
||||||
|
tools = registry.get_delegation_tools(["nonexistent"])
|
||||||
|
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
def test_delegation_tools_empty_list(self, registry):
|
||||||
|
"""Test delegation tools handles empty list."""
|
||||||
|
tools = registry.get_delegation_tools([])
|
||||||
|
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalRegistry:
|
||||||
|
"""Test the global registry instance."""
|
||||||
|
|
||||||
|
def test_global_registry_exists(self):
|
||||||
|
"""Test global registry is available."""
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
|
||||||
|
registry = get_household_registry()
|
||||||
|
assert isinstance(registry, HouseholdRegistry)
|
||||||
|
|
||||||
|
def test_global_registry_singleton(self):
|
||||||
|
"""Test get_household_registry returns same instance."""
|
||||||
|
from src.core.household_registry import get_household_registry
|
||||||
|
|
||||||
|
reg1 = get_household_registry()
|
||||||
|
reg2 = get_household_registry()
|
||||||
|
assert reg1 is reg2
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Tests for structured logging configuration.
|
||||||
|
|
||||||
|
Tests logging setup, context management, and FastAPI integration.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from io import StringIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.core.logging_config import (
|
||||||
|
add_log_level,
|
||||||
|
add_timestamp,
|
||||||
|
get_logger,
|
||||||
|
get_uvicorn_log_config,
|
||||||
|
log_operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoggingProcessors:
|
||||||
|
"""Test logging processor functions."""
|
||||||
|
|
||||||
|
def test_add_timestamp(self):
|
||||||
|
"""Test timestamp processor adds ISO timestamp."""
|
||||||
|
event_dict = {}
|
||||||
|
result = add_timestamp(None, "info", event_dict)
|
||||||
|
|
||||||
|
assert "timestamp" in result
|
||||||
|
assert isinstance(result["timestamp"], str)
|
||||||
|
# Should be ISO 8601 format
|
||||||
|
assert "T" in result["timestamp"] or "-" in result["timestamp"]
|
||||||
|
|
||||||
|
def test_add_log_level(self):
|
||||||
|
"""Test log level processor."""
|
||||||
|
event_dict = {}
|
||||||
|
result = add_log_level(None, "info", event_dict)
|
||||||
|
|
||||||
|
assert result["level"] == "INFO"
|
||||||
|
|
||||||
|
result = add_log_level(None, "error", {})
|
||||||
|
assert result["level"] == "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLogger:
|
||||||
|
"""Test logger retrieval."""
|
||||||
|
|
||||||
|
def test_get_logger_returns_bound_logger(self):
|
||||||
|
"""Test get_logger returns structlog BoundLogger."""
|
||||||
|
logger = get_logger("test")
|
||||||
|
# Logger should have standard logging methods
|
||||||
|
assert hasattr(logger, 'info')
|
||||||
|
assert hasattr(logger, 'debug')
|
||||||
|
assert hasattr(logger, 'warning')
|
||||||
|
assert hasattr(logger, 'error')
|
||||||
|
|
||||||
|
def test_get_logger_with_module_name(self):
|
||||||
|
"""Test logger with module name."""
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
assert logger is not None
|
||||||
|
|
||||||
|
def test_logger_has_standard_methods(self):
|
||||||
|
"""Test logger has standard logging methods."""
|
||||||
|
logger = get_logger("test")
|
||||||
|
assert hasattr(logger, "debug")
|
||||||
|
assert hasattr(logger, "info")
|
||||||
|
assert hasattr(logger, "warning")
|
||||||
|
assert hasattr(logger, "error")
|
||||||
|
assert hasattr(logger, "exception")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogOperation:
|
||||||
|
"""Test log_operation context manager."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_operation_success(self):
|
||||||
|
"""Test log_operation for successful operation."""
|
||||||
|
logger = get_logger("test")
|
||||||
|
|
||||||
|
async with log_operation("test_operation", {"user_id": "123"}) as ctx:
|
||||||
|
# Can update context during operation
|
||||||
|
ctx["result_count"] = 5
|
||||||
|
|
||||||
|
# Context should have been updated with success info
|
||||||
|
assert ctx["success"] is True
|
||||||
|
assert ctx["result_count"] == 5
|
||||||
|
assert "duration_seconds" in ctx
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_operation_failure(self):
|
||||||
|
"""Test log_operation for failed operation."""
|
||||||
|
logger = get_logger("test")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
async with log_operation("test_operation") as ctx:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
|
||||||
|
# Context should have failure info
|
||||||
|
assert ctx["success"] is False
|
||||||
|
assert ctx["error"] == "Test error"
|
||||||
|
assert ctx["error_type"] == "ValueError"
|
||||||
|
assert "duration_seconds" in ctx
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_operation_timing(self):
|
||||||
|
"""Test log_operation records duration."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async with log_operation("test_operation") as ctx:
|
||||||
|
await asyncio.sleep(0.01) # Small delay
|
||||||
|
|
||||||
|
# Should have measurable duration
|
||||||
|
assert ctx["duration_seconds"] > 0
|
||||||
|
assert ctx["duration_seconds"] < 1.0 # Should be quick
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_operation_initial_context(self):
|
||||||
|
"""Test log_operation with initial context."""
|
||||||
|
initial = {"request_id": "abc123", "user": "test_user"}
|
||||||
|
|
||||||
|
async with log_operation("test_operation", initial) as ctx:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initial context should be preserved
|
||||||
|
assert ctx["request_id"] == "abc123"
|
||||||
|
assert ctx["user"] == "test_user"
|
||||||
|
assert ctx["operation"] == "test_operation"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUvicornLogConfig:
|
||||||
|
"""Test uvicorn logging configuration."""
|
||||||
|
|
||||||
|
def test_get_uvicorn_log_config_returns_dict(self):
|
||||||
|
"""Test uvicorn config returns valid dict."""
|
||||||
|
config = get_uvicorn_log_config()
|
||||||
|
|
||||||
|
assert isinstance(config, dict)
|
||||||
|
assert "version" in config
|
||||||
|
assert "formatters" in config
|
||||||
|
assert "handlers" in config
|
||||||
|
assert "loggers" in config
|
||||||
|
|
||||||
|
def test_uvicorn_log_config_has_required_loggers(self):
|
||||||
|
"""Test config includes uvicorn loggers."""
|
||||||
|
config = get_uvicorn_log_config()
|
||||||
|
|
||||||
|
loggers = config["loggers"]
|
||||||
|
assert "uvicorn" in loggers
|
||||||
|
assert "uvicorn.error" in loggers
|
||||||
|
assert "uvicorn.access" in loggers
|
||||||
|
|
||||||
|
def test_uvicorn_log_config_format_selection(self):
|
||||||
|
"""Test config format changes based on environment."""
|
||||||
|
# Just test that the config is valid, format is determined by environment
|
||||||
|
config = get_uvicorn_log_config()
|
||||||
|
# Should have required structure
|
||||||
|
assert "version" in config
|
||||||
|
assert "formatters" in config
|
||||||
|
assert "handlers" in config
|
||||||
|
assert "loggers" in config
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoggingIntegration:
|
||||||
|
"""Test logging integration with standard library."""
|
||||||
|
|
||||||
|
def test_standard_logging_works(self):
|
||||||
|
"""Test standard logging.getLogger works."""
|
||||||
|
logger = logging.getLogger("test.standard")
|
||||||
|
# Should not raise
|
||||||
|
logger.info("Test message")
|
||||||
|
|
||||||
|
def test_structlog_and_stdlib_coexist(self):
|
||||||
|
"""Test structlog and stdlib can coexist."""
|
||||||
|
struct_logger = get_logger("test.struct")
|
||||||
|
std_logger = logging.getLogger("test.std")
|
||||||
|
|
||||||
|
# Both should work
|
||||||
|
struct_logger.info("Structured log")
|
||||||
|
std_logger.info("Standard log")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logging_in_async_context(self):
|
||||||
|
"""Test logging works in async context."""
|
||||||
|
logger = get_logger("test.async")
|
||||||
|
|
||||||
|
async def async_function():
|
||||||
|
logger.info("Async log message", task="async_task")
|
||||||
|
|
||||||
|
await async_function()
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoggingOutput:
|
||||||
|
"""Test actual logging output."""
|
||||||
|
|
||||||
|
def test_logger_outputs_structured_data(self):
|
||||||
|
"""Test logger can output structured data."""
|
||||||
|
logger = get_logger("test.output")
|
||||||
|
|
||||||
|
# Log with structured data
|
||||||
|
logger.info(
|
||||||
|
"user_action",
|
||||||
|
user_id="123",
|
||||||
|
action="login",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
# Should not raise, output tested in integration tests
|
||||||
|
|
||||||
|
def test_logger_handles_exceptions(self):
|
||||||
|
"""Test logger handles exception logging."""
|
||||||
|
logger = get_logger("test.exceptions")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("Error occurred", extra_field="value")
|
||||||
|
# Should not raise
|
||||||
|
|
||||||
|
def test_different_log_levels(self):
|
||||||
|
"""Test different log levels."""
|
||||||
|
logger = get_logger("test.levels")
|
||||||
|
|
||||||
|
logger.debug("Debug message", level="debug")
|
||||||
|
logger.info("Info message", level="info")
|
||||||
|
logger.warning("Warning message", level="warning")
|
||||||
|
logger.error("Error message", level="error")
|
||||||
|
# Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoggingConfiguration:
|
||||||
|
"""Test logging configuration behavior."""
|
||||||
|
|
||||||
|
def test_logging_respects_environment(self):
|
||||||
|
"""Test logging format changes with environment."""
|
||||||
|
from src.core.config import Environment, config
|
||||||
|
|
||||||
|
# In development, should use console format
|
||||||
|
if config.ENVIRONMENT == Environment.DEVELOPMENT:
|
||||||
|
assert config.log_format == "console"
|
||||||
|
|
||||||
|
# Mock production environment
|
||||||
|
with patch.object(config, "ENVIRONMENT", Environment.PRODUCTION):
|
||||||
|
assert config.log_format == "json"
|
||||||
|
|
||||||
|
def test_multiple_loggers_independent(self):
|
||||||
|
"""Test multiple loggers are independent."""
|
||||||
|
logger1 = get_logger("test.logger1")
|
||||||
|
logger2 = get_logger("test.logger2")
|
||||||
|
|
||||||
|
assert logger1 is not logger2
|
||||||
|
|
||||||
|
# Both should work independently
|
||||||
|
logger1.info("Logger 1 message")
|
||||||
|
logger2.info("Logger 2 message")
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Tests for the memory service (direct access layer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
|
|
||||||
|
from src.core.memory_service import (
|
||||||
|
MemoryService,
|
||||||
|
MemoryType,
|
||||||
|
MemoryRecord,
|
||||||
|
memory_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryType:
|
||||||
|
"""Tests for MemoryType enum."""
|
||||||
|
|
||||||
|
def test_user_profile_type(self):
|
||||||
|
"""Test user_profile type exists."""
|
||||||
|
assert MemoryType.USER_PROFILE.value == "user_profile"
|
||||||
|
|
||||||
|
def test_preference_type(self):
|
||||||
|
"""Test preference type exists."""
|
||||||
|
assert MemoryType.PREFERENCE.value == "preference"
|
||||||
|
|
||||||
|
def test_learned_fact_type(self):
|
||||||
|
"""Test learned_fact type exists."""
|
||||||
|
assert MemoryType.LEARNED_FACT.value == "learned_fact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryRecord:
|
||||||
|
"""Tests for MemoryRecord model."""
|
||||||
|
|
||||||
|
def test_create_minimal_record(self):
|
||||||
|
"""Test creating record with minimal fields."""
|
||||||
|
record = MemoryRecord(
|
||||||
|
id="test_1",
|
||||||
|
type=MemoryType.USER_PROFILE,
|
||||||
|
key="location",
|
||||||
|
value="Amsterdam",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.id == "test_1"
|
||||||
|
assert record.type == MemoryType.USER_PROFILE
|
||||||
|
assert record.key == "location"
|
||||||
|
assert record.value == "Amsterdam"
|
||||||
|
assert record.importance == 0.5 # Default
|
||||||
|
assert record.source == "explicit" # Default
|
||||||
|
|
||||||
|
def test_create_full_record(self):
|
||||||
|
"""Test creating record with all fields."""
|
||||||
|
record = MemoryRecord(
|
||||||
|
id="test_2",
|
||||||
|
type=MemoryType.LEARNED_FACT,
|
||||||
|
key="car",
|
||||||
|
value="Tesla Model 3",
|
||||||
|
keywords=["car", "vehicle", "tesla"],
|
||||||
|
importance=0.8,
|
||||||
|
source="conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.keywords == ["car", "vehicle", "tesla"]
|
||||||
|
assert record.importance == 0.8
|
||||||
|
assert record.source == "conversation"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceInit:
|
||||||
|
"""Tests for MemoryService initialization."""
|
||||||
|
|
||||||
|
def test_service_has_lazy_clients(self):
|
||||||
|
"""Test service initializes with lazy client loading."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
assert service._qdrant is None
|
||||||
|
assert service._embedding is None
|
||||||
|
assert service._cache is None
|
||||||
|
|
||||||
|
def test_global_instance_exists(self):
|
||||||
|
"""Test global memory_service instance exists."""
|
||||||
|
assert memory_service is not None
|
||||||
|
assert isinstance(memory_service, MemoryService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceProfileMethods:
|
||||||
|
"""Tests for profile-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_uses_context(self):
|
||||||
|
"""Test get_profile uses request context for user."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Amsterdam"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_profile("location")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.USER_PROFILE, "location")
|
||||||
|
assert result == "Amsterdam"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_explicit_user(self):
|
||||||
|
"""Test get_profile with explicit user parameter."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Berlin"
|
||||||
|
|
||||||
|
result = await service.get_profile("location", user="otheruser")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("otheruser", MemoryType.USER_PROFILE, "location")
|
||||||
|
assert result == "Berlin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_profile_high_importance(self):
|
||||||
|
"""Test set_profile uses high importance (0.9)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.set_profile("timezone", "Europe/Amsterdam")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.9
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServicePreferenceMethods:
|
||||||
|
"""Tests for preference-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preference(self):
|
||||||
|
"""Test get_preference retrieves correctly."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "celsius"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_preference("temperature_unit")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.PREFERENCE, "temperature_unit")
|
||||||
|
assert result == "celsius"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_preference_medium_importance(self):
|
||||||
|
"""Test set_preference uses medium importance (0.7)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.set_preference("theme", "dark")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceFactMethods:
|
||||||
|
"""Tests for fact-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fact_default_importance(self):
|
||||||
|
"""Test store_fact uses default importance (0.5)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.store_fact("car", "Tesla Model 3")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fact_custom_importance(self):
|
||||||
|
"""Test store_fact with custom importance."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.store_fact(
|
||||||
|
"employer",
|
||||||
|
"Acme Corp",
|
||||||
|
importance=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.8
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_fact(self):
|
||||||
|
"""Test get_fact retrieves correctly."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Tesla Model 3"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_fact("car")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.LEARNED_FACT, "car")
|
||||||
|
assert result == "Tesla Model 3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServicePrefetch:
|
||||||
|
"""Tests for prefetch_context method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_default_keys(self):
|
||||||
|
"""Test prefetch with default profile keys."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_profile.side_effect = [
|
||||||
|
"Amsterdam", # location
|
||||||
|
"Europe/Amsterdam", # timezone
|
||||||
|
"John", # name
|
||||||
|
]
|
||||||
|
mock_prefs.return_value = {"temperature_unit": "celsius"}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context()
|
||||||
|
|
||||||
|
assert result["profile"]["location"] == "Amsterdam"
|
||||||
|
assert result["profile"]["timezone"] == "Europe/Amsterdam"
|
||||||
|
assert result["profile"]["name"] == "John"
|
||||||
|
assert result["preferences"]["temperature_unit"] == "celsius"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_specific_keys(self):
|
||||||
|
"""Test prefetch with specific profile keys."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_profile.return_value = "Amsterdam"
|
||||||
|
mock_prefs.return_value = {}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context(
|
||||||
|
profile_keys=["location"],
|
||||||
|
include_preferences=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only fetch location
|
||||||
|
mock_profile.assert_called_once()
|
||||||
|
mock_prefs.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_no_profile(self):
|
||||||
|
"""Test prefetch without profile data."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_prefs.return_value = {"theme": "dark"}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context(include_profile=False)
|
||||||
|
|
||||||
|
mock_profile.assert_not_called()
|
||||||
|
assert "profile" not in result
|
||||||
|
assert result["preferences"]["theme"] == "dark"
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# End-to-End API Tests
|
||||||
|
|
||||||
|
These tests make real HTTP requests to the running Tatlock API server to verify the complete stack works correctly.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Server must be running** on `http://localhost:8777` (use `./wakeup.sh`)
|
||||||
|
2. **Ollama must be running** with `mistral-nemo:latest` model
|
||||||
|
3. **Redis must be running** (for benchmarking)
|
||||||
|
4. **Qdrant must be running** on `http://localhost:6333` (for memory tests)
|
||||||
|
|
||||||
|
## Running the Tests
|
||||||
|
|
||||||
|
### Start the server first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start the server (auto-reload enabled)
|
||||||
|
./wakeup.sh
|
||||||
|
|
||||||
|
# Logs are written to logs/server.log - tail them in another terminal:
|
||||||
|
tail -f logs/server.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the E2E tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
pytest tests/e2e/ -v -m e2e
|
||||||
|
|
||||||
|
# Run orchestration tests specifically
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py -v
|
||||||
|
|
||||||
|
# Run API endpoint tests
|
||||||
|
pytest tests/e2e/test_api_endpoints.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run specific test categories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Memory system tests
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestMemoryStorage -v
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestMemoryRecall -v
|
||||||
|
|
||||||
|
# Steward delegation tests
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestStewardDelegation -v
|
||||||
|
|
||||||
|
# Direct delegation bypass tests (new feature)
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestDirectDelegationBypass -v
|
||||||
|
|
||||||
|
# User isolation tests
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestUserContextIsolation -v
|
||||||
|
|
||||||
|
# Orchestration scenario tests
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestScenario1WeatherWithMemory -v
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestScenario4SimpleExpertDelegation -v
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestScenario6WikiCreation -v
|
||||||
|
|
||||||
|
# Generate evaluation report
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestEvaluationReport -v -s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Organization
|
||||||
|
|
||||||
|
### `test_api_endpoints.py` - Core API Tests
|
||||||
|
|
||||||
|
- Chat Completions endpoint (`/v1/chat/completions`)
|
||||||
|
- Responses API endpoint (`/v1/responses`)
|
||||||
|
- Streaming responses
|
||||||
|
- Error handling
|
||||||
|
- OpenAI format compliance
|
||||||
|
|
||||||
|
### `test_orchestration_e2e.py` - Orchestration Scenario Tests
|
||||||
|
|
||||||
|
Based on `ORCHESTRATION_SCENARIOS.md`:
|
||||||
|
|
||||||
|
| Class | Scenario | What it Tests |
|
||||||
|
|-------|----------|---------------|
|
||||||
|
| `TestMemoryStorage` | Memory storage | Store -> Qdrant verification |
|
||||||
|
| `TestMemoryRecall` | Memory recall | Store -> Recall flow |
|
||||||
|
| `TestStewardDelegation` | Steward routing | Capability recommendations |
|
||||||
|
| `TestDirectDelegation` | Direct bypass | Pure memory/librarian requests |
|
||||||
|
| `TestScenario1WeatherWithMemory` | Weather check | Multi-step with memory lookup |
|
||||||
|
| `TestScenario4SimpleExpertDelegation` | Calculator/datetime | Simple tool use |
|
||||||
|
| `TestScenario6WikiCreation` | Wiki operations | Librarian delegation |
|
||||||
|
| `TestScenario8MultiExpertCoordination` | Complex requests | Multiple capabilities |
|
||||||
|
| `TestUserContextIsolation` | User isolation | llm_tester vs production |
|
||||||
|
| `TestDataVerification` | Data presence | Qdrant structure verification |
|
||||||
|
| `TestIntegrationHealth` | System health | API/Qdrant reachability |
|
||||||
|
| `TestEvaluationReport` | Diagnostic | Generates behavior reports |
|
||||||
|
|
||||||
|
## User Isolation
|
||||||
|
|
||||||
|
Tests use the `llm_tester` user (development environment default) to isolate test data from production:
|
||||||
|
|
||||||
|
- Test memories: `memories_llm_tester` (Qdrant collection)
|
||||||
|
- Production memories: `memories_jpmschweitzer` (never modified by tests)
|
||||||
|
|
||||||
|
## Handling LLM Non-Determinism
|
||||||
|
|
||||||
|
LLM outputs are non-deterministic. Tests handle this by:
|
||||||
|
|
||||||
|
1. **Flexible assertions** - Check for behavior patterns, not exact text
|
||||||
|
2. **`assert_llm_behavior()`** - Helper for pattern matching with confidence levels
|
||||||
|
3. **Soft failures (`pytest.xfail`)** - Some tests may fail due to LLM variance without failing the suite
|
||||||
|
4. **Evaluation reports** - Generate diagnostic reports for human review
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
result = assert_llm_behavior(
|
||||||
|
message_text,
|
||||||
|
expected_patterns=[r"(remember|noted|stored)", r"purple"],
|
||||||
|
min_matches=1,
|
||||||
|
)
|
||||||
|
if not result.passed:
|
||||||
|
pytest.xfail(f"LLM response unclear: {result.evidence}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Verification
|
||||||
|
|
||||||
|
Tests verify data presence in Qdrant:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# QdrantVerifier helper
|
||||||
|
qdrant = QdrantVerifier()
|
||||||
|
points = await qdrant.scroll_points("memories_llm_tester")
|
||||||
|
memory = await qdrant.find_memory_by_key("memories_llm_tester", "favorite_color")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests fail with connection error
|
||||||
|
|
||||||
|
Make sure the server is running:
|
||||||
|
```bash
|
||||||
|
./wakeup.sh
|
||||||
|
curl http://localhost:8777/health # Should return 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests timeout
|
||||||
|
|
||||||
|
- Check Ollama is running: `curl http://localhost:11434/api/tags`
|
||||||
|
- Increase timeout if needed (default: 120s for LLM calls)
|
||||||
|
|
||||||
|
### Memory tests fail
|
||||||
|
|
||||||
|
- Check Qdrant is running: `curl http://localhost:6333/collections`
|
||||||
|
- Verify `memories_llm_tester` collection exists
|
||||||
|
|
||||||
|
### Inconsistent results
|
||||||
|
|
||||||
|
- LLM responses vary - this is expected
|
||||||
|
- Check the evaluation report for detailed diagnostics:
|
||||||
|
```bash
|
||||||
|
pytest tests/e2e/test_orchestration_e2e.py::TestEvaluationReport -v -s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests pollute production data
|
||||||
|
|
||||||
|
- This shouldn't happen - tests use `llm_tester` user
|
||||||
|
- If it does, check `ENVIRONMENT` is set to `development` in `.env`
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
1. Use existing fixtures (`client`, `qdrant`, `clean_test_memories`)
|
||||||
|
2. Use `assert_llm_behavior()` for flexible LLM output checking
|
||||||
|
3. Add `@pytest.mark.e2e` decorator
|
||||||
|
4. Consider adding soft failures for non-deterministic checks
|
||||||
|
5. Add test keys to `clean_test_memories` fixture if storing new memories
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestNewScenario:
|
||||||
|
async def test_something(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
qdrant: QdrantVerifier,
|
||||||
|
clean_test_memories,
|
||||||
|
):
|
||||||
|
response = await client.post("/v1/responses", json={...})
|
||||||
|
# Use assert_llm_behavior for flexible checking
|
||||||
|
result = assert_llm_behavior(response_text, expected_patterns=[...])
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests that make real HTTP requests to the running server.
|
||||||
|
|
||||||
|
These tests require the server to be running on localhost:8000.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
"""
|
||||||
|
End-to-end API tests that make real HTTP requests.
|
||||||
|
|
||||||
|
These tests hit the actual running server and test the full stack:
|
||||||
|
- HTTP request/response handling
|
||||||
|
- Steward preprocessing
|
||||||
|
- Tool execution
|
||||||
|
- Response formatting
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
|
||||||
|
BASE_URL = "http://localhost:8777"
|
||||||
|
API_TIMEOUT = 120.0 # 120 second timeout for LLM calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(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]:
|
||||||
|
"""HTTP client for making requests."""
|
||||||
|
async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatCompletionsE2E:
|
||||||
|
"""End-to-end tests for /v1/chat/completions endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_simple_calculation(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that a math request triggers calculator tool."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 144 divided by 12?"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify response structure
|
||||||
|
assert data["object"] == "chat.completion"
|
||||||
|
assert data["model"] == "Tatlock"
|
||||||
|
assert len(data["choices"]) == 1
|
||||||
|
|
||||||
|
# Verify response content
|
||||||
|
message = data["choices"][0]["message"]
|
||||||
|
assert message["role"] == "assistant"
|
||||||
|
content = message["content"]
|
||||||
|
|
||||||
|
# Should contain Steward's analysis in <think> tags
|
||||||
|
assert "<think>" in content
|
||||||
|
assert "</think>" in content
|
||||||
|
|
||||||
|
# Should contain the answer (12) - just check the number appears
|
||||||
|
assert "12" in content, f"Expected answer '12' not found in: {content}"
|
||||||
|
|
||||||
|
# Should show calculator was used - check for tool indicator
|
||||||
|
# Tool calls show up with 🧮 emoji when logged
|
||||||
|
has_calculator_indicator = "🧮" in content
|
||||||
|
|
||||||
|
# Verify usage stats
|
||||||
|
assert "usage" in data
|
||||||
|
assert data["usage"]["total_tokens"] > 0
|
||||||
|
|
||||||
|
print(f"✓ Calculator test passed. Found '12' in response. Tool indicator: {has_calculator_indicator}")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_web_search(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that a search request can trigger web search tool."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Search for the current population of Tokyo"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify response structure
|
||||||
|
assert data["object"] == "chat.completion"
|
||||||
|
assert len(data["choices"]) == 1
|
||||||
|
|
||||||
|
message = data["choices"][0]["message"]
|
||||||
|
content = message["content"]
|
||||||
|
|
||||||
|
# Should contain Steward's analysis
|
||||||
|
assert "<think>" in content
|
||||||
|
assert "</think>" in content
|
||||||
|
|
||||||
|
# Should mention Tokyo or population (flexible - LLM output varies)
|
||||||
|
assert "Tokyo" in content or "million" in content
|
||||||
|
|
||||||
|
# Check if search was used (🔍 emoji indicates search tool call)
|
||||||
|
has_search_indicator = "🔍" in content
|
||||||
|
|
||||||
|
print(f"✓ Search test passed. Search indicator present: {has_search_indicator}")
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Flaky: hits edge case with conversation history formatting")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multi_turn_conversation(self, client: httpx.AsyncClient):
|
||||||
|
"""Test multi-turn conversation maintains context."""
|
||||||
|
# First turn: Ask a question
|
||||||
|
response1 = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 15 times 4?"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response1.status_code == 200
|
||||||
|
data1 = response1.json()
|
||||||
|
message1 = data1["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should contain "60" somewhere in response
|
||||||
|
assert "60" in message1, f"Expected '60' not found in: {message1}"
|
||||||
|
|
||||||
|
# Second turn: Follow-up question referencing previous answer
|
||||||
|
response2 = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 15 times 4?"},
|
||||||
|
{"role": "assistant", "content": message1},
|
||||||
|
{"role": "user", "content": "Now add 20 to that result."}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response2.status_code == 200
|
||||||
|
data2 = response2.json()
|
||||||
|
message2 = data2["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should have Steward analysis
|
||||||
|
assert "<think>" in message2
|
||||||
|
|
||||||
|
# Should either have the answer "80" OR show calculation attempt (LLM variance)
|
||||||
|
has_answer = "80" in message2
|
||||||
|
has_calculation = "60" in message2 and "20" in message2
|
||||||
|
assert has_answer or has_calculation, f"Expected '80' or calculation in: {message2}"
|
||||||
|
|
||||||
|
print(f"✓ Multi-turn test passed. Answer found: {has_answer}, Calculation shown: {has_calculation}")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculation_and_search(self, client: httpx.AsyncClient):
|
||||||
|
"""Test request requiring both calculator and search."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Calculate the square root of 256"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
message = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should contain Steward's analysis
|
||||||
|
assert "<think>" in message
|
||||||
|
assert "</think>" in message
|
||||||
|
|
||||||
|
# Should calculate sqrt(256) = 16 (just check number appears)
|
||||||
|
assert "16" in message, f"Expected '16' (sqrt of 256) not found in: {message}"
|
||||||
|
|
||||||
|
# Check for calculator tool indicator
|
||||||
|
has_calculator = "🧮" in message
|
||||||
|
|
||||||
|
print(f"✓ Calculation test passed. Found '16'. Calculator indicator: {has_calculator}")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_simple_greeting_no_tools(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that simple greetings don't trigger unnecessary tools."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello, how are you?"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
message = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should still have Steward analysis
|
||||||
|
assert "<think>" in message
|
||||||
|
|
||||||
|
# Should NOT show tool usage indicators (no calculations or searches needed)
|
||||||
|
has_tools = "🧮" in message or "🔍" in message
|
||||||
|
|
||||||
|
# Should get some response (exact wording varies)
|
||||||
|
assert len(message) > 20, "Response should have content"
|
||||||
|
|
||||||
|
print(f"✓ Greeting test passed. No tools needed (tools used: {has_tools})")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_time_query(self, client: httpx.AsyncClient):
|
||||||
|
"""Test date/time queries trigger datetime tools."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is today's date?"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
message = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Should contain Steward analysis
|
||||||
|
assert "<think>" in message
|
||||||
|
|
||||||
|
# Check for datetime tool indicator (🕐 emoji)
|
||||||
|
has_datetime = "🕐" in message
|
||||||
|
|
||||||
|
# Should contain some date/time information (flexible - varies in format)
|
||||||
|
import re
|
||||||
|
has_date = (
|
||||||
|
re.search(r'\d{4}', message) or # Year
|
||||||
|
re.search(r'\d{1,2}', message) or # Day/month number
|
||||||
|
re.search(r'(January|February|March|April|May|June|July|August|September|October|November|December)', message, re.IGNORECASE) or
|
||||||
|
"today" in message.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert has_date, f"Expected date/time information in: {message}"
|
||||||
|
print(f"✓ Date/time test passed. Datetime tool indicator: {has_datetime}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponsesAPIE2E:
|
||||||
|
"""End-to-end tests for /v1/responses endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_response_with_reasoning(self, client: httpx.AsyncClient):
|
||||||
|
"""Test Responses API with reasoning output."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/responses",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [
|
||||||
|
{"role": "user", "content": "Calculate 25 times 16"}
|
||||||
|
],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify response structure
|
||||||
|
assert data["object"] == "response"
|
||||||
|
assert data["model"] == "Tatlock"
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
|
||||||
|
# Should have output items
|
||||||
|
assert len(data["output"]) >= 2 # At least reasoning + message
|
||||||
|
|
||||||
|
# First item should be Steward's reasoning
|
||||||
|
reasoning_item = data["output"][0]
|
||||||
|
assert reasoning_item["type"] == "reasoning"
|
||||||
|
assert "summary" in reasoning_item
|
||||||
|
assert "🎩" in str(reasoning_item["summary"]) or "Steward" in str(reasoning_item["summary"])
|
||||||
|
|
||||||
|
# Last item should be message
|
||||||
|
message_item = data["output"][-1]
|
||||||
|
assert message_item["type"] == "message"
|
||||||
|
assert message_item["role"] == "assistant"
|
||||||
|
|
||||||
|
# Should contain the answer (400) somewhere in response
|
||||||
|
message_content = message_item["content"][0]["text"]
|
||||||
|
assert "400" in message_content, f"Expected '400' (25*16) not found in: {message_content}"
|
||||||
|
|
||||||
|
# Verify usage stats
|
||||||
|
assert "usage" in data
|
||||||
|
assert data["usage"]["total_tokens"] > 0
|
||||||
|
|
||||||
|
print(f"✓ Responses API test passed. Found '400' with Steward reasoning.")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_response_multi_turn(self, client: httpx.AsyncClient):
|
||||||
|
"""Test Responses API with conversation history."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/responses",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [
|
||||||
|
{"role": "user", "content": "What is 7 times 8?"},
|
||||||
|
{"role": "assistant", "content": "Certainly, sir. 7 times 8 equals 56."},
|
||||||
|
{"role": "user", "content": "Double that number."}
|
||||||
|
],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should have Steward reasoning (wording may vary)
|
||||||
|
reasoning_item = data["output"][0]
|
||||||
|
reasoning_text = " ".join(reasoning_item["summary"])
|
||||||
|
|
||||||
|
# Steward analysis should be present (exact wording varies with LLM)
|
||||||
|
assert "🎩" in reasoning_text or "Steward" in reasoning_text
|
||||||
|
assert "tatlock_core" in reasoning_text.lower() or "calculat" in reasoning_text.lower()
|
||||||
|
|
||||||
|
# Should calculate 112 (56 * 2)
|
||||||
|
message_item = data["output"][-1]
|
||||||
|
message_content = message_item["content"][0]["text"]
|
||||||
|
assert "112" in message_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingE2E:
|
||||||
|
"""End-to-end tests for streaming endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_streaming(self, client: httpx.AsyncClient):
|
||||||
|
"""Test streaming chat completions."""
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 9 times 7?"}
|
||||||
|
],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
) as response:
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:] # Remove "data: " prefix
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
import json
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
# Should have received multiple chunks
|
||||||
|
assert len(chunks) > 0
|
||||||
|
|
||||||
|
# First chunk should have role
|
||||||
|
assert chunks[0]["choices"][0]["delta"]["role"] == "assistant"
|
||||||
|
|
||||||
|
# Should have received Steward's reasoning (in <think> tags)
|
||||||
|
full_content = "".join(
|
||||||
|
chunk["choices"][0]["delta"].get("content", "") or ""
|
||||||
|
for chunk in chunks
|
||||||
|
)
|
||||||
|
assert "<think>" in full_content
|
||||||
|
assert "</think>" in full_content
|
||||||
|
|
||||||
|
# Should contain answer (63)
|
||||||
|
assert "63" in full_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""End-to-end tests for error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_model(self, client: httpx.AsyncClient):
|
||||||
|
"""Test request with non-existent model."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "nonexistent-model",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_messages(self, client: httpx.AsyncClient):
|
||||||
|
"""Test request with missing required field."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
# Missing "messages" field
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_temperature(self, client: httpx.AsyncClient):
|
||||||
|
"""Test request with out-of-range temperature."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
],
|
||||||
|
"temperature": 5.0 # Max is 2.0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatResponsesWrapper:
|
||||||
|
"""Tests to verify Chat Completions properly wraps Responses API."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_responses_format_matches_spec(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that Responses API matches OpenAI Responses format spec."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/responses",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [
|
||||||
|
{"role": "user", "content": "Calculate 13 times 9"}
|
||||||
|
],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify OpenAI Responses format
|
||||||
|
assert data["object"] == "response"
|
||||||
|
assert data["model"] == "Tatlock"
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
assert "id" in data
|
||||||
|
assert "created_at" in data
|
||||||
|
assert "output" in data
|
||||||
|
assert isinstance(data["output"], list)
|
||||||
|
|
||||||
|
# Verify output items structure
|
||||||
|
for item in data["output"]:
|
||||||
|
assert "type" in item
|
||||||
|
assert "id" in item
|
||||||
|
assert "status" in item
|
||||||
|
assert item["type"] in ["reasoning", "message", "function_call"]
|
||||||
|
|
||||||
|
if item["type"] == "reasoning":
|
||||||
|
assert "summary" in item
|
||||||
|
assert isinstance(item["summary"], list)
|
||||||
|
|
||||||
|
elif item["type"] == "message":
|
||||||
|
assert "role" in item
|
||||||
|
assert "content" in item
|
||||||
|
assert isinstance(item["content"], list)
|
||||||
|
for content_item in item["content"]:
|
||||||
|
assert "type" in content_item
|
||||||
|
assert "text" in content_item
|
||||||
|
|
||||||
|
# Verify usage stats
|
||||||
|
assert "usage" in data
|
||||||
|
assert "input_tokens" in data["usage"]
|
||||||
|
assert "output_tokens" in data["usage"]
|
||||||
|
assert "total_tokens" in data["usage"]
|
||||||
|
|
||||||
|
print("✓ Responses API format matches OpenAI Responses spec")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_format_matches_openai_spec(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that Chat Completions response matches OpenAI spec."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What is 5 plus 3?"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify OpenAI Chat Completions format
|
||||||
|
assert data["object"] == "chat.completion"
|
||||||
|
assert data["model"] == "Tatlock"
|
||||||
|
assert "id" in data
|
||||||
|
assert "created" in data
|
||||||
|
assert "choices" in data
|
||||||
|
assert len(data["choices"]) == 1
|
||||||
|
|
||||||
|
choice = data["choices"][0]
|
||||||
|
assert choice["index"] == 0
|
||||||
|
assert choice["message"]["role"] == "assistant"
|
||||||
|
assert isinstance(choice["message"]["content"], str)
|
||||||
|
assert choice["finish_reason"] == "stop"
|
||||||
|
|
||||||
|
# Verify usage stats
|
||||||
|
assert "usage" in data
|
||||||
|
assert "prompt_tokens" in data["usage"]
|
||||||
|
assert "completion_tokens" in data["usage"]
|
||||||
|
assert "total_tokens" in data["usage"]
|
||||||
|
|
||||||
|
print("✓ Chat Completions format matches OpenAI spec")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_streaming_format_matches_openai_spec(self, client: httpx.AsyncClient):
|
||||||
|
"""Test that streaming Chat Completions matches OpenAI SSE spec."""
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Count to 3"}
|
||||||
|
],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
) as response:
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:]
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
import json
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
# Verify each chunk matches OpenAI format
|
||||||
|
assert chunk["object"] == "chat.completion.chunk"
|
||||||
|
assert chunk["model"] == "Tatlock"
|
||||||
|
assert "id" in chunk
|
||||||
|
assert "created" in chunk
|
||||||
|
assert "choices" in chunk
|
||||||
|
assert len(chunk["choices"]) == 1
|
||||||
|
|
||||||
|
choice = chunk["choices"][0]
|
||||||
|
assert choice["index"] == 0
|
||||||
|
assert "delta" in choice
|
||||||
|
|
||||||
|
# First chunk should have role
|
||||||
|
assert chunks[0]["choices"][0]["delta"]["role"] == "assistant"
|
||||||
|
|
||||||
|
# Should have content chunks
|
||||||
|
has_content = any(
|
||||||
|
"content" in chunk["choices"][0]["delta"]
|
||||||
|
for chunk in chunks
|
||||||
|
)
|
||||||
|
assert has_content
|
||||||
|
|
||||||
|
print(f"✓ Streaming format matches OpenAI spec ({len(chunks)} chunks)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStewardIntegration:
|
||||||
|
"""Tests specifically for Steward preprocessing behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_steward_recommends_calculator(self, client: httpx.AsyncClient):
|
||||||
|
"""Verify Steward recommends calculator for math."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/responses",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [
|
||||||
|
{"role": "user", "content": "Calculate 123 times 456"}
|
||||||
|
],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check Steward's reasoning
|
||||||
|
reasoning_item = data["output"][0]
|
||||||
|
reasoning_text = " ".join(reasoning_item["summary"]).lower()
|
||||||
|
|
||||||
|
# Should mention tatlock_core or calculation capability
|
||||||
|
assert "tatlock_core" in reasoning_text or "calculat" in reasoning_text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_steward_context_awareness(self, client: httpx.AsyncClient):
|
||||||
|
"""Verify Steward detects conversation context."""
|
||||||
|
response = await client.post(
|
||||||
|
"/v1/responses",
|
||||||
|
json={
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [
|
||||||
|
{"role": "user", "content": "My favorite number is 42"},
|
||||||
|
{"role": "assistant", "content": "Noted, sir. 42 is an excellent choice."},
|
||||||
|
{"role": "user", "content": "What was that number again?"}
|
||||||
|
],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check Steward's reasoning is present
|
||||||
|
reasoning_item = data["output"][0]
|
||||||
|
reasoning_text = " ".join(reasoning_item["summary"])
|
||||||
|
|
||||||
|
# Steward analysis should be present (exact wording varies)
|
||||||
|
assert "🎩" in reasoning_text or "Steward" in reasoning_text
|
||||||
|
|
||||||
|
# Should get some response (LLM may or may not recall "42" depending on context interpretation)
|
||||||
|
message_item = data["output"][-1]
|
||||||
|
message_content = message_item["content"][0]["text"]
|
||||||
|
assert len(message_content) > 20 # Has meaningful response
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Steward + Tatlock streaming.
|
||||||
|
|
||||||
|
Tests the complete streaming flow with Steward preprocessing.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.responses.schemas import ResponseRequest
|
||||||
|
from src.responses.streaming import StreamingCoordinator, StreamEventType
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def setup_household_registry():
|
||||||
|
"""Initialize household registry before running tests."""
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStewardStreaming:
|
||||||
|
"""Test Steward + Tatlock streaming integration."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_with_steward_basic(self):
|
||||||
|
"""Test basic streaming with Steward preprocessing."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "What's 2 + 2?"}],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the Steward analysis
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
# Mock the streaming method (async generator)
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
# Mock Steward recommendation
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Math calculation requires tatlock_core",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Tatlock streaming response as async generator
|
||||||
|
async def mock_stream(*args, **kwargs):
|
||||||
|
yield "Certainly, sir. "
|
||||||
|
yield "2 + 2 equals 4."
|
||||||
|
|
||||||
|
mock_tatlock_stream.return_value = mock_stream()
|
||||||
|
|
||||||
|
# Execute streaming
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
async for event in coordinator.stream_response_with_steward(request):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Verify event sequence
|
||||||
|
event_types = [e.event for e in events]
|
||||||
|
|
||||||
|
# Should have reasoning summary deltas
|
||||||
|
assert StreamEventType.REASONING_SUMMARY_DELTA in event_types
|
||||||
|
assert StreamEventType.REASONING_SUMMARY_DONE in event_types
|
||||||
|
|
||||||
|
# Should have output text deltas
|
||||||
|
assert StreamEventType.OUTPUT_TEXT_DELTA in event_types
|
||||||
|
assert StreamEventType.OUTPUT_TEXT_DONE in event_types
|
||||||
|
|
||||||
|
# Should end with response.done
|
||||||
|
assert events[-1].event == StreamEventType.RESPONSE_DONE
|
||||||
|
|
||||||
|
# Verify Steward and Tatlock were called
|
||||||
|
assert mock_steward.called
|
||||||
|
assert mock_tatlock_stream.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_with_conversation_history(self):
|
||||||
|
"""Test streaming with conversation history."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[
|
||||||
|
{"role": "user", "content": "What's 5 times 3?"},
|
||||||
|
{"role": "assistant", "content": "That equals 15, sir."},
|
||||||
|
{"role": "user", "content": "And divided by 3?"},
|
||||||
|
],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Follow-up calculation based on previous result of 15",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[0],
|
||||||
|
context_summary="Previous calculation in turn 0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_stream(*args, **kwargs):
|
||||||
|
yield "15 divided by 3 equals 5, sir."
|
||||||
|
|
||||||
|
mock_tatlock_stream.return_value = mock_stream()
|
||||||
|
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
async for event in coordinator.stream_response_with_steward(request):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Verify conversation history was passed to Steward
|
||||||
|
call_kwargs = mock_steward.call_args[1]
|
||||||
|
assert "conversation_history" in call_kwargs
|
||||||
|
assert len(call_kwargs["conversation_history"]) == 2 # First Q&A pair
|
||||||
|
|
||||||
|
# Verify final response includes both reasoning and message
|
||||||
|
final_event = events[-1]
|
||||||
|
assert final_event.event == StreamEventType.RESPONSE_DONE
|
||||||
|
assert len(final_event.response.output) == 2 # Reasoning + Message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_reasoning_contains_steward_analysis(self):
|
||||||
|
"""Test that reasoning summary contains Steward's analysis."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Test request"}],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="This is a test analysis with specific markers",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_stream(*args, **kwargs):
|
||||||
|
yield "Test response"
|
||||||
|
|
||||||
|
mock_tatlock_stream.return_value = mock_stream()
|
||||||
|
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
|
reasoning_deltas = []
|
||||||
|
|
||||||
|
async for event in coordinator.stream_response_with_steward(request):
|
||||||
|
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
|
||||||
|
reasoning_deltas.append(event.delta)
|
||||||
|
|
||||||
|
# Combine all reasoning deltas
|
||||||
|
full_reasoning = "".join(reasoning_deltas)
|
||||||
|
|
||||||
|
# Should contain Steward's analysis
|
||||||
|
assert "test analysis" in full_reasoning.lower()
|
||||||
|
assert len(reasoning_deltas) > 0, "Should have streamed reasoning deltas"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_with_missing_capabilities(self):
|
||||||
|
"""Test streaming when Steward detects missing capabilities."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Generate an image of a sunset"}],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Image generation not available in current toolset",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
missing_capabilities="Image generation capability would be needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_stream(*args, **kwargs):
|
||||||
|
yield "I'm afraid I don't have image generation capabilities, sir."
|
||||||
|
|
||||||
|
mock_tatlock_stream.return_value = mock_stream()
|
||||||
|
|
||||||
|
coordinator = StreamingCoordinator()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
async for event in coordinator.stream_response_with_steward(request):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Should complete successfully even with missing capabilities
|
||||||
|
assert events[-1].event == StreamEventType.RESPONSE_DONE
|
||||||
|
|
||||||
|
# Verify empty scoped tools were passed to stream method
|
||||||
|
tatlock_kwargs = mock_tatlock_stream.call_args[1]
|
||||||
|
assert "scoped_tools" in tatlock_kwargs
|
||||||
|
assert tatlock_kwargs["scoped_tools"] == []
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Steward → Tatlock flow.
|
||||||
|
|
||||||
|
Tests the complete Phase 2 request pipeline:
|
||||||
|
1. Steward analyzes request and recommends capabilities
|
||||||
|
2. Tool tracker monitors tool usage
|
||||||
|
3. Tatlock runs with scoped tools
|
||||||
|
4. Response includes both Steward reasoning and Tatlock output
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.responses.schemas import ResponseRequest
|
||||||
|
from src.responses.service import create_response_with_steward
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def setup_household_registry():
|
||||||
|
"""Initialize household registry before running tests."""
|
||||||
|
initialize_application()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStewardTatlockIntegration:
|
||||||
|
"""Test full Steward → Tatlock integration flow."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_simple_math_request(self):
|
||||||
|
"""Test math request flows through Steward → Tatlock correctly."""
|
||||||
|
# Create a simple math request
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "What's 2 + 2?"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the Steward analysis
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
# Mock Steward recommendation
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Math calculation requires tatlock_core",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Tatlock response
|
||||||
|
mock_tatlock.return_value = "Certainly, sir. 2 + 2 equals 4."
|
||||||
|
|
||||||
|
# Execute the flow
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify Steward was called
|
||||||
|
assert mock_steward.called
|
||||||
|
# Note: preprocess_request injects temporal context
|
||||||
|
steward_call_arg = mock_steward.call_args[0][0]
|
||||||
|
assert steward_call_arg.startswith("What's 2 + 2?"), \
|
||||||
|
f"Expected request to start with original message, got: {steward_call_arg}"
|
||||||
|
|
||||||
|
# Verify Tatlock was called with scoped tools
|
||||||
|
assert mock_tatlock.called
|
||||||
|
|
||||||
|
# Verify response structure
|
||||||
|
assert response.status == "completed"
|
||||||
|
assert len(response.output) == 2 # Reasoning + Message
|
||||||
|
|
||||||
|
# Check Steward reasoning output
|
||||||
|
reasoning_item = response.output[0]
|
||||||
|
assert reasoning_item.type == "reasoning"
|
||||||
|
assert "Math calculation" in reasoning_item.summary[1]
|
||||||
|
|
||||||
|
# Check Tatlock message output
|
||||||
|
message_item = response.output[1]
|
||||||
|
assert message_item.type == "message"
|
||||||
|
assert message_item.role == "assistant"
|
||||||
|
assert "4" in message_item.content[0].text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_with_conversation_history(self):
|
||||||
|
"""Test that conversation history flows through to Steward."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[
|
||||||
|
{"role": "user", "content": "What's 5 times 3?"},
|
||||||
|
{"role": "assistant", "content": "That equals 15, sir."},
|
||||||
|
{"role": "user", "content": "And divided by 3?"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Follow-up calculation",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(
|
||||||
|
has_previous_context=True,
|
||||||
|
relevant_turns=[0],
|
||||||
|
context_summary="Previous calculation in turn 0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tatlock.return_value = "15 divided by 3 equals 5, sir."
|
||||||
|
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify Steward received conversation history
|
||||||
|
call_kwargs = mock_steward.call_args[1]
|
||||||
|
assert "conversation_history" in call_kwargs
|
||||||
|
assert len(call_kwargs["conversation_history"]) == 2 # First Q&A pair
|
||||||
|
|
||||||
|
# Verify Tatlock received history
|
||||||
|
tatlock_kwargs = mock_tatlock.call_args[1]
|
||||||
|
assert "message_history" in tatlock_kwargs
|
||||||
|
|
||||||
|
# Verify response completed
|
||||||
|
assert response.status == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_capabilities_needed(self):
|
||||||
|
"""Test simple conversational request that needs no tools."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Hello!"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=[], # No tools needed
|
||||||
|
reasoning="Simple greeting, no tools required",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tatlock.return_value = "Good day, sir. How may I assist you?"
|
||||||
|
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify empty scoped tools were passed
|
||||||
|
tatlock_kwargs = mock_tatlock.call_args[1]
|
||||||
|
assert "scoped_tools" in tatlock_kwargs
|
||||||
|
assert tatlock_kwargs["scoped_tools"] == [] # No tools
|
||||||
|
|
||||||
|
assert response.status == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_tracker_integration(self):
|
||||||
|
"""Test that tool tracker is passed to Tatlock and finalized."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Calculate sqrt(16)"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
with patch("src.core.tool_tracking.ToolCallTracker.finalize") as mock_finalize:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Calculator needed",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tatlock.return_value = "The square root of 16 is 4, sir."
|
||||||
|
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify tool tracker was finalized
|
||||||
|
assert mock_finalize.called
|
||||||
|
assert response.status == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_capabilities_warning(self):
|
||||||
|
"""Test that missing capabilities are included in Steward's reasoning."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Generate an image of a sunset"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=[],
|
||||||
|
reasoning="Image generation not available",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
missing_capabilities="Image generation capability would be needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tatlock.return_value = "I'm afraid I don't have image generation capabilities, sir."
|
||||||
|
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify Steward's reasoning mentions missing capabilities
|
||||||
|
reasoning_item = response.output[0]
|
||||||
|
assert "not available" in reasoning_item.summary[1].lower()
|
||||||
|
|
||||||
|
assert response.status == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conversation_id_propagation(self):
|
||||||
|
"""Test that conversation ID flows through entire pipeline."""
|
||||||
|
request = ResponseRequest(
|
||||||
|
model="tatlock",
|
||||||
|
input=[{"role": "user", "content": "Test request"}],
|
||||||
|
metadata={"conversation_id": "test_conv_123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||||
|
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||||
|
with patch("src.responses.service.ToolCallTracker") as mock_tracker_class:
|
||||||
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
|
mock_steward.return_value = StewardRecommendation(
|
||||||
|
recommended_capabilities=["tatlock_core"],
|
||||||
|
reasoning="Test",
|
||||||
|
estimated_complexity="simple",
|
||||||
|
conversation_context=ConversationContext(has_previous_context=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_tatlock.return_value = "Test response"
|
||||||
|
|
||||||
|
mock_tracker = MagicMock()
|
||||||
|
mock_tracker.get_summary = MagicMock(return_value={})
|
||||||
|
mock_tracker.finalize = AsyncMock()
|
||||||
|
mock_tracker_class.return_value = mock_tracker
|
||||||
|
|
||||||
|
response = await create_response_with_steward(request)
|
||||||
|
|
||||||
|
# Verify conversation ID was passed to Steward
|
||||||
|
steward_kwargs = mock_steward.call_args[1]
|
||||||
|
assert steward_kwargs.get("conversation_id") == "test_conv_123"
|
||||||
|
|
||||||
|
# Verify conversation ID was passed to tracker
|
||||||
|
assert mock_tracker_class.called
|
||||||
|
tracker_call_args = mock_tracker_class.call_args
|
||||||
|
if tracker_call_args and len(tracker_call_args) > 1:
|
||||||
|
tracker_init_kwargs = tracker_call_args[1]
|
||||||
|
assert tracker_init_kwargs.get("conversation_id") == "test_conv_123"
|
||||||
|
|
||||||
|
assert response.status == "completed"
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Tatlock agent streaming through full API stack.
|
||||||
|
|
||||||
|
These tests verify the complete streaming flow from API endpoint through
|
||||||
|
StreamingCoordinator to TatlockAgent, ensuring no text duplication and
|
||||||
|
proper delta calculation.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_streaming_no_duplication(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify Tatlock streaming produces no text duplication.
|
||||||
|
|
||||||
|
This test catches the bug where accumulated text from PydanticAI was
|
||||||
|
being re-streamed multiple times by the StreamingCoordinator.
|
||||||
|
Note: Requires running server, may xfail if server unavailable or LLM times out.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Say hello"}],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
collected_deltas = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/responses",
|
||||||
|
json=request_data,
|
||||||
|
timeout=60.0, # Increase timeout for LLM response
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.xfail(f"Server returned {response.status_code}")
|
||||||
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("event: "):
|
||||||
|
event_type = line[7:].strip()
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str != "[DONE]":
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
|
||||||
|
# Collect output text deltas
|
||||||
|
if chunk.get("event") == "response.output_text.delta":
|
||||||
|
collected_deltas.append(chunk["delta"])
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
pytest.xfail(f"Streaming request failed (server may be unavailable): {e}")
|
||||||
|
|
||||||
|
# Reconstruct full text from deltas
|
||||||
|
full_text = "".join(collected_deltas)
|
||||||
|
|
||||||
|
# Verify we got some response (xfail if LLM didn't produce output)
|
||||||
|
if len(full_text) == 0:
|
||||||
|
pytest.xfail("No text received from streaming (LLM may have timed out)")
|
||||||
|
|
||||||
|
# Verify no obvious duplication patterns
|
||||||
|
# Check that common words don't appear excessively repeated
|
||||||
|
words = full_text.lower().split()
|
||||||
|
if len(words) > 0:
|
||||||
|
# Check for consecutive duplicate words (sign of duplication bug)
|
||||||
|
consecutive_dupes = sum(
|
||||||
|
1 for i in range(len(words) - 1)
|
||||||
|
if words[i] == words[i + 1] and len(words[i]) > 3
|
||||||
|
)
|
||||||
|
# Allow a few duplicates (natural language), but not excessive
|
||||||
|
assert consecutive_dupes < len(words) * 0.1, \
|
||||||
|
f"Too many consecutive duplicate words: {consecutive_dupes}/{len(words)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_chat_streaming_no_duplication(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify Tatlock streaming through Chat Completions API.
|
||||||
|
|
||||||
|
Tests the full stack through the chat completions wrapper to ensure
|
||||||
|
streaming works correctly without duplication.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
collected_content = []
|
||||||
|
|
||||||
|
async with async_client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=30.0,
|
||||||
|
) as response:
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
|
||||||
|
# Collect content deltas from choices
|
||||||
|
if "choices" in chunk and len(chunk["choices"]) > 0:
|
||||||
|
delta = chunk["choices"][0].get("delta", {})
|
||||||
|
if "content" in delta and delta["content"]:
|
||||||
|
collected_content.append(delta["content"])
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reconstruct full response
|
||||||
|
full_response = "".join(collected_content)
|
||||||
|
|
||||||
|
# Verify we got a response
|
||||||
|
assert len(full_response) > 0, "Should have received response content"
|
||||||
|
|
||||||
|
# Check for duplication patterns
|
||||||
|
words = full_response.lower().split()
|
||||||
|
if len(words) > 0:
|
||||||
|
consecutive_dupes = sum(
|
||||||
|
1 for i in range(len(words) - 1)
|
||||||
|
if words[i] == words[i + 1] and len(words[i]) > 3
|
||||||
|
)
|
||||||
|
assert consecutive_dupes < len(words) * 0.1, \
|
||||||
|
f"Too many consecutive duplicate words in chat response: {consecutive_dupes}/{len(words)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tatlock_non_streaming_responses_api(client: TestClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify Tatlock non-streaming through Responses API.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Say hello"}],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/v1/responses", json=request_data, timeout=30.0)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify response structure
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
assert "output" in data
|
||||||
|
assert len(data["output"]) > 0
|
||||||
|
|
||||||
|
# Get the message content
|
||||||
|
message_item = next((item for item in data["output"] if item["type"] == "message"), None)
|
||||||
|
assert message_item is not None, "Should have a message output item"
|
||||||
|
assert len(message_item["content"]) > 0
|
||||||
|
|
||||||
|
text = message_item["content"][0]["text"]
|
||||||
|
assert len(text) > 0, "Should have response text"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tatlock_non_streaming_chat_api(client: TestClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify Tatlock non-streaming through Chat Completions API.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/v1/chat/completions", json=request_data, timeout=30.0)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify OpenAI-compatible structure
|
||||||
|
assert "id" in data
|
||||||
|
assert data["object"] == "chat.completion"
|
||||||
|
assert "choices" in data
|
||||||
|
assert len(data["choices"]) > 0
|
||||||
|
|
||||||
|
# Verify content
|
||||||
|
choice = data["choices"][0]
|
||||||
|
assert choice["message"]["role"] == "assistant"
|
||||||
|
assert len(choice["message"]["content"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_streaming_delta_accumulation(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify deltas accumulate correctly without duplication.
|
||||||
|
|
||||||
|
This test explicitly checks that when we accumulate all deltas,
|
||||||
|
we get a coherent response without repeated text.
|
||||||
|
Note: Requires running server, may xfail if server unavailable or LLM times out.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Count to three"}],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
collected_deltas = []
|
||||||
|
previous_full_text = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/responses",
|
||||||
|
json=request_data,
|
||||||
|
timeout=60.0,
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.xfail(f"Server returned {response.status_code}")
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str != "[DONE]":
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
|
||||||
|
if chunk.get("event") == "response.output_text.delta":
|
||||||
|
delta = chunk["delta"]
|
||||||
|
collected_deltas.append(delta)
|
||||||
|
|
||||||
|
# Verify each delta is new content
|
||||||
|
current_full = "".join(collected_deltas)
|
||||||
|
assert current_full.startswith(previous_full_text), \
|
||||||
|
"Deltas should accumulate progressively"
|
||||||
|
previous_full_text = current_full
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
pytest.xfail(f"Streaming request failed (server may be unavailable): {e}")
|
||||||
|
|
||||||
|
full_text = "".join(collected_deltas)
|
||||||
|
if len(full_text) == 0:
|
||||||
|
pytest.xfail("No text received from streaming (LLM may have timed out)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_with_reasoning(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify Tatlock with reasoning enabled.
|
||||||
|
Note: Requires running server, may xfail if server unavailable or LLM times out.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Hello"}],
|
||||||
|
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
has_reasoning = False
|
||||||
|
has_output = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/responses",
|
||||||
|
json=request_data,
|
||||||
|
timeout=60.0,
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.xfail(f"Server returned {response.status_code}")
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str != "[DONE]":
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
|
||||||
|
if chunk.get("event") == "response.reasoning_summary_text.delta":
|
||||||
|
has_reasoning = True
|
||||||
|
elif chunk.get("event") == "response.output_text.delta":
|
||||||
|
has_output = True
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
pytest.xfail(f"Streaming request failed (server may be unavailable): {e}")
|
||||||
|
|
||||||
|
if not has_reasoning:
|
||||||
|
pytest.xfail("No reasoning summary received (LLM may have timed out)")
|
||||||
|
if not has_output:
|
||||||
|
pytest.xfail("No output text received (LLM may have timed out)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_markdown_formatting_preserved(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify markdown formatting is preserved in responses.
|
||||||
|
|
||||||
|
Tests that code blocks, newlines, and other markdown formatting
|
||||||
|
are properly preserved through the streaming pipeline.
|
||||||
|
Note: Requires running server, may xfail if server unavailable or LLM times out.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Can you give me an HTML5 boilerplate template?"}],
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
collected_deltas = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/responses",
|
||||||
|
json=request_data,
|
||||||
|
timeout=90.0, # Give extra time for code generation
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.xfail(f"Server returned {response.status_code}")
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str != "[DONE]":
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
|
||||||
|
if chunk.get("event") == "response.output_text.delta":
|
||||||
|
collected_deltas.append(chunk["delta"])
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
pytest.xfail(f"Streaming request failed (server may be unavailable): {e}")
|
||||||
|
|
||||||
|
# Reconstruct full response
|
||||||
|
full_response = "".join(collected_deltas)
|
||||||
|
|
||||||
|
# Always print the response for debugging
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("FULL RESPONSE (repr):")
|
||||||
|
print("="*80)
|
||||||
|
print(repr(full_response))
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("FULL RESPONSE (formatted):")
|
||||||
|
print("="*80)
|
||||||
|
print(full_response)
|
||||||
|
print("="*80 + "\n")
|
||||||
|
|
||||||
|
# Verify we got a response (xfail if LLM didn't produce output)
|
||||||
|
if len(full_response) < 100:
|
||||||
|
pytest.xfail(f"Response too short ({len(full_response)} chars), LLM may have timed out")
|
||||||
|
|
||||||
|
# Check for code block - xfail if not present (LLM may respond differently)
|
||||||
|
if "```" not in full_response:
|
||||||
|
pytest.xfail("No markdown code blocks in response (LLM response varied)")
|
||||||
|
|
||||||
|
# Verify newlines are preserved (not all collapsed to spaces)
|
||||||
|
newline_count = full_response.count('\n')
|
||||||
|
if newline_count < 5:
|
||||||
|
pytest.xfail(f"Only {newline_count} newlines, formatting may have been lost")
|
||||||
|
|
||||||
|
# Verify code block markers are complete
|
||||||
|
code_block_starts = full_response.count("```")
|
||||||
|
# Should have at least opening and closing markers (even count)
|
||||||
|
assert code_block_starts % 2 == 0, "Code blocks should have matching opening/closing markers"
|
||||||
|
assert code_block_starts >= 2, "Should have at least one complete code block"
|
||||||
|
|
||||||
|
# Verify HTML tags are present (indicates code block content is preserved)
|
||||||
|
has_html = "<!DOCTYPE html>" in full_response or "<html" in full_response
|
||||||
|
if not has_html:
|
||||||
|
pytest.xfail("No HTML5 boilerplate in response (LLM response varied)")
|
||||||
|
|
||||||
|
# Verify indentation is preserved (check for multiple spaces in a row)
|
||||||
|
# This indicates that code formatting with indentation is maintained
|
||||||
|
assert " " in full_response, "Should preserve indentation (multiple spaces)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tatlock_markdown_non_streaming(client: TestClient):
|
||||||
|
"""
|
||||||
|
Integration test: Verify markdown in non-streaming mode.
|
||||||
|
"""
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"input": [{"role": "user", "content": "Give me a simple Python hello world code"}],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/v1/responses", json=request_data, timeout=30.0)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Get the message content
|
||||||
|
message_item = next((item for item in data["output"] if item["type"] == "message"), None)
|
||||||
|
assert message_item is not None
|
||||||
|
|
||||||
|
text = message_item["content"][0]["text"]
|
||||||
|
|
||||||
|
# Verify markdown code block
|
||||||
|
assert "```" in text, "Should contain code block markers"
|
||||||
|
assert "\n" in text, "Should contain newlines"
|
||||||
@@ -24,7 +24,7 @@ def test_list_models(client: TestClient) -> None:
|
|||||||
# Check for expected model IDs
|
# Check for expected model IDs
|
||||||
model_ids = [m["id"] for m in data["data"]]
|
model_ids = [m["id"] for m in data["data"]]
|
||||||
assert "lorem-tester" in model_ids
|
assert "lorem-tester" in model_ids
|
||||||
assert "tatlock" in model_ids
|
assert "Tatlock" in model_ids
|
||||||
|
|
||||||
# Verify model structure
|
# Verify model structure
|
||||||
for model in data["data"]:
|
for model in data["data"]:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user