Merge pull request #44 from AlexanderWhitestone/feature/memory-layers-and-conversational-ai

Phase 3-4: Cascade LLM Router + Tool Registry Auto-Discovery
This commit is contained in:
Alexander Whitestone
2026-02-25 20:04:30 -05:00
committed by GitHub
84 changed files with 7058 additions and 0 deletions

326
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,326 @@
# Timmy Time — Implementation Summary
**Date:** 2026-02-25
**Phase:** 1, 2 Complete (MCP, Event Bus, Agents)
**Status:** ✅ Ready for Phase 3 (Cascade Router)
---
## What Was Built
### 1. MCP (Model Context Protocol) ✅
**Location:** `src/mcp/`
| Component | Purpose | Status |
|-----------|---------|--------|
| Registry | Tool catalog with health tracking | ✅ Complete |
| Server | MCP protocol implementation | ✅ Complete |
| Schemas | JSON schema utilities | ✅ Complete |
| Bootstrap | Auto-load all tools | ✅ Complete |
**Features:**
- 6 tools registered with full schemas
- Health tracking (healthy/degraded/unhealthy)
- Metrics collection (latency, error rates)
- Pattern-based discovery
- `@register_tool` decorator
**Tools Implemented:**
```python
web_search # DuckDuckGo search
read_file # File reading
write_file # File writing (with confirmation)
list_directory # Directory listing
python # Python execution
memory_search # Vector memory search
```
### 2. Event Bus ✅
**Location:** `src/events/bus.py`
**Features:**
- Async publish/subscribe
- Wildcard pattern matching (`agent.task.*`)
- Event history (last 1000 events)
- Concurrent handler execution
- System-wide singleton
**Usage:**
```python
from events.bus import event_bus, Event
@event_bus.subscribe("agent.task.*")
async def handle_task(event):
print(f"Task: {event.data}")
await event_bus.publish(Event(
type="agent.task.assigned",
source="timmy",
data={"task_id": "123"}
))
```
### 3. Sub-Agents ✅
**Location:** `src/agents/`
| Agent | ID | Role | Key Tools |
|-------|-----|------|-----------|
| Seer | seer | Research | web_search, read_file, memory_search |
| Forge | forge | Code | python, write_file, read_file |
| Quill | quill | Writing | write_file, read_file, memory_search |
| Echo | echo | Memory | memory_search, read_file, write_file |
| Helm | helm | Routing | memory_search |
| Timmy | timmy | Orchestrator | All tools |
**BaseAgent Features:**
- Agno Agent integration
- MCP tool registry access
- Event bus connectivity
- Structured logging
- Task execution framework
**Orchestrator Logic:**
```python
timmy = create_timmy_swarm()
# Automatic routing:
# - Simple questions → Direct response
# - "Remember..." → Echo agent
# - Complex tasks → Helm routes to specialist
```
### 4. Memory System (Previously Complete) ✅
**Three-Tier Architecture:**
```
Tier 1: Hot Memory (MEMORY.md)
↓ Always loaded
Tier 2: Vault (memory/)
├── self/identity.md
├── self/user_profile.md
├── self/methodology.md
├── notes/*.md
└── aar/*.md
Tier 3: Semantic Search
└── Vector embeddings over vault
```
**Handoff Protocol:**
- `last-session-handoff.md` written at session end
- Auto-loaded at next session start
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ USER INTERFACE │
│ (Dashboard/CLI) │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────────┐
│ TIMMY ORCHESTRATOR │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Request │ │ Router │ │ Response │ │
│ │ Analysis │→ │ (Helm) │→ │ Synthesis │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼──────┐ ┌───────▼──────┐ ┌───────▼──────┐
│ Seer │ │ Forge │ │ Quill │
│ (Research) │ │ (Code) │ │ (Writing) │
└──────────────┘ └──────────────┘ └──────────────┘
┌───────▼──────┐ ┌───────▼──────┐
│ Echo │ │ Helm │
│ (Memory) │ │ (Routing) │
└──────────────┘ └──────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MCP TOOL REGISTRY │
│ │
│ web_search read_file write_file list_directory │
│ python memory_search │
│ │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────────┐
│ EVENT BUS │
│ (Async pub/sub, wildcard patterns) │
└──────────────────────────┬──────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────────┐
│ MEMORY SYSTEM │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Hot │ │ Vault │ │ Semantic │ │
│ │ MEMORY │ │ Files │ │ Search │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Testing Results
```
All 973 tests pass ✅
Manual verification:
- MCP Bootstrap: ✅ 6 tools loaded
- Tool Registry: ✅ web_search, file_ops, etc.
- Event Bus: ✅ Events published/subscribed
- Agent Imports: ✅ All agents loadable
```
---
## Files Created
```
src/
├── mcp/
│ ├── __init__.py
│ ├── bootstrap.py # Auto-load tools
│ ├── registry.py # Tool catalog
│ ├── server.py # MCP protocol
│ └── schemas/
│ └── base.py # Schema utilities
├── tools/
│ ├── web_search.py # DuckDuckGo search
│ ├── file_ops.py # File operations
│ ├── code_exec.py # Python execution
│ └── memory_tool.py # Memory search
├── events/
│ └── bus.py # Event bus
└── agents/
├── __init__.py
├── base.py # Base agent class
├── timmy.py # Orchestrator
├── seer.py # Research
├── forge.py # Code
├── quill.py # Writing
├── echo.py # Memory
└── helm.py # Routing
MEMORY.md # Hot memory
memory/ # Vault structure
```
---
## Usage Example
```python
from agents import create_timmy_swarm
# Create fully configured Timmy
timmy = create_timmy_swarm()
# Simple chat (handles directly)
response = await timmy.orchestrate("What is your name?")
# Research (routes to Seer)
response = await timmy.orchestrate("Search for Bitcoin news")
# Code (routes to Forge)
response = await timmy.orchestrate("Write a Python script to...")
# Memory (routes to Echo)
response = await timmy.orchestrate("What did we discuss yesterday?")
```
---
## Next: Phase 3 (Cascade Router)
To complete the brief, implement:
### 1. Cascade LLM Router
```yaml
# config/providers.yaml
providers:
- name: ollama-local
type: ollama
url: http://localhost:11434
priority: 1
models: [llama3.2, deepseek-r1]
- name: openai-backup
type: openai
api_key: ${OPENAI_API_KEY}
priority: 2
models: [gpt-4o-mini]
```
Features:
- Priority-ordered fallback
- Latency/error tracking
- Cost accounting
- Health checks
### 2. Self-Upgrade Loop
- Detect failures from logs
- Propose fixes via Forge
- Present to user for approval
- Apply changes with rollback
### 3. Dashboard Integration
- Tool registry browser
- Agent activity feed
- Memory browser
- Upgrade queue
---
## Success Criteria Status
| Criteria | Status |
|----------|--------|
| Start with `python main.py` | 🟡 Need entry point |
| Dashboard at localhost | ✅ Exists |
| Timmy responds to questions | ✅ Working |
| Routes to sub-agents | ✅ Implemented |
| MCP tool discovery | ✅ Working |
| LLM failover | 🟡 Phase 3 |
| Search memory | ✅ Working |
| Self-upgrade proposals | 🟡 Phase 3 |
| Lightning payments | ✅ Mock exists |
---
## Key Achievements
1.**MCP Protocol** — Full implementation with schemas, registry, server
2.**6 Production Tools** — All with error handling and health tracking
3.**Event Bus** — Async pub/sub for agent communication
4.**6 Agents** — Full roster with specialized roles
5.**Orchestrator** — Intelligent routing logic
6.**Memory System** — Three-tier architecture
7.**All Tests Pass** — No regressions
---
## Ready for Phase 3
The foundation is solid. Next steps:
1. Cascade Router for LLM failover
2. Self-upgrade loop
3. Enhanced dashboard views
4. Production hardening

80
config/providers.yaml Normal file
View File

@@ -0,0 +1,80 @@
# Cascade LLM Router Configuration
# Providers are tried in priority order (1 = highest)
# On failure, automatically falls back to next provider
cascade:
# Timeout settings
timeout_seconds: 30
# Retry settings
max_retries_per_provider: 2
retry_delay_seconds: 1
# Circuit breaker settings
circuit_breaker:
failure_threshold: 5 # Open circuit after 5 failures
recovery_timeout: 60 # Try again after 60 seconds
half_open_max_calls: 2 # Allow 2 test calls when half-open
providers:
# Primary: Local Ollama (always try first for sovereignty)
- name: ollama-local
type: ollama
enabled: true
priority: 1
url: "http://localhost:11434"
models:
- name: llama3.2
default: true
context_window: 128000
- name: deepseek-r1:1.5b
context_window: 32000
# Secondary: Local AirLLM (if installed)
- name: airllm-local
type: airllm
enabled: false # Enable if pip install airllm
priority: 2
models:
- name: 70b
default: true
- name: 8b
- name: 405b
# Tertiary: OpenAI (if API key available)
- name: openai-backup
type: openai
enabled: false # Enable by setting OPENAI_API_KEY
priority: 3
api_key: "${OPENAI_API_KEY}" # Loaded from environment
base_url: null # Use default OpenAI endpoint
models:
- name: gpt-4o-mini
default: true
context_window: 128000
- name: gpt-4o
context_window: 128000
# Quaternary: Anthropic (if API key available)
- name: anthropic-backup
type: anthropic
enabled: false # Enable by setting ANTHROPIC_API_KEY
priority: 4
api_key: "${ANTHROPIC_API_KEY}"
models:
- name: claude-3-haiku-20240307
default: true
context_window: 200000
- name: claude-3-sonnet-20240229
context_window: 200000
# Cost tracking (optional, for budget monitoring)
cost_tracking:
enabled: true
budget_daily_usd: 10.0 # Alert if daily spend exceeds this
alert_threshold_percent: 80 # Alert at 80% of budget
# Metrics retention
metrics:
retention_hours: 168 # Keep 7 days of metrics
purge_interval_hours: 24

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_223632
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc12345
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: PASSED
```
5 passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_223632
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_223632
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_223632
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_223632
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_224732
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260225_224733
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_224733
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_224733
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_224734
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_224734
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260225_225049
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_225049
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_225049
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_225049
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_225049
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_230304
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_230305
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc12345
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: PASSED
```
5 passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_230305
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_230305
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_230305
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_230306
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260225_230553
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_230553
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_230553
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_230554
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_230554
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_231440
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc12345
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: PASSED
```
5 passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_231440
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_231440
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_231440
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_231440
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_231441
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260225_231645
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_231645
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_231645
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_231645
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_231645
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260225_232402
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_232402
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260225_232402
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260225_232402
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260225_232402
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260225_232403
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_002427
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc12345
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: PASSED
```
5 passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_002427
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260226_002427
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260226_002427
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_002428
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260226_002428
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_004233
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc12345
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: PASSED
```
5 passed
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_004233
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260226_004233
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,48 @@
# Self-Modify Report: 20260226_004234
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 2
**Autonomous cycles:** 0
## Attempt 1 -- syntax_validation
**Error:** src/foo.py: line 1: '(' was never closed
### LLM Response
```
bad llm
```
### Edits Written
#### src/foo.py
```python
def foo(
```
## Attempt 2 -- complete
### LLM Response
```
good llm
```
### Edits Written
#### src/foo.py
```python
def foo():
pass
```
### Test Result: PASSED
```
passed
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260226_004234
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

21
src/agents/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""Agents package — Timmy and sub-agents.
"""
from agents.timmy import TimmyOrchestrator, create_timmy_swarm
from agents.base import BaseAgent
from agents.seer import SeerAgent
from agents.forge import ForgeAgent
from agents.quill import QuillAgent
from agents.echo import EchoAgent
from agents.helm import HelmAgent
__all__ = [
"BaseAgent",
"TimmyOrchestrator",
"create_timmy_swarm",
"SeerAgent",
"ForgeAgent",
"QuillAgent",
"EchoAgent",
"HelmAgent",
]

139
src/agents/base.py Normal file
View File

@@ -0,0 +1,139 @@
"""Base agent class for all Timmy sub-agents.
All sub-agents inherit from BaseAgent and get:
- MCP tool registry access
- Event bus integration
- Memory integration
- Structured logging
"""
import logging
from abc import ABC, abstractmethod
from typing import Any, Optional
from agno.agent import Agent
from agno.models.ollama import Ollama
from config import settings
from events.bus import EventBus, Event
from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
class BaseAgent(ABC):
"""Base class for all Timmy sub-agents.
Sub-agents are specialized agents that handle specific tasks:
- Seer: Research and information gathering
- Mace: Security and validation
- Quill: Writing and content
- Forge: Code and tool building
- Echo: Memory and context
- Helm: Routing and orchestration
"""
def __init__(
self,
agent_id: str,
name: str,
role: str,
system_prompt: str,
tools: list[str] | None = None,
) -> None:
self.agent_id = agent_id
self.name = name
self.role = role
self.tools = tools or []
# Create Agno agent
self.agent = self._create_agent(system_prompt)
# Event bus for communication
self.event_bus: Optional[EventBus] = None
logger.info("%s agent initialized (id: %s)", name, agent_id)
def _create_agent(self, system_prompt: str) -> Agent:
"""Create the underlying Agno agent."""
# Get tools from registry
tool_instances = []
for tool_name in self.tools:
handler = tool_registry.get_handler(tool_name)
if handler:
tool_instances.append(handler)
return Agent(
name=self.name,
model=Ollama(id=settings.ollama_model, host=settings.ollama_url),
description=system_prompt,
tools=tool_instances if tool_instances else None,
add_history_to_context=True,
num_history_runs=10,
markdown=True,
telemetry=settings.telemetry_enabled,
)
def connect_event_bus(self, bus: EventBus) -> None:
"""Connect to the event bus for inter-agent communication."""
self.event_bus = bus
# Subscribe to relevant events
bus.subscribe(f"agent.{self.agent_id}.*")(self._handle_direct_message)
bus.subscribe("agent.task.assigned")(self._handle_task_assignment)
async def _handle_direct_message(self, event: Event) -> None:
"""Handle direct messages to this agent."""
logger.debug("%s received message: %s", self.name, event.type)
async def _handle_task_assignment(self, event: Event) -> None:
"""Handle task assignment events."""
assigned_agent = event.data.get("agent_id")
if assigned_agent == self.agent_id:
task_id = event.data.get("task_id")
description = event.data.get("description", "")
logger.info("%s assigned task %s: %s", self.name, task_id, description[:50])
# Execute the task
await self.execute_task(task_id, description, event.data)
@abstractmethod
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a task assigned to this agent.
Must be implemented by subclasses.
"""
pass
async def run(self, message: str) -> str:
"""Run the agent with a message.
Returns:
Agent response
"""
result = self.agent.run(message, stream=False)
response = result.content if hasattr(result, "content") else str(result)
# Emit completion event
if self.event_bus:
await self.event_bus.publish(Event(
type=f"agent.{self.agent_id}.response",
source=self.agent_id,
data={"input": message, "output": response},
))
return response
def get_capabilities(self) -> list[str]:
"""Get list of capabilities this agent provides."""
return self.tools
def get_status(self) -> dict:
"""Get current agent status."""
return {
"agent_id": self.agent_id,
"name": self.name,
"role": self.role,
"status": "ready",
"tools": self.tools,
}

81
src/agents/echo.py Normal file
View File

@@ -0,0 +1,81 @@
"""Echo Agent — Memory and context management.
Capabilities:
- Memory retrieval
- Context synthesis
- User profile management
- Conversation history
"""
from typing import Any
from agents.base import BaseAgent
ECHO_SYSTEM_PROMPT = """You are Echo, a memory and context management specialist.
Your role is to remember, retrieve, and synthesize information from the past.
## Capabilities
- Search past conversations
- Retrieve user preferences
- Synthesize context from multiple sources
- Manage user profile
## Guidelines
1. **Be accurate** — Only state what we actually know
2. **Be relevant** — Filter for context that matters now
3. **Be concise** — Summarize, don't dump everything
4. **Acknowledge uncertainty** — Say when memory is unclear
## Tool Usage
- Use memory_search to find relevant past context
- Use read_file to access vault files
- Use write_file to update user profile
## Response Format
Provide memory retrieval in this structure:
- Direct answer (what we know)
- Context (relevant past discussions)
- Confidence (certain/likely/speculative)
- Source (where this came from)
You work for Timmy, the sovereign AI orchestrator. Be the keeper of institutional knowledge.
"""
class EchoAgent(BaseAgent):
"""Memory and context specialist."""
def __init__(self, agent_id: str = "echo") -> None:
super().__init__(
agent_id=agent_id,
name="Echo",
role="memory",
system_prompt=ECHO_SYSTEM_PROMPT,
tools=["memory_search", "read_file", "write_file"],
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a memory retrieval task."""
# Extract what to search for
prompt = f"Search memory and provide relevant context:\n\nTask: {description}\n\nSynthesize findings clearly."
result = await self.run(prompt)
return {
"task_id": task_id,
"agent": self.agent_id,
"result": result,
"status": "completed",
}
async def recall(self, query: str, include_sources: bool = True) -> str:
"""Quick memory recall."""
sources = "with sources" if include_sources else ""
prompt = f"Recall information about: {query} {sources}\n\nProvide relevant context from memory."
return await self.run(prompt)

92
src/agents/forge.py Normal file
View File

@@ -0,0 +1,92 @@
"""Forge Agent — Code generation and tool building.
Capabilities:
- Code generation
- Tool/script creation
- System modifications
- Debugging assistance
"""
from typing import Any
from agents.base import BaseAgent
FORGE_SYSTEM_PROMPT = """You are Forge, a code generation and tool building specialist.
Your role is to write code, create tools, and modify systems.
## Capabilities
- Python code generation
- Tool/script creation
- File operations
- Code explanation and debugging
## Guidelines
1. **Write clean code** — Follow PEP 8, add docstrings
2. **Be safe** — Never execute destructive operations without confirmation
3. **Explain your work** — Provide context for what the code does
4. **Test mentally** — Walk through the logic before presenting
## Tool Usage
- Use python for code execution and testing
- Use write_file to save code (requires confirmation)
- Use read_file to examine existing code
- Use shell for system operations (requires confirmation)
## Response Format
Provide code in this structure:
- Purpose (what this code does)
- Code block (with language tag)
- Usage example
- Notes (any important considerations)
You work for Timmy, the sovereign AI orchestrator. Build reliable, well-documented tools.
"""
class ForgeAgent(BaseAgent):
"""Code and tool building specialist."""
def __init__(self, agent_id: str = "forge") -> None:
super().__init__(
agent_id=agent_id,
name="Forge",
role="code",
system_prompt=FORGE_SYSTEM_PROMPT,
tools=["python", "write_file", "read_file", "list_directory"],
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a code/task building task."""
prompt = f"Create the requested code or tool:\n\nTask: {description}\n\nProvide complete, working code with documentation."
result = await self.run(prompt)
return {
"task_id": task_id,
"agent": self.agent_id,
"result": result,
"status": "completed",
}
async def generate_tool(self, name: str, purpose: str, parameters: list) -> str:
"""Generate a new MCP tool."""
params_str = ", ".join(parameters)
prompt = f"""Create a new MCP tool named '{name}'.
Purpose: {purpose}
Parameters: {params_str}
Generate:
1. The tool function with proper error handling
2. The MCP schema
3. Registration code
Follow the MCP pattern used in existing tools."""
return await self.run(prompt)

106
src/agents/helm.py Normal file
View File

@@ -0,0 +1,106 @@
"""Helm Agent — Routing and orchestration decisions.
Capabilities:
- Task analysis
- Agent selection
- Workflow planning
- Priority management
"""
from typing import Any
from agents.base import BaseAgent
HELM_SYSTEM_PROMPT = """You are Helm, a routing and orchestration specialist.
Your role is to analyze tasks and decide how to route them to other agents.
## Capabilities
- Task analysis and decomposition
- Agent selection for tasks
- Workflow planning
- Priority assessment
## Guidelines
1. **Analyze carefully** — Understand what the task really needs
2. **Route wisely** — Match tasks to agent strengths
3. **Consider dependencies** — Some tasks need sequencing
4. **Be efficient** — Don't over-complicate simple tasks
## Agent Roster
- Seer: Research, information gathering
- Forge: Code, tools, system changes
- Quill: Writing, documentation
- Echo: Memory, context retrieval
- Mace: Security, validation (use for sensitive operations)
## Response Format
Provide routing decisions as:
- Task breakdown (subtasks if needed)
- Agent assignment (who does what)
- Execution order (sequence if relevant)
- Rationale (why this routing)
You work for Timmy, the sovereign AI orchestrator. Be the dispatcher that keeps everything flowing.
"""
class HelmAgent(BaseAgent):
"""Routing and orchestration specialist."""
def __init__(self, agent_id: str = "helm") -> None:
super().__init__(
agent_id=agent_id,
name="Helm",
role="routing",
system_prompt=HELM_SYSTEM_PROMPT,
tools=["memory_search"], # May need to check past routing decisions
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a routing task."""
prompt = f"Analyze and route this task:\n\nTask: {description}\n\nProvide routing decision with rationale."
result = await self.run(prompt)
return {
"task_id": task_id,
"agent": self.agent_id,
"result": result,
"status": "completed",
}
async def route_request(self, request: str) -> dict:
"""Analyze a request and suggest routing."""
prompt = f"""Analyze this request and determine the best agent(s) to handle it:
Request: {request}
Respond in this format:
Primary Agent: [agent name]
Reason: [why this agent]
Secondary Agents: [if needed]
Complexity: [simple/moderate/complex]
"""
result = await self.run(prompt)
# Parse result into structured format
# This is simplified - in production, use structured output
return {
"analysis": result,
"primary_agent": self._extract_agent(result),
}
def _extract_agent(self, text: str) -> str:
"""Extract agent name from routing text."""
agents = ["seer", "forge", "quill", "echo", "mace", "helm"]
text_lower = text.lower()
for agent in agents:
if agent in text_lower:
return agent
return "timmy" # Default to orchestrator

80
src/agents/quill.py Normal file
View File

@@ -0,0 +1,80 @@
"""Quill Agent — Writing and content generation.
Capabilities:
- Documentation writing
- Content creation
- Text editing
- Summarization
"""
from typing import Any
from agents.base import BaseAgent
QUILL_SYSTEM_PROMPT = """You are Quill, a writing and content generation specialist.
Your role is to create, edit, and improve written content.
## Capabilities
- Documentation writing
- Content creation
- Text editing and refinement
- Summarization
- Style adaptation
## Guidelines
1. **Write clearly** — Plain language, logical structure
2. **Know your audience** — Adapt tone and complexity
3. **Be concise** — Cut unnecessary words
4. **Use formatting** — Headers, lists, emphasis for readability
## Tool Usage
- Use write_file to save documents
- Use read_file to review existing content
- Use memory_search to check style preferences
## Response Format
Provide written content with:
- Clear structure (headers, sections)
- Appropriate tone for the context
- Proper formatting (markdown)
- Brief explanation of choices made
You work for Timmy, the sovereign AI orchestrator. Create polished, professional content.
"""
class QuillAgent(BaseAgent):
"""Writing and content specialist."""
def __init__(self, agent_id: str = "quill") -> None:
super().__init__(
agent_id=agent_id,
name="Quill",
role="writing",
system_prompt=QUILL_SYSTEM_PROMPT,
tools=["write_file", "read_file", "memory_search"],
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a writing task."""
prompt = f"Create the requested written content:\n\nTask: {description}\n\nWrite professionally with clear structure."
result = await self.run(prompt)
return {
"task_id": task_id,
"agent": self.agent_id,
"result": result,
"status": "completed",
}
async def write_documentation(self, topic: str, format: str = "markdown") -> str:
"""Write documentation for a topic."""
prompt = f"Write comprehensive documentation for: {topic}\n\nFormat: {format}\nInclude: Overview, Usage, Examples, Notes"
return await self.run(prompt)

91
src/agents/seer.py Normal file
View File

@@ -0,0 +1,91 @@
"""Seer Agent — Research and information gathering.
Capabilities:
- Web search
- Information synthesis
- Fact checking
- Source evaluation
"""
from typing import Any
from agents.base import BaseAgent
from events.bus import Event
SEER_SYSTEM_PROMPT = """You are Seer, a research and information gathering specialist.
Your role is to find, evaluate, and synthesize information from external sources.
## Capabilities
- Web search for current information
- File reading for local documents
- Information synthesis and summarization
- Source evaluation (credibility assessment)
## Guidelines
1. **Be thorough** — Search multiple angles, verify facts
2. **Be skeptical** — Evaluate source credibility
3. **Be concise** — Summarize findings clearly
4. **Cite sources** — Reference where information came from
## Tool Usage
- Use web_search for external information
- Use read_file for local documents
- Use memory_search to check if we already know this
## Response Format
Provide findings in structured format:
- Summary (2-3 sentences)
- Key facts (bullet points)
- Sources (where information came from)
- Confidence level (high/medium/low)
You work for Timmy, the sovereign AI orchestrator. Report findings clearly and objectively.
"""
class SeerAgent(BaseAgent):
"""Research specialist agent."""
def __init__(self, agent_id: str = "seer") -> None:
super().__init__(
agent_id=agent_id,
name="Seer",
role="research",
system_prompt=SEER_SYSTEM_PROMPT,
tools=["web_search", "read_file", "memory_search"],
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a research task."""
# Determine research approach
if "file" in description.lower() or "document" in description.lower():
# Local document research
prompt = f"Read and analyze the referenced document. Provide key findings:\n\nTask: {description}"
else:
# Web research
prompt = f"Research the following topic thoroughly. Search for current information, evaluate sources, and provide a comprehensive summary:\n\nTask: {description}"
result = await self.run(prompt)
return {
"task_id": task_id,
"agent": self.agent_id,
"result": result,
"status": "completed",
}
async def research_topic(self, topic: str, depth: str = "standard") -> str:
"""Quick research on a topic."""
prompts = {
"quick": f"Quick search on: {topic}. Provide 3-5 key facts.",
"standard": f"Research: {topic}. Search, synthesize, and summarize findings.",
"deep": f"Deep research on: {topic}. Multiple searches, fact-checking, comprehensive report.",
}
return await self.run(prompts.get(depth, prompts["standard"]))

184
src/agents/timmy.py Normal file
View File

@@ -0,0 +1,184 @@
"""Timmy — The orchestrator agent.
Coordinates all sub-agents and handles user interaction.
Uses the three-tier memory system and MCP tools.
"""
import logging
from typing import Any, Optional
from agno.agent import Agent
from agno.models.ollama import Ollama
from agents.base import BaseAgent
from config import settings
from events.bus import EventBus, event_bus
from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
TIMMY_ORCHESTRATOR_PROMPT = """You are Timmy, a sovereign AI orchestrator running locally on this Mac.
## Your Role
You are the primary interface between the user and the agent swarm. You:
1. Understand user requests
2. Decide whether to handle directly or delegate to sub-agents
3. Coordinate multi-agent workflows when needed
4. Maintain continuity using the three-tier memory system
## Sub-Agent Roster
| Agent | Role | When to Use |
|-------|------|-------------|
| Seer | Research | External info, web search, facts |
| Forge | Code | Programming, tools, file operations |
| Quill | Writing | Documentation, content creation |
| Echo | Memory | Past conversations, user profile |
| Helm | Routing | Complex multi-step workflows |
| Mace | Security | Validation, sensitive operations |
## Decision Framework
**Handle directly if:**
- Simple question (identity, capabilities)
- General knowledge
- Social/conversational
**Delegate if:**
- Requires specialized skills
- Needs external research (Seer)
- Involves code (Forge)
- Needs past context (Echo)
- Complex workflow (Helm)
## Memory System
You have three tiers of memory:
1. **Hot Memory** — Always loaded (MEMORY.md)
2. **Vault** — Structured storage (memory/)
3. **Semantic** — Vector search for recall
Use `memory_search` when the user refers to past conversations.
## Principles
1. **Sovereignty** — Everything local, no cloud
2. **Privacy** — User data stays on their Mac
3. **Clarity** — Think clearly, speak plainly
4. **Christian faith** — Grounded in biblical values
5. **Bitcoin economics** — Sound money, self-custody
Sir, affirmative.
"""
class TimmyOrchestrator(BaseAgent):
"""Main orchestrator agent that coordinates the swarm."""
def __init__(self) -> None:
super().__init__(
agent_id="timmy",
name="Timmy",
role="orchestrator",
system_prompt=TIMMY_ORCHESTRATOR_PROMPT,
tools=["web_search", "read_file", "write_file", "python", "memory_search"],
)
# Sub-agent registry
self.sub_agents: dict[str, BaseAgent] = {}
# Connect to event bus
self.connect_event_bus(event_bus)
logger.info("Timmy Orchestrator initialized")
def register_sub_agent(self, agent: BaseAgent) -> None:
"""Register a sub-agent with the orchestrator."""
self.sub_agents[agent.agent_id] = agent
agent.connect_event_bus(event_bus)
logger.info("Registered sub-agent: %s", agent.name)
async def orchestrate(self, user_request: str) -> str:
"""Main entry point for user requests.
Analyzes the request and either handles directly or delegates.
"""
# Quick classification
request_lower = user_request.lower()
# Direct response patterns (no delegation needed)
direct_patterns = [
"your name", "who are you", "what are you",
"hello", "hi", "how are you",
"help", "what can you do",
]
for pattern in direct_patterns:
if pattern in request_lower:
return await self.run(user_request)
# Check for memory references
memory_patterns = [
"we talked about", "we discussed", "remember",
"what did i say", "what did we decide",
"remind me", "have we",
]
for pattern in memory_patterns:
if pattern in request_lower:
# Use Echo agent for memory retrieval
echo = self.sub_agents.get("echo")
if echo:
return await echo.recall(user_request)
# Complex requests - use Helm for routing
helm = self.sub_agents.get("helm")
if helm:
routing = await helm.route_request(user_request)
agent_id = routing.get("primary_agent", "timmy")
if agent_id in self.sub_agents and agent_id != "timmy":
agent = self.sub_agents[agent_id]
return await agent.run(user_request)
# Default: handle directly
return await self.run(user_request)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
"""Execute a task (usually delegates to appropriate agent)."""
return await self.orchestrate(description)
def get_swarm_status(self) -> dict:
"""Get status of all agents in the swarm."""
return {
"orchestrator": self.get_status(),
"sub_agents": {
aid: agent.get_status()
for aid, agent in self.sub_agents.items()
},
"total_agents": 1 + len(self.sub_agents),
}
# Factory function for creating fully configured Timmy
def create_timmy_swarm() -> TimmyOrchestrator:
"""Create Timmy orchestrator with all sub-agents registered."""
from agents.seer import SeerAgent
from agents.forge import ForgeAgent
from agents.quill import QuillAgent
from agents.echo import EchoAgent
from agents.helm import HelmAgent
# Create orchestrator
timmy = TimmyOrchestrator()
# Register sub-agents
timmy.register_sub_agent(SeerAgent())
timmy.register_sub_agent(ForgeAgent())
timmy.register_sub_agent(QuillAgent())
timmy.register_sub_agent(EchoAgent())
timmy.register_sub_agent(HelmAgent())
return timmy

View File

@@ -27,6 +27,7 @@ from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.self_modify import router as self_modify_router
from router.api import router as cascade_router
logging.basicConfig(
level=logging.INFO,
@@ -101,6 +102,15 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
# Auto-bootstrap MCP tools
from mcp.bootstrap import auto_bootstrap, get_bootstrap_status
try:
registered = auto_bootstrap()
if registered:
logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
except Exception as exc:
logger.warning("MCP auto-bootstrap failed: %s", exc)
# Initialise Spark Intelligence engine
from spark.engine import spark_engine
if spark_engine.enabled:
@@ -156,6 +166,7 @@ app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
app.include_router(self_modify_router)
app.include_router(cascade_router)
@app.get("/", response_class=HTMLResponse)

168
src/events/bus.py Normal file
View File

@@ -0,0 +1,168 @@
"""Async Event Bus for inter-agent communication.
Agents publish and subscribe to events for loose coupling.
Events are typed and carry structured data.
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Coroutine
logger = logging.getLogger(__name__)
@dataclass
class Event:
"""A typed event in the system."""
type: str # e.g., "agent.task.assigned", "tool.execution.completed"
source: str # Agent or component that emitted the event
data: dict = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
id: str = field(default_factory=lambda: f"evt_{datetime.now(timezone.utc).timestamp()}")
# Type alias for event handlers
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
class EventBus:
"""Async event bus for publish/subscribe pattern.
Usage:
bus = EventBus()
# Subscribe to events
@bus.subscribe("agent.task.*")
async def handle_task(event: Event):
print(f"Task event: {event.data}")
# Publish events
await bus.publish(Event(
type="agent.task.assigned",
source="timmy",
data={"task_id": "123", "agent": "forge"}
))
"""
def __init__(self) -> None:
self._subscribers: dict[str, list[EventHandler]] = {}
self._history: list[Event] = []
self._max_history = 1000
logger.info("EventBus initialized")
def subscribe(self, event_pattern: str) -> Callable[[EventHandler], EventHandler]:
"""Decorator to subscribe to events matching a pattern.
Patterns support wildcards:
- "agent.task.assigned" — exact match
- "agent.task.*" — any task event
- "agent.*" — any agent event
- "*" — all events
"""
def decorator(handler: EventHandler) -> EventHandler:
if event_pattern not in self._subscribers:
self._subscribers[event_pattern] = []
self._subscribers[event_pattern].append(handler)
logger.debug("Subscribed handler to '%s'", event_pattern)
return handler
return decorator
def unsubscribe(self, event_pattern: str, handler: EventHandler) -> bool:
"""Remove a handler from a subscription."""
if event_pattern not in self._subscribers:
return False
if handler in self._subscribers[event_pattern]:
self._subscribers[event_pattern].remove(handler)
logger.debug("Unsubscribed handler from '%s'", event_pattern)
return True
return False
async def publish(self, event: Event) -> int:
"""Publish an event to all matching subscribers.
Returns:
Number of handlers invoked
"""
# Store in history
self._history.append(event)
if len(self._history) > self._max_history:
self._history = self._history[-self._max_history:]
# Find matching handlers
handlers: list[EventHandler] = []
for pattern, pattern_handlers in self._subscribers.items():
if self._match_pattern(event.type, pattern):
handlers.extend(pattern_handlers)
# Invoke handlers concurrently
if handlers:
await asyncio.gather(
*[self._invoke_handler(h, event) for h in handlers],
return_exceptions=True
)
logger.debug("Published event '%s' to %d handlers", event.type, len(handlers))
return len(handlers)
async def _invoke_handler(self, handler: EventHandler, event: Event) -> None:
"""Invoke a handler with error handling."""
try:
await handler(event)
except Exception as exc:
logger.error("Event handler failed for '%s': %s", event.type, exc)
def _match_pattern(self, event_type: str, pattern: str) -> bool:
"""Check if event type matches a wildcard pattern."""
if pattern == "*":
return True
if pattern.endswith(".*"):
prefix = pattern[:-2]
return event_type.startswith(prefix + ".")
return event_type == pattern
def get_history(
self,
event_type: str | None = None,
source: str | None = None,
limit: int = 100,
) -> list[Event]:
"""Get recent event history with optional filtering."""
events = self._history
if event_type:
events = [e for e in events if e.type == event_type]
if source:
events = [e for e in events if e.source == source]
return events[-limit:]
def clear_history(self) -> None:
"""Clear event history."""
self._history.clear()
# Module-level singleton
event_bus = EventBus()
# Convenience functions
async def emit(event_type: str, source: str, data: dict) -> int:
"""Quick emit an event."""
return await event_bus.publish(Event(
type=event_type,
source=source,
data=data,
))
def on(event_pattern: str) -> Callable[[EventHandler], EventHandler]:
"""Quick subscribe decorator."""
return event_bus.subscribe(event_pattern)

30
src/mcp/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
"""MCP (Model Context Protocol) package.
Provides tool registry, server, schema management, and auto-discovery.
"""
from mcp.registry import tool_registry, register_tool, ToolRegistry
from mcp.server import mcp_server, MCPServer, MCPHTTPServer
from mcp.schemas.base import create_tool_schema
from mcp.discovery import ToolDiscovery, mcp_tool, get_discovery
from mcp.bootstrap import auto_bootstrap, get_bootstrap_status
__all__ = [
# Registry
"tool_registry",
"register_tool",
"ToolRegistry",
# Server
"mcp_server",
"MCPServer",
"MCPHTTPServer",
# Schemas
"create_tool_schema",
# Discovery
"ToolDiscovery",
"mcp_tool",
"get_discovery",
# Bootstrap
"auto_bootstrap",
"get_bootstrap_status",
]

148
src/mcp/bootstrap.py Normal file
View File

@@ -0,0 +1,148 @@
"""MCP Auto-Bootstrap — Auto-discover and register tools on startup.
Usage:
from mcp.bootstrap import auto_bootstrap
# Auto-discover from 'tools' package
registered = auto_bootstrap()
# Or specify custom packages
registered = auto_bootstrap(packages=["tools", "custom_tools"])
"""
import logging
import os
from pathlib import Path
from typing import Optional
from .discovery import ToolDiscovery, get_discovery
from .registry import ToolRegistry, tool_registry
logger = logging.getLogger(__name__)
# Default packages to scan for tools
DEFAULT_TOOL_PACKAGES = ["tools"]
# Environment variable to disable auto-bootstrap
AUTO_BOOTSTRAP_ENV_VAR = "MCP_AUTO_BOOTSTRAP"
def auto_bootstrap(
packages: Optional[list[str]] = None,
registry: Optional[ToolRegistry] = None,
force: bool = False,
) -> list[str]:
"""Auto-discover and register MCP tools.
Args:
packages: Packages to scan (defaults to ["tools"])
registry: Registry to register tools with (defaults to singleton)
force: Force bootstrap even if disabled by env var
Returns:
List of registered tool names
"""
# Check if auto-bootstrap is disabled
if not force and os.environ.get(AUTO_BOOTSTRAP_ENV_VAR, "1") == "0":
logger.info("MCP auto-bootstrap disabled via %s", AUTO_BOOTSTRAP_ENV_VAR)
return []
packages = packages or DEFAULT_TOOL_PACKAGES
registry = registry or tool_registry
discovery = get_discovery(registry=registry)
registered: list[str] = []
logger.info("Starting MCP auto-bootstrap from packages: %s", packages)
for package in packages:
try:
# Check if package exists
try:
__import__(package)
except ImportError:
logger.debug("Package %s not found, skipping", package)
continue
# Discover and register
tools = discovery.auto_register(package)
registered.extend(tools)
except Exception as exc:
logger.warning("Failed to bootstrap from %s: %s", package, exc)
logger.info("MCP auto-bootstrap complete: %d tools registered", len(registered))
return registered
def bootstrap_from_directory(
directory: Path,
registry: Optional[ToolRegistry] = None,
) -> list[str]:
"""Bootstrap tools from a directory of Python files.
Args:
directory: Directory containing Python files with tools
registry: Registry to register tools with
Returns:
List of registered tool names
"""
registry = registry or tool_registry
discovery = get_discovery(registry=registry)
registered: list[str] = []
if not directory.exists():
logger.warning("Tools directory not found: %s", directory)
return registered
logger.info("Bootstrapping tools from directory: %s", directory)
# Find all Python files
for py_file in directory.rglob("*.py"):
if py_file.name.startswith("_"):
continue
try:
discovered = discovery.discover_file(py_file)
for tool in discovered:
if tool.function is None:
# Need to import and resolve the function
continue
try:
registry.register_tool(
name=tool.name,
function=tool.function,
description=tool.description,
category=tool.category,
tags=tool.tags,
)
registered.append(tool.name)
except Exception as exc:
logger.error("Failed to register %s: %s", tool.name, exc)
except Exception as exc:
logger.warning("Failed to process %s: %s", py_file, exc)
logger.info("Directory bootstrap complete: %d tools registered", len(registered))
return registered
def get_bootstrap_status() -> dict:
"""Get auto-bootstrap status.
Returns:
Dict with bootstrap status info
"""
discovery = get_discovery()
registry = tool_registry
return {
"auto_bootstrap_enabled": os.environ.get(AUTO_BOOTSTRAP_ENV_VAR, "1") != "0",
"discovered_tools_count": len(discovery.get_discovered()),
"registered_tools_count": len(registry.list_tools()),
"default_packages": DEFAULT_TOOL_PACKAGES,
}

441
src/mcp/discovery.py Normal file
View File

@@ -0,0 +1,441 @@
"""MCP Tool Auto-Discovery — Introspect Python modules to find tools.
Automatically discovers functions marked with @mcp_tool decorator
and registers them with the MCP registry. Generates JSON schemas
from type hints.
"""
import ast
import importlib
import inspect
import logging
import pkgutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional, get_type_hints
from .registry import ToolRegistry, tool_registry
logger = logging.getLogger(__name__)
# Decorator to mark functions as MCP tools
def mcp_tool(
name: Optional[str] = None,
description: Optional[str] = None,
category: str = "general",
tags: Optional[list[str]] = None,
):
"""Decorator to mark a function as an MCP tool.
Args:
name: Tool name (defaults to function name)
description: Tool description (defaults to docstring)
category: Tool category for organization
tags: Additional tags for filtering
Example:
@mcp_tool(name="weather", category="external")
def get_weather(city: str) -> dict:
'''Get weather for a city.'''
...
"""
def decorator(func: Callable) -> Callable:
func._mcp_tool = True
func._mcp_name = name or func.__name__
func._mcp_description = description or (func.__doc__ or "").strip()
func._mcp_category = category
func._mcp_tags = tags or []
return func
return decorator
@dataclass
class DiscoveredTool:
"""A tool discovered via introspection."""
name: str
description: str
function: Callable
module: str
category: str
tags: list[str]
parameters_schema: dict[str, Any]
returns_schema: dict[str, Any]
source_file: Optional[str] = None
line_number: int = 0
class ToolDiscovery:
"""Discovers and registers MCP tools from Python modules.
Usage:
discovery = ToolDiscovery()
# Discover from a module
tools = discovery.discover_module("tools.git")
# Auto-register with registry
discovery.auto_register("tools")
# Discover from all installed packages
tools = discovery.discover_all_packages()
"""
def __init__(self, registry: Optional[ToolRegistry] = None) -> None:
self.registry = registry or tool_registry
self._discovered: list[DiscoveredTool] = []
def discover_module(self, module_name: str) -> list[DiscoveredTool]:
"""Discover all MCP tools in a module.
Args:
module_name: Dotted path to module (e.g., "tools.git")
Returns:
List of discovered tools
"""
discovered = []
try:
module = importlib.import_module(module_name)
except ImportError as exc:
logger.warning("Failed to import module %s: %s", module_name, exc)
return discovered
# Get module file path for source location
module_file = getattr(module, "__file__", None)
# Iterate through module members
for name, obj in inspect.getmembers(module):
# Skip private and non-callable
if name.startswith("_") or not callable(obj):
continue
# Check if marked as MCP tool
if not getattr(obj, "_mcp_tool", False):
continue
# Get source location
try:
source_file = inspect.getfile(obj)
line_number = inspect.getsourcelines(obj)[1]
except (OSError, TypeError):
source_file = module_file
line_number = 0
# Build schemas from type hints
try:
sig = inspect.signature(obj)
parameters_schema = self._build_parameters_schema(sig)
returns_schema = self._build_returns_schema(sig, obj)
except Exception as exc:
logger.warning("Failed to build schema for %s: %s", name, exc)
parameters_schema = {"type": "object", "properties": {}}
returns_schema = {}
tool = DiscoveredTool(
name=getattr(obj, "_mcp_name", name),
description=getattr(obj, "_mcp_description", obj.__doc__ or ""),
function=obj,
module=module_name,
category=getattr(obj, "_mcp_category", "general"),
tags=getattr(obj, "_mcp_tags", []),
parameters_schema=parameters_schema,
returns_schema=returns_schema,
source_file=source_file,
line_number=line_number,
)
discovered.append(tool)
logger.debug("Discovered tool: %s from %s", tool.name, module_name)
self._discovered.extend(discovered)
logger.info("Discovered %d tools from module %s", len(discovered), module_name)
return discovered
def discover_package(self, package_name: str, recursive: bool = True) -> list[DiscoveredTool]:
"""Discover tools from all modules in a package.
Args:
package_name: Package name (e.g., "tools")
recursive: Whether to search subpackages
Returns:
List of discovered tools
"""
discovered = []
try:
package = importlib.import_module(package_name)
except ImportError as exc:
logger.warning("Failed to import package %s: %s", package_name, exc)
return discovered
package_path = getattr(package, "__path__", [])
if not package_path:
# Not a package, treat as module
return self.discover_module(package_name)
# Walk package modules
for _, name, is_pkg in pkgutil.iter_modules(package_path, prefix=f"{package_name}."):
if is_pkg and recursive:
discovered.extend(self.discover_package(name, recursive=True))
else:
discovered.extend(self.discover_module(name))
return discovered
def discover_file(self, file_path: Path) -> list[DiscoveredTool]:
"""Discover tools from a Python file.
Args:
file_path: Path to Python file
Returns:
List of discovered tools
"""
discovered = []
try:
source = file_path.read_text()
tree = ast.parse(source)
except Exception as exc:
logger.warning("Failed to parse %s: %s", file_path, exc)
return discovered
# Find all decorated functions
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
# Check for @mcp_tool decorator
is_tool = False
tool_name = node.name
tool_description = ast.get_docstring(node) or ""
tool_category = "general"
tool_tags: list[str] = []
for decorator in node.decorator_list:
if isinstance(decorator, ast.Call):
if isinstance(decorator.func, ast.Name) and decorator.func.id == "mcp_tool":
is_tool = True
# Extract decorator arguments
for kw in decorator.keywords:
if kw.arg == "name" and isinstance(kw.value, ast.Constant):
tool_name = kw.value.value
elif kw.arg == "description" and isinstance(kw.value, ast.Constant):
tool_description = kw.value.value
elif kw.arg == "category" and isinstance(kw.value, ast.Constant):
tool_category = kw.value.value
elif kw.arg == "tags" and isinstance(kw.value, ast.List):
tool_tags = [
elt.value for elt in kw.value.elts
if isinstance(elt, ast.Constant)
]
elif isinstance(decorator, ast.Name) and decorator.id == "mcp_tool":
is_tool = True
if not is_tool:
continue
# Build parameter schema from AST
parameters_schema = self._build_schema_from_ast(node)
# We can't get the actual function without importing
# So create a placeholder that will be resolved later
tool = DiscoveredTool(
name=tool_name,
description=tool_description,
function=None, # Will be resolved when registered
module=str(file_path),
category=tool_category,
tags=tool_tags,
parameters_schema=parameters_schema,
returns_schema={"type": "object"},
source_file=str(file_path),
line_number=node.lineno,
)
discovered.append(tool)
self._discovered.extend(discovered)
logger.info("Discovered %d tools from file %s", len(discovered), file_path)
return discovered
def auto_register(self, package_name: str = "tools") -> list[str]:
"""Automatically discover and register tools.
Args:
package_name: Package to scan for tools
Returns:
List of registered tool names
"""
discovered = self.discover_package(package_name)
registered = []
for tool in discovered:
if tool.function is None:
logger.warning("Skipping %s: no function resolved", tool.name)
continue
try:
self.registry.register_tool(
name=tool.name,
function=tool.function,
description=tool.description,
category=tool.category,
tags=tool.tags,
)
registered.append(tool.name)
logger.debug("Registered tool: %s", tool.name)
except Exception as exc:
logger.error("Failed to register %s: %s", tool.name, exc)
logger.info("Auto-registered %d/%d tools", len(registered), len(discovered))
return registered
def _build_parameters_schema(self, sig: inspect.Signature) -> dict[str, Any]:
"""Build JSON schema for function parameters."""
properties = {}
required = []
for name, param in sig.parameters.items():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
schema = self._type_to_schema(param.annotation)
if param.default is param.empty:
required.append(name)
else:
schema["default"] = param.default
properties[name] = schema
return {
"type": "object",
"properties": properties,
"required": required,
}
def _build_returns_schema(
self, sig: inspect.Signature, func: Callable
) -> dict[str, Any]:
"""Build JSON schema for return type."""
return_annotation = sig.return_annotation
if return_annotation is sig.empty:
return {"type": "object"}
return self._type_to_schema(return_annotation)
def _build_schema_from_ast(self, node: ast.FunctionDef) -> dict[str, Any]:
"""Build parameter schema from AST node."""
properties = {}
required = []
# Get defaults (reversed, since they're at the end)
defaults = [None] * (len(node.args.args) - len(node.args.defaults)) + list(node.args.defaults)
for arg, default in zip(node.args.args, defaults):
arg_name = arg.arg
arg_type = "string" # Default
# Try to get type from annotation
if arg.annotation:
if isinstance(arg.annotation, ast.Name):
arg_type = self._ast_type_to_json_type(arg.annotation.id)
elif isinstance(arg.annotation, ast.Constant):
arg_type = self._ast_type_to_json_type(str(arg.annotation.value))
schema = {"type": arg_type}
if default is None:
required.append(arg_name)
properties[arg_name] = schema
return {
"type": "object",
"properties": properties,
"required": required,
}
def _type_to_schema(self, annotation: Any) -> dict[str, Any]:
"""Convert Python type annotation to JSON schema."""
if annotation is inspect.Parameter.empty:
return {"type": "string"}
origin = getattr(annotation, "__origin__", None)
args = getattr(annotation, "__args__", ())
# Handle Optional[T] = Union[T, None]
if origin is not None:
if str(origin) == "typing.Union" and type(None) in args:
# Optional type
non_none_args = [a for a in args if a is not type(None)]
if len(non_none_args) == 1:
schema = self._type_to_schema(non_none_args[0])
return schema
return {"type": "object"}
# Handle List[T], Dict[K,V]
if origin in (list, tuple):
items_schema = {"type": "object"}
if args:
items_schema = self._type_to_schema(args[0])
return {"type": "array", "items": items_schema}
if origin is dict:
return {"type": "object"}
# Handle basic types
if annotation in (str,):
return {"type": "string"}
elif annotation in (int, float):
return {"type": "number"}
elif annotation in (bool,):
return {"type": "boolean"}
elif annotation in (list, tuple):
return {"type": "array"}
elif annotation in (dict,):
return {"type": "object"}
return {"type": "object"}
def _ast_type_to_json_type(self, type_name: str) -> str:
"""Convert AST type name to JSON schema type."""
type_map = {
"str": "string",
"int": "number",
"float": "number",
"bool": "boolean",
"list": "array",
"dict": "object",
"List": "array",
"Dict": "object",
"Optional": "object",
"Any": "object",
}
return type_map.get(type_name, "object")
def get_discovered(self) -> list[DiscoveredTool]:
"""Get all discovered tools."""
return list(self._discovered)
def clear(self) -> None:
"""Clear discovered tools cache."""
self._discovered.clear()
# Module-level singleton
discovery: Optional[ToolDiscovery] = None
def get_discovery(registry: Optional[ToolRegistry] = None) -> ToolDiscovery:
"""Get or create the tool discovery singleton."""
global discovery
if discovery is None:
discovery = ToolDiscovery(registry=registry)
return discovery

444
src/mcp/registry.py Normal file
View File

@@ -0,0 +1,444 @@
"""MCP Tool Registry — Dynamic tool discovery and management.
The registry maintains a catalog of all available tools, their schemas,
and health status. Tools can be registered dynamically at runtime.
Usage:
from mcp.registry import tool_registry
# Register a tool
tool_registry.register("web_search", web_search_schema, web_search_func)
# Discover tools
tools = tool_registry.discover(capabilities=["search"])
# Execute a tool
result = tool_registry.execute("web_search", {"query": "Bitcoin"})
"""
import asyncio
import inspect
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from mcp.schemas.base import create_tool_schema
logger = logging.getLogger(__name__)
@dataclass
class ToolRecord:
"""A registered tool with metadata."""
name: str
schema: dict
handler: Callable
category: str = "general"
health_status: str = "unknown" # healthy, degraded, unhealthy
last_execution: Optional[float] = None
execution_count: int = 0
error_count: int = 0
avg_latency_ms: float = 0.0
added_at: float = field(default_factory=time.time)
requires_confirmation: bool = False
tags: list[str] = field(default_factory=list)
source_module: Optional[str] = None
auto_discovered: bool = False
class ToolRegistry:
"""Central registry for all MCP tools."""
def __init__(self) -> None:
self._tools: dict[str, ToolRecord] = {}
self._categories: dict[str, list[str]] = {}
logger.info("ToolRegistry initialized")
def register(
self,
name: str,
schema: dict,
handler: Callable,
category: str = "general",
requires_confirmation: bool = False,
tags: Optional[list[str]] = None,
source_module: Optional[str] = None,
auto_discovered: bool = False,
) -> ToolRecord:
"""Register a new tool.
Args:
name: Unique tool name
schema: JSON schema describing inputs/outputs
handler: Function to execute
category: Tool category for organization
requires_confirmation: If True, user must approve before execution
tags: Tags for filtering and organization
source_module: Module where tool was defined
auto_discovered: Whether tool was auto-discovered
Returns:
The registered ToolRecord
"""
if name in self._tools:
logger.warning("Tool '%s' already registered, replacing", name)
record = ToolRecord(
name=name,
schema=schema,
handler=handler,
category=category,
requires_confirmation=requires_confirmation,
tags=tags or [],
source_module=source_module,
auto_discovered=auto_discovered,
)
self._tools[name] = record
# Add to category
if category not in self._categories:
self._categories[category] = []
if name not in self._categories[category]:
self._categories[category].append(name)
logger.info("Registered tool: %s (category: %s)", name, category)
return record
def register_tool(
self,
name: str,
function: Callable,
description: Optional[str] = None,
category: str = "general",
tags: Optional[list[str]] = None,
source_module: Optional[str] = None,
) -> ToolRecord:
"""Register a tool from a function (convenience method for discovery).
Args:
name: Tool name
function: Function to register
description: Tool description (defaults to docstring)
category: Tool category
tags: Tags for organization
source_module: Source module path
Returns:
The registered ToolRecord
"""
# Build schema from function signature
sig = inspect.signature(function)
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
param_schema: dict = {"type": "string"}
# Try to infer type from annotation
if param.annotation != inspect.Parameter.empty:
if param.annotation in (int, float):
param_schema = {"type": "number"}
elif param.annotation == bool:
param_schema = {"type": "boolean"}
elif param.annotation == list:
param_schema = {"type": "array"}
elif param.annotation == dict:
param_schema = {"type": "object"}
if param.default is param.empty:
required.append(param_name)
else:
param_schema["default"] = param.default
properties[param_name] = param_schema
schema = create_tool_schema(
name=name,
description=description or (function.__doc__ or f"Execute {name}"),
parameters=properties,
required=required,
)
return self.register(
name=name,
schema=schema,
handler=function,
category=category,
tags=tags,
source_module=source_module or function.__module__,
auto_discovered=True,
)
def unregister(self, name: str) -> bool:
"""Remove a tool from the registry."""
if name not in self._tools:
return False
record = self._tools.pop(name)
# Remove from category
if record.category in self._categories:
if name in self._categories[record.category]:
self._categories[record.category].remove(name)
logger.info("Unregistered tool: %s", name)
return True
def get(self, name: str) -> Optional[ToolRecord]:
"""Get a tool record by name."""
return self._tools.get(name)
def get_handler(self, name: str) -> Optional[Callable]:
"""Get just the handler function for a tool."""
record = self._tools.get(name)
return record.handler if record else None
def get_schema(self, name: str) -> Optional[dict]:
"""Get the JSON schema for a tool."""
record = self._tools.get(name)
return record.schema if record else None
def list_tools(self, category: Optional[str] = None) -> list[str]:
"""List all tool names, optionally filtered by category."""
if category:
return self._categories.get(category, [])
return list(self._tools.keys())
def list_categories(self) -> list[str]:
"""List all tool categories."""
return list(self._categories.keys())
def discover(
self,
query: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[list[str]] = None,
healthy_only: bool = True,
auto_discovered_only: bool = False,
) -> list[ToolRecord]:
"""Discover tools matching criteria.
Args:
query: Search in tool names and descriptions
category: Filter by category
tags: Filter by tags (must have all specified tags)
healthy_only: Only return healthy tools
auto_discovered_only: Only return auto-discovered tools
Returns:
List of matching ToolRecords
"""
results = []
for name, record in self._tools.items():
# Category filter
if category and record.category != category:
continue
# Tags filter
if tags:
if not all(tag in record.tags for tag in tags):
continue
# Health filter
if healthy_only and record.health_status == "unhealthy":
continue
# Auto-discovered filter
if auto_discovered_only and not record.auto_discovered:
continue
# Query filter
if query:
query_lower = query.lower()
name_match = query_lower in name.lower()
desc = record.schema.get("description", "")
desc_match = query_lower in desc.lower()
tag_match = any(query_lower in tag.lower() for tag in record.tags)
if not (name_match or desc_match or tag_match):
continue
results.append(record)
return results
async def execute(self, name: str, params: dict) -> Any:
"""Execute a tool by name with given parameters.
Args:
name: Tool name
params: Parameters to pass to the tool
Returns:
Tool execution result
Raises:
ValueError: If tool not found
RuntimeError: If tool execution fails
"""
record = self._tools.get(name)
if not record:
raise ValueError(f"Tool '{name}' not found in registry")
start_time = time.time()
try:
# Check if handler is async
if inspect.iscoroutinefunction(record.handler):
result = await record.handler(**params)
else:
result = record.handler(**params)
# Update metrics
latency_ms = (time.time() - start_time) * 1000
record.last_execution = time.time()
record.execution_count += 1
# Update rolling average latency
if record.execution_count == 1:
record.avg_latency_ms = latency_ms
else:
record.avg_latency_ms = (
record.avg_latency_ms * 0.9 + latency_ms * 0.1
)
# Mark healthy on success
record.health_status = "healthy"
logger.debug("Tool '%s' executed in %.2fms", name, latency_ms)
return result
except Exception as exc:
record.error_count += 1
record.execution_count += 1
# Degrade health on repeated errors
error_rate = record.error_count / record.execution_count
if error_rate > 0.5:
record.health_status = "unhealthy"
logger.error("Tool '%s' marked unhealthy (error rate: %.1f%%)",
name, error_rate * 100)
elif error_rate > 0.2:
record.health_status = "degraded"
logger.warning("Tool '%s' degraded (error rate: %.1f%%)",
name, error_rate * 100)
raise RuntimeError(f"Tool '{name}' execution failed: {exc}") from exc
def check_health(self, name: str) -> str:
"""Check health status of a tool."""
record = self._tools.get(name)
if not record:
return "not_found"
return record.health_status
def get_metrics(self, name: Optional[str] = None) -> dict:
"""Get metrics for a tool or all tools."""
if name:
record = self._tools.get(name)
if not record:
return {}
return {
"name": record.name,
"category": record.category,
"health": record.health_status,
"executions": record.execution_count,
"errors": record.error_count,
"avg_latency_ms": round(record.avg_latency_ms, 2),
}
# Return metrics for all tools
return {
name: self.get_metrics(name)
for name in self._tools.keys()
}
def to_dict(self) -> dict:
"""Export registry as dictionary (for API/dashboard)."""
return {
"tools": [
{
"name": r.name,
"schema": r.schema,
"category": r.category,
"health": r.health_status,
"requires_confirmation": r.requires_confirmation,
"tags": r.tags,
"source_module": r.source_module,
"auto_discovered": r.auto_discovered,
}
for r in self._tools.values()
],
"categories": self._categories,
"total_tools": len(self._tools),
"auto_discovered_count": sum(1 for r in self._tools.values() if r.auto_discovered),
}
# Module-level singleton
tool_registry = ToolRegistry()
def get_registry() -> ToolRegistry:
"""Get the global tool registry singleton."""
return tool_registry
def register_tool(
name: Optional[str] = None,
category: str = "general",
schema: Optional[dict] = None,
requires_confirmation: bool = False,
):
"""Decorator for registering a function as an MCP tool.
Usage:
@register_tool(name="web_search", category="research")
def web_search(query: str, max_results: int = 5) -> str:
...
"""
def decorator(func: Callable) -> Callable:
tool_name = name or func.__name__
# Auto-generate schema if not provided
if schema is None:
# Try to infer from type hints
sig = inspect.signature(func)
params = {}
required = []
for param_name, param in sig.parameters.items():
if param.default == inspect.Parameter.empty:
required.append(param_name)
params[param_name] = {"type": "string"}
else:
params[param_name] = {
"type": "string",
"default": str(param.default),
}
tool_schema = create_tool_schema(
name=tool_name,
description=func.__doc__ or f"Execute {tool_name}",
parameters=params,
required=required,
)
else:
tool_schema = schema
tool_registry.register(
name=tool_name,
schema=tool_schema,
handler=func,
category=category,
requires_confirmation=requires_confirmation,
)
return func
return decorator

52
src/mcp/schemas/base.py Normal file
View File

@@ -0,0 +1,52 @@
"""Base schemas for MCP (Model Context Protocol) tools.
All tools must provide a JSON schema describing their interface.
This enables dynamic discovery and type-safe invocation.
"""
from typing import Any
def create_tool_schema(
name: str,
description: str,
parameters: dict[str, Any],
required: list[str] | None = None,
returns: dict[str, Any] | None = None,
) -> dict:
"""Create a standard MCP tool schema.
Args:
name: Tool name (must be unique)
description: Human-readable description
parameters: JSON schema for input parameters
required: List of required parameter names
returns: JSON schema for return value
Returns:
Complete tool schema dict
"""
return {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": parameters,
"required": required or [],
},
"returns": returns or {"type": "string"},
}
# Common parameter schemas
PARAM_STRING = {"type": "string"}
PARAM_INTEGER = {"type": "integer"}
PARAM_BOOLEAN = {"type": "boolean"}
PARAM_ARRAY_STRINGS = {"type": "array", "items": {"type": "string"}}
PARAM_OBJECT = {"type": "object"}
# Common return schemas
RETURN_STRING = {"type": "string"}
RETURN_OBJECT = {"type": "object"}
RETURN_ARRAY = {"type": "array"}
RETURN_BOOLEAN = {"type": "boolean"}

210
src/mcp/server.py Normal file
View File

@@ -0,0 +1,210 @@
"""MCP (Model Context Protocol) Server.
Implements the MCP protocol for tool discovery and execution.
Agents communicate with this server to discover and invoke tools.
The server can run:
1. In-process (direct method calls) — fastest, for local agents
2. HTTP API — for external clients
3. Stdio — for subprocess-based agents
"""
import asyncio
import json
import logging
from typing import Any, Optional
from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
class MCPServer:
"""Model Context Protocol server for tool management.
Provides standard MCP endpoints:
- list_tools: Discover available tools
- call_tool: Execute a tool
- get_schema: Get tool input/output schemas
"""
def __init__(self) -> None:
self.registry = tool_registry
logger.info("MCP Server initialized")
def list_tools(
self,
category: Optional[str] = None,
query: Optional[str] = None,
) -> list[dict]:
"""List available tools.
MCP Protocol: tools/list
"""
tools = self.registry.discover(
query=query,
category=category,
healthy_only=True,
)
return [
{
"name": t.name,
"description": t.schema.get("description", ""),
"parameters": t.schema.get("parameters", {}),
"category": t.category,
}
for t in tools
]
async def call_tool(self, name: str, arguments: dict) -> dict:
"""Execute a tool with given arguments.
MCP Protocol: tools/call
Args:
name: Tool name
arguments: Tool parameters
Returns:
Result dict with content or error
"""
try:
result = await self.registry.execute(name, arguments)
return {
"content": [
{"type": "text", "text": str(result)}
],
"isError": False,
}
except Exception as exc:
logger.error("Tool execution failed: %s", exc)
return {
"content": [
{"type": "text", "text": f"Error: {exc}"}
],
"isError": True,
}
def get_schema(self, name: str) -> Optional[dict]:
"""Get the JSON schema for a tool.
MCP Protocol: tools/schema
"""
return self.registry.get_schema(name)
def get_tool_info(self, name: str) -> Optional[dict]:
"""Get detailed info about a tool including health metrics."""
record = self.registry.get(name)
if not record:
return None
return {
"name": record.name,
"schema": record.schema,
"category": record.category,
"health": record.health_status,
"metrics": {
"executions": record.execution_count,
"errors": record.error_count,
"avg_latency_ms": round(record.avg_latency_ms, 2),
},
"requires_confirmation": record.requires_confirmation,
}
def health_check(self) -> dict:
"""Server health status."""
tools = self.registry.list_tools()
healthy = sum(
1 for t in tools
if self.registry.check_health(t) == "healthy"
)
return {
"status": "healthy",
"total_tools": len(tools),
"healthy_tools": healthy,
"degraded_tools": sum(
1 for t in tools
if self.registry.check_health(t) == "degraded"
),
"unhealthy_tools": sum(
1 for t in tools
if self.registry.check_health(t) == "unhealthy"
),
}
class MCPHTTPServer:
"""HTTP API wrapper for MCP Server."""
def __init__(self) -> None:
self.mcp = MCPServer()
def get_routes(self) -> dict:
"""Get FastAPI route handlers."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/mcp", tags=["mcp"])
class ToolCallRequest(BaseModel):
name: str
arguments: dict = {}
@router.get("/tools")
async def list_tools(
category: Optional[str] = None,
query: Optional[str] = None,
):
"""List available tools."""
return {"tools": self.mcp.list_tools(category, query)}
@router.post("/tools/call")
async def call_tool(request: ToolCallRequest):
"""Execute a tool."""
result = await self.mcp.call_tool(request.name, request.arguments)
return result
@router.get("/tools/{name}")
async def get_tool(name: str):
"""Get tool info."""
info = self.mcp.get_tool_info(name)
if not info:
raise HTTPException(404, f"Tool '{name}' not found")
return info
@router.get("/tools/{name}/schema")
async def get_schema(name: str):
"""Get tool schema."""
schema = self.mcp.get_schema(name)
if not schema:
raise HTTPException(404, f"Tool '{name}' not found")
return schema
@router.get("/health")
async def health():
"""Server health check."""
return self.mcp.health_check()
return router
# Module-level singleton
mcp_server = MCPServer()
# Convenience functions for agents
def discover_tools(query: Optional[str] = None) -> list[dict]:
"""Quick tool discovery."""
return mcp_server.list_tools(query=query)
async def use_tool(name: str, **kwargs) -> str:
"""Execute a tool and return result text."""
result = await mcp_server.call_tool(name, kwargs)
if result.get("isError"):
raise RuntimeError(result["content"][0]["text"])
return result["content"][0]["text"]

12
src/router/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""Cascade LLM Router — Automatic failover between providers."""
from .cascade import CascadeRouter, Provider, ProviderStatus, get_router
from .api import router
__all__ = [
"CascadeRouter",
"Provider",
"ProviderStatus",
"get_router",
"router",
]

199
src/router/api.py Normal file
View File

@@ -0,0 +1,199 @@
"""API endpoints for Cascade Router monitoring and control."""
import asyncio
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .cascade import CascadeRouter, get_router
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/router", tags=["router"])
class CompletionRequest(BaseModel):
"""Request body for completions."""
messages: list[dict[str, str]]
model: str | None = None
temperature: float = 0.7
max_tokens: int | None = None
class CompletionResponse(BaseModel):
"""Response from completion endpoint."""
content: str
provider: str
model: str
latency_ms: float
class ProviderControl(BaseModel):
"""Control a provider's status."""
action: str # "enable", "disable", "reset_circuit"
async def get_cascade_router() -> CascadeRouter:
"""Dependency to get the cascade router."""
return get_router()
@router.post("/complete", response_model=CompletionResponse)
async def complete(
request: CompletionRequest,
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Complete a conversation with automatic failover.
Routes through providers in priority order until one succeeds.
"""
try:
result = await cascade.complete(
messages=request.messages,
model=request.model,
temperature=request.temperature,
max_tokens=request.max_tokens,
)
return result
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
@router.get("/status")
async def get_status(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Get router status and provider health."""
return cascade.get_status()
@router.get("/metrics")
async def get_metrics(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Get detailed metrics for all providers."""
return cascade.get_metrics()
@router.get("/providers")
async def list_providers(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> list[dict[str, Any]]:
"""List all configured providers."""
return [
{
"name": p.name,
"type": p.type,
"enabled": p.enabled,
"priority": p.priority,
"status": p.status.value,
"circuit_state": p.circuit_state.value,
"default_model": p.get_default_model(),
"models": [m["name"] for m in p.models],
}
for p in cascade.providers
]
@router.post("/providers/{provider_name}/control")
async def control_provider(
provider_name: str,
control: ProviderControl,
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, str]:
"""Control a provider (enable/disable/reset)."""
provider = None
for p in cascade.providers:
if p.name == provider_name:
provider = p
break
if not provider:
raise HTTPException(status_code=404, detail=f"Provider {provider_name} not found")
if control.action == "enable":
provider.enabled = True
provider.status = provider.status.__class__.HEALTHY
return {"message": f"Provider {provider_name} enabled"}
elif control.action == "disable":
provider.enabled = False
from .cascade import ProviderStatus
provider.status = ProviderStatus.DISABLED
return {"message": f"Provider {provider_name} disabled"}
elif control.action == "reset_circuit":
from .cascade import CircuitState, ProviderStatus
provider.circuit_state = CircuitState.CLOSED
provider.circuit_opened_at = None
provider.half_open_calls = 0
provider.metrics.consecutive_failures = 0
provider.status = ProviderStatus.HEALTHY
return {"message": f"Circuit breaker reset for {provider_name}"}
else:
raise HTTPException(status_code=400, detail=f"Unknown action: {control.action}")
@router.post("/health-check")
async def run_health_check(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Run health checks on all providers."""
results = []
for provider in cascade.providers:
# Quick ping to check availability
is_healthy = cascade._check_provider_available(provider)
from .cascade import ProviderStatus
if is_healthy:
if provider.status == ProviderStatus.UNHEALTHY:
# Reset circuit if it was open but now healthy
provider.circuit_state = provider.circuit_state.__class__.CLOSED
provider.circuit_opened_at = None
provider.status = ProviderStatus.HEALTHY if provider.metrics.error_rate < 0.1 else ProviderStatus.DEGRADED
else:
provider.status = ProviderStatus.UNHEALTHY
results.append({
"name": provider.name,
"type": provider.type,
"healthy": is_healthy,
"status": provider.status.value,
})
return {
"checked_at": asyncio.get_event_loop().time(),
"providers": results,
"healthy_count": sum(1 for r in results if r["healthy"]),
}
@router.get("/config")
async def get_config(
cascade: Annotated[CascadeRouter, Depends(get_cascade_router)],
) -> dict[str, Any]:
"""Get router configuration (without secrets)."""
cfg = cascade.config
return {
"timeout_seconds": cfg.timeout_seconds,
"max_retries_per_provider": cfg.max_retries_per_provider,
"retry_delay_seconds": cfg.retry_delay_seconds,
"circuit_breaker": {
"failure_threshold": cfg.circuit_breaker_failure_threshold,
"recovery_timeout": cfg.circuit_breaker_recovery_timeout,
"half_open_max_calls": cfg.circuit_breaker_half_open_max_calls,
},
"providers": [
{
"name": p.name,
"type": p.type,
"priority": p.priority,
"enabled": p.enabled,
}
for p in cascade.providers
],
}

566
src/router/cascade.py Normal file
View File

@@ -0,0 +1,566 @@
"""Cascade LLM Router — Automatic failover between providers.
Routes requests through an ordered list of LLM providers,
automatically failing over on rate limits or errors.
Tracks metrics for latency, errors, and cost.
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Optional
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None # type: ignore
try:
import requests
except ImportError:
requests = None # type: ignore
logger = logging.getLogger(__name__)
class ProviderStatus(Enum):
"""Health status of a provider."""
HEALTHY = "healthy"
DEGRADED = "degraded" # Working but slow or occasional errors
UNHEALTHY = "unhealthy" # Circuit breaker open
DISABLED = "disabled"
class CircuitState(Enum):
"""Circuit breaker state."""
CLOSED = "closed" # Normal operation
OPEN = "open" # Failing, rejecting requests
HALF_OPEN = "half_open" # Testing if recovered
@dataclass
class ProviderMetrics:
"""Metrics for a single provider."""
total_requests: int = 0
successful_requests: int = 0
failed_requests: int = 0
total_latency_ms: float = 0.0
last_request_time: Optional[str] = None
last_error_time: Optional[str] = None
consecutive_failures: int = 0
@property
def avg_latency_ms(self) -> float:
if self.total_requests == 0:
return 0.0
return self.total_latency_ms / self.total_requests
@property
def error_rate(self) -> float:
if self.total_requests == 0:
return 0.0
return self.failed_requests / self.total_requests
@dataclass
class Provider:
"""LLM provider configuration and state."""
name: str
type: str # ollama, openai, anthropic, airllm
enabled: bool
priority: int
url: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
models: list[dict] = field(default_factory=list)
# Runtime state
status: ProviderStatus = ProviderStatus.HEALTHY
metrics: ProviderMetrics = field(default_factory=ProviderMetrics)
circuit_state: CircuitState = CircuitState.CLOSED
circuit_opened_at: Optional[float] = None
half_open_calls: int = 0
def get_default_model(self) -> Optional[str]:
"""Get the default model for this provider."""
for model in self.models:
if model.get("default"):
return model["name"]
if self.models:
return self.models[0]["name"]
return None
@dataclass
class RouterConfig:
"""Cascade router configuration."""
timeout_seconds: int = 30
max_retries_per_provider: int = 2
retry_delay_seconds: int = 1
circuit_breaker_failure_threshold: int = 5
circuit_breaker_recovery_timeout: int = 60
circuit_breaker_half_open_max_calls: int = 2
cost_tracking_enabled: bool = True
budget_daily_usd: float = 10.0
class CascadeRouter:
"""Routes LLM requests with automatic failover.
Usage:
router = CascadeRouter()
response = await router.complete(
messages=[{"role": "user", "content": "Hello"}],
model="llama3.2"
)
# Check metrics
metrics = router.get_metrics()
"""
def __init__(self, config_path: Optional[Path] = None) -> None:
self.config_path = config_path or Path("config/providers.yaml")
self.providers: list[Provider] = []
self.config: RouterConfig = RouterConfig()
self._load_config()
logger.info("CascadeRouter initialized with %d providers", len(self.providers))
def _load_config(self) -> None:
"""Load configuration from YAML."""
if not self.config_path.exists():
logger.warning("Config not found: %s, using defaults", self.config_path)
return
try:
if yaml is None:
raise RuntimeError("PyYAML not installed")
content = self.config_path.read_text()
# Expand environment variables
content = self._expand_env_vars(content)
data = yaml.safe_load(content)
# Load cascade settings
cascade = data.get("cascade", {})
self.config = RouterConfig(
timeout_seconds=cascade.get("timeout_seconds", 30),
max_retries_per_provider=cascade.get("max_retries_per_provider", 2),
retry_delay_seconds=cascade.get("retry_delay_seconds", 1),
circuit_breaker_failure_threshold=cascade.get("circuit_breaker", {}).get("failure_threshold", 5),
circuit_breaker_recovery_timeout=cascade.get("circuit_breaker", {}).get("recovery_timeout", 60),
circuit_breaker_half_open_max_calls=cascade.get("circuit_breaker", {}).get("half_open_max_calls", 2),
)
# Load providers
for p_data in data.get("providers", []):
# Skip disabled providers
if not p_data.get("enabled", False):
continue
provider = Provider(
name=p_data["name"],
type=p_data["type"],
enabled=p_data.get("enabled", True),
priority=p_data.get("priority", 99),
url=p_data.get("url"),
api_key=p_data.get("api_key"),
base_url=p_data.get("base_url"),
models=p_data.get("models", []),
)
# Check if provider is actually available
if self._check_provider_available(provider):
self.providers.append(provider)
else:
logger.warning("Provider %s not available, skipping", provider.name)
# Sort by priority
self.providers.sort(key=lambda p: p.priority)
except Exception as exc:
logger.error("Failed to load config: %s", exc)
def _expand_env_vars(self, content: str) -> str:
"""Expand ${VAR} syntax in YAML content."""
import os
import re
def replace_var(match):
var_name = match.group(1)
return os.environ.get(var_name, match.group(0))
return re.sub(r"\$\{(\w+)\}", replace_var, content)
def _check_provider_available(self, provider: Provider) -> bool:
"""Check if a provider is actually available."""
if provider.type == "ollama":
# Check if Ollama is running
if requests is None:
# Can't check without requests, assume available
return True
try:
url = provider.url or "http://localhost:11434"
response = requests.get(f"{url}/api/tags", timeout=5)
return response.status_code == 200
except Exception:
return False
elif provider.type == "airllm":
# Check if airllm is installed
try:
import airllm
return True
except ImportError:
return False
elif provider.type in ("openai", "anthropic"):
# Check if API key is set
return provider.api_key is not None and provider.api_key != ""
return True
async def complete(
self,
messages: list[dict],
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
) -> dict:
"""Complete a chat conversation with automatic failover.
Args:
messages: List of message dicts with role and content
model: Preferred model (tries this first, then provider defaults)
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
Returns:
Dict with content, provider_used, and metrics
Raises:
RuntimeError: If all providers fail
"""
errors = []
for provider in self.providers:
# Skip unhealthy providers (circuit breaker)
if provider.status == ProviderStatus.UNHEALTHY:
# Check if circuit breaker can close
if self._can_close_circuit(provider):
provider.circuit_state = CircuitState.HALF_OPEN
provider.half_open_calls = 0
logger.info("Circuit breaker half-open for %s", provider.name)
else:
logger.debug("Skipping %s (circuit open)", provider.name)
continue
# Try this provider
for attempt in range(self.config.max_retries_per_provider):
try:
result = await self._try_provider(
provider=provider,
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
# Success! Update metrics and return
self._record_success(provider, result.get("latency_ms", 0))
return {
"content": result["content"],
"provider": provider.name,
"model": result.get("model", model or provider.get_default_model()),
"latency_ms": result.get("latency_ms", 0),
}
except Exception as exc:
error_msg = str(exc)
logger.warning(
"Provider %s attempt %d failed: %s",
provider.name, attempt + 1, error_msg
)
errors.append(f"{provider.name}: {error_msg}")
if attempt < self.config.max_retries_per_provider - 1:
await asyncio.sleep(self.config.retry_delay_seconds)
# All retries failed for this provider
self._record_failure(provider)
# All providers failed
raise RuntimeError(f"All providers failed: {'; '.join(errors)}")
async def _try_provider(
self,
provider: Provider,
messages: list[dict],
model: Optional[str],
temperature: float,
max_tokens: Optional[int],
) -> dict:
"""Try a single provider request."""
start_time = time.time()
if provider.type == "ollama":
result = await self._call_ollama(
provider=provider,
messages=messages,
model=model or provider.get_default_model(),
temperature=temperature,
)
elif provider.type == "openai":
result = await self._call_openai(
provider=provider,
messages=messages,
model=model or provider.get_default_model(),
temperature=temperature,
max_tokens=max_tokens,
)
elif provider.type == "anthropic":
result = await self._call_anthropic(
provider=provider,
messages=messages,
model=model or provider.get_default_model(),
temperature=temperature,
max_tokens=max_tokens,
)
else:
raise ValueError(f"Unknown provider type: {provider.type}")
latency_ms = (time.time() - start_time) * 1000
result["latency_ms"] = latency_ms
return result
async def _call_ollama(
self,
provider: Provider,
messages: list[dict],
model: str,
temperature: float,
) -> dict:
"""Call Ollama API."""
import aiohttp
url = f"{provider.url}/api/chat"
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
},
}
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as response:
if response.status != 200:
text = await response.text()
raise RuntimeError(f"Ollama error {response.status}: {text}")
data = await response.json()
return {
"content": data["message"]["content"],
"model": model,
}
async def _call_openai(
self,
provider: Provider,
messages: list[dict],
model: str,
temperature: float,
max_tokens: Optional[int],
) -> dict:
"""Call OpenAI API."""
import openai
client = openai.AsyncOpenAI(
api_key=provider.api_key,
base_url=provider.base_url,
timeout=self.config.timeout_seconds,
)
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
response = await client.chat.completions.create(**kwargs)
return {
"content": response.choices[0].message.content,
"model": response.model,
}
async def _call_anthropic(
self,
provider: Provider,
messages: list[dict],
model: str,
temperature: float,
max_tokens: Optional[int],
) -> dict:
"""Call Anthropic API."""
import anthropic
client = anthropic.AsyncAnthropic(
api_key=provider.api_key,
timeout=self.config.timeout_seconds,
)
# Convert messages to Anthropic format
system_msg = None
conversation = []
for msg in messages:
if msg["role"] == "system":
system_msg = msg["content"]
else:
conversation.append({
"role": msg["role"],
"content": msg["content"],
})
kwargs = {
"model": model,
"messages": conversation,
"temperature": temperature,
"max_tokens": max_tokens or 1024,
}
if system_msg:
kwargs["system"] = system_msg
response = await client.messages.create(**kwargs)
return {
"content": response.content[0].text,
"model": response.model,
}
def _record_success(self, provider: Provider, latency_ms: float) -> None:
"""Record a successful request."""
provider.metrics.total_requests += 1
provider.metrics.successful_requests += 1
provider.metrics.total_latency_ms += latency_ms
provider.metrics.last_request_time = datetime.now(timezone.utc).isoformat()
provider.metrics.consecutive_failures = 0
# Close circuit breaker if half-open
if provider.circuit_state == CircuitState.HALF_OPEN:
provider.half_open_calls += 1
if provider.half_open_calls >= self.config.circuit_breaker_half_open_max_calls:
self._close_circuit(provider)
# Update status based on error rate
if provider.metrics.error_rate < 0.1:
provider.status = ProviderStatus.HEALTHY
elif provider.metrics.error_rate < 0.3:
provider.status = ProviderStatus.DEGRADED
def _record_failure(self, provider: Provider) -> None:
"""Record a failed request."""
provider.metrics.total_requests += 1
provider.metrics.failed_requests += 1
provider.metrics.last_error_time = datetime.now(timezone.utc).isoformat()
provider.metrics.consecutive_failures += 1
# Check if we should open circuit breaker
if provider.metrics.consecutive_failures >= self.config.circuit_breaker_failure_threshold:
self._open_circuit(provider)
# Update status
if provider.metrics.error_rate > 0.3:
provider.status = ProviderStatus.DEGRADED
if provider.metrics.error_rate > 0.5:
provider.status = ProviderStatus.UNHEALTHY
def _open_circuit(self, provider: Provider) -> None:
"""Open the circuit breaker for a provider."""
provider.circuit_state = CircuitState.OPEN
provider.circuit_opened_at = time.time()
provider.status = ProviderStatus.UNHEALTHY
logger.warning("Circuit breaker OPEN for %s", provider.name)
def _can_close_circuit(self, provider: Provider) -> bool:
"""Check if circuit breaker can transition to half-open."""
if provider.circuit_opened_at is None:
return False
elapsed = time.time() - provider.circuit_opened_at
return elapsed >= self.config.circuit_breaker_recovery_timeout
def _close_circuit(self, provider: Provider) -> None:
"""Close the circuit breaker (provider healthy again)."""
provider.circuit_state = CircuitState.CLOSED
provider.circuit_opened_at = None
provider.half_open_calls = 0
provider.metrics.consecutive_failures = 0
provider.status = ProviderStatus.HEALTHY
logger.info("Circuit breaker CLOSED for %s", provider.name)
def get_metrics(self) -> dict:
"""Get metrics for all providers."""
return {
"providers": [
{
"name": p.name,
"type": p.type,
"status": p.status.value,
"circuit_state": p.circuit_state.value,
"metrics": {
"total_requests": p.metrics.total_requests,
"successful": p.metrics.successful_requests,
"failed": p.metrics.failed_requests,
"error_rate": round(p.metrics.error_rate, 3),
"avg_latency_ms": round(p.metrics.avg_latency_ms, 2),
},
}
for p in self.providers
]
}
def get_status(self) -> dict:
"""Get current router status."""
healthy = sum(1 for p in self.providers if p.status == ProviderStatus.HEALTHY)
return {
"total_providers": len(self.providers),
"healthy_providers": healthy,
"degraded_providers": sum(1 for p in self.providers if p.status == ProviderStatus.DEGRADED),
"unhealthy_providers": sum(1 for p in self.providers if p.status == ProviderStatus.UNHEALTHY),
"providers": [
{
"name": p.name,
"type": p.type,
"status": p.status.value,
"priority": p.priority,
"default_model": p.get_default_model(),
}
for p in self.providers
],
}
# Module-level singleton
cascade_router: Optional[CascadeRouter] = None
def get_router() -> CascadeRouter:
"""Get or create the cascade router singleton."""
global cascade_router
if cascade_router is None:
cascade_router = CascadeRouter()
return cascade_router

124
src/tools/code_exec.py Normal file
View File

@@ -0,0 +1,124 @@
"""Code execution tool.
MCP-compliant tool for executing Python code.
"""
import logging
import traceback
from typing import Any
from mcp.registry import register_tool
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_BOOLEAN, RETURN_STRING
logger = logging.getLogger(__name__)
PYTHON_SCHEMA = create_tool_schema(
name="python",
description="Execute Python code. Use for calculations, data processing, or when precise computation is needed. Code runs in a restricted environment.",
parameters={
"code": {
**PARAM_STRING,
"description": "Python code to execute",
},
"return_output": {
**PARAM_BOOLEAN,
"description": "Return the value of the last expression",
"default": True,
},
},
required=["code"],
returns=RETURN_STRING,
)
def python(code: str, return_output: bool = True) -> str:
"""Execute Python code in restricted environment.
Args:
code: Python code to execute
return_output: Whether to return last expression value
Returns:
Execution result or error message
"""
# Safe globals for code execution
safe_globals = {
"__builtins__": {
"abs": abs,
"all": all,
"any": any,
"bin": bin,
"bool": bool,
"dict": dict,
"enumerate": enumerate,
"filter": filter,
"float": float,
"format": format,
"hex": hex,
"int": int,
"isinstance": isinstance,
"issubclass": issubclass,
"len": len,
"list": list,
"map": map,
"max": max,
"min": min,
"next": next,
"oct": oct,
"ord": ord,
"pow": pow,
"print": lambda *args, **kwargs: None, # Disabled
"range": range,
"repr": repr,
"reversed": reversed,
"round": round,
"set": set,
"slice": slice,
"sorted": sorted,
"str": str,
"sum": sum,
"tuple": tuple,
"type": type,
"zip": zip,
}
}
# Allowed modules
allowed_modules = ["math", "random", "statistics", "datetime", "json"]
for mod_name in allowed_modules:
try:
safe_globals[mod_name] = __import__(mod_name)
except ImportError:
pass
try:
# Compile and execute
compiled = compile(code, "<string>", "eval" if return_output else "exec")
if return_output:
result = eval(compiled, safe_globals, {})
return f"Result: {result}"
else:
exec(compiled, safe_globals, {})
return "Code executed successfully."
except SyntaxError:
# Try as exec if eval fails
try:
compiled = compile(code, "<string>", "exec")
exec(compiled, safe_globals, {})
return "Code executed successfully."
except Exception as exc:
error_msg = traceback.format_exc()
logger.error("Python execution failed: %s", exc)
return f"Error: {exc}\n\n{error_msg}"
except Exception as exc:
error_msg = traceback.format_exc()
logger.error("Python execution failed: %s", exc)
return f"Error: {exc}\n\n{error_msg}"
# Register with MCP
register_tool(name="python", schema=PYTHON_SCHEMA, category="code")(python)

179
src/tools/file_ops.py Normal file
View File

@@ -0,0 +1,179 @@
"""File operations tool.
MCP-compliant tool for reading, writing, and listing files.
"""
import logging
from pathlib import Path
from typing import Any
from mcp.registry import register_tool
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_BOOLEAN, RETURN_STRING
logger = logging.getLogger(__name__)
# Read File Schema
READ_FILE_SCHEMA = create_tool_schema(
name="read_file",
description="Read contents of a file. Use when user explicitly asks to read a file.",
parameters={
"path": {
**PARAM_STRING,
"description": "Path to file (relative to project root or absolute)",
},
"limit": {
"type": "integer",
"description": "Maximum lines to read (0 = all)",
"default": 0,
},
},
required=["path"],
returns=RETURN_STRING,
)
# Write File Schema
WRITE_FILE_SCHEMA = create_tool_schema(
name="write_file",
description="Write content to a file. Use when user explicitly asks to save content.",
parameters={
"path": {
**PARAM_STRING,
"description": "Path to file",
},
"content": {
**PARAM_STRING,
"description": "Content to write",
},
"append": {
**PARAM_BOOLEAN,
"description": "Append to file instead of overwrite",
"default": False,
},
},
required=["path", "content"],
returns=RETURN_STRING,
)
# List Directory Schema
LIST_DIR_SCHEMA = create_tool_schema(
name="list_directory",
description="List files in a directory.",
parameters={
"path": {
**PARAM_STRING,
"description": "Directory path (default: current)",
"default": ".",
},
"pattern": {
**PARAM_STRING,
"description": "File pattern filter (e.g., '*.py')",
"default": "*",
},
},
returns=RETURN_STRING,
)
def _resolve_path(path: str) -> Path:
"""Resolve path relative to project root."""
from config import settings
p = Path(path)
if p.is_absolute():
return p
# Try relative to project root
project_root = Path(__file__).parent.parent.parent
return project_root / p
def read_file(path: str, limit: int = 0) -> str:
"""Read file contents."""
try:
filepath = _resolve_path(path)
if not filepath.exists():
return f"Error: File not found: {path}"
if not filepath.is_file():
return f"Error: Path is not a file: {path}"
content = filepath.read_text()
if limit > 0:
lines = content.split('\n')[:limit]
content = '\n'.join(lines)
if len(content.split('\n')) == limit:
content += f"\n\n... [{limit} lines shown]"
return content
except Exception as exc:
logger.error("Read file failed: %s", exc)
return f"Error reading file: {exc}"
def write_file(path: str, content: str, append: bool = False) -> str:
"""Write content to file."""
try:
filepath = _resolve_path(path)
# Ensure directory exists
filepath.parent.mkdir(parents=True, exist_ok=True)
mode = "a" if append else "w"
filepath.write_text(content)
action = "appended to" if append else "wrote"
return f"Successfully {action} {filepath}"
except Exception as exc:
logger.error("Write file failed: %s", exc)
return f"Error writing file: {exc}"
def list_directory(path: str = ".", pattern: str = "*") -> str:
"""List directory contents."""
try:
dirpath = _resolve_path(path)
if not dirpath.exists():
return f"Error: Directory not found: {path}"
if not dirpath.is_dir():
return f"Error: Path is not a directory: {path}"
items = list(dirpath.glob(pattern))
files = []
dirs = []
for item in items:
if item.is_dir():
dirs.append(f"📁 {item.name}/")
else:
size = item.stat().st_size
size_str = f"{size}B" if size < 1024 else f"{size//1024}KB"
files.append(f"📄 {item.name} ({size_str})")
result = [f"Contents of {dirpath}:", ""]
result.extend(sorted(dirs))
result.extend(sorted(files))
return "\n".join(result)
except Exception as exc:
logger.error("List directory failed: %s", exc)
return f"Error listing directory: {exc}"
# Register with MCP
register_tool(name="read_file", schema=READ_FILE_SCHEMA, category="files")(read_file)
register_tool(
name="write_file",
schema=WRITE_FILE_SCHEMA,
category="files",
requires_confirmation=True,
)(write_file)
register_tool(name="list_directory", schema=LIST_DIR_SCHEMA, category="files")(list_directory)

70
src/tools/memory_tool.py Normal file
View File

@@ -0,0 +1,70 @@
"""Memory search tool.
MCP-compliant tool for searching Timmy's memory.
"""
import logging
from typing import Any
from mcp.registry import register_tool
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
logger = logging.getLogger(__name__)
MEMORY_SEARCH_SCHEMA = create_tool_schema(
name="memory_search",
description="Search Timmy's memory for past conversations, facts, and context. Use when user asks about previous discussions or when you need to recall something from memory.",
parameters={
"query": {
**PARAM_STRING,
"description": "What to search for in memory",
},
"top_k": {
**PARAM_INTEGER,
"description": "Number of results to return (1-10)",
"default": 5,
"minimum": 1,
"maximum": 10,
},
},
required=["query"],
returns=RETURN_STRING,
)
def memory_search(query: str, top_k: int = 5) -> str:
"""Search Timmy's memory.
Args:
query: Search query
top_k: Number of results
Returns:
Relevant memories from past conversations
"""
try:
from timmy.semantic_memory import memory_search as semantic_search
results = semantic_search(query, top_k=top_k)
if not results:
return "No relevant memories found."
formatted = ["Relevant memories from past conversations:", ""]
for i, (content, score) in enumerate(results, 1):
relevance = "🔥" if score > 0.8 else "" if score > 0.5 else "📄"
formatted.append(f"{relevance} [{i}] (score: {score:.2f})")
formatted.append(f" {content[:300]}...")
formatted.append("")
return "\n".join(formatted)
except Exception as exc:
logger.error("Memory search failed: %s", exc)
return f"Memory search error: {exc}"
# Register with MCP
register_tool(name="memory_search", schema=MEMORY_SEARCH_SCHEMA, category="memory")(memory_search)

74
src/tools/web_search.py Normal file
View File

@@ -0,0 +1,74 @@
"""Web search tool using DuckDuckGo.
MCP-compliant tool for searching the web.
"""
import logging
from typing import Any
from mcp.registry import register_tool
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
logger = logging.getLogger(__name__)
WEB_SEARCH_SCHEMA = create_tool_schema(
name="web_search",
description="Search the web using DuckDuckGo. Use for current events, news, real-time data, and information not in your training data.",
parameters={
"query": {
**PARAM_STRING,
"description": "Search query string",
},
"max_results": {
**PARAM_INTEGER,
"description": "Maximum number of results (1-10)",
"default": 5,
"minimum": 1,
"maximum": 10,
},
},
required=["query"],
returns=RETURN_STRING,
)
def web_search(query: str, max_results: int = 5) -> str:
"""Search the web using DuckDuckGo.
Args:
query: Search query
max_results: Maximum results to return
Returns:
Formatted search results
"""
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return "No results found."
formatted = []
for i, r in enumerate(results, 1):
title = r.get("title", "No title")
body = r.get("body", "No description")
href = r.get("href", "")
formatted.append(f"{i}. {title}\n {body[:150]}...\n {href}")
return "\n\n".join(formatted)
except Exception as exc:
logger.error("Web search failed: %s", exc)
return f"Search error: {exc}"
# Register with MCP
register_tool(
name="web_search",
schema=WEB_SEARCH_SCHEMA,
category="research",
)(web_search)

265
tests/test_mcp_bootstrap.py Normal file
View File

@@ -0,0 +1,265 @@
"""Tests for MCP Auto-Bootstrap.
Tests follow pytest best practices:
- No module-level state
- Proper fixture cleanup
- Isolated tests
"""
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from mcp.bootstrap import (
auto_bootstrap,
bootstrap_from_directory,
get_bootstrap_status,
DEFAULT_TOOL_PACKAGES,
AUTO_BOOTSTRAP_ENV_VAR,
)
from mcp.discovery import mcp_tool, ToolDiscovery
from mcp.registry import ToolRegistry
@pytest.fixture
def fresh_registry():
"""Create a fresh registry for each test."""
return ToolRegistry()
@pytest.fixture
def fresh_discovery(fresh_registry):
"""Create a fresh discovery instance for each test."""
return ToolDiscovery(registry=fresh_registry)
class TestAutoBootstrap:
"""Test auto_bootstrap function."""
def test_auto_bootstrap_disabled_by_env(self, fresh_registry):
"""Test that auto-bootstrap can be disabled via env var."""
with patch.dict(os.environ, {AUTO_BOOTSTRAP_ENV_VAR: "0"}):
registered = auto_bootstrap(registry=fresh_registry)
assert len(registered) == 0
def test_auto_bootstrap_forced_overrides_env(self, fresh_registry):
"""Test that force=True overrides env var."""
with patch.dict(os.environ, {AUTO_BOOTSTRAP_ENV_VAR: "0"}):
# Empty packages list - just test that it runs
registered = auto_bootstrap(
packages=[],
registry=fresh_registry,
force=True,
)
assert len(registered) == 0 # No packages, but didn't abort
def test_auto_bootstrap_nonexistent_package(self, fresh_registry):
"""Test bootstrap from non-existent package."""
registered = auto_bootstrap(
packages=["nonexistent_package_xyz_12345"],
registry=fresh_registry,
force=True,
)
assert len(registered) == 0
def test_auto_bootstrap_empty_packages(self, fresh_registry):
"""Test bootstrap with empty packages list."""
registered = auto_bootstrap(
packages=[],
registry=fresh_registry,
force=True,
)
assert len(registered) == 0
def test_auto_bootstrap_registers_tools(self, fresh_registry, fresh_discovery):
"""Test that auto-bootstrap registers discovered tools."""
@mcp_tool(name="bootstrap_tool", category="bootstrap")
def bootstrap_func(value: str) -> str:
"""A bootstrap test tool."""
return value
# Manually register it
fresh_registry.register_tool(
name="bootstrap_tool",
function=bootstrap_func,
category="bootstrap",
)
# Verify it's in the registry
record = fresh_registry.get("bootstrap_tool")
assert record is not None
assert record.auto_discovered is True
class TestBootstrapFromDirectory:
"""Test bootstrap_from_directory function."""
def test_bootstrap_from_directory(self, fresh_registry, tmp_path):
"""Test bootstrapping from a directory."""
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
tool_file = tools_dir / "my_tools.py"
tool_file.write_text('''
from mcp.discovery import mcp_tool
@mcp_tool(name="dir_tool", category="directory")
def dir_tool(value: str) -> str:
"""A tool from directory."""
return value
''')
registered = bootstrap_from_directory(tools_dir, registry=fresh_registry)
# Function won't be resolved (AST only), so not registered
assert len(registered) == 0
def test_bootstrap_from_nonexistent_directory(self, fresh_registry):
"""Test bootstrapping from non-existent directory."""
registered = bootstrap_from_directory(
Path("/nonexistent/tools"),
registry=fresh_registry
)
assert len(registered) == 0
def test_bootstrap_skips_private_files(self, fresh_registry, tmp_path):
"""Test that private files are skipped."""
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
private_file = tools_dir / "_private.py"
private_file.write_text('''
from mcp.discovery import mcp_tool
@mcp_tool(name="private_tool")
def private_tool():
pass
''')
registered = bootstrap_from_directory(tools_dir, registry=fresh_registry)
assert len(registered) == 0
class TestGetBootstrapStatus:
"""Test get_bootstrap_status function."""
def test_status_default_enabled(self):
"""Test status when auto-bootstrap is enabled by default."""
with patch.dict(os.environ, {}, clear=True):
status = get_bootstrap_status()
assert status["auto_bootstrap_enabled"] is True
assert "discovered_tools_count" in status
assert "registered_tools_count" in status
assert status["default_packages"] == DEFAULT_TOOL_PACKAGES
def test_status_disabled(self):
"""Test status when auto-bootstrap is disabled."""
with patch.dict(os.environ, {AUTO_BOOTSTRAP_ENV_VAR: "0"}):
status = get_bootstrap_status()
assert status["auto_bootstrap_enabled"] is False
class TestIntegration:
"""Integration tests for bootstrap + discovery + registry."""
def test_full_workflow(self, fresh_registry):
"""Test the full auto-discovery and registration workflow."""
@mcp_tool(name="integration_tool", category="integration")
def integration_func(data: str) -> str:
"""Integration test tool."""
return f"processed: {data}"
fresh_registry.register_tool(
name="integration_tool",
function=integration_func,
category="integration",
source_module="test_module",
)
record = fresh_registry.get("integration_tool")
assert record is not None
assert record.auto_discovered is True
assert record.source_module == "test_module"
export = fresh_registry.to_dict()
assert export["total_tools"] == 1
assert export["auto_discovered_count"] == 1
def test_tool_execution_after_registration(self, fresh_registry):
"""Test that registered tools can be executed."""
@mcp_tool(name="exec_tool", category="execution")
def exec_func(input: str) -> str:
"""Executable test tool."""
return input.upper()
fresh_registry.register_tool(
name="exec_tool",
function=exec_func,
category="execution",
)
import asyncio
result = asyncio.run(fresh_registry.execute("exec_tool", {"input": "hello"}))
assert result == "HELLO"
metrics = fresh_registry.get_metrics("exec_tool")
assert metrics["executions"] == 1
assert metrics["health"] == "healthy"
def test_discover_filtering(self, fresh_registry):
"""Test filtering registered tools."""
@mcp_tool(name="cat1_tool", category="category1")
def cat1_func():
pass
@mcp_tool(name="cat2_tool", category="category2")
def cat2_func():
pass
fresh_registry.register_tool(
name="cat1_tool",
function=cat1_func,
category="category1"
)
fresh_registry.register_tool(
name="cat2_tool",
function=cat2_func,
category="category2"
)
cat1_tools = fresh_registry.discover(category="category1")
assert len(cat1_tools) == 1
assert cat1_tools[0].name == "cat1_tool"
auto_tools = fresh_registry.discover(auto_discovered_only=True)
assert len(auto_tools) == 2
def test_registry_export_includes_metadata(self, fresh_registry):
"""Test that registry export includes all metadata."""
@mcp_tool(name="meta_tool", category="meta", tags=["tag1", "tag2"])
def meta_func():
pass
fresh_registry.register_tool(
name="meta_tool",
function=meta_func,
category="meta",
tags=["tag1", "tag2"],
)
export = fresh_registry.to_dict()
for tool_dict in export["tools"]:
assert "tags" in tool_dict
assert "source_module" in tool_dict
assert "auto_discovered" in tool_dict

329
tests/test_mcp_discovery.py Normal file
View File

@@ -0,0 +1,329 @@
"""Tests for MCP Tool Auto-Discovery.
Tests follow pytest best practices:
- No module-level state
- Proper fixture cleanup
- Isolated tests
"""
import ast
import inspect
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from mcp.discovery import DiscoveredTool, ToolDiscovery, mcp_tool
from mcp.registry import ToolRegistry
@pytest.fixture
def fresh_registry():
"""Create a fresh registry for each test."""
return ToolRegistry()
@pytest.fixture
def discovery(fresh_registry):
"""Create a fresh discovery instance for each test."""
return ToolDiscovery(registry=fresh_registry)
@pytest.fixture
def mock_module_with_tools():
"""Create a mock module with MCP tools for testing."""
# Create a fresh module
mock_module = types.ModuleType("mock_test_module")
mock_module.__file__ = "mock_test_module.py"
# Add decorated functions
@mcp_tool(name="echo", category="test", tags=["utility"])
def echo_func(message: str) -> str:
"""Echo a message back."""
return message
@mcp_tool(category="math")
def add_func(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
def not_decorated():
"""Not a tool."""
pass
mock_module.echo_func = echo_func
mock_module.add_func = add_func
mock_module.not_decorated = not_decorated
# Inject into sys.modules
sys.modules["mock_test_module"] = mock_module
yield mock_module
# Cleanup
del sys.modules["mock_test_module"]
class TestMCPToolDecorator:
"""Test the @mcp_tool decorator."""
def test_decorator_sets_explicit_name(self):
"""Test that decorator uses explicit name."""
@mcp_tool(name="custom_name", category="test")
def my_func():
pass
assert my_func._mcp_name == "custom_name"
assert my_func._mcp_category == "test"
def test_decorator_uses_function_name(self):
"""Test that decorator uses function name when not specified."""
@mcp_tool(category="math")
def my_add_func():
pass
assert my_add_func._mcp_name == "my_add_func"
def test_decorator_captures_docstring(self):
"""Test that decorator captures docstring as description."""
@mcp_tool(name="test")
def with_doc():
"""This is the description."""
pass
assert "This is the description" in with_doc._mcp_description
def test_decorator_sets_tags(self):
"""Test that decorator sets tags."""
@mcp_tool(name="test", tags=["tag1", "tag2"])
def tagged_func():
pass
assert tagged_func._mcp_tags == ["tag1", "tag2"]
def test_undecorated_function(self):
"""Test that undecorated functions don't have MCP attributes."""
def plain_func():
pass
assert not hasattr(plain_func, "_mcp_tool")
class TestDiscoveredTool:
"""Test DiscoveredTool dataclass."""
def test_tool_creation(self):
"""Test creating a DiscoveredTool."""
def dummy_func():
pass
tool = DiscoveredTool(
name="test",
description="A test tool",
function=dummy_func,
module="test_module",
category="test",
tags=["utility"],
parameters_schema={"type": "object"},
returns_schema={"type": "string"},
)
assert tool.name == "test"
assert tool.function == dummy_func
assert tool.category == "test"
class TestToolDiscoveryInit:
"""Test ToolDiscovery initialization."""
def test_uses_provided_registry(self, fresh_registry):
"""Test initialization with provided registry."""
discovery = ToolDiscovery(registry=fresh_registry)
assert discovery.registry is fresh_registry
class TestDiscoverModule:
"""Test discovering tools from modules."""
def test_discover_finds_decorated_tools(self, discovery, mock_module_with_tools):
"""Test discovering tools from a module."""
tools = discovery.discover_module("mock_test_module")
tool_names = [t.name for t in tools]
assert "echo" in tool_names
assert "add_func" in tool_names
assert "not_decorated" not in tool_names
def test_discover_nonexistent_module(self, discovery):
"""Test discovering from non-existent module."""
tools = discovery.discover_module("nonexistent.module.xyz")
assert len(tools) == 0
def test_discovered_tool_has_correct_metadata(self, discovery, mock_module_with_tools):
"""Test that discovered tools have correct metadata."""
tools = discovery.discover_module("mock_test_module")
echo_tool = next(t for t in tools if t.name == "echo")
assert echo_tool.category == "test"
assert "utility" in echo_tool.tags
def test_discovered_tool_has_schema(self, discovery, mock_module_with_tools):
"""Test that discovered tools have parameter schemas."""
tools = discovery.discover_module("mock_test_module")
add_tool = next(t for t in tools if t.name == "add_func")
assert "properties" in add_tool.parameters_schema
assert "a" in add_tool.parameters_schema["properties"]
class TestDiscoverFile:
"""Test discovering tools from Python files."""
def test_discover_from_file(self, discovery, tmp_path):
"""Test discovering tools from a Python file."""
test_file = tmp_path / "test_tools.py"
test_file.write_text('''
from mcp.discovery import mcp_tool
@mcp_tool(name="file_tool", category="file_ops", tags=["io"])
def file_tool(path: str) -> dict:
"""Process a file."""
return {"path": path}
''')
tools = discovery.discover_file(test_file)
assert len(tools) == 1
assert tools[0].name == "file_tool"
assert tools[0].category == "file_ops"
def test_discover_from_nonexistent_file(self, discovery, tmp_path):
"""Test discovering from non-existent file."""
tools = discovery.discover_file(tmp_path / "nonexistent.py")
assert len(tools) == 0
def test_discover_from_invalid_python(self, discovery, tmp_path):
"""Test discovering from invalid Python file."""
test_file = tmp_path / "invalid.py"
test_file.write_text("not valid python @#$%")
tools = discovery.discover_file(test_file)
assert len(tools) == 0
class TestSchemaBuilding:
"""Test JSON schema building from type hints."""
def test_string_parameter(self, discovery):
"""Test string parameter schema."""
def func(name: str) -> str:
return name
sig = inspect.signature(func)
schema = discovery._build_parameters_schema(sig)
assert schema["properties"]["name"]["type"] == "string"
def test_int_parameter(self, discovery):
"""Test int parameter schema."""
def func(count: int) -> int:
return count
sig = inspect.signature(func)
schema = discovery._build_parameters_schema(sig)
assert schema["properties"]["count"]["type"] == "number"
def test_bool_parameter(self, discovery):
"""Test bool parameter schema."""
def func(enabled: bool) -> bool:
return enabled
sig = inspect.signature(func)
schema = discovery._build_parameters_schema(sig)
assert schema["properties"]["enabled"]["type"] == "boolean"
def test_required_parameters(self, discovery):
"""Test that required parameters are marked."""
def func(required: str, optional: str = "default") -> str:
return required
sig = inspect.signature(func)
schema = discovery._build_parameters_schema(sig)
assert "required" in schema["required"]
assert "optional" not in schema["required"]
def test_default_values(self, discovery):
"""Test that default values are captured."""
def func(name: str = "default") -> str:
return name
sig = inspect.signature(func)
schema = discovery._build_parameters_schema(sig)
assert schema["properties"]["name"]["default"] == "default"
class TestTypeToSchema:
"""Test type annotation to JSON schema conversion."""
def test_str_annotation(self, discovery):
"""Test string annotation."""
schema = discovery._type_to_schema(str)
assert schema["type"] == "string"
def test_int_annotation(self, discovery):
"""Test int annotation."""
schema = discovery._type_to_schema(int)
assert schema["type"] == "number"
def test_optional_annotation(self, discovery):
"""Test Optional[T] annotation."""
from typing import Optional
schema = discovery._type_to_schema(Optional[str])
assert schema["type"] == "string"
class TestAutoRegister:
"""Test auto-registration of discovered tools."""
def test_auto_register_module(self, discovery, mock_module_with_tools, fresh_registry):
"""Test auto-registering tools from a module."""
registered = discovery.auto_register("mock_test_module")
assert "echo" in registered
assert "add_func" in registered
assert fresh_registry.get("echo") is not None
def test_auto_register_skips_unresolved_functions(self, discovery, fresh_registry):
"""Test that tools without resolved functions are skipped."""
# Add a discovered tool with no function
discovery._discovered.append(DiscoveredTool(
name="no_func",
description="No function",
function=None, # type: ignore
module="test",
category="test",
tags=[],
parameters_schema={},
returns_schema={},
))
registered = discovery.auto_register("mock_test_module")
assert "no_func" not in registered
class TestClearDiscovered:
"""Test clearing discovered tools cache."""
def test_clear_discovered(self, discovery, mock_module_with_tools):
"""Test clearing discovered tools."""
discovery.discover_module("mock_test_module")
assert len(discovery.get_discovered()) > 0
discovery.clear()
assert len(discovery.get_discovered()) == 0

358
tests/test_router_api.py Normal file
View File

@@ -0,0 +1,358 @@
"""Tests for Cascade Router API endpoints."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from router.cascade import CircuitState, Provider, ProviderStatus
from router.api import router, get_cascade_router
def make_mock_router():
"""Create a mock CascadeRouter."""
router = MagicMock()
# Create test providers
provider1 = Provider(
name="ollama-local",
type="ollama",
enabled=True,
priority=1,
url="http://localhost:11434",
models=[{"name": "llama3.2", "default": True, "context_window": 128000}],
)
provider1.status = ProviderStatus.HEALTHY
provider1.circuit_state = CircuitState.CLOSED
provider2 = Provider(
name="openai-backup",
type="openai",
enabled=True,
priority=2,
api_key="sk-test",
models=[{"name": "gpt-4o-mini", "default": True, "context_window": 128000}],
)
provider2.status = ProviderStatus.DEGRADED
provider2.circuit_state = CircuitState.CLOSED
router.providers = [provider1, provider2]
router.config.timeout_seconds = 30
router.config.max_retries_per_provider = 2
router.config.circuit_breaker_failure_threshold = 5
return router
@pytest.fixture
def mock_router():
"""Create test client with mocked router."""
from fastapi import FastAPI
app = FastAPI()
app.include_router(router)
# Create mock router
mock = make_mock_router()
# Override dependency
async def mock_get_router():
return mock
app.dependency_overrides[get_cascade_router] = mock_get_router
client = TestClient(app)
return client, mock
class TestCompleteEndpoint:
"""Test /complete endpoint."""
def test_complete_success(self, mock_router):
"""Test successful completion."""
client, mock = mock_router
mock.complete = AsyncMock(return_value={
"content": "Hello! How can I help?",
"provider": "ollama-local",
"model": "llama3.2",
"latency_ms": 250.5,
})
response = client.post("/api/v1/router/complete", json={
"messages": [{"role": "user", "content": "Hi"}],
"model": "llama3.2",
"temperature": 0.7,
})
assert response.status_code == 200
data = response.json()
assert data["content"] == "Hello! How can I help?"
assert data["provider"] == "ollama-local"
assert data["latency_ms"] == 250.5
def test_complete_all_providers_fail(self, mock_router):
"""Test 503 when all providers fail."""
client, mock = mock_router
mock.complete = AsyncMock(side_effect=RuntimeError("All providers failed"))
response = client.post("/api/v1/router/complete", json={
"messages": [{"role": "user", "content": "Hi"}],
})
assert response.status_code == 503
assert "All providers failed" in response.json()["detail"]
def test_complete_default_temperature(self, mock_router):
"""Test completion with default temperature."""
client, mock = mock_router
mock.complete = AsyncMock(return_value={
"content": "Response",
"provider": "ollama-local",
"model": "llama3.2",
"latency_ms": 100.0,
})
response = client.post("/api/v1/router/complete", json={
"messages": [{"role": "user", "content": "Hi"}],
})
assert response.status_code == 200
# Check that complete was called with correct temperature
call_args = mock.complete.call_args
assert call_args.kwargs["temperature"] == 0.7
class TestStatusEndpoint:
"""Test /status endpoint."""
def test_get_status(self, mock_router):
"""Test getting router status."""
client, mock = mock_router
mock.get_status = MagicMock(return_value={
"total_providers": 2,
"healthy_providers": 1,
"degraded_providers": 1,
"unhealthy_providers": 0,
"providers": [
{
"name": "ollama-local",
"type": "ollama",
"status": "healthy",
"priority": 1,
"default_model": "llama3.2",
},
{
"name": "openai-backup",
"type": "openai",
"status": "degraded",
"priority": 2,
"default_model": "gpt-4o-mini",
},
],
})
response = client.get("/api/v1/router/status")
assert response.status_code == 200
data = response.json()
assert data["total_providers"] == 2
assert data["healthy_providers"] == 1
assert data["degraded_providers"] == 1
assert len(data["providers"]) == 2
class TestMetricsEndpoint:
"""Test /metrics endpoint."""
def test_get_metrics(self, mock_router):
"""Test getting detailed metrics."""
client, mock = mock_router
# Setup the mock return value on the mock_router object
mock.get_metrics = MagicMock(return_value={
"providers": [
{
"name": "ollama-local",
"type": "ollama",
"status": "healthy",
"circuit_state": "closed",
"metrics": {
"total_requests": 100,
"successful": 98,
"failed": 2,
"error_rate": 0.02,
"avg_latency_ms": 150.5,
},
},
],
})
response = client.get("/api/v1/router/metrics")
assert response.status_code == 200
data = response.json()
assert len(data["providers"]) == 1
metrics = data["providers"][0]["metrics"]
assert metrics["total_requests"] == 100
assert metrics["error_rate"] == 0.02
assert metrics["avg_latency_ms"] == 150.5
class TestListProvidersEndpoint:
"""Test /providers endpoint."""
def test_list_providers(self, mock_router):
"""Test listing all providers."""
client, mock = mock_router
response = client.get("/api/v1/router/providers")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Check first provider
assert data[0]["name"] == "ollama-local"
assert data[0]["type"] == "ollama"
assert data[0]["enabled"] is True
assert data[0]["priority"] == 1
assert data[0]["default_model"] == "llama3.2"
assert "llama3.2" in data[0]["models"]
class TestControlProviderEndpoint:
"""Test /providers/{name}/control endpoint."""
def test_disable_provider(self, mock_router):
"""Test disabling a provider."""
client, mock = mock_router
response = client.post(
"/api/v1/router/providers/ollama-local/control",
json={"action": "disable"}
)
assert response.status_code == 200
assert "disabled" in response.json()["message"]
# Check that the provider was disabled
provider = mock.providers[0]
assert provider.enabled is False
assert provider.status == ProviderStatus.DISABLED
def test_enable_provider(self, mock_router):
"""Test enabling a provider."""
client, mock = mock_router
# First disable it
mock.providers[0].enabled = False
mock.providers[0].status = ProviderStatus.DISABLED
response = client.post(
"/api/v1/router/providers/ollama-local/control",
json={"action": "enable"}
)
assert response.status_code == 200
assert "enabled" in response.json()["message"]
assert mock.providers[0].enabled is True
def test_reset_circuit(self, mock_router):
"""Test resetting circuit breaker."""
client, mock = mock_router
# Set to open state
mock.providers[0].circuit_state = CircuitState.OPEN
mock.providers[0].status = ProviderStatus.UNHEALTHY
mock.providers[0].metrics.consecutive_failures = 10
response = client.post(
"/api/v1/router/providers/ollama-local/control",
json={"action": "reset_circuit"}
)
assert response.status_code == 200
assert "reset" in response.json()["message"]
provider = mock.providers[0]
assert provider.circuit_state == CircuitState.CLOSED
assert provider.status == ProviderStatus.HEALTHY
assert provider.metrics.consecutive_failures == 0
def test_control_unknown_provider(self, mock_router):
"""Test controlling unknown provider returns 404."""
client, mock = mock_router
response = client.post(
"/api/v1/router/providers/unknown/control",
json={"action": "disable"}
)
assert response.status_code == 404
assert "not found" in response.json()["detail"]
def test_control_unknown_action(self, mock_router):
"""Test unknown action returns 400."""
client, mock = mock_router
response = client.post(
"/api/v1/router/providers/ollama-local/control",
json={"action": "invalid_action"}
)
assert response.status_code == 400
assert "Unknown action" in response.json()["detail"]
class TestHealthCheckEndpoint:
"""Test /health-check endpoint."""
def test_health_check_all_healthy(self, mock_router):
"""Test health check when all providers are healthy."""
client, mock = mock_router
with patch.object(mock, "_check_provider_available") as mock_check:
mock_check.return_value = True
response = client.post("/api/v1/router/health-check")
assert response.status_code == 200
data = response.json()
assert data["healthy_count"] == 2
assert len(data["providers"]) == 2
for p in data["providers"]:
assert p["healthy"] is True
def test_health_check_with_failure(self, mock_router):
"""Test health check when some providers fail."""
client, mock = mock_router
with patch.object(mock, "_check_provider_available") as mock_check:
# First provider fails, second succeeds
mock_check.side_effect = [False, True]
response = client.post("/api/v1/router/health-check")
assert response.status_code == 200
data = response.json()
assert data["healthy_count"] == 1
assert data["providers"][0]["healthy"] is False
assert data["providers"][1]["healthy"] is True
class TestGetConfigEndpoint:
"""Test /config endpoint."""
def test_get_config(self, mock_router):
"""Test getting router configuration."""
client, mock = mock_router
response = client.get("/api/v1/router/config")
assert response.status_code == 200
data = response.json()
assert data["timeout_seconds"] == 30
assert data["max_retries_per_provider"] == 2
assert "circuit_breaker" in data
assert data["circuit_breaker"]["failure_threshold"] == 5
# Check providers list (without secrets)
assert len(data["providers"]) == 2
assert "api_key" not in data["providers"][0]

View File

@@ -0,0 +1,523 @@
"""Tests for Cascade LLM Router."""
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
from router.cascade import (
CascadeRouter,
CircuitState,
Provider,
ProviderMetrics,
ProviderStatus,
RouterConfig,
)
class TestProviderMetrics:
"""Test provider metrics tracking."""
def test_empty_metrics(self):
"""Test metrics with no requests."""
metrics = ProviderMetrics()
assert metrics.total_requests == 0
assert metrics.avg_latency_ms == 0.0
assert metrics.error_rate == 0.0
def test_avg_latency_calculation(self):
"""Test average latency calculation."""
metrics = ProviderMetrics(
total_requests=4,
total_latency_ms=1000.0, # 4 requests, 1000ms total
)
assert metrics.avg_latency_ms == 250.0
def test_error_rate_calculation(self):
"""Test error rate calculation."""
metrics = ProviderMetrics(
total_requests=10,
successful_requests=7,
failed_requests=3,
)
assert metrics.error_rate == 0.3
class TestProvider:
"""Test Provider dataclass."""
def test_get_default_model(self):
"""Test getting default model."""
provider = Provider(
name="test",
type="ollama",
enabled=True,
priority=1,
models=[
{"name": "llama3", "default": True},
{"name": "mistral"},
],
)
assert provider.get_default_model() == "llama3"
def test_get_default_model_no_default(self):
"""Test getting first model when no default set."""
provider = Provider(
name="test",
type="ollama",
enabled=True,
priority=1,
models=[
{"name": "llama3"},
{"name": "mistral"},
],
)
assert provider.get_default_model() == "llama3"
def test_get_default_model_empty(self):
"""Test with no models."""
provider = Provider(
name="test",
type="ollama",
enabled=True,
priority=1,
models=[],
)
assert provider.get_default_model() is None
class TestRouterConfig:
"""Test router configuration."""
def test_default_config(self):
"""Test default configuration values."""
config = RouterConfig()
assert config.timeout_seconds == 30
assert config.max_retries_per_provider == 2
assert config.retry_delay_seconds == 1
assert config.circuit_breaker_failure_threshold == 5
class TestCascadeRouterInit:
"""Test CascadeRouter initialization."""
def test_init_without_config(self, tmp_path):
"""Test initialization without config file."""
router = CascadeRouter(config_path=tmp_path / "nonexistent.yaml")
assert len(router.providers) == 0
assert router.config.timeout_seconds == 30
def test_init_with_config(self, tmp_path):
"""Test initialization with config file."""
config = {
"cascade": {
"timeout_seconds": 60,
"max_retries_per_provider": 3,
},
"providers": [
{
"name": "test-ollama",
"type": "ollama",
"enabled": False, # Disabled to avoid availability check
"priority": 1,
"url": "http://localhost:11434",
}
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert router.config.timeout_seconds == 60
assert router.config.max_retries_per_provider == 3
assert len(router.providers) == 0 # Provider is disabled
def test_env_var_expansion(self, tmp_path, monkeypatch):
"""Test environment variable expansion in config."""
monkeypatch.setenv("TEST_API_KEY", "secret123")
config = {
"cascade": {},
"providers": [
{
"name": "test-openai",
"type": "openai",
"enabled": True,
"priority": 1,
"api_key": "${TEST_API_KEY}",
}
],
}
config_path = tmp_path / "providers.yaml"
config_path.write_text(yaml.dump(config))
router = CascadeRouter(config_path=config_path)
assert len(router.providers) == 1
assert router.providers[0].api_key == "secret123"
class TestCascadeRouterMetrics:
"""Test metrics tracking."""
def test_record_success(self):
"""Test recording successful request."""
provider = Provider(name="test", type="ollama", enabled=True, priority=1)
router = CascadeRouter(config_path=Path("/nonexistent"))
router._record_success(provider, 150.0)
assert provider.metrics.total_requests == 1
assert provider.metrics.successful_requests == 1
assert provider.metrics.total_latency_ms == 150.0
assert provider.metrics.consecutive_failures == 0
def test_record_failure(self):
"""Test recording failed request."""
provider = Provider(name="test", type="ollama", enabled=True, priority=1)
router = CascadeRouter(config_path=Path("/nonexistent"))
router._record_failure(provider)
assert provider.metrics.total_requests == 1
assert provider.metrics.failed_requests == 1
assert provider.metrics.consecutive_failures == 1
def test_circuit_breaker_opens(self):
"""Test circuit breaker opens after failures."""
provider = Provider(name="test", type="ollama", enabled=True, priority=1)
router = CascadeRouter(config_path=Path("/nonexistent"))
router.config.circuit_breaker_failure_threshold = 3
# Record 3 failures
for _ in range(3):
router._record_failure(provider)
assert provider.circuit_state == CircuitState.OPEN
assert provider.status == ProviderStatus.UNHEALTHY
assert provider.circuit_opened_at is not None
def test_circuit_breaker_can_close(self):
"""Test circuit breaker can transition to closed."""
provider = Provider(name="test", type="ollama", enabled=True, priority=1)
router = CascadeRouter(config_path=Path("/nonexistent"))
router.config.circuit_breaker_failure_threshold = 3
router.config.circuit_breaker_recovery_timeout = 1
# Open the circuit
for _ in range(3):
router._record_failure(provider)
assert provider.circuit_state == CircuitState.OPEN
# Wait for recovery timeout
time.sleep(1.1)
# Check if can close
assert router._can_close_circuit(provider) is True
def test_half_open_to_closed(self):
"""Test circuit breaker closes after successful test calls."""
provider = Provider(name="test", type="ollama", enabled=True, priority=1)
router = CascadeRouter(config_path=Path("/nonexistent"))
router.config.circuit_breaker_half_open_max_calls = 2
# Manually set to half-open
provider.circuit_state = CircuitState.HALF_OPEN
provider.half_open_calls = 0
# Record successful calls
router._record_success(provider, 100.0)
assert provider.circuit_state == CircuitState.HALF_OPEN # Still half-open
router._record_success(provider, 100.0)
assert provider.circuit_state == CircuitState.CLOSED # Now closed
assert provider.status == ProviderStatus.HEALTHY
class TestCascadeRouterGetMetrics:
"""Test get_metrics method."""
def test_get_metrics_empty(self):
"""Test getting metrics with no providers."""
router = CascadeRouter(config_path=Path("/nonexistent"))
metrics = router.get_metrics()
assert "providers" in metrics
assert len(metrics["providers"]) == 0
def test_get_metrics_with_providers(self):
"""Test getting metrics with providers."""
router = CascadeRouter(config_path=Path("/nonexistent"))
# Add a test provider
provider = Provider(
name="test",
type="ollama",
enabled=True,
priority=1,
)
provider.metrics.total_requests = 10
provider.metrics.successful_requests = 8
provider.metrics.failed_requests = 2
provider.metrics.total_latency_ms = 2000.0
router.providers = [provider]
metrics = router.get_metrics()
assert len(metrics["providers"]) == 1
p_metrics = metrics["providers"][0]
assert p_metrics["name"] == "test"
assert p_metrics["metrics"]["total_requests"] == 10
assert p_metrics["metrics"]["error_rate"] == 0.2
assert p_metrics["metrics"]["avg_latency_ms"] == 200.0
class TestCascadeRouterGetStatus:
"""Test get_status method."""
def test_get_status(self):
"""Test getting router status."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="test",
type="ollama",
enabled=True,
priority=1,
models=[{"name": "llama3", "default": True}],
)
router.providers = [provider]
status = router.get_status()
assert status["total_providers"] == 1
assert status["healthy_providers"] == 1
assert status["degraded_providers"] == 0
assert status["unhealthy_providers"] == 0
assert len(status["providers"]) == 1
@pytest.mark.asyncio
class TestCascadeRouterComplete:
"""Test complete method with failover."""
async def test_complete_with_ollama(self):
"""Test successful completion with Ollama."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="ollama-local",
type="ollama",
enabled=True,
priority=1,
url="http://localhost:11434",
models=[{"name": "llama3.2", "default": True}],
)
router.providers = [provider]
# Mock the Ollama call
with patch.object(router, "_call_ollama") as mock_call:
mock_call.return_value = AsyncMock()()
mock_call.return_value = {
"content": "Hello, world!",
"model": "llama3.2",
}
result = await router.complete(
messages=[{"role": "user", "content": "Hi"}],
)
assert result["content"] == "Hello, world!"
assert result["provider"] == "ollama-local"
assert result["model"] == "llama3.2"
async def test_failover_to_second_provider(self):
"""Test failover when first provider fails."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider1 = Provider(
name="ollama-failing",
type="ollama",
enabled=True,
priority=1,
url="http://localhost:11434",
models=[{"name": "llama3.2", "default": True}],
)
provider2 = Provider(
name="ollama-backup",
type="ollama",
enabled=True,
priority=2,
url="http://backup:11434",
models=[{"name": "llama3.2", "default": True}],
)
router.providers = [provider1, provider2]
# First provider fails, second succeeds
call_count = [0]
async def side_effect(*args, **kwargs):
call_count[0] += 1
# First 2 retries for provider1 fail, then provider2 succeeds
if call_count[0] <= router.config.max_retries_per_provider:
raise RuntimeError("Connection failed")
return {"content": "Backup response", "model": "llama3.2"}
with patch.object(router, "_call_ollama") as mock_call:
mock_call.side_effect = side_effect
result = await router.complete(
messages=[{"role": "user", "content": "Hi"}],
)
assert result["content"] == "Backup response"
assert result["provider"] == "ollama-backup"
async def test_all_providers_fail(self):
"""Test error when all providers fail."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="failing",
type="ollama",
enabled=True,
priority=1,
models=[{"name": "llama3.2", "default": True}],
)
router.providers = [provider]
with patch.object(router, "_call_ollama") as mock_call:
mock_call.side_effect = RuntimeError("Always fails")
with pytest.raises(RuntimeError) as exc_info:
await router.complete(messages=[{"role": "user", "content": "Hi"}])
assert "All providers failed" in str(exc_info.value)
async def test_skips_unhealthy_provider(self):
"""Test that unhealthy providers are skipped."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider1 = Provider(
name="unhealthy",
type="ollama",
enabled=True,
priority=1,
status=ProviderStatus.UNHEALTHY,
circuit_state=CircuitState.OPEN,
circuit_opened_at=time.time(), # Just opened
models=[{"name": "llama3.2", "default": True}],
)
provider2 = Provider(
name="healthy",
type="ollama",
enabled=True,
priority=2,
models=[{"name": "llama3.2", "default": True}],
)
router.providers = [provider1, provider2]
with patch.object(router, "_call_ollama") as mock_call:
mock_call.return_value = {"content": "Success", "model": "llama3.2"}
result = await router.complete(
messages=[{"role": "user", "content": "Hi"}],
)
# Should use the healthy provider
assert result["provider"] == "healthy"
class TestProviderAvailabilityCheck:
"""Test provider availability checking."""
def test_check_ollama_without_requests(self):
"""Test Ollama returns True when requests not available (fallback)."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="ollama",
type="ollama",
enabled=True,
priority=1,
url="http://localhost:11434",
)
# When requests is None, assume available
import router.cascade as cascade_module
old_requests = cascade_module.requests
cascade_module.requests = None
try:
assert router._check_provider_available(provider) is True
finally:
cascade_module.requests = old_requests
def test_check_openai_with_key(self):
"""Test OpenAI with API key."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="openai",
type="openai",
enabled=True,
priority=1,
api_key="sk-test123",
)
assert router._check_provider_available(provider) is True
def test_check_openai_without_key(self):
"""Test OpenAI without API key."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="openai",
type="openai",
enabled=True,
priority=1,
api_key=None,
)
assert router._check_provider_available(provider) is False
def test_check_airllm_installed(self):
"""Test AirLLM when installed."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="airllm",
type="airllm",
enabled=True,
priority=1,
)
with patch("builtins.__import__") as mock_import:
mock_import.return_value = MagicMock()
assert router._check_provider_available(provider) is True
def test_check_airllm_not_installed(self):
"""Test AirLLM when not installed."""
router = CascadeRouter(config_path=Path("/nonexistent"))
provider = Provider(
name="airllm",
type="airllm",
enabled=True,
priority=1,
)
# Patch __import__ to simulate airllm not being available
def raise_import_error(name, *args, **kwargs):
if name == "airllm":
raise ImportError("No module named 'airllm'")
return __builtins__.__import__(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=raise_import_error):
assert router._check_provider_available(provider) is False