Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427ad311dc | ||
|
|
4f911929f4 | ||
|
|
f49c5ac02c | ||
|
|
a3c7fcf8c3 | ||
|
|
901a04825d | ||
|
|
334d313d17 | ||
|
|
8092740fa4 | ||
|
|
e469746f75 | ||
|
|
31e7884d8f | ||
|
|
e15def607d | ||
|
|
6dd1c2e2a9 |
+1
-1
@@ -17,7 +17,7 @@ PREFER_CLOUD_BACKEND=true
|
|||||||
|
|
||||||
# Ollama Configuration (local fallback when Claude unavailable)
|
# Ollama Configuration (local fallback when Claude unavailable)
|
||||||
OLLAMA_HOST=http://localhost:11434
|
OLLAMA_HOST=http://localhost:11434
|
||||||
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
OLLAMA_DEFAULT_MODEL=gemma4:e2b
|
||||||
OLLAMA_TIMEOUT=120
|
OLLAMA_TIMEOUT=120
|
||||||
|
|
||||||
# SearXNG Configuration
|
# SearXNG Configuration
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v[0-9]*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
+11
-6
@@ -46,27 +46,32 @@ ENV/
|
|||||||
.ipynb_checkpoints/
|
.ipynb_checkpoints/
|
||||||
*.ipynb
|
*.ipynb
|
||||||
|
|
||||||
# Testing & Coverage
|
# Caches (pytest, mypy, ruff)
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Build output (coverage, logs)
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Legacy cache/output locations (in case tools fall back)
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
|
||||||
coverage.xml
|
coverage.xml
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
|
# Testing
|
||||||
.tox/
|
.tox/
|
||||||
.nox/
|
.nox/
|
||||||
*.cover
|
*.cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
.dmypy.json
|
||||||
dmypy.json
|
dmypy.json
|
||||||
.pyre/
|
.pyre/
|
||||||
.pytype/
|
.pytype/
|
||||||
|
|
||||||
# Linting
|
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/*
|
logs/*
|
||||||
!logs/traces/
|
!logs/traces/
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
> **📖 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.
|
> **📖 Important**: Before working on this project, read [docs/philosophy.md](docs/philosophy.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
> **Start every session by reading this file.**
|
> **Start every session by reading this file.**
|
||||||
|
|||||||
+50
-1
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-04-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Switch default Ollama model to gemma4:e2b** - Replaces mistral-nemo as the local LLM backend; gemma4:e2b has native function calling support, faster tool calling (2-4s vs 15-20s), better parameter accuracy on word problems, and uses less VRAM (8GB vs 9.2GB)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tool calling benchmark script** (`scripts/benchmark_tool_calling.py`) - Compares tool calling accuracy and latency across Ollama models via the Tatlock API
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Streaming SSE compatibility with Open WebUI** - Switch from `exclude_none=True` to `exclude_unset=True` for SSE chunk serialization; `exclude_none` was too aggressive — it stripped `finish_reason: null` from intermediate chunks (which OpenAI includes), while `exclude_unset` correctly omits only fields never passed to the constructor (like `reasoning_content` on content-only chunks) while preserving explicitly-set `finish_reason: null`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Project structure consolidation** - Moved documentation to `docs/`, consolidated all config into `pyproject.toml`, replaced `wakeup.sh`/`pytest.ini`/`requirements*.txt` with `Makefile` + `pyproject.toml`
|
||||||
|
- **CI test gate** - Unit tests now gate release and build jobs in Gitea Actions workflow
|
||||||
|
- **Build output organization** - Tool caches in `.cache/`, generated output (coverage, logs) in `build/`
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Streaming JSON compatibility** - Exclude null fields from streaming chunks using `exclude_none=True`; OpenAI's API omits null fields entirely, and including them (e.g., `content: null`, `reasoning_content: null`) caused parsing issues in Open WebUI
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Open WebUI streaming compatibility** - Replaced `sse_starlette` `EventSourceResponse` with plain `StreamingResponse` for chat completions; `sse_starlette` added `\r\n` line endings and extra SSE fields that Open WebUI couldn't parse
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Steward analysis leaking into responses** - Removed internal routing analysis (`DELEGATE: tatlock_core...`) from user-visible reasoning in both streaming and non-streaming paths
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-02-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **tool_choice format incompatibility** - Removed `extra_body` tool_choice hack for Claude backend; PydanticAI handles tool_choice natively for Anthropic, preventing infinite tool call loops
|
||||||
|
- **CI trigger** - Changed workflow trigger from `release:published` to `push:tags:v[0-9]*`
|
||||||
|
|
||||||
## [2.0.1] - 2026-02-05
|
## [2.0.1] - 2026-02-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -896,7 +943,9 @@ 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/v2.0.0...main
|
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.1.0...main
|
||||||
|
[2.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.0.5...v2.1.0
|
||||||
|
[2.0.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.0.0...v2.0.5
|
||||||
[2.0.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.11.0...v2.0.0
|
[2.0.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.11.0...v2.0.0
|
||||||
[1.11.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.10.0...v1.11.0
|
[1.11.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.10.0...v1.11.0
|
||||||
[1.10.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.9.0...v1.10.0
|
[1.10.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.9.0...v1.10.0
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Claude Code-specific notes for this project. For general development instructions, architecture, coding standards, and deployment — see [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
|
## Setup & Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup # Create venv and install all dependencies
|
||||||
|
make test # Unit tests (no external services)
|
||||||
|
make test-integration # Integration tests (needs Claude/Ollama)
|
||||||
|
make run # Start dev server on port 8777
|
||||||
|
make lint # Ruff linter + formatter check
|
||||||
|
make typecheck # Mypy
|
||||||
|
make clean # Remove caches and build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies are in `pyproject.toml` (`[project.dependencies]` and `[project.optional-dependencies.dev]`).
|
||||||
|
|
||||||
|
## Critical Gotchas
|
||||||
|
|
||||||
|
**ASGITransport does NOT trigger FastAPI lifespan events.** The session-scoped `_initialize_app` fixture in `tests/conftest.py` calls `initialize_application()` explicitly via `asyncio.run()`. Without this, `check_claude_health()` never runs and `_claude_available` stays `None`, causing all tests to silently fall back to Ollama.
|
||||||
|
|
||||||
|
**AsyncIO scope mismatch.** `asyncio_default_fixture_loop_scope = function` is set in `pyproject.toml`. Session-scoped async fixtures cause `ScopeMismatch` errors. The fix is to use a sync fixture with `asyncio.run()` for session-scoped initialization.
|
||||||
|
|
||||||
|
**Ollama is unreliable for tool calling.** `mistral-nemo` on Ollama often does mental math instead of calling calculator tools, and frequently gets wrong answers. Claude reliably calls tools. If integration tests give wrong math answers, check which backend is actually being used.
|
||||||
|
|
||||||
|
**Integration test timeouts.** Set to 120s to match `OLLAMA_TIMEOUT` config. Ollama on tower-of-joy can be slow, especially on first request.
|
||||||
|
|
||||||
|
**`get_benchmark_store` does not exist.** The benchmarking module (`src/core/benchmarks.py`) was never implemented. `scripts/benchmark_analysis.py` also references it and is broken. Do not add mocks for it in tests.
|
||||||
|
|
||||||
|
**Steward tests need household registry.** Use `register_household_members()` (sync) in fixtures, not `initialize_application()` (async). The steward extracts capabilities from the registry.
|
||||||
+2
-2
@@ -5,8 +5,8 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y curl \
|
RUN apt-get update && apt-get install -y curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
|||||||
@@ -1,920 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.PHONY: help setup run test test-unit test-integration lint typecheck clean
|
||||||
|
|
||||||
|
VENV := .venv
|
||||||
|
PYTHON := $(VENV)/bin/python
|
||||||
|
PIP := $(VENV)/bin/pip
|
||||||
|
PYTEST := $(VENV)/bin/pytest
|
||||||
|
RUFF := $(VENV)/bin/ruff
|
||||||
|
MYPY := $(VENV)/bin/mypy
|
||||||
|
UVICORN := $(VENV)/bin/uvicorn
|
||||||
|
|
||||||
|
HOST := 0.0.0.0
|
||||||
|
PORT := 8777
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
setup: ## Create venv and install all dependencies
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -e ".[dev]"
|
||||||
|
|
||||||
|
run: ## Start the development server on port 8777
|
||||||
|
@mkdir -p build/logs
|
||||||
|
@if lsof -Pi :$(PORT) -sTCP:LISTEN -t >/dev/null 2>&1; then \
|
||||||
|
echo "Error: Port $(PORT) is already in use"; \
|
||||||
|
echo "Run: lsof -i :$(PORT) to see what's using it"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
$(UVICORN) src.main:app --reload --host $(HOST) --port $(PORT) 2>&1 | tee build/logs/server.log
|
||||||
|
|
||||||
|
test: ## Run unit tests (no external services needed)
|
||||||
|
$(PYTEST) --ignore=tests/e2e --ignore=tests/integration
|
||||||
|
|
||||||
|
test-unit: test ## Alias for test
|
||||||
|
|
||||||
|
test-integration: ## Run integration tests (needs Claude/Ollama)
|
||||||
|
$(PYTEST) tests/agents/test_tatlock_agent.py -v
|
||||||
|
|
||||||
|
lint: ## Run ruff linter and formatter check
|
||||||
|
$(RUFF) check src tests
|
||||||
|
$(RUFF) format --check src tests
|
||||||
|
|
||||||
|
typecheck: ## Run mypy type checking
|
||||||
|
$(MYPY) src
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts, caches, and coverage reports
|
||||||
|
rm -rf .cache build
|
||||||
|
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
# Tatlock Enhancement Plan: Bidirectional Claude Integration
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Implement a **bidirectional architecture** that:
|
|
||||||
1. **Superpowers Tatlock** by swapping Ollama→Claude backend (200k context, better reasoning, same butler personality)
|
|
||||||
2. **Exposes Tatlock as MCP server** for Claude instances on any device (phone, browser, desktop)
|
|
||||||
|
|
||||||
This gives you the flexibility to use whichever AI is best/most accessible at any moment.
|
|
||||||
|
|
||||||
## Key Insight: Blanket Backend Swap (Simpler Than Sidecar)
|
|
||||||
|
|
||||||
Instead of adding a Claude "Analyst" sidecar agent, **swap the underlying model for ALL agents**:
|
|
||||||
|
|
||||||
```
|
|
||||||
CURRENT: TatlockAgent → OpenAIChatModel → OllamaProvider → Ollama (mistral-nemo)
|
|
||||||
PROPOSED: TatlockAgent → AnthropicModel → AnthropicProvider → Claude API
|
|
||||||
↘ (fallback when offline) → OllamaProvider → Ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this works:**
|
|
||||||
- PydanticAI natively supports Anthropic via `AnthropicModel` + `AnthropicProvider`
|
|
||||||
- The same `TATLOCK_SYSTEM_PROMPT` is passed to Claude - butler personality preserved
|
|
||||||
- Claude is **better** at following system prompts than mistral-nemo
|
|
||||||
- 200k context for ALL queries, not just "complex" ones
|
|
||||||
- Simpler architecture: no routing logic, no sidecar delegation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Research Findings
|
|
||||||
|
|
||||||
### Industry Best Practices (2025-2026)
|
|
||||||
|
|
||||||
**MCP Protocol Updates** ([MCP Spec Updates June 2025](https://auth0.com/blog/mcp-specs-update-all-about-auth/)):
|
|
||||||
- Streamable HTTP replaced SSE (March 2025) - better for cloud deployment
|
|
||||||
- OAuth 2.0 required for remote servers - MCP servers are OAuth Resource Servers
|
|
||||||
- Tool Output Schemas now available - better structured data handling
|
|
||||||
- MCP Registry launched (Sept 2025) - community server discovery
|
|
||||||
|
|
||||||
**Community Patterns** ([Claude Code Router](https://github.com/musistudio/claude-code-router)):
|
|
||||||
- Task-based routing is becoming standard: route simple→local, complex→cloud
|
|
||||||
- Translation proxies bridge Anthropic Messages API ↔ OpenAI format
|
|
||||||
- Cost savings of up to 98% reported with smart routing
|
|
||||||
|
|
||||||
**Home Automation MCP** ([ha-mcp](https://github.com/homeassistant-ai/ha-mcp)):
|
|
||||||
- Production-ready MCP servers exist for Home Assistant
|
|
||||||
- Support Claude Code, Gemini CLI, Open WebUI, VSCode, Cursor
|
|
||||||
- Pattern: expose local tools securely to remote AI clients
|
|
||||||
|
|
||||||
**Remote MCP Access** ([mcp-remote](https://www.npmjs.com/package/mcp-remote)):
|
|
||||||
- Bridge local MCP servers to Claude Desktop/Browser via proxy
|
|
||||||
- Supports authentication headers for security
|
|
||||||
- Works with ngrok/Cloudflare Tunnel for HTTPS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ BIDIRECTIONAL TATLOCK-CLAUDE ARCHITECTURE │
|
|
||||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ╔═══════════════════════════════════════════════════════════════════════════════╗ │
|
|
||||||
│ ║ SCENARIO A: Using Tatlock (Open WebUI, local apps) ║ │
|
|
||||||
│ ║ ───────────────────────────────────────────────── ║ │
|
|
||||||
│ ║ ║ │
|
|
||||||
│ ║ Request → Steward → Tatlock → Tools + Expert Delegation ║ │
|
|
||||||
│ ║ │ ║ │
|
|
||||||
│ ║ ├─→ Librarian (Claude) → research, wiki, RAG ║ │
|
|
||||||
│ ║ ├─→ Biographer (Claude) → memory, preferences ║ │
|
|
||||||
│ ║ ├─→ Housekeeper (Claude) → home automation ║ │
|
|
||||||
│ ║ └─→ All powered by Claude with Ollama fallback ║ │
|
|
||||||
│ ║ ║ │
|
|
||||||
│ ║ Butler personality preserved, 200k context for all queries ║ │
|
|
||||||
│ ╚═══════════════════════════════════════════════════════════════════════════════╝ │
|
|
||||||
│ │
|
|
||||||
│ ╔═══════════════════════════════════════════════════════════════════════════════╗ │
|
|
||||||
│ ║ SCENARIO B: Using Claude.ai / Claude Desktop / Phone ║ │
|
|
||||||
│ ║ ──────────────────────────────────────────────────── ║ │
|
|
||||||
│ ║ ║ │
|
|
||||||
│ ║ Claude ──[MCP over HTTPS]──► Tatlock MCP Server → Household Tools ║ │
|
|
||||||
│ ║ │ ║ │
|
|
||||||
│ ║ ├─→ calculator, datetime ║ │
|
|
||||||
│ ║ ├─→ web_search, wiki_search ║ │
|
|
||||||
│ ║ ├─→ hybrid_search (RAG) ║ │
|
|
||||||
│ ║ ├─→ memory_recall, store_insight ║ │
|
|
||||||
│ ║ └─→ home_control (lights, climate) ║ │
|
|
||||||
│ ║ ║ │
|
|
||||||
│ ║ Full 200k context, your local tools accessible from anywhere ║ │
|
|
||||||
│ ╚═══════════════════════════════════════════════════════════════════════════════╝ │
|
|
||||||
│ │
|
|
||||||
│ ╔═══════════════════════════════════════════════════════════════════════════════╗ │
|
|
||||||
│ ║ SCENARIO C: Offline (internet down) ║ │
|
|
||||||
│ ║ ────────────────────────────────── ║ │
|
|
||||||
│ ║ ║ │
|
|
||||||
│ ║ Tatlock operates fully locally with Ollama ║ │
|
|
||||||
│ ║ • All tools work (except web search) ║ │
|
|
||||||
│ ║ • Graceful degradation with same butler personality ║ │
|
|
||||||
│ ╚═══════════════════════════════════════════════════════════════════════════════╝ │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Blanket Backend Swap (Claude for All Agents)
|
|
||||||
|
|
||||||
Replace Ollama with Claude as the default backend for all PydanticAI agents, with automatic offline fallback.
|
|
||||||
|
|
||||||
**New Files:**
|
|
||||||
```
|
|
||||||
src/anthropic/
|
|
||||||
├── __init__.py
|
|
||||||
├── provider.py # Claude provider with health check
|
|
||||||
└── model_selector.py # Chooses Claude or Ollama based on availability
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation (`src/anthropic/provider.py`):**
|
|
||||||
```python
|
|
||||||
from pydantic_ai.models.anthropic import AnthropicModel
|
|
||||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
|
||||||
from src.ollama.provider import get_ollama_provider
|
|
||||||
from src.core.config import config
|
|
||||||
|
|
||||||
_anthropic_available: bool | None = None
|
|
||||||
|
|
||||||
async def check_anthropic_health() -> bool:
|
|
||||||
"""Check if Anthropic API is reachable."""
|
|
||||||
global _anthropic_available
|
|
||||||
try:
|
|
||||||
from anthropic import AsyncAnthropic
|
|
||||||
client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
|
||||||
await client.messages.create(
|
|
||||||
model=config.ANTHROPIC_MODEL,
|
|
||||||
max_tokens=1,
|
|
||||||
messages=[{"role": "user", "content": "hi"}]
|
|
||||||
)
|
|
||||||
_anthropic_available = True
|
|
||||||
except Exception:
|
|
||||||
_anthropic_available = False
|
|
||||||
return _anthropic_available
|
|
||||||
|
|
||||||
def get_model(prefer_cloud: bool = True):
|
|
||||||
"""Get the best available model. Returns Claude if available, otherwise Ollama."""
|
|
||||||
if prefer_cloud and config.ANTHROPIC_API_KEY and _anthropic_available:
|
|
||||||
provider = AnthropicProvider(api_key=config.ANTHROPIC_API_KEY)
|
|
||||||
return AnthropicModel(
|
|
||||||
model_name=config.ANTHROPIC_MODEL,
|
|
||||||
provider=provider,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return OpenAIChatModel(
|
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
|
||||||
provider=get_ollama_provider()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Modify TatlockAgent (`src/agents/tatlock.py`):**
|
|
||||||
```python
|
|
||||||
def _ensure_agent(self):
|
|
||||||
if self._agent is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
from src.anthropic.model_selector import get_model
|
|
||||||
|
|
||||||
model = get_model(prefer_cloud=True)
|
|
||||||
|
|
||||||
self._agent = Agent(
|
|
||||||
model,
|
|
||||||
system_prompt=TATLOCK_SYSTEM_PROMPT, # Same butler personality!
|
|
||||||
)
|
|
||||||
self._register_tools()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: MCP Server (Expose Tools to Claude)
|
|
||||||
|
|
||||||
Create an MCP server that exposes Tatlock's household tools to external Claude instances.
|
|
||||||
|
|
||||||
**New Files:**
|
|
||||||
```
|
|
||||||
src/mcp/
|
|
||||||
├── __init__.py
|
|
||||||
├── server.py # MCP server using mcp Python SDK
|
|
||||||
├── tool_adapters.py # Convert PydanticAI tools → MCP schemas
|
|
||||||
├── auth.py # API key authentication
|
|
||||||
└── transport.py # Streamable HTTP transport
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker Stack Addition (`stacks/agents.yml`):**
|
|
||||||
```yaml
|
|
||||||
tatlock-mcp:
|
|
||||||
image: git.schweitz.internal/jpmschweitzer/tatlock:latest
|
|
||||||
command: ["python", "-m", "src.mcp.server"]
|
|
||||||
ports:
|
|
||||||
- "8002:8002"
|
|
||||||
environment:
|
|
||||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
|
||||||
networks:
|
|
||||||
- docker-dataplane
|
|
||||||
```
|
|
||||||
|
|
||||||
**Claude Desktop Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"tatlock": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["mcp-remote", "https://mcp.schweitz.net/sse", "--header", "Authorization: Bearer ${MCP_AUTH_TOKEN}"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
### Phase 1 - Backend Swap
|
|
||||||
|
|
||||||
**New Files:**
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/anthropic/__init__.py` | Package init |
|
|
||||||
| `src/anthropic/provider.py` | Claude provider with health check |
|
|
||||||
| `src/anthropic/model_selector.py` | Choose Claude or Ollama based on availability |
|
|
||||||
|
|
||||||
**Modified Files:**
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `src/core/config.py` | Add `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `PREFER_CLOUD_BACKEND` |
|
|
||||||
| `src/agents/tatlock.py` | Use `get_model()` instead of hardcoded Ollama |
|
|
||||||
| `src/agents/librarian/agent.py` | Use `get_model()` instead of hardcoded Ollama |
|
|
||||||
| `src/agents/biographer/agent.py` | Use `get_model()` instead of hardcoded Ollama |
|
|
||||||
| `src/agents/steward/agent.py` | Convert to PydanticAI or add Anthropic API support |
|
|
||||||
| `src/core/startup.py` | Add Anthropic health check on startup |
|
|
||||||
| `requirements.txt` | Add `anthropic>=0.40.0` |
|
|
||||||
| `.env.example` | Document new environment variables |
|
|
||||||
|
|
||||||
### Phase 2 - MCP Server
|
|
||||||
|
|
||||||
**New Files:**
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/mcp/__init__.py` | Package init |
|
|
||||||
| `src/mcp/server.py` | MCP server implementation |
|
|
||||||
| `src/mcp/tool_adapters.py` | PydanticAI → MCP schema conversion |
|
|
||||||
| `src/mcp/auth.py` | Token-based authentication |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cost Analysis
|
|
||||||
|
|
||||||
- **Claude API**: $5-30/month (10-50 calls/day, ~2k input + 1k output tokens/call)
|
|
||||||
- **MCP via Claude Pro**: Included in subscription
|
|
||||||
- **Total**: ~$10-80/month for full bidirectional integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Plan
|
|
||||||
|
|
||||||
### Phase 1 Testing
|
|
||||||
```bash
|
|
||||||
# 1. Run with Claude backend
|
|
||||||
ANTHROPIC_API_KEY=your-key docker-compose up -d tatlock
|
|
||||||
|
|
||||||
# 2. Verify Claude is being used
|
|
||||||
docker logs tatlock 2>&1 | grep -i "anthropic\|claude"
|
|
||||||
|
|
||||||
# 3. Test butler personality
|
|
||||||
curl -X POST http://tatlock.schweitz.internal:8000/v1/responses \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"model": "Tatlock", "input": "Hello, who are you?"}'
|
|
||||||
|
|
||||||
# 4. Test offline fallback
|
|
||||||
ANTHROPIC_API_KEY="" docker-compose up -d tatlock
|
|
||||||
docker logs tatlock 2>&1 | grep -i "ollama\|fallback"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2 Testing
|
|
||||||
```bash
|
|
||||||
# 1. Start MCP server
|
|
||||||
docker-compose up -d tatlock-mcp
|
|
||||||
|
|
||||||
# 2. Test MCP endpoint
|
|
||||||
curl -X POST https://mcp.schweitz.net/tools/list \
|
|
||||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
1. **Phase 1: Backend Swap** (~1 week)
|
|
||||||
- Immediate value: 200k context for ALL queries
|
|
||||||
- Low risk: provider abstraction, graceful offline fallback
|
|
||||||
|
|
||||||
2. **Phase 2: MCP Server** (~2-3 weeks)
|
|
||||||
- Enables cross-device access
|
|
||||||
- Bidirectional: Tatlock superpowered by Claude AND accessible to Claude
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Phases (Optional)
|
|
||||||
|
|
||||||
- **Phase 3: LiteLLM Gateway** - Unified endpoint for all models, config-driven routing
|
|
||||||
- **Phase 4: Multi-Provider** - Add OpenAI, Vertex AI, etc.
|
|
||||||
- **Phase 5: Smart Routing** - Context-aware model selection, cost ceiling enforcement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Offline Behavior
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| No API key | Use Ollama exclusively |
|
|
||||||
| API unreachable | Use Ollama, log warning |
|
|
||||||
| API rate limited | Fallback to Ollama |
|
|
||||||
|
|
||||||
| Aspect | Claude | Ollama |
|
|
||||||
|--------|--------|--------|
|
|
||||||
| Context | 200k tokens | ~8k tokens |
|
|
||||||
| Latency | 1-3s (network) | 0.5-1s (local) |
|
|
||||||
| Personality | Preserved | Preserved |
|
|
||||||
| Tools | All work | All work |
|
|
||||||
| Cost | API charges | Free |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### Phase 1: Backend Swap - CODE COMPLETE (awaiting API access)
|
|
||||||
|
|
||||||
- [x] Add Anthropic config settings to `src/core/config.py`
|
|
||||||
- [x] Add `pydantic-ai-slim[openai,anthropic]` to requirements.txt
|
|
||||||
- [x] Create `src/anthropic/` module (model_selector.py)
|
|
||||||
- [x] Add Claude health check to startup.py
|
|
||||||
- [x] Refactor all PydanticAI agents to use `get_model()`
|
|
||||||
- [x] Librarian
|
|
||||||
- [x] Biographer
|
|
||||||
- [x] Housekeeper
|
|
||||||
- [x] Tatlock (6 locations)
|
|
||||||
- [x] Add Claude API path to Steward agent (direct API calls)
|
|
||||||
- [x] Update `.env.example` with new variables
|
|
||||||
- [x] Test Ollama fallback (working)
|
|
||||||
- [ ] Test with Claude API key (blocked: no API access currently)
|
|
||||||
|
|
||||||
**Note:** Implementation complete. Currently runs in Ollama-only mode. Will automatically use Claude when `ANTHROPIC_API_KEY` is configured.
|
|
||||||
|
|
||||||
### Phase 2: MCP Server - NOT STARTED
|
|
||||||
|
|
||||||
- [ ] Create `src/mcp/` module
|
|
||||||
- [ ] Tool adapters (PydanticAI → MCP schema)
|
|
||||||
- [ ] Authentication middleware
|
|
||||||
- [ ] Streamable HTTP transport
|
|
||||||
- [ ] Docker stack configuration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Repository Handovers
|
|
||||||
|
|
||||||
Handover documents created in each repo: `PROJECT_CLAUDIFICATION_HANDOVER.md`
|
|
||||||
|
|
||||||
### library-desk - HANDOVER CREATED
|
|
||||||
|
|
||||||
- [x] Write handover document
|
|
||||||
- [ ] Review HybridRAG response size limits
|
|
||||||
- [ ] Review smart_create endpoint for Claude optimization
|
|
||||||
- [ ] Evaluate response formats for LLM consumption
|
|
||||||
|
|
||||||
### core-api - HANDOVER CREATED
|
|
||||||
|
|
||||||
- [x] Write handover document
|
|
||||||
- [ ] Review list_devices response format
|
|
||||||
- [ ] Review error messages for LLM consumption
|
|
||||||
- [ ] Evaluate rate limiting for faster Claude processing
|
|
||||||
|
|
||||||
### portainer-core - HANDOVER CREATED (blocking for production)
|
|
||||||
|
|
||||||
- [x] Write handover document
|
|
||||||
- [ ] Update stack with new environment variables
|
|
||||||
- [ ] Configure secrets management for API key
|
|
||||||
- [ ] Update CONTAINERS.md documentation
|
|
||||||
|
|
||||||
### webber - HANDOVER CREATED
|
|
||||||
|
|
||||||
- [x] Write handover document
|
|
||||||
- [ ] Review content truncation limits
|
|
||||||
- [ ] Evaluate extraction quality for LLM consumption
|
|
||||||
|
|
||||||
### tatlock-ui - HANDOVER CREATED
|
|
||||||
|
|
||||||
- [x] Write handover document
|
|
||||||
- [ ] Test streaming responses with Claude backend
|
|
||||||
- [ ] Test conversation history with larger context
|
|
||||||
- [ ] Verify tool call display and reasoning rendering
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Tatlock - Your Homelab Butler
|
# Tatlock - Your Homelab Butler
|
||||||
|
|
||||||
> **📖 For the complete system vision and architectural philosophy, see [PHILOSOPHY.md](PHILOSOPHY.md)**
|
> **📖 For the complete system vision and architectural philosophy, see [docs/philosophy.md](docs/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.
|
A privacy-first, offline-capable personal assistant system that coordinates specialized AI agents to help with research, development, home automation, and daily organization.
|
||||||
|
|
||||||
@@ -89,12 +89,8 @@ A privacy-first, offline-capable personal assistant system that coordinates spec
|
|||||||
git clone https://git.schweitz.net/jpmschweitzer/tatlock.git
|
git clone https://git.schweitz.net/jpmschweitzer/tatlock.git
|
||||||
cd tatlock
|
cd tatlock
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
make setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run the Server
|
### Run the Server
|
||||||
@@ -396,8 +392,7 @@ tatlock/
|
|||||||
│ │ └── multi_tenancy.py # User isolation utilities
|
│ │ └── multi_tenancy.py # User isolation utilities
|
||||||
│ └── main.py # Application entry point
|
│ └── main.py # Application entry point
|
||||||
├── tests/ # Comprehensive test suite
|
├── tests/ # Comprehensive test suite
|
||||||
├── PHILOSOPHY.md # System vision and architecture
|
├── docs/ # Project documentation
|
||||||
├── IMPLEMENTATION_ROADMAP.md # Development phases
|
|
||||||
├── CHANGELOG.md # Version history
|
├── CHANGELOG.md # Version history
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
@@ -416,8 +411,8 @@ For LLM agent development guidelines and architectural decisions, see [AGENTS.md
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **System Philosophy**: [PHILOSOPHY.md](PHILOSOPHY.md) - Vision, goals, and architectural patterns
|
- **System Philosophy**: [docs/philosophy.md](docs/philosophy.md) - Vision, goals, and architectural patterns
|
||||||
- **User Guide**: This file - Installation, usage, and examples
|
- **Development Roadmap**: [docs/roadmap.md](docs/roadmap.md) - Open work and planned phases
|
||||||
- **Developer Guidelines**: [AGENTS.md](AGENTS.md) - LLM agent development patterns
|
- **Developer Guidelines**: [AGENTS.md](AGENTS.md) - LLM agent development patterns
|
||||||
- **Version History**: [CHANGELOG.md](CHANGELOG.md) - Changes and releases
|
- **Version History**: [CHANGELOG.md](CHANGELOG.md) - Changes and releases
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Claude Integration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Tatlock uses a bidirectional Claude architecture:
|
||||||
|
- **Scenario A**: Tatlock powered by Claude backend (with Ollama fallback) — **COMPLETE**
|
||||||
|
- **Scenario B**: Tatlock exposed as MCP server for external Claude instances — **OPEN**
|
||||||
|
- **Scenario C**: Offline operation via Ollama — **COMPLETE**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Server (Expose Tools to Claude) — NOT STARTED
|
||||||
|
|
||||||
|
Create an MCP server that exposes Tatlock's household tools to external Claude instances.
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/mcp/
|
||||||
|
├── __init__.py
|
||||||
|
├── server.py # MCP server using mcp Python SDK
|
||||||
|
├── tool_adapters.py # Convert PydanticAI tools → MCP schemas
|
||||||
|
├── auth.py # API key authentication
|
||||||
|
└── transport.py # Streamable HTTP transport
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Stack Addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tatlock-mcp:
|
||||||
|
image: git.schweitz.internal/jpmschweitzer/tatlock:latest
|
||||||
|
command: ["python", "-m", "src.mcp.server"]
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||||
|
networks:
|
||||||
|
- docker-dataplane
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Desktop Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tatlock": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["mcp-remote", "https://mcp.schweitz.net/sse", "--header", "Authorization: Bearer ${MCP_AUTH_TOKEN}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] Create `src/mcp/` module
|
||||||
|
- [ ] Tool adapters (PydanticAI → MCP schema)
|
||||||
|
- [ ] Authentication middleware
|
||||||
|
- [ ] Streamable HTTP transport
|
||||||
|
- [ ] Docker stack configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Phases
|
||||||
|
|
||||||
|
- **LiteLLM Gateway** — Unified endpoint for all models, config-driven routing
|
||||||
|
- **Multi-Provider** — Add OpenAI, Vertex AI, etc.
|
||||||
|
- **Smart Routing** — Context-aware model selection, cost ceiling enforcement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline Behavior
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| No API key | Use Ollama exclusively |
|
||||||
|
| API unreachable | Use Ollama, log warning |
|
||||||
|
| API rate limited | Fallback to Ollama |
|
||||||
|
|
||||||
|
| Aspect | Claude | Ollama |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Context | 200k tokens | ~8k tokens |
|
||||||
|
| Latency | 1-3s (network) | 0.5-1s (local) |
|
||||||
|
| Personality | Preserved | Preserved |
|
||||||
|
| Tools | All work | All work |
|
||||||
|
| Cost | API charges | Free |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Repo Handovers
|
||||||
|
|
||||||
|
Handover documents created in each repo: `PROJECT_CLAUDIFICATION_HANDOVER.md`
|
||||||
|
|
||||||
|
### Open Items
|
||||||
|
|
||||||
|
- **library-desk**: Review HybridRAG response size limits, smart_create endpoint, response formats
|
||||||
|
- **core-api**: Review list_devices response format, error messages, rate limiting
|
||||||
|
- **portainer-core**: Update stack with new env vars, configure secrets, update CONTAINERS.md
|
||||||
|
- **webber**: Review content truncation limits, extraction quality
|
||||||
|
- **tatlock-ui**: Test streaming with Claude backend, conversation history, tool call display
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
# Tatlock Implementation Roadmap
|
||||||
|
|
||||||
|
> **Reference**: See [philosophy.md](philosophy.md) for the target architecture and vision
|
||||||
|
|
||||||
|
This document tracks open/planned work. Completed phases have been removed.
|
||||||
|
|
||||||
|
## Current State (v2.0.5)
|
||||||
|
|
||||||
|
**What we have**:
|
||||||
|
- OpenAI-compatible API (Responses API + Chat Completions)
|
||||||
|
- Two-tier architecture (Steward → Tatlock)
|
||||||
|
- Household staff: Tatlock (Butler), Steward, Librarian, Biographer
|
||||||
|
- Core tools: Calculator, Date/Time, Web search (SearXNG)
|
||||||
|
- Memory system: Qdrant (vector), Redis (session cache), multi-tenancy via ContextVar
|
||||||
|
- Dual backend: Claude (preferred) + Ollama (fallback)
|
||||||
|
- 439 tests with good coverage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Expert Household Staff — Remaining Agents
|
||||||
|
|
||||||
|
**Goal**: Implement remaining domain-specific expert agents
|
||||||
|
|
||||||
|
### Planned Agents
|
||||||
|
|
||||||
|
1. **The Developer** (Software Development)
|
||||||
|
- Code generation assistance
|
||||||
|
- Debugging support
|
||||||
|
- Documentation generation
|
||||||
|
- Architecture guidance
|
||||||
|
|
||||||
|
2. **The Handyman** (System Maintenance)
|
||||||
|
- System status queries
|
||||||
|
- Log analysis
|
||||||
|
- Basic troubleshooting
|
||||||
|
- Infrastructure monitoring
|
||||||
|
|
||||||
|
3. **The Secretary** (Scheduling & Organization)
|
||||||
|
- Calendar integration
|
||||||
|
- Task management
|
||||||
|
- Reminder system
|
||||||
|
- Schedule conflict detection
|
||||||
|
|
||||||
|
4. **The Housekeeper** (Home Automation)
|
||||||
|
- 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
|
||||||
|
- [ ] Each agent implemented as separate module
|
||||||
|
- [ ] Agents callable via tool framework
|
||||||
|
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Persistence Layer — Database & Multi-Tenancy
|
||||||
|
|
||||||
|
**Goal**: Add persistent storage and multi-user support
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
1. **PostgreSQL Integration**
|
||||||
|
- Docker compose configuration
|
||||||
|
- Database schema with tenant isolation
|
||||||
|
- Alembic migrations
|
||||||
|
- SQLAlchemy models
|
||||||
|
|
||||||
|
2. **Multi-Tenant Architecture**
|
||||||
|
- Tenant identification middleware
|
||||||
|
- Tenant-scoped database sessions
|
||||||
|
- User authentication system
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] PostgreSQL container running
|
||||||
|
- [ ] Multiple users authenticate separately
|
||||||
|
- [ ] Each user sees only their own data
|
||||||
|
- [ ] Conversations persist across restarts
|
||||||
|
- [ ] Database migrations work correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: MCP (Model Context Protocol) Integration
|
||||||
|
|
||||||
|
**Goal**: Enable rich tool integrations via MCP
|
||||||
|
|
||||||
|
See also [claude-integration.md](claude-integration.md) for MCP server implementation details.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Advanced Memory & Context — Remaining Work
|
||||||
|
|
||||||
|
**Goal**: Implement sophisticated context management and personalization
|
||||||
|
|
||||||
|
### Open Deliverables
|
||||||
|
|
||||||
|
1. **Context Management**
|
||||||
|
- Smart context window trimming
|
||||||
|
- Conversation branching
|
||||||
|
- Topic tracking
|
||||||
|
|
||||||
|
2. **Personalization**
|
||||||
|
- User preference learning
|
||||||
|
- Interaction pattern analysis
|
||||||
|
- Adaptive responses
|
||||||
|
- Custom agent personalities per user
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
- [ ] Conversations automatically embedded to Qdrant
|
||||||
|
- [ ] Memory improves over time (learning from interactions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Extended Household Staff
|
||||||
|
|
||||||
|
**Goal**: Add specialized agents for additional domains
|
||||||
|
|
||||||
|
### Future Agents
|
||||||
|
- **The Accountant** — Expense tracking, budgets, financial reports
|
||||||
|
- **The Chef** — Meal planning, recipes, nutrition tracking
|
||||||
|
- Others as needs emerge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: User Experience Refinement
|
||||||
|
|
||||||
|
**Goal**: Polish the interaction experience
|
||||||
|
|
||||||
|
- Personality tuning and consistency
|
||||||
|
- Better progress indicators
|
||||||
|
- Response time improvements
|
||||||
|
- Streaming smoothness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Production Hardening
|
||||||
|
|
||||||
|
**Goal**: Make the system production-ready for homelab deployment
|
||||||
|
|
||||||
|
- Complete docker-compose stack
|
||||||
|
- Health checks and monitoring
|
||||||
|
- Authentication hardening and rate limiting
|
||||||
|
- Installation and troubleshooting documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 4 (Remaining Agents)
|
||||||
|
↓
|
||||||
|
Phase 5 (Database/Multi-Tenancy) ← Can be deferred
|
||||||
|
↓
|
||||||
|
Phase 7 (MCP) → Phase 8 (Advanced Memory)
|
||||||
|
↓
|
||||||
|
Phase 9 (Extended Staff) → Phase 10 (UX) → Phase 11 (Production)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Can Be Deferred**: Phase 5 until you need persistence
|
||||||
|
**Parallel Opportunities**: Phases 7 and 8 can overlap; 9 and 10 ongoing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Implement The Developer agent for code assistance
|
||||||
|
2. Add Home Assistant integration for The Housekeeper
|
||||||
|
3. Integrate scheduling service for The Secretary
|
||||||
|
4. MCP server for external Claude access
|
||||||
+58
-3
@@ -4,17 +4,67 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "2.0.1"
|
version = "2.2.0"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"fastapi>=0.123,<0.124",
|
||||||
|
"uvicorn[standard]>=0.38,<0.39",
|
||||||
|
"pydantic>=2.11,<2.13",
|
||||||
|
"pydantic-settings>=2.12,<2.13",
|
||||||
|
"pydantic-ai-slim[openai,anthropic]>=1.27,<1.28",
|
||||||
|
"httpx>=0.28,<0.29",
|
||||||
|
"sse-starlette>=3.0,<3.1",
|
||||||
|
"python-dotenv>=1.2,<1.3",
|
||||||
|
"starlette>=0.45,<0.46",
|
||||||
|
"redis[hiredis]>=5.2,<6.0",
|
||||||
|
"qdrant-client>=1.12,<2.0",
|
||||||
|
"structlog>=24.1,<25.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3,<8.4",
|
||||||
|
"pytest-asyncio>=0.25,<0.26",
|
||||||
|
"pytest-cov>=6.0,<6.1",
|
||||||
|
"pytest-mock>=3.14,<3.15",
|
||||||
|
"ruff>=0.8,<0.9",
|
||||||
|
"mypy>=1.14,<1.15",
|
||||||
|
"faker>=34.0,<35.0",
|
||||||
|
"coverage[toml]>=7.7,<7.8",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
cache_dir = ".cache/pytest"
|
||||||
|
markers = [
|
||||||
|
"unit: Unit tests",
|
||||||
|
"integration: Integration tests",
|
||||||
|
"slow: Slow running tests",
|
||||||
|
]
|
||||||
|
addopts = [
|
||||||
|
"--verbose",
|
||||||
|
"--strict-markers",
|
||||||
|
"--tb=short",
|
||||||
|
"--cov=src",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html:build/coverage/html",
|
||||||
|
"--cov-report=xml:build/coverage/coverage.xml",
|
||||||
|
"--cov-branch",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["src"]
|
source = ["src"]
|
||||||
branch = true
|
branch = true
|
||||||
|
data_file = "build/coverage/.coverage"
|
||||||
omit = [
|
omit = [
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"*/__pycache__/*",
|
"*/__pycache__/*",
|
||||||
@@ -37,11 +87,15 @@ exclude_lines = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.html]
|
[tool.coverage.html]
|
||||||
directory = "htmlcov"
|
directory = "build/coverage/html"
|
||||||
|
|
||||||
|
[tool.coverage.xml]
|
||||||
|
output = "build/coverage/coverage.xml"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
cache-dir = ".cache/ruff"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
@@ -64,6 +118,7 @@ ignore = [
|
|||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
|
cache_dir = ".cache/mypy"
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
disallow_untyped_defs = true
|
disallow_untyped_defs = true
|
||||||
|
|||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
asyncio_mode = auto
|
|
||||||
asyncio_default_fixture_loop_scope = function
|
|
||||||
|
|
||||||
# Markers
|
|
||||||
markers =
|
|
||||||
unit: Unit tests
|
|
||||||
integration: Integration tests
|
|
||||||
slow: Slow running tests
|
|
||||||
|
|
||||||
# Coverage options (overridden by pyproject.toml)
|
|
||||||
addopts =
|
|
||||||
--verbose
|
|
||||||
--strict-markers
|
|
||||||
--tb=short
|
|
||||||
--cov=src
|
|
||||||
--cov-report=term-missing
|
|
||||||
--cov-report=html
|
|
||||||
--cov-report=xml
|
|
||||||
--cov-branch
|
|
||||||
|
|
||||||
# Ignore warnings from dependencies
|
|
||||||
filterwarnings =
|
|
||||||
ignore::DeprecationWarning
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Development and Testing Dependencies
|
|
||||||
# Install with: pip install -r requirements.txt -r requirements-dev.txt
|
|
||||||
|
|
||||||
# Testing Framework
|
|
||||||
# Latest pytest with async support
|
|
||||||
pytest>=8.3,<8.4
|
|
||||||
pytest-asyncio>=0.25,<0.26
|
|
||||||
pytest-cov>=6.0,<6.1
|
|
||||||
|
|
||||||
# Test client for FastAPI
|
|
||||||
httpx>=0.28,<0.29 # Already in requirements.txt but needed for test client
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
# Linting and formatting
|
|
||||||
ruff>=0.8,<0.9
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
mypy>=1.14,<1.15
|
|
||||||
|
|
||||||
# Testing utilities
|
|
||||||
pytest-mock>=3.14,<3.15
|
|
||||||
faker>=34.0,<35.0
|
|
||||||
|
|
||||||
# Coverage reporting
|
|
||||||
coverage[toml]>=7.7,<7.8
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Core FastAPI framework and server
|
|
||||||
# FastAPI: Modern, fast web framework for building APIs
|
|
||||||
# Latest: 0.123.9 (Dec 4, 2025) - No known CVEs
|
|
||||||
fastapi>=0.123,<0.124
|
|
||||||
|
|
||||||
# ASGI server for running FastAPI
|
|
||||||
# Latest: 0.38.0 (Oct 18, 2025) - No known CVEs
|
|
||||||
# Note: Old versions had CVE-2020-7694/7695, but 0.38.0 is secure
|
|
||||||
uvicorn[standard]>=0.38,<0.39
|
|
||||||
|
|
||||||
# Additional dependencies
|
|
||||||
# Pydantic for data validation (comes with pydantic-ai but pinning explicitly)
|
|
||||||
# Updated to >=2.11 due to ag-ui-protocol dependency requirement
|
|
||||||
# Latest: 2.12.4 (Nov 5, 2025) - No known CVEs
|
|
||||||
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
|
|
||||||
# PydanticAI: Agent framework for using Pydantic with LLMs
|
|
||||||
# Using slim version with openai (Ollama) and anthropic (Claude) extras
|
|
||||||
# See DEPENDENCY_SLIM.md for rollback instructions if this breaks
|
|
||||||
pydantic-ai-slim[openai,anthropic]>=1.27,<1.28
|
|
||||||
|
|
||||||
# HTTP client for Ollama communication
|
|
||||||
# Latest: 0.28.1 - No known CVEs
|
|
||||||
httpx>=0.28,<0.29
|
|
||||||
|
|
||||||
# Server-Sent Events for streaming responses
|
|
||||||
# Required for OpenAI-compatible streaming endpoints
|
|
||||||
# Latest: 3.0.2 (Oct 30, 2025) - No known CVEs
|
|
||||||
sse-starlette>=3.0,<3.1
|
|
||||||
|
|
||||||
# Configuration management
|
|
||||||
# Latest: 1.2.1 (Oct 26, 2025) - No known CVEs
|
|
||||||
python-dotenv>=1.2,<1.3
|
|
||||||
|
|
||||||
# ASGI toolkit (dependency of FastAPI, pinning for security)
|
|
||||||
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:
|
|
||||||
# Using >=X.Y,<X.(Y+1) format to lock to minor versions
|
|
||||||
# This protects against supply chain attacks while allowing patch updates
|
|
||||||
# Update regularly and review changelogs before upgrading minor versions
|
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
"""
|
||||||
|
Benchmark tool calling across different Ollama models via Tatlock API.
|
||||||
|
|
||||||
|
Sends test prompts through the full Tatlock pipeline (Steward -> Orchestration
|
||||||
|
-> Synthesis) and records tool selection accuracy, latency, and response quality.
|
||||||
|
|
||||||
|
Between models, swaps OLLAMA_DEFAULT_MODEL in .env and waits for uvicorn
|
||||||
|
auto-reload. Requires the server to be running via ./wakeup.sh.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
.venv/bin/python scripts/benchmark_tool_calling.py
|
||||||
|
.venv/bin/python scripts/benchmark_tool_calling.py --models "gemma4:e4b,gemma4:e2b"
|
||||||
|
.venv/bin/python scripts/benchmark_tool_calling.py --iterations 3
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
API_BASE = "http://localhost:8777"
|
||||||
|
CHAT_URL = f"{API_BASE}/v1/chat/completions"
|
||||||
|
HEALTH_URL = f"{API_BASE}/health"
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
ENV_PATH = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
|
DEFAULT_MODELS = ["mistral-nemo-large:latest", "gemma4:e4b", "gemma4:e2b"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test scenarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scenario:
|
||||||
|
name: str
|
||||||
|
prompt: str
|
||||||
|
expected_tool: str | None # None = no tool expected
|
||||||
|
# Patterns to check in the response text for indirect tool-use evidence
|
||||||
|
success_patterns: list[str] = field(default_factory=list)
|
||||||
|
category: str = "basic"
|
||||||
|
|
||||||
|
|
||||||
|
SCENARIOS = [
|
||||||
|
# --- Should call calculate_math ---
|
||||||
|
Scenario(
|
||||||
|
name="Simple arithmetic",
|
||||||
|
prompt="What is 144 divided by 12?",
|
||||||
|
expected_tool="calculate_math",
|
||||||
|
success_patterns=["12"],
|
||||||
|
category="calculator",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Square root",
|
||||||
|
prompt="What's the square root of 256?",
|
||||||
|
expected_tool="calculate_math",
|
||||||
|
success_patterns=["16"],
|
||||||
|
category="calculator",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Complex math",
|
||||||
|
prompt="Calculate pi times the square of 5",
|
||||||
|
expected_tool="calculate_math",
|
||||||
|
success_patterns=["78.5"], # pi * 25 ≈ 78.54
|
||||||
|
category="calculator",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Word problem",
|
||||||
|
prompt="If I have 3 bags with 17 apples each and I eat 4, how many apples do I have?",
|
||||||
|
expected_tool="calculate_math",
|
||||||
|
success_patterns=["47"],
|
||||||
|
category="calculator",
|
||||||
|
),
|
||||||
|
|
||||||
|
# --- Should call get_current_time ---
|
||||||
|
Scenario(
|
||||||
|
name="Current date",
|
||||||
|
prompt="What's today's date?",
|
||||||
|
expected_tool="get_current_time",
|
||||||
|
success_patterns=["2026"], # Should contain current year
|
||||||
|
category="datetime",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Current time",
|
||||||
|
prompt="What time is it right now?",
|
||||||
|
expected_tool="get_current_time",
|
||||||
|
success_patterns=[":"], # Time format contains colons
|
||||||
|
category="datetime",
|
||||||
|
),
|
||||||
|
|
||||||
|
# --- Should call calculate_date_offset ---
|
||||||
|
Scenario(
|
||||||
|
name="Relative date past",
|
||||||
|
prompt="What was the date 2 weeks ago?",
|
||||||
|
expected_tool="calculate_date_offset",
|
||||||
|
success_patterns=["2026"],
|
||||||
|
category="datetime",
|
||||||
|
),
|
||||||
|
|
||||||
|
# --- Should call calculate_time_difference ---
|
||||||
|
Scenario(
|
||||||
|
name="Date difference",
|
||||||
|
prompt="How many days between January 1st 2025 and March 15th 2025?",
|
||||||
|
expected_tool="calculate_time_difference",
|
||||||
|
success_patterns=["73", "74"], # 73 or 74 days
|
||||||
|
category="datetime",
|
||||||
|
),
|
||||||
|
|
||||||
|
# --- Should NOT call any tool ---
|
||||||
|
Scenario(
|
||||||
|
name="Greeting",
|
||||||
|
prompt="Hello! How are you?",
|
||||||
|
expected_tool=None,
|
||||||
|
success_patterns=["sir"], # Butler personality
|
||||||
|
category="no_tool",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Knowledge question",
|
||||||
|
prompt="What is the capital of France?",
|
||||||
|
expected_tool=None,
|
||||||
|
success_patterns=["Paris"],
|
||||||
|
category="no_tool",
|
||||||
|
),
|
||||||
|
Scenario(
|
||||||
|
name="Opinion request",
|
||||||
|
prompt="What do you think about rainy days?",
|
||||||
|
expected_tool=None,
|
||||||
|
category="no_tool",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunResult:
|
||||||
|
scenario: str
|
||||||
|
model: str
|
||||||
|
iteration: int
|
||||||
|
latency: float
|
||||||
|
response_text: str
|
||||||
|
has_correct_answer: bool
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelStats:
|
||||||
|
model: str
|
||||||
|
results: list[RunResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return len(self.results)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def errors(self) -> int:
|
||||||
|
return sum(1 for r in self.results if r.error)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accuracy(self) -> float:
|
||||||
|
valid = [r for r in self.results if not r.error]
|
||||||
|
if not valid:
|
||||||
|
return 0
|
||||||
|
return sum(1 for r in valid if r.has_correct_answer) / len(valid) * 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_latency(self) -> float:
|
||||||
|
lats = [r.latency for r in self.results if not r.error]
|
||||||
|
return statistics.mean(lats) if lats else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def p95_latency(self) -> float:
|
||||||
|
lats = sorted(r.latency for r in self.results if not r.error)
|
||||||
|
if not lats:
|
||||||
|
return 0
|
||||||
|
return lats[min(int(len(lats) * 0.95), len(lats) - 1)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_latency(self) -> float:
|
||||||
|
lats = [r.latency for r in self.results if not r.error]
|
||||||
|
return max(lats) if lats else 0
|
||||||
|
|
||||||
|
def category_accuracy(self, category: str) -> float:
|
||||||
|
cat_scenarios = {s.name for s in SCENARIOS if s.category == category}
|
||||||
|
valid = [r for r in self.results if not r.error and r.scenario in cat_scenarios]
|
||||||
|
if not valid:
|
||||||
|
return 0
|
||||||
|
return sum(1 for r in valid if r.has_correct_answer) / len(valid) * 100
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# .env manipulation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def swap_model_in_env(model_name: str):
|
||||||
|
"""Swap OLLAMA_DEFAULT_MODEL in .env file."""
|
||||||
|
content = ENV_PATH.read_text()
|
||||||
|
content = re.sub(
|
||||||
|
r'^OLLAMA_DEFAULT_MODEL=.*$',
|
||||||
|
f'OLLAMA_DEFAULT_MODEL={model_name}',
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
ENV_PATH.write_text(content)
|
||||||
|
print(f" .env updated: OLLAMA_DEFAULT_MODEL={model_name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_server_reload(client: httpx.AsyncClient, timeout: float = 30):
|
||||||
|
"""Wait for uvicorn to auto-reload after .env change."""
|
||||||
|
# Give uvicorn a moment to detect the file change
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# Poll health endpoint
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
r = await client.get(HEALTH_URL, timeout=5)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
raise TimeoutError("Server did not come back after reload")
|
||||||
|
|
||||||
|
|
||||||
|
async def warm_up_ollama_model(client: httpx.AsyncClient, model_name: str):
|
||||||
|
"""Send a throwaway request to load the model into VRAM."""
|
||||||
|
print(f" Warming up {model_name} in Ollama...", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
f"{OLLAMA_URL}/api/generate",
|
||||||
|
json={"model": model_name, "prompt": "hi", "stream": False},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
duration = r.json().get("total_duration", 0) / 1e9
|
||||||
|
print(f"OK ({duration:.1f}s)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARN: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core benchmark logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def run_scenario(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
scenario: Scenario,
|
||||||
|
model: str,
|
||||||
|
iteration: int,
|
||||||
|
) -> RunResult:
|
||||||
|
"""Run a single scenario through the Tatlock API."""
|
||||||
|
payload = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [{"role": "user", "content": scenario.prompt}],
|
||||||
|
}
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
r = await client.post(CHAT_URL, json=payload, timeout=120)
|
||||||
|
latency = time.monotonic() - start
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
return RunResult(
|
||||||
|
scenario=scenario.name,
|
||||||
|
model=model,
|
||||||
|
iteration=iteration,
|
||||||
|
latency=latency,
|
||||||
|
response_text="",
|
||||||
|
has_correct_answer=False,
|
||||||
|
error=f"HTTP {r.status_code}: {r.text[:100]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
response_text = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Check if the response contains expected patterns
|
||||||
|
has_correct = True
|
||||||
|
if scenario.success_patterns:
|
||||||
|
has_correct = any(
|
||||||
|
p.lower() in response_text.lower()
|
||||||
|
for p in scenario.success_patterns
|
||||||
|
)
|
||||||
|
|
||||||
|
return RunResult(
|
||||||
|
scenario=scenario.name,
|
||||||
|
model=model,
|
||||||
|
iteration=iteration,
|
||||||
|
latency=latency,
|
||||||
|
response_text=response_text,
|
||||||
|
has_correct_answer=has_correct,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
latency = time.monotonic() - start
|
||||||
|
return RunResult(
|
||||||
|
scenario=scenario.name,
|
||||||
|
model=model,
|
||||||
|
iteration=iteration,
|
||||||
|
latency=latency,
|
||||||
|
response_text="",
|
||||||
|
has_correct_answer=False,
|
||||||
|
error=str(e)[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def benchmark_model(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
model_name: str,
|
||||||
|
iterations: int,
|
||||||
|
) -> ModelStats:
|
||||||
|
"""Run all scenarios for a single model."""
|
||||||
|
stats = ModelStats(model=model_name)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}")
|
||||||
|
print(f" Model: {model_name}")
|
||||||
|
print(f"{'=' * 70}")
|
||||||
|
|
||||||
|
# Swap model in .env
|
||||||
|
swap_model_in_env(model_name)
|
||||||
|
|
||||||
|
# Warm up model in Ollama BEFORE server reload picks it up
|
||||||
|
await warm_up_ollama_model(client, model_name)
|
||||||
|
|
||||||
|
# Wait for server to reload with new model
|
||||||
|
print(" Waiting for server reload...", end=" ", flush=True)
|
||||||
|
await wait_for_server_reload(client)
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
# Run a throwaway request through the full pipeline to warm up
|
||||||
|
print(" Warming up pipeline...", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
await client.post(
|
||||||
|
CHAT_URL,
|
||||||
|
json={"model": "Tatlock", "messages": [{"role": "user", "content": "hi"}]},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
print("OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARN: {e}")
|
||||||
|
|
||||||
|
for iteration in range(iterations):
|
||||||
|
if iterations > 1:
|
||||||
|
print(f"\n --- Iteration {iteration + 1}/{iterations} ---")
|
||||||
|
|
||||||
|
for scenario in SCENARIOS:
|
||||||
|
result = await run_scenario(client, scenario, model_name, iteration)
|
||||||
|
stats.results.append(result)
|
||||||
|
|
||||||
|
# Display
|
||||||
|
if result.error:
|
||||||
|
print(
|
||||||
|
f" [ERR ] {scenario.name:30s} {result.latency:5.1f}s "
|
||||||
|
f"{result.error[:60]}"
|
||||||
|
)
|
||||||
|
elif result.has_correct_answer:
|
||||||
|
preview = result.response_text[:60].replace("\n", " ")
|
||||||
|
print(f" [OK ] {scenario.name:30s} {result.latency:5.1f}s {preview}")
|
||||||
|
else:
|
||||||
|
preview = result.response_text[:60].replace("\n", " ")
|
||||||
|
print(f" [MISS] {scenario.name:30s} {result.latency:5.1f}s {preview}")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def print_comparison(all_stats: list[ModelStats]):
|
||||||
|
"""Print side-by-side comparison table."""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print(" COMPARISON SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
col_width = max(len(s.model) for s in all_stats) + 2
|
||||||
|
label_width = 32
|
||||||
|
|
||||||
|
header = f"{'Metric':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
header += f" {s.model:>{col_width}}"
|
||||||
|
print(f"\n{header}")
|
||||||
|
print("-" * (label_width + (col_width + 2) * len(all_stats)))
|
||||||
|
|
||||||
|
# Answer accuracy
|
||||||
|
row = f"{'Correct answer rate':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.accuracy:>{col_width - 1}.1f}%"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
# Latency
|
||||||
|
row = f"{'Avg latency':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.avg_latency:>{col_width - 1}.1f}s"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
row = f"{'P95 latency':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.p95_latency:>{col_width - 1}.1f}s"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
row = f"{'Max latency':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.max_latency:>{col_width - 1}.1f}s"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
row = f"{'Errors':<{label_width}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.errors:>{col_width}}"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
# Per-category
|
||||||
|
categories = sorted(set(sc.category for sc in SCENARIOS))
|
||||||
|
print(f"\n{'Per-category accuracy':<{label_width}}")
|
||||||
|
print("-" * (label_width + (col_width + 2) * len(all_stats)))
|
||||||
|
for cat in categories:
|
||||||
|
row = f" {cat:<{label_width - 2}}"
|
||||||
|
for s in all_stats:
|
||||||
|
row += f" {s.category_accuracy(cat):>{col_width - 1}.1f}%"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
# Mismatches
|
||||||
|
print(f"\n{'Missed answers':<50}")
|
||||||
|
print("-" * 80)
|
||||||
|
any_miss = False
|
||||||
|
for scenario in SCENARIOS:
|
||||||
|
misses = []
|
||||||
|
for s in all_stats:
|
||||||
|
sc_results = [r for r in s.results if r.scenario == scenario.name]
|
||||||
|
fails = [r for r in sc_results if not r.has_correct_answer and not r.error]
|
||||||
|
if fails:
|
||||||
|
preview = fails[0].response_text[:50].replace("\n", " ")
|
||||||
|
misses.append(f"{s.model}: \"{preview}\"")
|
||||||
|
if misses:
|
||||||
|
any_miss = True
|
||||||
|
print(f" {scenario.name}")
|
||||||
|
for m in misses:
|
||||||
|
print(f" {m}")
|
||||||
|
|
||||||
|
if not any_miss:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
|
||||||
|
|
||||||
|
def save_results(all_stats: list[ModelStats], output_path: Path):
|
||||||
|
"""Save detailed results to JSON."""
|
||||||
|
data = {}
|
||||||
|
for stats in all_stats:
|
||||||
|
data[stats.model] = {
|
||||||
|
"summary": {
|
||||||
|
"accuracy": stats.accuracy,
|
||||||
|
"avg_latency": round(stats.avg_latency, 2),
|
||||||
|
"p95_latency": round(stats.p95_latency, 2),
|
||||||
|
"max_latency": round(stats.max_latency, 2),
|
||||||
|
"errors": stats.errors,
|
||||||
|
"total_runs": stats.total,
|
||||||
|
},
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"scenario": r.scenario,
|
||||||
|
"iteration": r.iteration,
|
||||||
|
"latency": round(r.latency, 3),
|
||||||
|
"has_correct_answer": r.has_correct_answer,
|
||||||
|
"response_text": r.response_text,
|
||||||
|
"error": r.error,
|
||||||
|
}
|
||||||
|
for r in stats.results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(data, indent=2))
|
||||||
|
print(f"\nDetailed results saved to: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Benchmark tool calling across Ollama models via Tatlock API")
|
||||||
|
parser.add_argument(
|
||||||
|
"--iterations", type=int, default=1,
|
||||||
|
help="Iterations per model (default: 1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--models", type=str, default=",".join(DEFAULT_MODELS),
|
||||||
|
help=f"Comma-separated models (default: {','.join(DEFAULT_MODELS)})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=str, default="logs/benchmark_results.json",
|
||||||
|
help="JSON output path (default: logs/benchmark_results.json)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
models = [m.strip() for m in args.models.split(",")]
|
||||||
|
|
||||||
|
# Verify server is running
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.get(HEALTH_URL, timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
print("Server is running.")
|
||||||
|
except Exception:
|
||||||
|
print("ERROR: Server not running. Start it with ./wakeup.sh first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(" Tool Calling Benchmark (via Tatlock API)")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" Models: {', '.join(models)}")
|
||||||
|
print(f" Scenarios: {len(SCENARIOS)}")
|
||||||
|
print(f" Iterations: {args.iterations}")
|
||||||
|
print(f" Total runs: {len(SCENARIOS) * args.iterations * len(models)}")
|
||||||
|
|
||||||
|
# Remember original model to restore after benchmark
|
||||||
|
original_env = ENV_PATH.read_text()
|
||||||
|
|
||||||
|
all_stats = []
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for model in models:
|
||||||
|
stats = await benchmark_model(client, model, args.iterations)
|
||||||
|
all_stats.append(stats)
|
||||||
|
|
||||||
|
# Restore original .env
|
||||||
|
ENV_PATH.write_text(original_env)
|
||||||
|
print(f"\n .env restored to original")
|
||||||
|
|
||||||
|
print_comparison(all_stats)
|
||||||
|
save_results(all_stats, Path(args.output))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -494,13 +494,13 @@ class TatlockAgent(AgentInterface):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
# Force tool_choice: required to make LLM actually call tools
|
# Force tool_choice to make LLM actually call tools
|
||||||
from pydantic_ai.settings import ModelSettings
|
from src.anthropic.model_selector import get_tool_choice_settings
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
model_settings=get_tool_choice_settings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -624,7 +624,6 @@ class TatlockAgent(AgentInterface):
|
|||||||
- tool_outputs: Dict mapping tool names to their outputs
|
- tool_outputs: Dict mapping tool names to their outputs
|
||||||
- raw_output: The agent's raw text output
|
- raw_output: The agent's raw text output
|
||||||
"""
|
"""
|
||||||
from pydantic_ai.settings import ModelSettings
|
|
||||||
from pydantic_ai.messages import (
|
from pydantic_ai.messages import (
|
||||||
ModelRequest,
|
ModelRequest,
|
||||||
ModelResponse,
|
ModelResponse,
|
||||||
@@ -684,11 +683,12 @@ class TatlockAgent(AgentInterface):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
|
from src.anthropic.model_selector import get_tool_choice_settings
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
model_settings=get_tool_choice_settings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract tool calls and results from the agent's messages
|
# Extract tool calls and results from the agent's messages
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ Provides model selection with automatic fallback between Claude and Ollama.
|
|||||||
from src.anthropic.model_selector import (
|
from src.anthropic.model_selector import (
|
||||||
check_claude_health,
|
check_claude_health,
|
||||||
get_model,
|
get_model,
|
||||||
|
get_tool_choice_settings,
|
||||||
is_claude_available,
|
is_claude_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"check_claude_health",
|
"check_claude_health",
|
||||||
"get_model",
|
"get_model",
|
||||||
|
"get_tool_choice_settings",
|
||||||
"is_claude_available",
|
"is_claude_available",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIC
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_choice_settings() -> 'ModelSettings':
|
||||||
|
"""
|
||||||
|
Get model_settings for forcing tool calls on the first request.
|
||||||
|
|
||||||
|
For Claude: PydanticAI handles tool_choice natively, so no extra_body needed.
|
||||||
|
For Ollama: Pass tool_choice="required" via extra_body to force tool calling.
|
||||||
|
"""
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
|
||||||
|
if is_claude_available() and config.PREFER_CLOUD_BACKEND:
|
||||||
|
# PydanticAI's Anthropic model handles tool_choice internally
|
||||||
|
return ModelSettings()
|
||||||
|
else:
|
||||||
|
# Ollama needs explicit tool_choice via extra_body
|
||||||
|
return ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
|
||||||
|
|
||||||
def get_model_info() -> dict:
|
def get_model_info() -> dict:
|
||||||
"""
|
"""
|
||||||
Get information about the current model configuration.
|
Get information about the current model configuration.
|
||||||
|
|||||||
+17
-13
@@ -7,7 +7,7 @@ import logging
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from src.chat import service
|
from src.chat import service
|
||||||
from src.chat.schemas import (
|
from src.chat.schemas import (
|
||||||
@@ -22,36 +22,33 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
|||||||
|
|
||||||
async def _stream_response(
|
async def _stream_response(
|
||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Generate SSE stream for chat completion.
|
Generate SSE stream for chat completion.
|
||||||
|
|
||||||
EventSourceResponse adds "data: " prefix automatically.
|
Yields raw SSE-formatted strings matching OpenAI's format exactly:
|
||||||
We just yield the dict/string content.
|
data: {json}\n\n
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async for chunk in service.create_chat_completion_stream(request):
|
async for chunk in service.create_chat_completion_stream(request):
|
||||||
# Yield dict - EventSourceResponse will format as SSE
|
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
|
||||||
yield {"data": chunk.model_dump_json()}
|
|
||||||
|
|
||||||
# Send [DONE] message
|
yield "data: [DONE]\n\n"
|
||||||
yield {"data": "[DONE]"}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in streaming response: {e}")
|
logger.error(f"Error in streaming response: {e}")
|
||||||
error_data = {"error": {"message": str(e), "type": "internal_error"}}
|
error_data = json.dumps({"error": {"message": str(e), "type": "internal_error"}})
|
||||||
yield {"data": json.dumps(error_data)}
|
yield f"data: {error_data}\n\n"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/completions", response_model=ChatCompletionResponse)
|
@router.post("/completions", response_model=ChatCompletionResponse)
|
||||||
async def create_chat_completion(
|
async def create_chat_completion(
|
||||||
request: ChatCompletionRequest,
|
request: ChatCompletionRequest,
|
||||||
) -> ChatCompletionResponse | EventSourceResponse:
|
) -> ChatCompletionResponse | StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Create chat completion (OpenAI-compatible).
|
Create chat completion (OpenAI-compatible).
|
||||||
|
|
||||||
Supports both regular and streaming responses.
|
Supports both regular and streaming responses.
|
||||||
Currently returns mock lorem ipsum responses.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Chat completion request
|
request: Chat completion request
|
||||||
@@ -63,6 +60,13 @@ async def create_chat_completion(
|
|||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
logger.info("Streaming response requested")
|
logger.info("Streaming response requested")
|
||||||
return EventSourceResponse(_stream_response(request))
|
return StreamingResponse(
|
||||||
|
_stream_response(request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return await service.create_chat_completion(request)
|
return await service.create_chat_completion(request)
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ class Config(BaseSettings):
|
|||||||
description="Ollama server URL"
|
description="Ollama server URL"
|
||||||
)
|
)
|
||||||
OLLAMA_DEFAULT_MODEL: str = Field(
|
OLLAMA_DEFAULT_MODEL: str = Field(
|
||||||
default="mistral-nemo:latest",
|
default="gemma4:e2b",
|
||||||
description="Default Ollama model"
|
description="Default Ollama model"
|
||||||
)
|
)
|
||||||
OLLAMA_TIMEOUT: int = Field(
|
OLLAMA_TIMEOUT: int = Field(
|
||||||
|
|||||||
@@ -651,13 +651,11 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
# Build response output items
|
# Build response output items
|
||||||
output_items = []
|
output_items = []
|
||||||
|
|
||||||
# Add Steward reasoning as a reasoning output item
|
# Add Steward reasoning as reasoning output
|
||||||
|
if enriched.steward_reasoning:
|
||||||
output_items.append(ReasoningOutputItem(
|
output_items.append(ReasoningOutputItem(
|
||||||
id=f"reasoning_{generate_id()}",
|
id=f"rs_{generate_id()}",
|
||||||
summary=[
|
summary=[enriched.steward_reasoning],
|
||||||
"🎩 Steward's Analysis:",
|
|
||||||
enriched.steward_reasoning,
|
|
||||||
],
|
|
||||||
status="completed"
|
status="completed"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -166,26 +166,6 @@ class StreamingCoordinator:
|
|||||||
conversation_id=conversation_id,
|
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
|
# Initialize tool tracker
|
||||||
tracker = ToolCallTracker(
|
tracker = ToolCallTracker(
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import pytest
|
|||||||
|
|
||||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
|
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
|
||||||
from src.core.startup import initialize_application
|
from src.core.startup import register_household_members
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
def setup_household_registry():
|
def setup_household_registry():
|
||||||
"""Initialize household registry before running tests."""
|
"""Initialize household registry before running tests."""
|
||||||
initialize_application()
|
register_household_members()
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyzeRequest:
|
class TestAnalyzeRequest:
|
||||||
@@ -29,9 +29,6 @@ class TestAnalyzeRequest:
|
|||||||
mock_agent.analyze = AsyncMock(return_value="Simple greeting requires no tools. This is a simple request.")
|
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_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(
|
result = await analyze_request(
|
||||||
"Hello!",
|
"Hello!",
|
||||||
conversation_history=[],
|
conversation_history=[],
|
||||||
@@ -50,9 +47,6 @@ class TestAnalyzeRequest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
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(
|
result = await analyze_request(
|
||||||
"What's sqrt(144)?",
|
"What's sqrt(144)?",
|
||||||
conversation_history=[],
|
conversation_history=[],
|
||||||
@@ -75,9 +69,6 @@ class TestAnalyzeRequest:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
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(
|
result = await analyze_request(
|
||||||
"And what's that times 5?",
|
"And what's that times 5?",
|
||||||
conversation_history=conversation_history,
|
conversation_history=conversation_history,
|
||||||
@@ -100,9 +91,6 @@ class TestAnalyzeRequest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
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(
|
result = await analyze_request(
|
||||||
"Generate an image of a sunset",
|
"Generate an image of a sunset",
|
||||||
conversation_history=[],
|
conversation_history=[],
|
||||||
@@ -120,9 +108,6 @@ class TestAnalyzeRequest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
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(
|
result = await analyze_request(
|
||||||
"Test request",
|
"Test request",
|
||||||
conversation_history=[],
|
conversation_history=[],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
|||||||
response_1 = await async_client.post(
|
response_1 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data_1,
|
json=request_data_1,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_1.status_code == 200
|
assert response_1.status_code == 200
|
||||||
@@ -56,7 +56,7 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
|||||||
response_2 = await async_client.post(
|
response_2 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data_2,
|
json=request_data_2,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_2.status_code == 200
|
assert response_2.status_code == 200
|
||||||
@@ -95,7 +95,7 @@ async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
|||||||
response_1 = await async_client.post(
|
response_1 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_1,
|
json=request_1,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_1.status_code == 200
|
assert response_1.status_code == 200
|
||||||
@@ -117,7 +117,7 @@ async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
|||||||
response_2 = await async_client.post(
|
response_2 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_2,
|
json=request_2,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_2.status_code == 200
|
assert response_2.status_code == 200
|
||||||
@@ -150,7 +150,7 @@ async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
|||||||
response = await async_client.post(
|
response = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data,
|
json=request_data,
|
||||||
timeout=60.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -192,7 +192,7 @@ async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
|||||||
response = await async_client.post(
|
response = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data,
|
json=request_data,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -243,7 +243,7 @@ async def test_tatlock_tool_call_logging_datetime(async_client: AsyncClient):
|
|||||||
response = await async_client.post(
|
response = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data,
|
json=request_data,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -293,7 +293,7 @@ async def test_tatlock_no_tool_calls_no_logging(async_client: AsyncClient):
|
|||||||
response = await async_client.post(
|
response = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data,
|
json=request_data,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -336,7 +336,7 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
|||||||
response_1 = await async_client.post(
|
response_1 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_1,
|
json=request_1,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_1.status_code == 200
|
assert response_1.status_code == 200
|
||||||
@@ -362,7 +362,7 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
|||||||
response_2 = await async_client.post(
|
response_2 = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_2,
|
json=request_2,
|
||||||
timeout=30.0
|
timeout=120.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response_2.status_code == 200
|
assert response_2.status_code == 200
|
||||||
@@ -378,3 +378,55 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
|||||||
)
|
)
|
||||||
if not has_calculation:
|
if not has_calculation:
|
||||||
pytest.xfail(f"LLM did not remember calculation (non-deterministic): {second_response[:200]}")
|
pytest.xfail(f"LLM did not remember calculation (non-deterministic): {second_response[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tatlock_ollama_fallback(async_client: AsyncClient):
|
||||||
|
"""
|
||||||
|
Test that Tatlock falls back to Ollama when Claude is unavailable.
|
||||||
|
|
||||||
|
Patches _claude_available to False to force the Ollama path,
|
||||||
|
then verifies the system still produces a valid response.
|
||||||
|
"""
|
||||||
|
import src.anthropic.model_selector as model_selector
|
||||||
|
|
||||||
|
# Save original value
|
||||||
|
original = model_selector._claude_available
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Force Ollama fallback
|
||||||
|
model_selector._claude_available = False
|
||||||
|
|
||||||
|
# Verify we're actually using Ollama
|
||||||
|
info = model_selector.get_model_info()
|
||||||
|
assert info["backend"] == "ollama", f"Expected ollama backend, got {info['backend']}"
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"model": "Tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Say hello to me."}
|
||||||
|
],
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
timeout=120.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify response structure is valid
|
||||||
|
assert "choices" in data
|
||||||
|
assert len(data["choices"]) == 1
|
||||||
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
assert len(full_response) > 0, "Ollama should produce a non-empty response"
|
||||||
|
|
||||||
|
print(f"\nOllama fallback response: {full_response[:200]}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original value
|
||||||
|
model_selector._claude_available = original
|
||||||
|
|||||||
+14
-1
@@ -2,6 +2,8 @@
|
|||||||
Shared test fixtures for all tests.
|
Shared test fixtures for all tests.
|
||||||
Following FastAPI testing best practices.
|
Following FastAPI testing best practices.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
@@ -9,6 +11,17 @@ from httpx import AsyncClient, ASGITransport
|
|||||||
from src.main import app
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _initialize_app():
|
||||||
|
"""
|
||||||
|
Run application lifespan (Claude health check, household registration, etc.)
|
||||||
|
once per test session. ASGITransport doesn't trigger lifespan events,
|
||||||
|
so we call it explicitly.
|
||||||
|
"""
|
||||||
|
from src.core.startup import initialize_application
|
||||||
|
asyncio.run(initialize_application())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client() -> TestClient:
|
def client() -> TestClient:
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +50,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": "lorem-tester",
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "user", "content": "Hello, world!"}
|
{"role": "user", "content": "Hello, world!"}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ Tests for tool call tracking.
|
|||||||
|
|
||||||
Tests capability extraction and recommendation matching.
|
Tests capability extraction and recommendation matching.
|
||||||
"""
|
"""
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.tool_tracking import ToolCallTracker
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
@@ -35,15 +33,11 @@ class TestToolCallTracker:
|
|||||||
recommended_capabilities=["librarian", "biographer"]
|
recommended_capabilities=["librarian", "biographer"]
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
|
||||||
mock_store.return_value.record = AsyncMock()
|
|
||||||
|
|
||||||
await tracker.track_call("delegate_to_librarian", 1.0)
|
await tracker.track_call("delegate_to_librarian", 1.0)
|
||||||
|
|
||||||
# Should NOT log warning since librarian was recommended
|
# Should record the call
|
||||||
call_args = mock_store.return_value.record.call_args
|
assert "delegate_to_librarian" in tracker.actual_calls
|
||||||
benchmark = call_args[0][0]
|
assert tracker.actual_calls["delegate_to_librarian"] == [1.0]
|
||||||
assert benchmark.was_recommended is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_track_call_detects_not_recommended(self):
|
async def test_track_call_detects_not_recommended(self):
|
||||||
@@ -52,14 +46,12 @@ class TestToolCallTracker:
|
|||||||
recommended_capabilities=["librarian"]
|
recommended_capabilities=["librarian"]
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
|
||||||
mock_store.return_value.record = AsyncMock()
|
|
||||||
|
|
||||||
await tracker.track_call("delegate_to_housekeeper", 1.0)
|
await tracker.track_call("delegate_to_housekeeper", 1.0)
|
||||||
|
|
||||||
call_args = mock_store.return_value.record.call_args
|
# Should record the call even though not recommended
|
||||||
benchmark = call_args[0][0]
|
assert "delegate_to_housekeeper" in tracker.actual_calls
|
||||||
assert benchmark.was_recommended is False
|
summary = tracker.get_summary()
|
||||||
|
assert summary["accuracy"]["not_recommended_but_used"] == 1
|
||||||
|
|
||||||
def test_get_summary_with_delegation_tools(self):
|
def test_get_summary_with_delegation_tools(self):
|
||||||
"""Test summary correctly maps delegation tools to capabilities."""
|
"""Test summary correctly maps delegation tools to capabilities."""
|
||||||
@@ -87,15 +79,9 @@ class TestToolCallTracker:
|
|||||||
"delegate_to_librarian": [1.0],
|
"delegate_to_librarian": [1.0],
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
|
||||||
mock_store.return_value.record = AsyncMock()
|
|
||||||
|
|
||||||
await tracker.finalize()
|
await tracker.finalize()
|
||||||
|
|
||||||
# Should record benchmark for unused biographer
|
# Summary should show biographer as recommended but unused
|
||||||
assert mock_store.return_value.record.called
|
summary = tracker.get_summary()
|
||||||
call_args = mock_store.return_value.record.call_args
|
assert summary["accuracy"]["recommended_and_used"] == 1 # librarian
|
||||||
benchmark = call_args[0][0]
|
assert summary["accuracy"]["recommended_but_unused"] == 1 # biographer
|
||||||
assert benchmark.tool_name == "biographer"
|
|
||||||
assert benchmark.was_recommended is True
|
|
||||||
assert benchmark.was_actually_used is False
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
#!/bin/bash
|
|
||||||
# Tatlock Server Startup Script
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${GREEN}Starting Tatlock server...${NC}"
|
|
||||||
|
|
||||||
# Check if port 8777 is already in use
|
|
||||||
if lsof -Pi :8777 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
|
||||||
echo -e "${RED}Error: Port 8777 is already in use${NC}"
|
|
||||||
echo "Run: lsof -i :8777 to see what's using it"
|
|
||||||
echo "Or run: kill \$(lsof -t -i:8777) to stop it"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Activate virtual environment if not already activated
|
|
||||||
if [ -z "$VIRTUAL_ENV" ]; then
|
|
||||||
if [ -d ".venv" ]; then
|
|
||||||
echo -e "${YELLOW}Activating virtual environment...${NC}"
|
|
||||||
source .venv/bin/activate
|
|
||||||
else
|
|
||||||
echo -e "${RED}Error: Virtual environment not found${NC}"
|
|
||||||
echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create logs directory if it doesn't exist
|
|
||||||
LOGS_DIR="logs"
|
|
||||||
mkdir -p "$LOGS_DIR"
|
|
||||||
|
|
||||||
# Clear/create log file
|
|
||||||
LOG_FILE="$LOGS_DIR/server.log"
|
|
||||||
> "$LOG_FILE"
|
|
||||||
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
echo -e "${GREEN}Starting uvicorn server on http://tower-of-joy:8777${NC}"
|
|
||||||
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8777 2>&1 | tee "$LOG_FILE"
|
|
||||||
Reference in New Issue
Block a user