Compare commits
@@ -15,6 +15,12 @@ This document contains instructions and documentation references for AI assistan
|
|||||||
* **Act:** Execute the changes in small, atomic steps.
|
* **Act:** Execute the changes in small, atomic steps.
|
||||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||||
|
|
||||||
|
### 🌐 Internal Service Access
|
||||||
|
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
||||||
|
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||||
|
* Public repos are readable without authentication
|
||||||
|
* Related repos: `library-desk`, `scheduler`
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
### 🛡️ Git Discipline
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||||
|
|||||||
+79
-1
@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] - 2025-12-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Phase F: Memory System (The Biographer)
|
||||||
|
|
||||||
|
- **Memory Infrastructure** (Phase F.1):
|
||||||
|
- `src/core/context.py`: ContextVar-based request context for async-safe user/conversation tracking
|
||||||
|
- `get_user()`, `get_conversation_id()` helpers
|
||||||
|
- `RequestContext` manager for clean setup/teardown
|
||||||
|
- `src/core/multi_tenancy.py`: User ID sanitization and collection naming
|
||||||
|
- Per-user collection pattern: `memories_{user}`
|
||||||
|
- Redis key patterns: `session:{user}:{conv}`, `entities:{user}:{conv}`
|
||||||
|
- `src/core/embeddings.py`: Ollama embedding client
|
||||||
|
- nomic-embed-text model (768 dimensions)
|
||||||
|
- `embed()`, `embed_batch()`, `health_check()` methods
|
||||||
|
- `src/core/qdrant.py`: Qdrant vector database client
|
||||||
|
- `ensure_collection()`, `upsert_memory()`, `search_memories()`, `delete_memory()`
|
||||||
|
- Type-based filtering for memory queries
|
||||||
|
- `src/core/memory_cache.py`: Redis session memory cache
|
||||||
|
- Session context with 24h TTL (db=2, separate from benchmarks)
|
||||||
|
- Recent entities tracking per conversation
|
||||||
|
|
||||||
|
- **Memory Service** (Phase F.2a):
|
||||||
|
- `src/core/memory_service.py`: Direct access layer for fast, LLM-free memory lookups
|
||||||
|
- Profile methods: `get_profile()`, `set_profile()`
|
||||||
|
- Preference methods: `get_preference()`, `set_preference()`, `get_all_preferences()`
|
||||||
|
- Fact methods: `store_fact()`, `get_fact()`
|
||||||
|
- Session context: `get_session_context()`, `set_session_context()`, `update_session_context()`
|
||||||
|
- Steward integration: `prefetch_context()` for request preprocessing
|
||||||
|
|
||||||
|
- **The Biographer Agent** (Phase F.2b):
|
||||||
|
- `src/agents/biographer/`: Household memory keeper agent
|
||||||
|
- PydanticAI agent with discreet chronicler personality
|
||||||
|
- System prompt emphasizes privacy and accurate recall
|
||||||
|
- **Biographer Tools** (`src/agents/biographer/tools.py`):
|
||||||
|
- `recall_semantic`: Semantic search for memories by meaning
|
||||||
|
- `list_memories`: Browse stored memories by type
|
||||||
|
- `store_insight`: Record new facts from conversation
|
||||||
|
- `update_profile`: Update core profile fields (name, location, timezone)
|
||||||
|
- `update_preference`: Update user preferences (units, theme)
|
||||||
|
- `forget_memory`: Remove specific memories
|
||||||
|
- **Capability Registration**:
|
||||||
|
- `BIOGRAPHER_CAPABILITY` with context domain
|
||||||
|
- Automatic registration on startup
|
||||||
|
- Low cost (vector search, minimal LLM)
|
||||||
|
|
||||||
|
- **Delegation Wrapper**:
|
||||||
|
- `delegate_to_biographer()` in `src/agents/delegation.py`
|
||||||
|
- Async delegation with error handling
|
||||||
|
|
||||||
|
- **Steward Memory Integration**:
|
||||||
|
- Memory context pre-fetch during request analysis
|
||||||
|
- Profile and preferences included in Steward's note to Butler
|
||||||
|
- Keyword-based context determination (weather → location, time → timezone)
|
||||||
|
|
||||||
|
- **Configuration**:
|
||||||
|
- `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_EMBEDDING_DIM` (768)
|
||||||
|
- `OLLAMA_EMBEDDING_MODEL` (nomic-embed-text)
|
||||||
|
- `REDIS_MEMORY_DB` (2), `REDIS_MEMORY_TTL_HOURS` (24)
|
||||||
|
|
||||||
|
- **Test Suite**:
|
||||||
|
- 34 new tests for memory system
|
||||||
|
- Biographer capability tests (15 tests)
|
||||||
|
- Memory service tests (19 tests)
|
||||||
|
|
||||||
|
- **OpenAI Standard `user` Field**:
|
||||||
|
- Added `user` field to `ResponseRequest` schema
|
||||||
|
- Request context set at API entry point
|
||||||
|
- Propagates through async calls via ContextVar
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Application startup now registers The Biographer with Household Registry
|
||||||
|
- Steward analysis includes memory context pre-fetch
|
||||||
|
- Librarian client methods now use `get_user()` from context (12 methods updated)
|
||||||
|
- Request router sets user/conversation context at entry
|
||||||
|
|
||||||
## [1.1.0] - 2025-12-11
|
## [1.1.0] - 2025-12-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -390,7 +467,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- CORS middleware
|
- CORS middleware
|
||||||
- Exception handlers (OpenAI-compatible error format)
|
- Exception handlers (OpenAI-compatible error format)
|
||||||
|
|
||||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.1.0...main
|
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.2.0...main
|
||||||
|
[1.2.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.0.0a...v1.1.0
|
[1.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.0.0a...v1.1.0
|
||||||
[1.0.0a]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.5...v1.0.0a
|
[1.0.0a]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.5...v1.0.0a
|
||||||
[0.2.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.0...v0.2.5
|
[0.2.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v0.2.0...v0.2.5
|
||||||
|
|||||||
+124
-87
@@ -4,35 +4,37 @@
|
|||||||
|
|
||||||
This document outlines the phased implementation plan to transform the current OpenAI-compatible API into the full Tatlock household butler system.
|
This document outlines the phased implementation plan to transform the current OpenAI-compatible API into the full Tatlock household butler system.
|
||||||
|
|
||||||
## Current State (v0.1.1+ - Phase 1 Mostly Complete)
|
## Current State (v1.2.0 - Phase F Complete)
|
||||||
|
|
||||||
**What we have**:
|
**What we have**:
|
||||||
- ✅ **The Orchestrator** - FastAPI infrastructure layer
|
- ✅ **The Orchestrator** - FastAPI infrastructure layer
|
||||||
- OpenAI-compatible API endpoints (Responses API + Chat Completions)
|
- OpenAI-compatible API endpoints (Responses API + Chat Completions)
|
||||||
- Streaming coordination and conversation management
|
- Streaming coordination and conversation management
|
||||||
- Response format with reasoning support
|
- Response format with reasoning support
|
||||||
- Test infrastructure (131 tests, 81.78% coverage)
|
- Test infrastructure (~400 tests)
|
||||||
- ✅ **Tatlock Agent** - Real PydanticAI integration
|
- ✅ **Two-Tier Architecture**
|
||||||
- Connected to Ollama (mistral-nemo:latest)
|
- The Steward analyzes requests and recommends capabilities
|
||||||
- British butler personality with research mindset
|
- Tatlock coordinates execution with scoped tools
|
||||||
- Streaming responses with reasoning
|
- Real-time streaming of analysis and reasoning
|
||||||
- Tool calling framework functional
|
- ✅ **Household Staff**
|
||||||
- ✅ **Permanent Tools**
|
- **Tatlock** (Butler): Primary interface with witty personality
|
||||||
- Calculator (safe mathematical expressions)
|
- **The Steward**: Request analysis and capability recommendation
|
||||||
- Date/Time toolkit (current time, relative dates, time differences)
|
- **The Librarian**: Research via library-desk HybridRAG + wiki
|
||||||
- Web search (SearXNG integration)
|
- **The Biographer**: User memory, profiles, preferences, semantic recall
|
||||||
|
- ✅ **Core Tools**
|
||||||
|
- Calculator, Date/Time toolkit, Web search (SearXNG)
|
||||||
|
- ✅ **Memory System**
|
||||||
|
- Direct access layer (memory_service) for fast lookups
|
||||||
|
- Vector storage (Qdrant) for semantic recall
|
||||||
|
- Session cache (Redis) with 24h TTL
|
||||||
|
- Multi-tenancy via ContextVar
|
||||||
- ✅ Mock agent (lorem-tester for testing)
|
- ✅ Mock agent (lorem-tester for testing)
|
||||||
- ✅ Agent interface abstraction
|
|
||||||
|
|
||||||
**What we need**:
|
**What we need**:
|
||||||
- **The Household** - Full multi-agent coordination:
|
- More household staff (Developer, Secretary, Handyman, Housekeeper)
|
||||||
- The Steward (first-tier request analysis)
|
|
||||||
- Tatlock coordination layer (expert agent delegation)
|
|
||||||
- Expert household staff agents (Librarian, Developer, Handyman, etc.)
|
|
||||||
- Multi-tenant database architecture
|
|
||||||
- Containerized service ecosystem
|
|
||||||
- MCP (Model Context Protocol) integration
|
- MCP (Model Context Protocol) integration
|
||||||
- Dynamic model switching for specialized tasks
|
- Dynamic model switching for specialized tasks
|
||||||
|
- Full multi-tenant database (PostgreSQL)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -359,15 +361,18 @@ User Request → Orchestrator → Steward Analysis → Recommendations → Tatlo
|
|||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
|
|
||||||
- [ ] **Steward analyzes incoming requests** using PydanticAI agent
|
- [x] **Steward analyzes incoming requests** using PydanticAI agent
|
||||||
- [ ] **Produces structured recommendations** (tools, agents, reasoning)
|
- [x] **Produces structured recommendations** (tools, agents, reasoning)
|
||||||
- [ ] **Recommendations formatted as prepended note** to Tatlock
|
- [x] **Recommendations formatted as prepended note** to Tatlock
|
||||||
- [ ] **Tool registry is queryable and extensible** via clean API
|
- [x] **Tool registry is queryable and extensible** via clean API
|
||||||
- [ ] **Steward output visible in reasoning stream** for transparency
|
- [x] **Steward output visible in reasoning stream** for transparency
|
||||||
- [ ] **Only recommended tools available** to Tatlock (scoped context)
|
- [x] **Only recommended tools available** to Tatlock (scoped context)
|
||||||
- [ ] **Base model stays loaded** between Steward and Tatlock calls
|
- [x] **Base model stays loaded** between Steward and Tatlock calls
|
||||||
- [ ] **Recommendations are accurate** (not over/under-inclusive)
|
- [x] **Recommendations are accurate** (not over/under-inclusive)
|
||||||
- [ ] **Integration tests pass** for full Steward → Tatlock flow
|
- [x] **Integration tests pass** for full Steward → Tatlock flow
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** (v0.2.5)
|
||||||
|
|
||||||
### Performance Targets
|
### Performance Targets
|
||||||
|
|
||||||
@@ -435,12 +440,15 @@ The Steward is the foundation of the household architecture. Without it, we'd ne
|
|||||||
- Wait time transparency
|
- Wait time transparency
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
- [ ] Tatlock receives enriched requests (user + Steward notes)
|
- [x] Tatlock receives enriched requests (user + Steward notes)
|
||||||
- [ ] Only recommended tools are available
|
- [x] Only recommended tools are available
|
||||||
- [ ] Tatlock coordinates multiple tool calls
|
- [x] Tatlock coordinates multiple tool calls
|
||||||
- [ ] All actions streamed to reasoning output
|
- [x] All actions streamed to reasoning output
|
||||||
- [ ] Responses have consistent personality
|
- [x] Responses have consistent personality
|
||||||
- [ ] Synthesizes multi-source results coherently
|
- [x] Synthesizes multi-source results coherently
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** (v1.1.0)
|
||||||
|
|
||||||
### Estimated Effort
|
### Estimated Effort
|
||||||
**4-5 weeks** - Complex coordination logic
|
**4-5 weeks** - Complex coordination logic
|
||||||
@@ -453,39 +461,44 @@ The Steward is the foundation of the household architecture. Without it, we'd ne
|
|||||||
|
|
||||||
### Priority Expert Agents
|
### Priority Expert Agents
|
||||||
|
|
||||||
1. **The Librarian** (Research & Knowledge Management) ⭐ **Priority**
|
1. **The Librarian** (Research & Knowledge Management) ✅ **COMPLETE** (v1.1.0)
|
||||||
- Research assistance and synthesis
|
- Research assistance via library-desk HybridRAG
|
||||||
- Automatic research dossier generation
|
- Wiki page management (search, create, update)
|
||||||
- Knowledge base queries and organization
|
- Semantic vector search
|
||||||
- Reference management
|
- Knowledge graph queries
|
||||||
- Wiki integration (future: dedicated wiki container)
|
- Dossier browsing
|
||||||
- Mind map maintenance (future)
|
|
||||||
- *Rationale: Helps guide development priorities through better research*
|
|
||||||
|
|
||||||
2. **The Developer** (Software Development)
|
2. **The Biographer** (User Memory) ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- User profile management (name, location, timezone)
|
||||||
|
- Preference storage (units, theme)
|
||||||
|
- Semantic memory recall ("What car do I drive?")
|
||||||
|
- Fact storage from conversations
|
||||||
|
- Session context caching
|
||||||
|
|
||||||
|
3. **The Developer** (Software Development) 🔜 **Planned**
|
||||||
- Code generation assistance
|
- Code generation assistance
|
||||||
- Debugging support
|
- Debugging support
|
||||||
- Documentation generation
|
- Documentation generation
|
||||||
- Architecture guidance
|
- Architecture guidance
|
||||||
- *Rationale: Directly supports building the system itself*
|
- *Rationale: Directly supports building the system itself*
|
||||||
|
|
||||||
3. **The Handyman** (System Maintenance)
|
4. **The Handyman** (System Maintenance) 🔜 **Planned**
|
||||||
- System status queries
|
- System status queries
|
||||||
- Log analysis
|
- Log analysis
|
||||||
- Basic troubleshooting
|
- Basic troubleshooting
|
||||||
- Infrastructure monitoring
|
- Infrastructure monitoring
|
||||||
|
|
||||||
4. **The Secretary** (Scheduling & Organization)
|
5. **The Secretary** (Scheduling & Organization) 🔜 **Planned**
|
||||||
- Calendar integration (placeholder)
|
- Calendar integration
|
||||||
- Task management (placeholder)
|
- Task management
|
||||||
- Reminder system
|
- Reminder system
|
||||||
- Schedule conflict detection
|
- Schedule conflict detection
|
||||||
|
|
||||||
5. **The Housekeeper** (Home Automation)
|
6. **The Housekeeper** (Home Automation) 🔜 **Planned**
|
||||||
|
- Home Assistant integration
|
||||||
- Device control interface
|
- Device control interface
|
||||||
- Status queries
|
- Status queries
|
||||||
- Automation triggers
|
- Automation triggers
|
||||||
- Environmental monitoring
|
|
||||||
|
|
||||||
### Each Agent Includes
|
### Each Agent Includes
|
||||||
- Specialized prompt and personality
|
- Specialized prompt and personality
|
||||||
@@ -494,12 +507,15 @@ The Steward is the foundation of the household architecture. Without it, we'd ne
|
|||||||
- Integration with Butler orchestration
|
- Integration with Butler orchestration
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
- [ ] Each agent implemented as separate module
|
- [x] Each agent implemented as separate module
|
||||||
- [ ] Agents callable via tool framework
|
- [x] Agents callable via tool framework
|
||||||
- [ ] Agents use specialized prompts
|
- [x] Agents use specialized prompts
|
||||||
- [ ] Results integrate cleanly with Butler
|
- [x] Results integrate cleanly with Butler
|
||||||
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**🔶 PARTIAL** - Librarian and Biographer complete, others planned
|
||||||
|
|
||||||
### Estimated Effort
|
### Estimated Effort
|
||||||
**6-8 weeks** - Parallel development possible
|
**6-8 weeks** - Parallel development possible
|
||||||
|
|
||||||
@@ -556,31 +572,37 @@ The core orchestration (Steward → Butler → Experts) can work entirely with i
|
|||||||
|
|
||||||
### Services to Integrate
|
### Services to Integrate
|
||||||
|
|
||||||
1. **Redis (Memory & Caching)**
|
1. **Redis (Memory & Caching)** ✅ **COMPLETE** (v1.2.0)
|
||||||
- Docker compose setup
|
- Benchmark storage (db=1)
|
||||||
- Conversation cache
|
- Memory cache for sessions (db=2)
|
||||||
- Short-term memory
|
- 24h TTL for session context
|
||||||
- Session management
|
- Recent entities tracking
|
||||||
|
|
||||||
3. **Qdrant (Vector Storage)**
|
2. **Qdrant (Vector Storage)** ✅ **COMPLETE** (v1.2.0)
|
||||||
- Docker compose setup
|
- Per-user memory collections
|
||||||
- Long-term memory embeddings
|
- 768-dim nomic-embed-text vectors
|
||||||
- Semantic search
|
- Semantic search for recall
|
||||||
- Conversation history vectors
|
- Type-based filtering
|
||||||
|
|
||||||
4. **SearxNG (Web Search)**
|
3. **SearxNG (Web Search)** ✅ **COMPLETE** (v0.2.0)
|
||||||
- Docker compose setup
|
|
||||||
- Search tool integration
|
- Search tool integration
|
||||||
- Result processing
|
- Result processing
|
||||||
- Privacy-preserving queries
|
- Privacy-preserving queries
|
||||||
|
|
||||||
|
4. **library-desk (Research API)** ✅ **COMPLETE** (v1.1.0)
|
||||||
|
- HybridRAG search
|
||||||
|
- Wiki management
|
||||||
|
- Knowledge graph queries
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
- [ ] All services defined in docker-compose.yml
|
- [x] Services communicate correctly
|
||||||
- [ ] Services communicate correctly
|
- [x] Tatlock can invoke web search
|
||||||
- [ ] Tatlock can invoke web search
|
- [x] Redis used for session data
|
||||||
- [ ] Redis used for session data
|
- [x] Qdrant stores user memories
|
||||||
- [ ] Qdrant stores conversation embeddings
|
- [x] Ollama serves the base model
|
||||||
- [ ] Ollama serves the base model
|
|
||||||
|
### Status
|
||||||
|
**✅ COMPLETE** - All core services integrated
|
||||||
|
|
||||||
### Estimated Effort
|
### Estimated Effort
|
||||||
**3-4 weeks** - Infrastructure setup
|
**3-4 weeks** - Infrastructure setup
|
||||||
@@ -629,33 +651,48 @@ The core orchestration (Steward → Butler → Experts) can work entirely with i
|
|||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
1. **Long-Term Memory**
|
1. **Long-Term Memory** ✅ **COMPLETE** (v1.2.0 - Phase F)
|
||||||
- Conversation embedding pipeline
|
- Memory service for direct key-based access
|
||||||
- Semantic search over history
|
- Qdrant vector storage for semantic recall
|
||||||
- Memory consolidation
|
- Embedding via nomic-embed-text
|
||||||
- Relevance ranking
|
- The Biographer agent for memory management
|
||||||
|
|
||||||
2. **Context Management**
|
2. **Session Memory** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Redis session cache with 24h TTL
|
||||||
|
- Recent entities tracking
|
||||||
|
- Conversation context preservation
|
||||||
|
- Multi-tenancy via ContextVar
|
||||||
|
|
||||||
|
3. **Steward Integration** ✅ **COMPLETE** (v1.2.0)
|
||||||
|
- Memory pre-fetch during request analysis
|
||||||
|
- Profile/preferences included in context
|
||||||
|
- Keyword-based context determination
|
||||||
|
|
||||||
|
4. **Context Management** 🔜 **Future**
|
||||||
- Smart context window trimming
|
- Smart context window trimming
|
||||||
- Conversation branching
|
- Conversation branching
|
||||||
- Topic tracking
|
- Topic tracking
|
||||||
- Memory retrieval integration
|
- Memory retrieval integration
|
||||||
|
|
||||||
3. **Personalization**
|
5. **Personalization** 🔜 **Future**
|
||||||
- User preference learning
|
- User preference learning
|
||||||
- Interaction pattern analysis
|
- Interaction pattern analysis
|
||||||
- Adaptive responses
|
- Adaptive responses
|
||||||
- Custom agent personalities per user
|
- Custom agent personalities per user
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
|
- [x] User facts stored in Qdrant with semantic search
|
||||||
|
- [x] Profile and preferences accessible via memory_service
|
||||||
|
- [x] Session context cached in Redis
|
||||||
|
- [x] User preferences affect responses (via Steward pre-fetch)
|
||||||
- [ ] Conversations automatically embedded to Qdrant
|
- [ ] Conversations automatically embedded to Qdrant
|
||||||
- [ ] Relevant history retrieved for new requests
|
- [ ] Memory improves over time (learning from interactions)
|
||||||
- [ ] Context stays within model limits
|
|
||||||
- [ ] User preferences affect responses
|
### Status
|
||||||
- [ ] Memory improves over time
|
**🔶 PARTIAL** - Core memory system complete, advanced features planned
|
||||||
|
|
||||||
### Estimated Effort
|
### Estimated Effort
|
||||||
**4-5 weeks** - AI/ML heavy
|
**4-5 weeks** - AI/ML heavy (remaining work)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -871,13 +908,13 @@ Phase 9 (Extended Staff) → Phase 10 (UX) → Phase 11 (Production)
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Immediate**: Commit model name fix (Tatlock)
|
1. **Priority**: Implement The Developer agent for code assistance
|
||||||
2. **Week 1-2**: Begin Phase 1 (PostgreSQL + multi-tenancy design)
|
2. **Integration**: Add Home Assistant integration for The Housekeeper
|
||||||
3. **Week 3**: Parallel prototype of Steward agent
|
3. **Calendar**: Integrate scheduling service for The Secretary
|
||||||
4. **Ongoing**: Update this roadmap as we learn
|
4. **Ongoing**: Add more household staff as needed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Document Status**: Active planning document
|
**Document Status**: Active planning document
|
||||||
**Created**: 2025-12-06
|
**Created**: 2025-12-06
|
||||||
**Last Updated**: 2025-12-06
|
**Last Updated**: 2025-12-13
|
||||||
|
|||||||
@@ -0,0 +1,679 @@
|
|||||||
|
# Orchestration Scenarios and Tool Flows
|
||||||
|
|
||||||
|
This document outlines example scenarios of varying complexity to illustrate the desired orchestration patterns between Tatlock (Butler/Coordinator), expert agents (The Librarian, etc.), and the user.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
User Request
|
||||||
|
↓
|
||||||
|
[Steward] → Analyzes request, has visibility into ALL capabilities
|
||||||
|
→ Makes routing decision: which experts needed
|
||||||
|
→ Passes simplified instruction to Tatlock (not raw tool schemas)
|
||||||
|
↓
|
||||||
|
[Tatlock/Butler] → Coordinator, receives "use Librarian for wiki creation"
|
||||||
|
→ Calls expert agents as tools
|
||||||
|
→ Synthesizes responses into butler-voice answer
|
||||||
|
↓
|
||||||
|
[Expert Agents] → The Librarian, Home Automation, Memory, etc.
|
||||||
|
→ Each has their own specialized tools
|
||||||
|
→ Return structured results to Tatlock
|
||||||
|
↓
|
||||||
|
[External APIs] → library-desk, home-assistant, user-db, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles**:
|
||||||
|
|
||||||
|
1. **Steward sees everything** - Has access to all capability descriptions to make informed routing decisions
|
||||||
|
2. **Simplified passthrough** - Tatlock receives "delegate to Librarian for research" not 16 tool schemas
|
||||||
|
3. **Expert agents are tools** - Tatlock calls `librarian_agent(task)`, not `hybrid_search()` directly
|
||||||
|
4. **Each expert owns their tools** - Librarian has wiki tools, Home Automation has device tools
|
||||||
|
5. **Results flow up** - Tatlock synthesizes all expert responses into coherent butler answer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1: Weather Check (Multi-Step with Memory Lookup)
|
||||||
|
|
||||||
|
**User**: "What's the weather like?"
|
||||||
|
|
||||||
|
### Complexity Analysis
|
||||||
|
|
||||||
|
This seemingly simple request requires:
|
||||||
|
1. **Location determination** - Where does the user want weather for?
|
||||||
|
2. **Memory/database lookup** - Retrieve user's home location or current location
|
||||||
|
3. **Weather data fetch** - Search for weather at determined location
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: memory (user context), tatlock_core (web search)
|
||||||
|
→ Complexity: moderate
|
||||||
|
→ Note: Location must be determined before weather lookup
|
||||||
|
|
||||||
|
2. Tatlock Execution - Step 1
|
||||||
|
<think>User asked about weather but didn't specify location.
|
||||||
|
Checking user profile for home location...</think>
|
||||||
|
→ Calls: memory_agent(task: "get user home location")
|
||||||
|
→ Memory queries user database
|
||||||
|
→ Returns: "User home location: Amsterdam, Netherlands"
|
||||||
|
|
||||||
|
3. Tatlock Execution - Step 2
|
||||||
|
<think>User is based in Amsterdam. Fetching current weather...</think>
|
||||||
|
→ Calls: search_web("current weather Amsterdam Netherlands")
|
||||||
|
→ Receives: "Amsterdam: 12°C, light rain, humidity 78%"
|
||||||
|
|
||||||
|
4. Response
|
||||||
|
"Currently 12°C with light rain in Amsterdam, sir. You might want
|
||||||
|
to grab an umbrella if you're heading out."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Steward → Tatlock Note**:
|
||||||
|
```
|
||||||
|
Weather query - location not specified.
|
||||||
|
1. First: Query memory for user's location (home or current)
|
||||||
|
2. Then: Search weather for that location
|
||||||
|
Capabilities: memory, tatlock_core
|
||||||
|
Complexity: moderate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tatlock → Memory Agent**:
|
||||||
|
```
|
||||||
|
Task: Retrieve user's location for weather query.
|
||||||
|
Context: User asked about weather without specifying location.
|
||||||
|
Action required: Return user's home location or current known location.
|
||||||
|
|
||||||
|
Reference (user's original request): "What's the weather like?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory Agent → Tatlock Response**:
|
||||||
|
```
|
||||||
|
User location retrieved:
|
||||||
|
- Home location: Amsterdam, Netherlands
|
||||||
|
- Last known location: Amsterdam (home)
|
||||||
|
- Location confidence: high
|
||||||
|
- Source: user profile settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative Flow: Location Ambiguity
|
||||||
|
|
||||||
|
If user has multiple locations or is traveling:
|
||||||
|
|
||||||
|
```
|
||||||
|
Memory Agent → Tatlock Response:
|
||||||
|
User has multiple locations:
|
||||||
|
- Home: Amsterdam, Netherlands
|
||||||
|
- Office: Rotterdam, Netherlands
|
||||||
|
- Currently traveling: Unknown
|
||||||
|
|
||||||
|
Recommendation: Ask user to clarify or use home location as default.
|
||||||
|
```
|
||||||
|
|
||||||
|
Tatlock could then either:
|
||||||
|
- Ask user: "Shall I check the weather in Amsterdam, sir, or elsewhere?"
|
||||||
|
- Default to home: Use Amsterdam and mention the assumption
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2: Adjust Temperature Based on Weather (Conditional Multi-Expert)
|
||||||
|
|
||||||
|
**User**: "Check the weather and if it's cold, turn up the heating"
|
||||||
|
|
||||||
|
### Complexity Analysis
|
||||||
|
|
||||||
|
This requires:
|
||||||
|
1. **Location lookup** - Where to check weather (implicit: user's home)
|
||||||
|
2. **Weather fetch** - Get current outdoor temperature
|
||||||
|
3. **Conditional evaluation** - Is it "cold"? (requires threshold judgment)
|
||||||
|
4. **Home automation** - Adjust heating if condition met
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: memory, tatlock_core, home_automation
|
||||||
|
→ Complexity: moderate
|
||||||
|
→ Note: Conditional logic - heating only if cold
|
||||||
|
→ Sequence: location → weather → evaluate → (maybe) heating
|
||||||
|
|
||||||
|
2. Tatlock Execution - Step 1
|
||||||
|
<think>Need to check weather at user's location first...</think>
|
||||||
|
→ Calls: memory_agent(task: "get user home location")
|
||||||
|
→ Returns: "Amsterdam, Netherlands"
|
||||||
|
|
||||||
|
3. Tatlock Execution - Step 2
|
||||||
|
<think>Fetching weather for Amsterdam...</think>
|
||||||
|
→ Calls: search_web("current weather Amsterdam Netherlands")
|
||||||
|
→ Receives: "Current temperature: 8°C, cloudy, wind 15km/h"
|
||||||
|
|
||||||
|
4. Tatlock Evaluation
|
||||||
|
<think>Temperature is 8°C - that's cold by most standards.
|
||||||
|
User requested heating adjustment if cold. Will proceed...</think>
|
||||||
|
|
||||||
|
5. Tatlock Execution - Step 3
|
||||||
|
<think>Delegating heating adjustment to Home Automation...</think>
|
||||||
|
→ Calls: home_automation_agent(task)
|
||||||
|
→ Home Automation executes: set_thermostat(temperature=21)
|
||||||
|
→ Receives: "Thermostat set to 21°C"
|
||||||
|
|
||||||
|
6. Response
|
||||||
|
"It's rather brisk outside at 8°C, sir. I've taken the liberty of raising
|
||||||
|
the heating to a comfortable 21°C. The house should warm up shortly."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Steward → Tatlock Note**:
|
||||||
|
```
|
||||||
|
Conditional weather-to-heating request.
|
||||||
|
1. Get user location from memory
|
||||||
|
2. Check weather at location
|
||||||
|
3. IF cold (suggest: below 15°C): delegate to home_automation to increase heating
|
||||||
|
4. IF not cold: inform user, no action needed
|
||||||
|
Capabilities: memory, tatlock_core, home_automation
|
||||||
|
Complexity: moderate (conditional)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tatlock → Home Automation Agent**:
|
||||||
|
```
|
||||||
|
Task: Increase heating due to cold weather conditions.
|
||||||
|
Context: Current outdoor temperature is 8°C in Amsterdam. User requested
|
||||||
|
heating adjustment if cold - condition is met.
|
||||||
|
Action required: Set thermostat to comfortable temperature (recommend 21°C).
|
||||||
|
|
||||||
|
Reference (user's original request): "Check the weather and if it's cold, turn up the heating"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Home Automation → Tatlock Response**:
|
||||||
|
```
|
||||||
|
Action completed: Thermostat adjusted
|
||||||
|
- Previous setting: 18°C
|
||||||
|
- New setting: 21°C
|
||||||
|
- Zone: All zones (whole house)
|
||||||
|
- Estimated time to reach target: 15 minutes
|
||||||
|
- Note: Eco mode disabled temporarily for faster heating
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative Flow: Not Cold
|
||||||
|
|
||||||
|
```
|
||||||
|
Weather returns: "Current temperature: 18°C, partly sunny"
|
||||||
|
|
||||||
|
Tatlock Evaluation:
|
||||||
|
<think>18°C isn't cold - no heating adjustment needed.
|
||||||
|
Will inform user that conditions don't warrant heating...</think>
|
||||||
|
|
||||||
|
Response:
|
||||||
|
"It's a pleasant 18°C outside, sir - not particularly cold.
|
||||||
|
I don't believe the heating requires adjustment, but do let
|
||||||
|
me know if you'd like it warmer regardless."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3: Adjust Lights Based on Time (Conditional Logic)
|
||||||
|
|
||||||
|
**User**: "Set the lights appropriately for the time of day"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: tatlock_core (datetime), home_automation
|
||||||
|
→ Complexity: moderate
|
||||||
|
|
||||||
|
2. Tatlock Execution - Step 1
|
||||||
|
<think>Checking current time to determine appropriate lighting...</think>
|
||||||
|
→ Calls: get_current_datetime()
|
||||||
|
→ Receives: "2024-12-11 21:45"
|
||||||
|
|
||||||
|
3. Tatlock Evaluation
|
||||||
|
<think>It's evening (21:45). Will set warm, dimmed lighting for wind-down time...</think>
|
||||||
|
|
||||||
|
4. Tatlock Execution - Step 2
|
||||||
|
→ Calls: home_automation_agent(task)
|
||||||
|
→ Home Automation executes: set_scene("evening_relaxation")
|
||||||
|
→ Receives: "Scene 'evening_relaxation' activated - warm white 40%"
|
||||||
|
|
||||||
|
5. Response
|
||||||
|
"It's quarter to ten in the evening, sir. I've set the lights to a warm,
|
||||||
|
subdued glow - ideal for winding down. Shall I also draw the curtains?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Home Automation Agent**:
|
||||||
|
```
|
||||||
|
Task: Set lighting appropriate for current time of day.
|
||||||
|
Context: Current time is 21:45 (evening). User wants lights adjusted automatically.
|
||||||
|
Action required: Activate appropriate lighting scene for evening/night.
|
||||||
|
|
||||||
|
Reference (user's original request): "Set the lights appropriately for the time of day"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4: Create a Reminder (Simple Expert Delegation)
|
||||||
|
|
||||||
|
**User**: "Remind me to call the dentist tomorrow at 2pm"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: personal_assistant (reminders)
|
||||||
|
→ Complexity: simple
|
||||||
|
|
||||||
|
2. Tatlock Execution
|
||||||
|
<think>Creating reminder for tomorrow afternoon...</think>
|
||||||
|
→ Calls: personal_assistant_agent(task)
|
||||||
|
→ PA executes: create_reminder(title, datetime, notification_time)
|
||||||
|
→ Receives: "Reminder created: ID 847"
|
||||||
|
|
||||||
|
3. Response
|
||||||
|
"Very good, sir. I've set a reminder to call the dentist for tomorrow at
|
||||||
|
2 o'clock in the afternoon. I'll notify you 15 minutes beforehand."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Personal Assistant Agent**:
|
||||||
|
```
|
||||||
|
Task: Create a reminder for the user.
|
||||||
|
Details:
|
||||||
|
- Subject: Call the dentist
|
||||||
|
- When: Tomorrow at 14:00
|
||||||
|
- Notification: 15 minutes before (default)
|
||||||
|
|
||||||
|
Reference (user's original request): "Remind me to call the dentist tomorrow at 2pm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 5: Schedule Recurring Automation (Complex, Multi-Expert)
|
||||||
|
|
||||||
|
**User**: "Schedule the lights to turn on at 09:00 every weekend day"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: home_automation (scheduling)
|
||||||
|
→ Complexity: moderate
|
||||||
|
→ Note: Recurring schedule, weekend-only
|
||||||
|
|
||||||
|
2. Tatlock Execution
|
||||||
|
<think>Setting up recurring weekend morning lighting schedule...</think>
|
||||||
|
→ Calls: home_automation_agent(task)
|
||||||
|
→ HA executes: create_schedule(trigger, days, action)
|
||||||
|
→ Receives: "Schedule created: ID wake_light_weekend"
|
||||||
|
|
||||||
|
3. Response
|
||||||
|
"The weekend wake-up lighting is now scheduled, sir. Every Saturday and
|
||||||
|
Sunday at 9 o'clock sharp, the lights will illuminate. Would you prefer
|
||||||
|
a gradual sunrise simulation, or an immediate full brightness?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Home Automation Agent**:
|
||||||
|
```
|
||||||
|
Task: Create a recurring lighting schedule.
|
||||||
|
Details:
|
||||||
|
- Action: Turn on lights
|
||||||
|
- Time: 09:00
|
||||||
|
- Days: Saturday, Sunday (weekends only)
|
||||||
|
- Recurrence: Weekly
|
||||||
|
|
||||||
|
Reference (user's original request): "Schedule the lights to turn on at 09:00 every weekend day"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 6: Create Wiki Page About Topic (Expert with Research)
|
||||||
|
|
||||||
|
**User**: "Create a wiki page about CI/CD"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: librarian
|
||||||
|
→ Complexity: complex
|
||||||
|
→ Specific action: smart_create (research-backed page creation)
|
||||||
|
|
||||||
|
2. Tatlock Execution
|
||||||
|
<think>Delegating to The Librarian for research and wiki page creation...</think>
|
||||||
|
→ Calls: librarian_agent(task)
|
||||||
|
|
||||||
|
3. Librarian Internal Flow
|
||||||
|
→ hybrid_search("CI/CD continuous integration deployment")
|
||||||
|
→ Synthesizes content from wiki, graph, and web sources
|
||||||
|
→ create_wiki_page(title, path, content, tags)
|
||||||
|
→ Returns summary to Tatlock
|
||||||
|
|
||||||
|
4. Tatlock Receives
|
||||||
|
"Page created: CI/CD
|
||||||
|
Path: /technology/cicd
|
||||||
|
Sources used: 12 (3 wiki, 4 graph, 5 web)
|
||||||
|
Forward links: 8, Backward links: 3"
|
||||||
|
|
||||||
|
5. Response
|
||||||
|
"The wiki page on CI/CD has been created, sir. The Librarian has compiled
|
||||||
|
information from 12 sources, including our existing documentation on DevOps
|
||||||
|
practices. The page is now available at /technology/cicd and has been
|
||||||
|
cross-linked with related topics like Jenkins, GitHub Actions, and Docker."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Librarian Agent**:
|
||||||
|
```
|
||||||
|
Task: Create a comprehensive wiki page about CI/CD.
|
||||||
|
Context: User wants a new wiki page created. Use research capabilities to
|
||||||
|
gather information and create a well-sourced page.
|
||||||
|
|
||||||
|
Action required:
|
||||||
|
1. Research the topic using hybrid search (wiki, knowledge graph, web)
|
||||||
|
2. Create wiki page with synthesized content
|
||||||
|
3. Apply appropriate tags (suggest: technology, devops, automation)
|
||||||
|
4. Report back with page details and research summary
|
||||||
|
|
||||||
|
Reference (user's original request): "Create a wiki page about CI/CD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Librarian → Tatlock Response**:
|
||||||
|
```
|
||||||
|
Task completed: Wiki page created
|
||||||
|
|
||||||
|
Page Details:
|
||||||
|
- Title: CI/CD (Continuous Integration/Continuous Deployment)
|
||||||
|
- Path: /technology/cicd
|
||||||
|
- Tags: technology, devops, automation, software-development
|
||||||
|
|
||||||
|
Research Summary:
|
||||||
|
- Wiki sources: 3 pages (DevOps Overview, Jenkins Setup, GitHub Actions Guide)
|
||||||
|
- Knowledge graph entities: 4 (linked to Docker, Kubernetes, Git, Testing)
|
||||||
|
- Web sources: 5 articles (current best practices, 2024 trends)
|
||||||
|
- Total sources synthesized: 12
|
||||||
|
|
||||||
|
Knowledge Graph Updates:
|
||||||
|
- Forward links created: 8 (to existing related pages)
|
||||||
|
- Backward links created: 3 (updated existing pages to reference new page)
|
||||||
|
|
||||||
|
Content includes sections on:
|
||||||
|
- Definition and core concepts
|
||||||
|
- CI vs CD distinction
|
||||||
|
- Popular tools comparison
|
||||||
|
- Best practices
|
||||||
|
- Integration with our documented infrastructure
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 7: Update Stock Wiki with Evaluation Tracker (Complex, Multi-Tool Expert)
|
||||||
|
|
||||||
|
**User**: "Update the GOOG stock wiki page with a rolling evaluation tracker"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: librarian
|
||||||
|
→ Complexity: complex
|
||||||
|
→ Specific action: search existing page, fetch current data, update page
|
||||||
|
|
||||||
|
2. Tatlock Execution
|
||||||
|
<think>This requires finding the existing page, fetching current stock data,
|
||||||
|
and updating with a new evaluation section. Delegating to The Librarian...</think>
|
||||||
|
→ Calls: librarian_agent(task)
|
||||||
|
|
||||||
|
3. Librarian Internal Flow - Step 1
|
||||||
|
<think>First, locating the existing GOOG wiki page...</think>
|
||||||
|
→ search_wiki("GOOG stock Alphabet")
|
||||||
|
→ Receives: Page ID 234, path /investments/stocks/goog
|
||||||
|
|
||||||
|
4. Librarian Internal Flow - Step 2
|
||||||
|
<think>Fetching current page content to preserve existing information...</think>
|
||||||
|
→ get_wiki_page(234)
|
||||||
|
→ Receives: Current page content
|
||||||
|
|
||||||
|
5. Librarian Internal Flow - Step 3
|
||||||
|
<think>Researching current stock data and evaluation metrics...</think>
|
||||||
|
→ hybrid_search("GOOG Alphabet stock price evaluation 2024")
|
||||||
|
→ Receives: Current price, P/E ratio, analyst ratings, etc.
|
||||||
|
|
||||||
|
6. Librarian Internal Flow - Step 4
|
||||||
|
<think>Updating page with new rolling evaluation section...</think>
|
||||||
|
→ update_wiki_page(234, content=updated_content)
|
||||||
|
→ Receives: Update confirmation
|
||||||
|
|
||||||
|
7. Tatlock Receives
|
||||||
|
"Page updated: GOOG Stock Analysis
|
||||||
|
Added section: Rolling Evaluation Tracker
|
||||||
|
Data points added: Price history, P/E trend, Analyst consensus
|
||||||
|
Last updated: 2024-12-11"
|
||||||
|
|
||||||
|
8. Response
|
||||||
|
"The GOOG stock page has been updated, sir. I've added a rolling evaluation
|
||||||
|
tracker with current metrics: the stock is trading at $178.32 with a P/E
|
||||||
|
of 24.8, and analyst consensus remains 'Buy'. The tracker includes a
|
||||||
|
90-day price trend and quarterly earnings history. Shall I set up
|
||||||
|
automatic weekly updates?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Librarian Agent**:
|
||||||
|
```
|
||||||
|
Task: Update the GOOG (Alphabet) stock wiki page with a rolling evaluation tracker.
|
||||||
|
Context: User wants to add ongoing stock evaluation tracking to an existing page.
|
||||||
|
|
||||||
|
Actions required:
|
||||||
|
1. Find the existing GOOG stock wiki page
|
||||||
|
2. Read current page content (preserve existing information)
|
||||||
|
3. Research current stock data and evaluation metrics
|
||||||
|
4. Update the page with a new "Rolling Evaluation Tracker" section including:
|
||||||
|
- Current price and change
|
||||||
|
- Key ratios (P/E, P/B, etc.)
|
||||||
|
- Analyst consensus
|
||||||
|
- Price trend (30/60/90 day)
|
||||||
|
- Recent earnings summary
|
||||||
|
5. Report back with update summary
|
||||||
|
|
||||||
|
Reference (user's original request): "Update the GOOG stock wiki page with a rolling evaluation tracker"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Librarian → Tatlock Response**:
|
||||||
|
```
|
||||||
|
Task completed: Wiki page updated
|
||||||
|
|
||||||
|
Page Details:
|
||||||
|
- Title: GOOG - Alphabet Inc. Stock Analysis
|
||||||
|
- Path: /investments/stocks/goog
|
||||||
|
- Page ID: 234
|
||||||
|
|
||||||
|
Update Summary:
|
||||||
|
- New section added: "Rolling Evaluation Tracker"
|
||||||
|
- Existing content: Preserved (company overview, investment thesis)
|
||||||
|
|
||||||
|
Evaluation Data Added:
|
||||||
|
- Current Price: $178.32 (+1.2% today)
|
||||||
|
- P/E Ratio: 24.8 (vs sector avg 28.3)
|
||||||
|
- P/B Ratio: 6.2
|
||||||
|
- 52-Week Range: $120.21 - $191.75
|
||||||
|
- Analyst Consensus: Buy (24 buy, 8 hold, 2 sell)
|
||||||
|
- Price Trend: +15.3% (90-day)
|
||||||
|
- Last Earnings: Q3 2024 - Beat estimates by 8%
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- Web: 4 financial data sources
|
||||||
|
- Existing wiki: Investment strategy context
|
||||||
|
|
||||||
|
Suggested follow-up: Set up automated weekly data refresh?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 8: Complex Multi-Expert Coordination
|
||||||
|
|
||||||
|
**User**: "Check if there's anything about the new project deadline in my notes,
|
||||||
|
if so create a reminder, and if the deadline is this week, also update the
|
||||||
|
project wiki page with a status note"
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Steward Analysis
|
||||||
|
→ Capabilities needed: librarian, personal_assistant
|
||||||
|
→ Complexity: complex
|
||||||
|
→ Note: Conditional multi-step, requires information passing between experts
|
||||||
|
|
||||||
|
2. Tatlock Execution - Step 1
|
||||||
|
<think>First, searching for project deadline information in the wiki...</think>
|
||||||
|
→ Calls: librarian_agent(search_task)
|
||||||
|
→ Librarian: hybrid_search("new project deadline")
|
||||||
|
→ Returns: "Project Alpha deadline: December 15, 2024 (this Friday)"
|
||||||
|
|
||||||
|
3. Tatlock Evaluation
|
||||||
|
<think>Found deadline: December 15. That's this week (Friday).
|
||||||
|
Need to: 1) Create reminder, 2) Update project wiki page...</think>
|
||||||
|
|
||||||
|
4. Tatlock Execution - Step 2 (parallel if possible)
|
||||||
|
<think>Creating reminder and updating wiki status...</think>
|
||||||
|
|
||||||
|
→ Calls: personal_assistant_agent(reminder_task)
|
||||||
|
→ PA: create_reminder("Project Alpha deadline", "2024-12-15 09:00")
|
||||||
|
→ Returns: "Reminder created for Dec 15 at 9am"
|
||||||
|
|
||||||
|
→ Calls: librarian_agent(update_task)
|
||||||
|
→ Librarian: search_wiki → get_wiki_page → update_wiki_page
|
||||||
|
→ Returns: "Project Alpha page updated with deadline status note"
|
||||||
|
|
||||||
|
5. Response
|
||||||
|
"I've found the deadline in your notes, sir - Project Alpha is due this
|
||||||
|
Friday, December 15th. I've set a reminder for 9 o'clock that morning,
|
||||||
|
and I've updated the project wiki page with a status note indicating
|
||||||
|
the imminent deadline. Is there anything else you need to prepare?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intra-System Prompts
|
||||||
|
|
||||||
|
**Tatlock → Librarian Agent (Search)**:
|
||||||
|
```
|
||||||
|
Task: Search for information about a new project deadline.
|
||||||
|
Context: User wants to find deadline information from their notes/wiki.
|
||||||
|
|
||||||
|
Action required:
|
||||||
|
1. Search wiki and knowledge base for project deadline information
|
||||||
|
2. Return: Project name, deadline date, and any relevant context
|
||||||
|
|
||||||
|
Reference (user's original request): "Check if there's anything about the new project deadline in my notes..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tatlock → Personal Assistant Agent**:
|
||||||
|
```
|
||||||
|
Task: Create a reminder for a project deadline.
|
||||||
|
Details:
|
||||||
|
- Subject: Project Alpha deadline
|
||||||
|
- When: December 15, 2024 at 09:00
|
||||||
|
- Priority: High (deadline is this week)
|
||||||
|
- Notification: Morning of the deadline
|
||||||
|
|
||||||
|
Reference: Creating reminder based on deadline found in user's notes.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tatlock → Librarian Agent (Update)**:
|
||||||
|
```
|
||||||
|
Task: Update the Project Alpha wiki page with a deadline status note.
|
||||||
|
Context: Project deadline is December 15, 2024 (this Friday). User requested
|
||||||
|
a status update since the deadline is this week.
|
||||||
|
|
||||||
|
Action required:
|
||||||
|
1. Find the Project Alpha wiki page
|
||||||
|
2. Add a status note/banner indicating the imminent deadline
|
||||||
|
3. Optionally update any status fields
|
||||||
|
|
||||||
|
Reference: Part of user's request to track and highlight near-term deadlines.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Pattern Guidelines
|
||||||
|
|
||||||
|
### Tatlock's Think Updates (Streaming to User)
|
||||||
|
|
||||||
|
During multi-step operations, Tatlock should emit `<think>` updates to keep the user informed:
|
||||||
|
|
||||||
|
```
|
||||||
|
<think>Analyzing your request...</think>
|
||||||
|
<think>Searching for deadline information in the wiki...</think>
|
||||||
|
<think>Found the deadline - December 15th. Creating reminder...</think>
|
||||||
|
<think>Updating the project page with status note...</think>
|
||||||
|
<think>All tasks complete. Composing response...</think>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tatlock's Final Response Pattern
|
||||||
|
|
||||||
|
1. **Acknowledge** - Confirm understanding of the request
|
||||||
|
2. **Summarize actions** - What was done, by whom (implicitly)
|
||||||
|
3. **Key details** - Important information the user should know
|
||||||
|
4. **Proactive offer** - Suggest related actions or follow-ups
|
||||||
|
5. **Butler voice** - Formal but warm, with personality
|
||||||
|
|
||||||
|
### Expert Agent Response Pattern
|
||||||
|
|
||||||
|
1. **Task status** - Completed/Partial/Failed
|
||||||
|
2. **Action summary** - What was done
|
||||||
|
3. **Key data** - Information Tatlock needs to synthesize
|
||||||
|
4. **Metadata** - IDs, counts, timestamps for reference
|
||||||
|
5. **Suggestions** - Optional follow-up actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling Scenarios
|
||||||
|
|
||||||
|
### Expert Agent Failure
|
||||||
|
|
||||||
|
```
|
||||||
|
Tatlock → Librarian: "Create wiki page about quantum computing"
|
||||||
|
Librarian → Tatlock: "Error: library-desk API unavailable (connection timeout)"
|
||||||
|
|
||||||
|
Tatlock Response:
|
||||||
|
"I'm afraid The Librarian is having some difficulty reaching the wiki
|
||||||
|
service at the moment, sir. I can attempt a basic web search on quantum
|
||||||
|
computing if you'd like, or we can try the wiki operation again in a
|
||||||
|
few minutes."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Completion
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "Create a reminder and add it to my calendar"
|
||||||
|
|
||||||
|
Tatlock → PA: Create reminder
|
||||||
|
PA → Tatlock: "Reminder created successfully"
|
||||||
|
|
||||||
|
Tatlock → Calendar: Add to calendar
|
||||||
|
Calendar → Tatlock: "Error: Calendar sync not configured"
|
||||||
|
|
||||||
|
Tatlock Response:
|
||||||
|
"I've created the reminder, sir, but I wasn't able to add it to your
|
||||||
|
calendar - it appears the calendar integration needs to be configured.
|
||||||
|
The reminder will still alert you at the scheduled time. Shall I help
|
||||||
|
set up the calendar connection?"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: Key Design Principles
|
||||||
|
|
||||||
|
1. **Tatlock is the orchestrator** - Never exposes raw tool complexity to users
|
||||||
|
2. **Expert agents are tools** - Tatlock calls them, they return structured responses
|
||||||
|
3. **Context flows down** - Each expert gets only what they need to complete their task
|
||||||
|
4. **Results flow up** - Tatlock synthesizes all responses into coherent butler-voice answer
|
||||||
|
5. **Think updates maintain engagement** - User sees progress during complex operations
|
||||||
|
6. **Errors are handled gracefully** - Tatlock explains and offers alternatives
|
||||||
|
7. **Proactive suggestions** - Tatlock anticipates follow-up needs
|
||||||
@@ -1,535 +0,0 @@
|
|||||||
# Phase 2 Completion Summary: The Steward
|
|
||||||
|
|
||||||
**Status**: ✅ COMPLETE
|
|
||||||
**Completed**: 2025-12-07
|
|
||||||
**Duration**: 1 day (accelerated from 7-week plan)
|
|
||||||
**Test Coverage**: 223 passing tests (99.5% pass rate)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Phase 2 successfully implements **The Steward** - a first-tier LLM agent that creates a two-tier architecture for intelligent request routing. The Steward analyzes incoming requests, identifies relevant household capabilities, and provides scoped tool recommendations to Tatlock (the Butler).
|
|
||||||
|
|
||||||
This architecture prevents cognitive overload by ensuring Tatlock only sees tools relevant to each specific request, while maintaining full conversation context awareness and providing complete observability through benchmarking and logging.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Delivered Features
|
|
||||||
|
|
||||||
### 1. The Steward Agent ✅
|
|
||||||
**Location**: `src/agents/steward/`
|
|
||||||
|
|
||||||
- **Request Analysis**: Analyzes user requests with full conversation history
|
|
||||||
- **Capability Recommendation**: Recommends relevant household tools/capabilities
|
|
||||||
- **Context Awareness**: Identifies references to previous conversation turns
|
|
||||||
- **Complexity Assessment**: Estimates request complexity (simple/moderate/complex)
|
|
||||||
- **Missing Capability Detection**: Explicitly states when needed tools are unavailable
|
|
||||||
- **VRAM Efficiency**: Uses same Ollama model as Tatlock (mistral-nemo:latest)
|
|
||||||
|
|
||||||
**Key Files**:
|
|
||||||
- `agent.py`: Steward PydanticAI agent implementation
|
|
||||||
- `schemas.py`: `StewardRecommendation` and `ConversationContext` structures
|
|
||||||
- `service.py`: Service layer with logging and benchmarking
|
|
||||||
|
|
||||||
### 2. Household Registry ✅
|
|
||||||
**Location**: `src/core/household_registry.py`
|
|
||||||
|
|
||||||
- **Centralized Capability Management**: Single source of truth for household tools
|
|
||||||
- **Executive Summaries**: High-level capability descriptions for Steward/Butler coordination
|
|
||||||
- **PydanticAI Toolsets**: Native toolset composition and scoping
|
|
||||||
- **Domain Organization**: Tools organized by household member (e.g., `tatlock_core`)
|
|
||||||
- **Dynamic Tool Scoping**: Creates combined toolsets based on recommendations
|
|
||||||
|
|
||||||
**Architecture**:
|
|
||||||
```
|
|
||||||
HouseholdRegistry
|
|
||||||
├─ HouseholdMember (tatlock_core)
|
|
||||||
│ ├─ HouseholdCapability (summary)
|
|
||||||
│ └─ FunctionToolset (calculator, datetime, search)
|
|
||||||
├─ Future: HouseholdMember (librarian)
|
|
||||||
└─ Future: HouseholdMember (developer)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Request Preprocessing Pipeline ✅
|
|
||||||
**Location**: `src/core/preprocessing.py`
|
|
||||||
|
|
||||||
**4-Phase Flow**:
|
|
||||||
1. **Steward Analysis**: Analyzes request with full conversation history
|
|
||||||
2. **Tool Scoping**: Creates combined toolset from recommendations
|
|
||||||
3. **Note Formatting**: Prepares Steward note for Butler (invisible to user)
|
|
||||||
4. **Enrichment**: Returns `EnrichedRequest` with all context
|
|
||||||
|
|
||||||
**Integration**: Fully integrated with Responses API via `create_response_with_steward()`
|
|
||||||
|
|
||||||
### 4. Tool Usage Tracking ✅
|
|
||||||
**Location**: `src/core/tool_tracking.py`
|
|
||||||
|
|
||||||
**Capabilities**:
|
|
||||||
- Tracks recommended vs. actual tool usage
|
|
||||||
- Logs unexpected tool calls (not recommended but used)
|
|
||||||
- Logs unused recommendations (recommended but not used)
|
|
||||||
- Records timing data for each tool call
|
|
||||||
- Stores benchmarks to Redis for analysis
|
|
||||||
|
|
||||||
**Metrics Supported**:
|
|
||||||
- Precision: Recommended and used / All recommendations
|
|
||||||
- Recall: Recommended and used / All tool calls
|
|
||||||
- F1 Score: Harmonic mean of precision and recall
|
|
||||||
|
|
||||||
### 5. Streaming Transparency ✅
|
|
||||||
**Location**: `src/responses/streaming.py`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Streams Steward's analysis first (reasoning summary deltas)
|
|
||||||
- Streams Tatlock's response second (output text deltas)
|
|
||||||
- Full SSE support with proper event types
|
|
||||||
- Conversation context visible in stream
|
|
||||||
- Missing capabilities warnings included
|
|
||||||
|
|
||||||
**Event Sequence**:
|
|
||||||
```
|
|
||||||
1. response.reasoning_summary_text.delta (Steward analysis)
|
|
||||||
2. response.reasoning_summary_text.done
|
|
||||||
3. response.output_text.delta (Tatlock response)
|
|
||||||
4. response.output_text.done
|
|
||||||
5. response.done (final response)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Structured Logging ✅
|
|
||||||
**Location**: `src/core/logging_config.py`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- JSON-formatted structured logging via `structlog`
|
|
||||||
- Operation timing via context managers (`log_operation`)
|
|
||||||
- Metadata enrichment for debugging
|
|
||||||
- Integrated with benchmark recording
|
|
||||||
- Machine-parseable output for analysis
|
|
||||||
|
|
||||||
### 7. Redis Benchmark Storage ✅
|
|
||||||
**Location**: `src/core/benchmarks.py`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Cross-session performance metrics storage
|
|
||||||
- Time-series data with 30-day automatic expiry
|
|
||||||
- Operations tracked: `steward_analysis`, `tool_call`
|
|
||||||
- Queryable by operation type, time range, metadata
|
|
||||||
- Supports accuracy analysis (recommended vs. used)
|
|
||||||
|
|
||||||
**Benchmark Schema**:
|
|
||||||
- Timestamp, operation, duration, success/failure
|
|
||||||
- Steward-specific: recommendation_count, complexity
|
|
||||||
- Tool-specific: tool_name, was_recommended, was_actually_used
|
|
||||||
- Context: conversation_id, metadata dict
|
|
||||||
|
|
||||||
### 8. Benchmark Analysis Tools ✅
|
|
||||||
**Location**: `scripts/benchmark_analysis.py`
|
|
||||||
|
|
||||||
**CLI Features**:
|
|
||||||
```bash
|
|
||||||
# Steward performance over last 24 hours
|
|
||||||
python scripts/benchmark_analysis.py --operation steward_analysis --hours 24
|
|
||||||
|
|
||||||
# Tool recommendation accuracy over last 7 days
|
|
||||||
python scripts/benchmark_analysis.py --tool-accuracy --days 7
|
|
||||||
|
|
||||||
# Summary of all operations
|
|
||||||
python scripts/benchmark_analysis.py --summary --hours 1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Metrics Provided**:
|
|
||||||
- Average Steward latency (target: < 2s)
|
|
||||||
- Success rate percentage
|
|
||||||
- Recommendation count distribution
|
|
||||||
- Complexity distribution
|
|
||||||
- Tool-specific accuracy (precision/recall/F1)
|
|
||||||
- Per-tool usage patterns
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Request Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User Request
|
|
||||||
↓
|
|
||||||
Responses API (FastAPI)
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Preprocessing Pipeline │
|
|
||||||
│ ├─ Steward Agent │
|
|
||||||
│ │ ├─ Receives: Full conversation history │
|
|
||||||
│ │ ├─ Analyzes: Context + requirements │
|
|
||||||
│ │ ├─ Queries: Household registry │
|
|
||||||
│ │ └─ Returns: StewardRecommendation │
|
|
||||||
│ │ │
|
|
||||||
│ ├─ Create Scoped Toolset │
|
|
||||||
│ │ └─ CombinedToolset from capabilities │
|
|
||||||
│ │ │
|
|
||||||
│ └─ Format Steward Note │
|
|
||||||
│ └─ Context summary for Butler │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
Tatlock Agent (Butler)
|
|
||||||
├─ Receives: Enriched request + note
|
|
||||||
├─ Tools: ONLY scoped recommendations
|
|
||||||
├─ Tracking: Tool usage monitored
|
|
||||||
└─ Context: Full conversation history
|
|
||||||
↓
|
|
||||||
Response to User
|
|
||||||
├─ Steward's reasoning (streamed first)
|
|
||||||
└─ Tatlock's response (streamed second)
|
|
||||||
|
|
||||||
Background:
|
|
||||||
└─ Redis: Benchmarks + metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
### Two-Tier Abstraction
|
|
||||||
|
|
||||||
**Tier 1: Executive Summaries (Steward/Butler coordination)**
|
|
||||||
```python
|
|
||||||
HouseholdCapability(
|
|
||||||
name="tatlock_core",
|
|
||||||
role="Butler's Core Tools",
|
|
||||||
category="core",
|
|
||||||
description="Mathematical calculation, date/time operations, web search",
|
|
||||||
domains=["computation", "information", "datetime"],
|
|
||||||
cost="low",
|
|
||||||
requires_network=True
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tier 2: Implementation Details (Tool execution)**
|
|
||||||
```python
|
|
||||||
FunctionToolset containing:
|
|
||||||
- calculate(expression: str) -> str
|
|
||||||
- get_current_datetime(format_str: str) -> str
|
|
||||||
- calculate_time_offset(offset: str) -> str
|
|
||||||
- time_difference(date1: str, date2: str) -> str
|
|
||||||
- search_web(query: str, num_results: int) -> str
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Coverage
|
|
||||||
|
|
||||||
### Test Statistics
|
|
||||||
- **Total Tests**: 223 (219 passing, 1 pre-existing failure unrelated to Phase 2)
|
|
||||||
- **Pass Rate**: 99.5%
|
|
||||||
- **Coverage**: 77.6% overall
|
|
||||||
|
|
||||||
### Test Categories
|
|
||||||
|
|
||||||
#### Unit Tests ✅
|
|
||||||
- **Household Registry** (12 tests): Registration, retrieval, toolset composition
|
|
||||||
- **Steward Schemas** (11 tests): Data structures, formatting
|
|
||||||
- **Steward Service** (9 tests): Request analysis, context detection, capabilities
|
|
||||||
- **Preprocessing** (6 tests via integration): Request enrichment, tool scoping
|
|
||||||
|
|
||||||
#### Integration Tests ✅
|
|
||||||
- **Steward → Tatlock Flow** (6 tests):
|
|
||||||
- Simple math request
|
|
||||||
- Conversation history propagation
|
|
||||||
- No capabilities needed (conversational)
|
|
||||||
- Tool tracker integration
|
|
||||||
- Missing capabilities warning
|
|
||||||
- Conversation ID propagation
|
|
||||||
|
|
||||||
- **Streaming Integration** (4 tests):
|
|
||||||
- Basic streaming with Steward
|
|
||||||
- Conversation history in streaming
|
|
||||||
- Reasoning contains Steward analysis
|
|
||||||
- Missing capabilities in stream
|
|
||||||
|
|
||||||
### Key Test Files
|
|
||||||
- `tests/agents/steward/test_steward_schemas.py`
|
|
||||||
- `tests/agents/steward/test_steward_service.py`
|
|
||||||
- `tests/integration/test_steward_tatlock_integration.py`
|
|
||||||
- `tests/integration/test_steward_streaming.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Achievements
|
|
||||||
|
|
||||||
### 1. PydanticAI Native Patterns ✅
|
|
||||||
- `FunctionToolset` for tool grouping
|
|
||||||
- `CombinedToolset` for dynamic composition
|
|
||||||
- Decorator-based tool registration (`@agent.tool`)
|
|
||||||
- Structured outputs via Pydantic models (`StewardRecommendation`)
|
|
||||||
- Dependency injection for tracking (`RunContext[ToolCallTracker]`)
|
|
||||||
|
|
||||||
### 2. Tool Scoping Enforcement ✅
|
|
||||||
- Compile-time scoping via toolset creation
|
|
||||||
- Tools not even visible to LLM if not recommended
|
|
||||||
- Fresh agent instances with scoped tools only
|
|
||||||
- No runtime permission checks needed
|
|
||||||
|
|
||||||
### 3. Conversation Context Awareness ✅
|
|
||||||
- Steward sees FULL conversation history
|
|
||||||
- Identifies references to previous turns
|
|
||||||
- Provides contextual notes to Butler
|
|
||||||
- Example: "User mentioned Python debugging in turn 3"
|
|
||||||
|
|
||||||
### 4. Plain Text Approach ✅
|
|
||||||
- Steward returns natural language analysis
|
|
||||||
- Service layer parses for structured data
|
|
||||||
- Keyword extraction for capabilities
|
|
||||||
- Pattern matching for complexity and context
|
|
||||||
|
|
||||||
### 5. Observability ✅
|
|
||||||
- Structured logging for all operations
|
|
||||||
- Benchmark recording to Redis
|
|
||||||
- Tool usage tracking (recommended vs. actual)
|
|
||||||
- Cross-session performance analysis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Characteristics
|
|
||||||
|
|
||||||
### Latency (Estimated)
|
|
||||||
- **Steward Analysis**: ~1-2 seconds (single LLM call)
|
|
||||||
- **Tatlock Execution**: ~2-5 seconds (depends on tool usage)
|
|
||||||
- **Total Added Overhead**: ~1-2 seconds vs. direct Tatlock call
|
|
||||||
- **Streaming Transparency**: Steward reasoning visible immediately
|
|
||||||
|
|
||||||
### Resource Usage
|
|
||||||
- **VRAM**: Same model for both agents (mistral-nemo:latest)
|
|
||||||
- **Model Loading**: No additional model loads (efficient!)
|
|
||||||
- **Redis**: Minimal (benchmarks with 30-day expiry)
|
|
||||||
- **Network**: Only when web search tools used
|
|
||||||
|
|
||||||
### Accuracy Targets
|
|
||||||
- **Recommendation Precision**: > 90% (tools recommended and actually used)
|
|
||||||
- **Recommendation Recall**: > 90% (tools used were recommended)
|
|
||||||
- **False Positives**: < 10% (recommended but not used)
|
|
||||||
- **False Negatives**: < 10% (used but not recommended)
|
|
||||||
|
|
||||||
*Note: Actual metrics available via `scripts/benchmark_analysis.py` after production usage*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### Core Implementation
|
|
||||||
1. `src/core/household_registry.py` - Capability management
|
|
||||||
2. `src/core/preprocessing.py` - Request preprocessing pipeline
|
|
||||||
3. `src/core/tool_tracking.py` - Tool usage tracking
|
|
||||||
4. `src/core/logging_config.py` - Structured logging (M1)
|
|
||||||
5. `src/core/benchmarks.py` - Redis benchmark storage (M1)
|
|
||||||
|
|
||||||
### Steward Agent
|
|
||||||
6. `src/agents/steward/agent.py` - Steward PydanticAI agent
|
|
||||||
7. `src/agents/steward/schemas.py` - Data structures
|
|
||||||
8. `src/agents/steward/service.py` - Service layer
|
|
||||||
|
|
||||||
### Tatlock Core Organization
|
|
||||||
9. `src/agents/tatlock_core/tools.py` - Tool implementations (reorganized)
|
|
||||||
10. `src/agents/tatlock_core/toolset.py` - PydanticAI toolset
|
|
||||||
11. `src/agents/tatlock_core/capability.py` - Registry integration
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
12. `tests/agents/steward/test_steward_schemas.py` - Schema tests
|
|
||||||
13. `tests/agents/steward/test_steward_service.py` - Service tests
|
|
||||||
14. `tests/integration/test_steward_tatlock_integration.py` - Full flow tests
|
|
||||||
15. `tests/integration/test_steward_streaming.py` - Streaming tests
|
|
||||||
|
|
||||||
### Tools & Documentation
|
|
||||||
16. `scripts/benchmark_analysis.py` - Performance analysis CLI
|
|
||||||
17. `PHASE2_PLAN.md` - Detailed implementation plan
|
|
||||||
18. `PHASE2_COMPLETE.md` - This completion summary
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
- `src/agents/tatlock.py` - Added `run_with_scoped_tools()` method
|
|
||||||
- `src/responses/service.py` - Added `create_response_with_steward()`
|
|
||||||
- `src/responses/router.py` - Steward routing logic
|
|
||||||
- `src/responses/streaming.py` - Added `stream_response_with_steward()`
|
|
||||||
- `CHANGELOG.md` - Phase 2 documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
### Technical ✅
|
|
||||||
- ✅ Household registry operational with executive summaries
|
|
||||||
- ✅ Steward produces structured recommendations
|
|
||||||
- ✅ Steward analyzes full conversation context
|
|
||||||
- ✅ Tool scoping enforced (Tatlock can't use non-recommended tools)
|
|
||||||
- ✅ Model efficiency preserved (no reload delays)
|
|
||||||
- ✅ Performance benchmarks recorded to Redis
|
|
||||||
- ✅ Tool usage tracking (recommended vs. actual)
|
|
||||||
- ✅ Streaming transparency implemented
|
|
||||||
|
|
||||||
### Observability ✅
|
|
||||||
- ✅ Structured logging (JSON format)
|
|
||||||
- ✅ Benchmark analysis tools available
|
|
||||||
- ✅ Tool recommendation accuracy measurable
|
|
||||||
- ✅ Cross-session performance trends visible
|
|
||||||
|
|
||||||
### Architectural ✅
|
|
||||||
- ✅ PydanticAI patterns followed (Toolsets, decorators, structured outputs)
|
|
||||||
- ✅ Clean separation: registry vs. agents vs. tools
|
|
||||||
- ✅ Two-tier abstraction working (summaries vs. details)
|
|
||||||
- ✅ Future-proof for expert agents (Phase 4)
|
|
||||||
|
|
||||||
### Testing ✅
|
|
||||||
- ✅ 223 tests passing (99.5% pass rate)
|
|
||||||
- ✅ Integration tests for full flow
|
|
||||||
- ✅ Streaming integration tests
|
|
||||||
- ✅ 77.6% test coverage maintained
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Non-Streaming Request
|
|
||||||
```python
|
|
||||||
from src.responses.service import create_response_with_steward
|
|
||||||
from src.responses.schemas import ResponseRequest
|
|
||||||
|
|
||||||
request = ResponseRequest(
|
|
||||||
model="tatlock",
|
|
||||||
input=[
|
|
||||||
{"role": "user", "content": "What's sqrt(144)?"}
|
|
||||||
],
|
|
||||||
metadata={"conversation_id": "conv_123"}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await create_response_with_steward(request)
|
|
||||||
|
|
||||||
# Response includes:
|
|
||||||
# 1. Steward's analysis (reasoning output)
|
|
||||||
# 2. Tatlock's answer (message output)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Streaming Request
|
|
||||||
```python
|
|
||||||
from src.responses.streaming import StreamingCoordinator
|
|
||||||
|
|
||||||
coordinator = StreamingCoordinator()
|
|
||||||
|
|
||||||
async for event in coordinator.stream_response_with_steward(request):
|
|
||||||
if event.event == "response.reasoning_summary_text.delta":
|
|
||||||
print(f"Steward: {event.delta}", end="")
|
|
||||||
elif event.event == "response.output_text.delta":
|
|
||||||
print(f"Tatlock: {event.delta}", end="")
|
|
||||||
elif event.event == "response.done":
|
|
||||||
print(f"\nFinal response: {event.response.id}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benchmark Analysis
|
|
||||||
```bash
|
|
||||||
# View Steward performance
|
|
||||||
python scripts/benchmark_analysis.py --operation steward_analysis --hours 24
|
|
||||||
|
|
||||||
# Analyze tool accuracy
|
|
||||||
python scripts/benchmark_analysis.py --tool-accuracy --days 7
|
|
||||||
|
|
||||||
# Get summary
|
|
||||||
python scripts/benchmark_analysis.py --summary --hours 1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future-Proofing for Phase 4
|
|
||||||
|
|
||||||
### Expert Agent Pattern (Ready to Use)
|
|
||||||
|
|
||||||
When adding The Librarian, The Developer, or other expert agents:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/agents/librarian/
|
|
||||||
├── agent.py # Librarian PydanticAI agent
|
|
||||||
├── tools.py # Research, wiki, knowledge tools
|
|
||||||
├── toolset.py # PydanticAI toolset
|
|
||||||
└── capability.py # Registry integration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```python
|
|
||||||
from src.core.household_registry import get_household_registry
|
|
||||||
|
|
||||||
registry = get_household_registry()
|
|
||||||
registry.register(
|
|
||||||
name="librarian",
|
|
||||||
capability=LIBRARIAN_CAPABILITY,
|
|
||||||
toolset=librarian_toolset,
|
|
||||||
agent=librarian_agent # For delegation
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Delegation from Tatlock** (Phase 4):
|
|
||||||
```python
|
|
||||||
@tatlock_agent.tool
|
|
||||||
async def consult_librarian(
|
|
||||||
ctx: RunContext[None],
|
|
||||||
research_query: str
|
|
||||||
) -> str:
|
|
||||||
"""Consult the Librarian for research assistance."""
|
|
||||||
return await librarian_agent.run(research_query, usage=ctx.usage)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lessons Learned
|
|
||||||
|
|
||||||
### What Went Well
|
|
||||||
1. **PydanticAI Integration**: Native toolset patterns work beautifully
|
|
||||||
2. **Two-Tier Architecture**: Clean separation between coordination and execution
|
|
||||||
3. **Plain Text Approach**: More flexible than structured output for Steward
|
|
||||||
4. **Test Coverage**: Comprehensive integration tests caught edge cases early
|
|
||||||
5. **Streaming**: SSE events provide excellent real-time transparency
|
|
||||||
|
|
||||||
### Challenges Overcome
|
|
||||||
1. **Schema vs. Agent OutputItems**: Fixed `_calculate_usage` to handle both types
|
|
||||||
2. **Registry Initialization**: Added fixtures to ensure registry available in tests
|
|
||||||
3. **Plain Text Parsing**: Keyword extraction works well but needs careful test mocking
|
|
||||||
4. **Complexity Substring Matching**: "Complexity:" contains "complex" - fixed test mocks
|
|
||||||
|
|
||||||
### Optimizations
|
|
||||||
1. **Single Model**: Using same Ollama model for both agents saves VRAM
|
|
||||||
2. **Sequential Execution**: No parallel LLM calls needed (Steward → Tatlock)
|
|
||||||
3. **Tool Scoping**: Fresh agent instances more reliable than runtime filtering
|
|
||||||
4. **Benchmark Expiry**: 30-day TTL prevents Redis bloat
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### Immediate
|
|
||||||
- Monitor Steward accuracy in production
|
|
||||||
- Collect real-world benchmarks
|
|
||||||
- Iterate on Steward prompt based on metrics
|
|
||||||
|
|
||||||
### Phase 3 (Optional)
|
|
||||||
- Web search delegation to The Librarian
|
|
||||||
- Enhanced research capabilities
|
|
||||||
- Multi-source information synthesis
|
|
||||||
|
|
||||||
### Phase 4
|
|
||||||
- Expert agent delegation (Librarian, Developer, etc.)
|
|
||||||
- Dynamic agent selection based on request
|
|
||||||
- Cross-agent collaboration patterns
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Phase 2 successfully delivers a production-ready two-tier architecture with The Steward managing intelligent request routing and tool scoping. The implementation is:
|
|
||||||
|
|
||||||
- ✅ **Complete**: All planned features delivered
|
|
||||||
- ✅ **Tested**: 223 tests with 99.5% pass rate
|
|
||||||
- ✅ **Observable**: Full logging and benchmarking
|
|
||||||
- ✅ **Efficient**: Single model, minimal overhead
|
|
||||||
- ✅ **Extensible**: Ready for expert agents in Phase 4
|
|
||||||
|
|
||||||
The Steward provides intelligent capability coordination while maintaining conversation context awareness, creating a foundation for scalable multi-agent collaboration in future phases.
|
|
||||||
|
|
||||||
**Phase 2 Status**: ✅ **COMPLETE**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Document Version**: 1.0
|
|
||||||
**Created**: 2025-12-07
|
|
||||||
**Author**: Development Team
|
|
||||||
**Reference**: [PHASE2_PLAN.md](PHASE2_PLAN.md)
|
|
||||||
-865
@@ -1,865 +0,0 @@
|
|||||||
# Phase 2 Implementation Plan: The Steward
|
|
||||||
|
|
||||||
**Status**: Active Planning
|
|
||||||
**Created**: 2025-12-07
|
|
||||||
**Estimated Duration**: 4-5 weeks
|
|
||||||
**Goal**: Implement first-tier request analysis and household capability coordination
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Phase 2 introduces **The Steward** - a first-tier LLM agent that analyzes incoming requests, identifies relevant household capabilities, and provides focused recommendations to Tatlock (the Butler). This creates a two-tier architecture that prevents cognitive overload and enables efficient tool/agent coordination.
|
|
||||||
|
|
||||||
### Key Deliverables
|
|
||||||
|
|
||||||
1. **Household Registry**: Centralized capability catalog with PydanticAI Toolsets
|
|
||||||
2. **Steward Agent**: Request analyzer with conversation context awareness
|
|
||||||
3. **Tool Scoping**: Dynamic toolset creation based on recommendations
|
|
||||||
4. **Observability**: Performance benchmarking and tool usage tracking via Redis
|
|
||||||
5. **Integration**: Full Steward → Tatlock request flow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Architectural Principles
|
|
||||||
|
|
||||||
### 1. Household-Based Organization
|
|
||||||
- Each expert agent owns their tools in a domain directory
|
|
||||||
- Tools organized as functional clusters around capabilities
|
|
||||||
- Example: `src/agents/tatlock_core/` contains calculator, datetime, web search
|
|
||||||
|
|
||||||
### 2. Two-Tier Capability Abstraction
|
|
||||||
- **Executive Summary**: High-level capabilities for Steward/Butler coordination
|
|
||||||
- **Implementation Details**: Full tool specifications for household members
|
|
||||||
- Steward sees summaries, household members see full details
|
|
||||||
|
|
||||||
### 3. PydanticAI Native Patterns
|
|
||||||
- Use `FunctionToolset` and `CombinedToolset` for composition
|
|
||||||
- Decorator-based tool registration (`@agent.tool`)
|
|
||||||
- Structured outputs via Pydantic models
|
|
||||||
- Agent delegation pattern for expert agents (Phase 4)
|
|
||||||
|
|
||||||
### 4. Separate Registries
|
|
||||||
- **Household Registry**: Tools + capabilities (new in Phase 2)
|
|
||||||
- **Model Registry**: Agents/models (existing from Phase 1)
|
|
||||||
- Clean separation of concerns
|
|
||||||
|
|
||||||
### 5. Start Minimal
|
|
||||||
- Only 3 core Tatlock tools initially: calculator, datetime, web search
|
|
||||||
- No new tools until expert agents exist (Phase 4)
|
|
||||||
- Prove the pattern before expanding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Milestones
|
|
||||||
|
|
||||||
|
|
||||||
### Milestone 1: Household Registry + Logging Infrastructure (Week 1-2)
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
Create a registry system that aggregates household capabilities using PydanticAI Toolsets and establish observability infrastructure.
|
|
||||||
|
|
||||||
#### Tasks
|
|
||||||
|
|
||||||
**1.1 Create Household Registry Module**
|
|
||||||
|
|
||||||
Location: `src/core/household_registry.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic_ai import FunctionToolset, CombinedToolset
|
|
||||||
|
|
||||||
class HouseholdCapability(BaseModel):
|
|
||||||
"""Executive summary of a household member's capabilities."""
|
|
||||||
name: str # "tatlock_core", "librarian", "developer"
|
|
||||||
role: str # "Butler's Core Tools", "The Librarian"
|
|
||||||
category: str # "core", "research", "technical"
|
|
||||||
description: str # One-sentence description
|
|
||||||
domains: list[str] # ["computation", "information", "datetime"]
|
|
||||||
cost: str # "low", "medium", "high"
|
|
||||||
requires_network: bool
|
|
||||||
|
|
||||||
class HouseholdMember(BaseModel):
|
|
||||||
"""Full specification of a household member."""
|
|
||||||
capability: HouseholdCapability
|
|
||||||
toolset: FunctionToolset
|
|
||||||
agent: Agent | None = None # For expert agents in Phase 4
|
|
||||||
|
|
||||||
class HouseholdRegistry:
|
|
||||||
"""Registry of household capabilities and implementations."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._members: dict[str, HouseholdMember] = {}
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
capability: HouseholdCapability,
|
|
||||||
toolset: FunctionToolset,
|
|
||||||
agent: Agent | None = None
|
|
||||||
):
|
|
||||||
"""Register a household member."""
|
|
||||||
self._members[name] = HouseholdMember(
|
|
||||||
capability=capability,
|
|
||||||
toolset=toolset,
|
|
||||||
agent=agent
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_all_capabilities(self) -> list[HouseholdCapability]:
|
|
||||||
"""Get executive summaries for Steward/Butler."""
|
|
||||||
return [m.capability for m in self._members.values()]
|
|
||||||
|
|
||||||
def get_scoped_toolset(self, names: list[str]) -> CombinedToolset:
|
|
||||||
"""Create combined toolset from recommended capabilities."""
|
|
||||||
toolsets = [self._members[name].toolset for name in names]
|
|
||||||
return CombinedToolset(toolsets)
|
|
||||||
|
|
||||||
# Global registry instance
|
|
||||||
household_registry = HouseholdRegistry()
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
**1.2 Reorganize Tatlock Core Tools**
|
|
||||||
|
|
||||||
Create domain-based organization:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/agents/tatlock_core/
|
|
||||||
├── __init__.py
|
|
||||||
├── tools.py # Tool implementations (moved from src/agents/tools.py)
|
|
||||||
├── toolset.py # PydanticAI toolset registration
|
|
||||||
└── capability.py # Executive summary for registry
|
|
||||||
```
|
|
||||||
|
|
||||||
**1.3 Create Logging Infrastructure**
|
|
||||||
|
|
||||||
Location: `src/core/logging_config.py`
|
|
||||||
|
|
||||||
- Structured logging with `structlog`
|
|
||||||
- JSON format for machine parsing
|
|
||||||
- Operation timing and metadata tracking
|
|
||||||
- Context manager for automatic timing
|
|
||||||
|
|
||||||
**1.4 Create Redis Benchmark Storage**
|
|
||||||
|
|
||||||
Location: `src/core/benchmarks.py`
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Performance benchmark recording (Steward analysis, tool calls)
|
|
||||||
- Cross-session persistence via Redis
|
|
||||||
- Time-series storage with automatic expiry (30 days)
|
|
||||||
- Queryable metrics for analysis
|
|
||||||
|
|
||||||
Benchmark schema:
|
|
||||||
```python
|
|
||||||
class PerformanceBenchmark(BaseModel):
|
|
||||||
timestamp: datetime
|
|
||||||
operation: str # "steward_analysis", "tool_call"
|
|
||||||
duration_seconds: float
|
|
||||||
success: bool
|
|
||||||
|
|
||||||
# Steward-specific
|
|
||||||
recommendation_count: Optional[int]
|
|
||||||
confidence: Optional[float]
|
|
||||||
|
|
||||||
# Tool-specific
|
|
||||||
tool_name: Optional[str]
|
|
||||||
was_recommended: Optional[bool]
|
|
||||||
was_actually_used: Optional[bool]
|
|
||||||
|
|
||||||
# Context
|
|
||||||
conversation_id: Optional[str]
|
|
||||||
metadata: dict
|
|
||||||
```
|
|
||||||
|
|
||||||
**1.5 Testing**
|
|
||||||
|
|
||||||
- Test household registry registration and retrieval
|
|
||||||
- Test Toolset composition
|
|
||||||
- Test benchmark recording to Redis
|
|
||||||
- Test structured logging output
|
|
||||||
|
|
||||||
#### Success Criteria
|
|
||||||
- ✅ Household registry operational
|
|
||||||
- ✅ Tatlock core tools organized in domain directory
|
|
||||||
- ✅ Redis benchmarks working
|
|
||||||
- ✅ Structured logging functional
|
|
||||||
- ✅ Tests pass and maintain 80%+ coverage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Milestone 2: Minimal Steward Agent with Context Analysis (Week 3-4)
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
Create a Steward agent that analyzes requests with full conversation context and recommends relevant household capabilities.
|
|
||||||
|
|
||||||
#### Tasks
|
|
||||||
|
|
||||||
**2.1 Create Steward Agent**
|
|
||||||
|
|
||||||
Location: `src/agents/steward/agent.py`
|
|
||||||
|
|
||||||
Structured output schema:
|
|
||||||
```python
|
|
||||||
class ConversationContext(BaseModel):
|
|
||||||
"""Contextual information from conversation history."""
|
|
||||||
has_previous_context: bool
|
|
||||||
relevant_turns: list[int] # 0-indexed turn numbers
|
|
||||||
context_summary: str # Summary for Butler
|
|
||||||
|
|
||||||
class StewardRecommendation(BaseModel):
|
|
||||||
"""Structured recommendation from Steward analysis."""
|
|
||||||
recommended_capabilities: list[str]
|
|
||||||
reasoning: str
|
|
||||||
estimated_complexity: Literal["simple", "moderate", "complex"]
|
|
||||||
conversation_context: ConversationContext
|
|
||||||
missing_capabilities: Optional[str] = None
|
|
||||||
```
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
- Uses same model as Tatlock (`ollama:mistral-nemo`) for VRAM efficiency
|
|
||||||
- Receives FULL conversation history
|
|
||||||
- Queries household registry via tool
|
|
||||||
- Conservative recommendations (avoid over-inclusion)
|
|
||||||
- Explicit handling of missing capabilities
|
|
||||||
|
|
||||||
**2.2 Steward System Prompt**
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
1. **Capability Recommendation**: Query registry, recommend only necessary tools
|
|
||||||
2. **Conversation Analysis**: Identify references to previous topics
|
|
||||||
3. **Complexity Assessment**: Simple/moderate/complex classification
|
|
||||||
4. **Missing Capability Detection**: Suggest what's needed if no tools available
|
|
||||||
|
|
||||||
**2.3 Steward Service Layer with Logging**
|
|
||||||
|
|
||||||
Location: `src/agents/steward/service.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def analyze_request(
|
|
||||||
user_request: str,
|
|
||||||
conversation_history: list[dict] # FULL conversation
|
|
||||||
) -> StewardRecommendation:
|
|
||||||
"""Analyze request with full conversation context."""
|
|
||||||
|
|
||||||
async with log_operation("steward_analysis", {...}) as log_ctx:
|
|
||||||
result = await steward_agent.run(
|
|
||||||
user_request,
|
|
||||||
message_history=convert_to_pydantic_history(conversation_history),
|
|
||||||
usage_limits=UsageLimits(request_limit=3)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log and benchmark
|
|
||||||
log_ctx["recommendation_count"] = len(result.data.recommended_capabilities)
|
|
||||||
await benchmark_store.record(...)
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
```
|
|
||||||
|
|
||||||
**2.4 Testing**
|
|
||||||
|
|
||||||
Test scenarios:
|
|
||||||
- Calculator request → recommends tatlock_core
|
|
||||||
- Simple greeting → recommends []
|
|
||||||
- Web search request → recommends tatlock_core
|
|
||||||
- Request referencing previous turn → identifies context
|
|
||||||
- Impossible request → returns missing_capabilities
|
|
||||||
|
|
||||||
#### Success Criteria
|
|
||||||
- ✅ Steward queries household registry successfully
|
|
||||||
- ✅ Produces structured recommendations
|
|
||||||
- ✅ Analyzes full conversation context
|
|
||||||
- ✅ Handles missing capabilities gracefully
|
|
||||||
- ✅ Conservative recommendations (> 90% accuracy)
|
|
||||||
- ✅ Benchmarks recorded to Redis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Milestone 3: Request Preprocessing & Tool Tracking (Week 5-6)
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
Wire Steward into request flow, implement tool scoping, and track tool usage.
|
|
||||||
|
|
||||||
#### Tasks
|
|
||||||
|
|
||||||
**3.1 Create Preprocessing Pipeline**
|
|
||||||
|
|
||||||
Location: `src/core/preprocessing.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class EnrichedRequest:
|
|
||||||
"""Request enriched with Steward's analysis."""
|
|
||||||
original_request: str
|
|
||||||
steward_note: str # Formatted note for Tatlock
|
|
||||||
scoped_toolset: CombinedToolset # Only recommended tools
|
|
||||||
recommendation: StewardRecommendation
|
|
||||||
steward_reasoning_output: str # For streaming to user
|
|
||||||
|
|
||||||
async def preprocess_request(
|
|
||||||
user_request: str,
|
|
||||||
conversation_history: list[dict] # FULL conversation
|
|
||||||
) -> EnrichedRequest:
|
|
||||||
"""Analyze via Steward and prepare scoped context."""
|
|
||||||
# Call Steward with full conversation
|
|
||||||
recommendation = await analyze_request(user_request, conversation_history)
|
|
||||||
|
|
||||||
# Format note to Tatlock (includes conversation context)
|
|
||||||
steward_note = format_steward_note(recommendation)
|
|
||||||
|
|
||||||
# Create scoped toolset
|
|
||||||
scoped_toolset = household_registry.get_scoped_toolset(
|
|
||||||
recommendation.recommended_capabilities
|
|
||||||
)
|
|
||||||
|
|
||||||
return EnrichedRequest(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Note formatting:
|
|
||||||
- Includes conversation context summary
|
|
||||||
- Highlights missing capabilities if applicable
|
|
||||||
- Provides complexity estimate
|
|
||||||
|
|
||||||
**3.2 Tool Usage Tracking**
|
|
||||||
|
|
||||||
Location: `src/core/tool_tracking.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ToolCallTracker:
|
|
||||||
"""Tracks tool calls for benchmarking."""
|
|
||||||
|
|
||||||
def __init__(self, recommended_tools: list[str]):
|
|
||||||
self.recommended_tools = set(recommended_tools)
|
|
||||||
self.actual_calls: dict[str, list[float]] = {}
|
|
||||||
|
|
||||||
async def track_call(self, tool_name: str, duration: float):
|
|
||||||
"""Record a tool call with timing."""
|
|
||||||
# Log if tool wasn't recommended
|
|
||||||
if tool_name not in self.recommended_tools:
|
|
||||||
logger.warning("tool_call_not_recommended", ...)
|
|
||||||
|
|
||||||
# Record benchmark to Redis
|
|
||||||
await benchmark_store.record(...)
|
|
||||||
|
|
||||||
async def finalize(self):
|
|
||||||
"""Log unused recommended tools."""
|
|
||||||
unused = self.recommended_tools - set(self.actual_calls.keys())
|
|
||||||
# Record benchmarks for unused tools
|
|
||||||
```
|
|
||||||
|
|
||||||
**3.3 Integrate with Responses API**
|
|
||||||
|
|
||||||
Modify `src/responses/service.py`:
|
|
||||||
```python
|
|
||||||
async def generate_response(request: ResponseRequest) -> ResponseOutput:
|
|
||||||
# Preprocess via Steward (with full conversation)
|
|
||||||
enriched = await preprocess_request(
|
|
||||||
user_message,
|
|
||||||
conversation_history=request.input[:-1]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run Tatlock with scoped tools and tracker
|
|
||||||
result = await run_tatlock_with_scoped_tools(
|
|
||||||
enriched.original_request,
|
|
||||||
enriched.steward_note,
|
|
||||||
enriched.scoped_toolset,
|
|
||||||
enriched.recommendation.recommended_capabilities, # For tracking
|
|
||||||
message_history,
|
|
||||||
usage_tracker
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build response with Steward reasoning
|
|
||||||
return build_response_with_steward_reasoning(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
**3.4 Update Tatlock Agent**
|
|
||||||
|
|
||||||
Location: `src/agents/tatlock.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def run_tatlock_with_scoped_tools(
|
|
||||||
user_request: str,
|
|
||||||
steward_note: str,
|
|
||||||
scoped_toolset: CombinedToolset,
|
|
||||||
recommended_tools: list[str],
|
|
||||||
message_history: list[dict],
|
|
||||||
usage: UsageeLimits
|
|
||||||
):
|
|
||||||
# Initialize tracker
|
|
||||||
tracker = ToolCallTracker(recommended_tools)
|
|
||||||
|
|
||||||
# Prepend Steward's note (invisible to user, visible to Tatlock)
|
|
||||||
enriched_prompt = f"{steward_note}\n\n{user_request}"
|
|
||||||
|
|
||||||
# Run with ONLY scoped tools
|
|
||||||
result = await tatlock_agent.run(
|
|
||||||
enriched_prompt,
|
|
||||||
message_history=convert_to_pydantic_history(message_history),
|
|
||||||
toolsets=[scoped_toolset], # Tool scoping enforced
|
|
||||||
deps=tracker, # For tracking
|
|
||||||
usage=usage
|
|
||||||
)
|
|
||||||
|
|
||||||
# Finalize tracking
|
|
||||||
await tracker.finalize()
|
|
||||||
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
**3.5 Add Streaming Transparency**
|
|
||||||
|
|
||||||
Modify `src/responses/streaming.py`:
|
|
||||||
- Stream Steward's reasoning first
|
|
||||||
- Then stream Tatlock's response
|
|
||||||
- Include conversation context notes
|
|
||||||
- Format missing capabilities warnings
|
|
||||||
|
|
||||||
**3.6 Testing**
|
|
||||||
|
|
||||||
Integration tests:
|
|
||||||
- Full Steward → Tatlock flow
|
|
||||||
- Tool scoping enforcement (can't use non-recommended tools)
|
|
||||||
- Tool usage tracking (recommended vs. actual)
|
|
||||||
- Conversation context propagation
|
|
||||||
- Missing capabilities handling
|
|
||||||
|
|
||||||
#### Success Criteria
|
|
||||||
- ✅ Full request flow working (User → Steward → Tatlock)
|
|
||||||
- ✅ Steward reasoning visible in output stream
|
|
||||||
- ✅ Tool scoping enforced (only recommended tools available)
|
|
||||||
- ✅ Tool usage tracked and logged to Redis
|
|
||||||
- ✅ Conversation context passed through pipeline
|
|
||||||
- ✅ Integration tests pass end-to-end
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
### Milestone 4: Testing, Benchmarking & Refinement (Week 7)
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
Validate the system, optimize performance, refine prompts, and establish monitoring.
|
|
||||||
|
|
||||||
#### Tasks
|
|
||||||
|
|
||||||
**4.1 Comprehensive Testing**
|
|
||||||
|
|
||||||
Test categories:
|
|
||||||
- End-to-end integration tests (full request flow)
|
|
||||||
- Performance benchmarks (latency targets)
|
|
||||||
- Prompt refinement (recommendation accuracy)
|
|
||||||
- Edge cases (errors, timeouts, missing capabilities)
|
|
||||||
- Conversation context accuracy
|
|
||||||
|
|
||||||
**4.2 Performance Validation**
|
|
||||||
|
|
||||||
Targets:
|
|
||||||
- Steward analysis: < 2 seconds
|
|
||||||
- Total added latency: < 3 seconds
|
|
||||||
- Model stays hot in VRAM (no reload delays)
|
|
||||||
- Tool recommendation accuracy: > 90%
|
|
||||||
|
|
||||||
**4.3 Benchmark Analysis Tools**
|
|
||||||
|
|
||||||
Create `scripts/benchmark_analysis.py`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View Steward performance over last 24 hours
|
|
||||||
python scripts/benchmark_analysis.py --operation steward_analysis --hours 24
|
|
||||||
|
|
||||||
# Analyze tool recommendation accuracy
|
|
||||||
python scripts/benchmark_analysis.py --tool-accuracy --days 7
|
|
||||||
```
|
|
||||||
|
|
||||||
Metrics to track:
|
|
||||||
- Average Steward analysis time
|
|
||||||
- Recommendation count distribution
|
|
||||||
- Tool accuracy (recommended & used, recommended but unused, not recommended but used)
|
|
||||||
- Recommendation precision percentage
|
|
||||||
|
|
||||||
**4.4 Prompt Engineering**
|
|
||||||
|
|
||||||
Iterate on Steward system prompt:
|
|
||||||
- Test with diverse request types
|
|
||||||
- Tune conservativeness (balance false positives/negatives)
|
|
||||||
- Validate conversation context analysis
|
|
||||||
- Test missing capability detection
|
|
||||||
|
|
||||||
**4.5 Documentation**
|
|
||||||
|
|
||||||
Update documentation:
|
|
||||||
- README.md: Steward explanation and examples
|
|
||||||
- AGENTS.md: Household registration pattern
|
|
||||||
- IMPLEMENTATION_ROADMAP.md: Mark Phase 2 complete
|
|
||||||
- Add benchmark analysis guide
|
|
||||||
|
|
||||||
#### Success Criteria
|
|
||||||
- ✅ < 3 seconds added latency for Steward analysis
|
|
||||||
- ✅ > 90% recommendation accuracy (manual evaluation)
|
|
||||||
- ✅ All integration tests pass
|
|
||||||
- ✅ Benchmark tools functional
|
|
||||||
- ✅ Documentation complete and accurate
|
|
||||||
- ✅ Ready for Phase 3/4 (expert agents)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
User Request
|
|
||||||
↓
|
|
||||||
Orchestrator (FastAPI)
|
|
||||||
↓
|
|
||||||
Preprocessing Pipeline
|
|
||||||
├─→ Steward Agent
|
|
||||||
│ ├─ Receives: FULL conversation history
|
|
||||||
│ ├─ Analyzes: Context, references, requirements
|
|
||||||
│ ├─ Queries: Household registry (capabilities)
|
|
||||||
│ ├─ Outputs: StewardRecommendation
|
|
||||||
│ │ ├─ recommended_capabilities: list[str]
|
|
||||||
│ │ ├─ conversation_context: ConversationContext
|
|
||||||
│ │ ├─ missing_capabilities: str | None
|
|
||||||
│ │ └─ reasoning: str
|
|
||||||
│ └─ Logs: Performance benchmarks → Redis
|
|
||||||
│
|
|
||||||
├─→ Create Scoped Toolset
|
|
||||||
│ └─ CombinedToolset from recommended capabilities
|
|
||||||
│
|
|
||||||
└─→ Format Steward Note
|
|
||||||
└─ Includes conversation context for Tatlock
|
|
||||||
↓
|
|
||||||
Tatlock Agent (with scoped tools)
|
|
||||||
├─ Receives: Enriched request + Steward note
|
|
||||||
├─ Has access to: ONLY recommended tools
|
|
||||||
├─ Tool calls tracked: ToolCallTracker
|
|
||||||
└─ Logs: Tool usage benchmarks → Redis
|
|
||||||
↓
|
|
||||||
Response to User
|
|
||||||
├─ Steward's reasoning (streamed first)
|
|
||||||
└─ Tatlock's response (streamed second)
|
|
||||||
|
|
||||||
Background:
|
|
||||||
└─ Redis: Performance benchmarks, tool usage analysis
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions Summary
|
|
||||||
|
|
||||||
### 1. Logging & Performance Benchmarks
|
|
||||||
**Decision**: Full observability with Redis-backed benchmark storage
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Track Steward recommendations vs. Tatlock's actual tool usage
|
|
||||||
- Measure performance metrics (latency, token usage)
|
|
||||||
- Cross-session analysis for optimization
|
|
||||||
- Identify recommendation accuracy over time
|
|
||||||
|
|
||||||
### 2. Steward Fallback Behavior
|
|
||||||
**Decision**: Explicit missing capability communication
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- No suitable tools → Steward states "missing capabilities" with description
|
|
||||||
- Can suggest what type of tool would be helpful
|
|
||||||
- Code errors → standard exception handlers (don't suppress real errors)
|
|
||||||
- Better UX than silent failures or defaulting to all tools
|
|
||||||
|
|
||||||
### 3. Conversation History for Steward
|
|
||||||
**Decision**: Steward sees FULL conversation, not just current turn
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Can identify references to previous topics
|
|
||||||
- Provides contextual notes to Butler
|
|
||||||
- "Two sets of eyes" on conversation
|
|
||||||
- Example: "User mentioned Python debugging in turn 3, relevant details: async code"
|
|
||||||
|
|
||||||
### 4. Registry Pattern
|
|
||||||
**Decision**: Separate Household Registry from Model Registry
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Tools belong to household members, not models
|
|
||||||
- Clean separation of concerns
|
|
||||||
- Executive summaries for coordination, details for execution
|
|
||||||
|
|
||||||
### 5. Tool Composition
|
|
||||||
**Decision**: PydanticAI FunctionToolset + CombinedToolset
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Native PydanticAI pattern
|
|
||||||
- Clean composition and filtering
|
|
||||||
- Dynamic scoping per request
|
|
||||||
|
|
||||||
### 6. Tool Scoping
|
|
||||||
**Decision**: Compile-time scoping via toolset creation
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Tools not even visible to LLM
|
|
||||||
- Cleaner than runtime permission checks
|
|
||||||
- Enforced at PydanticAI level
|
|
||||||
|
|
||||||
### 7. Organization
|
|
||||||
**Decision**: Domain-based household directories
|
|
||||||
|
|
||||||
**Rationale**:
|
|
||||||
- Each household member owns their tools
|
|
||||||
- Clear bounded contexts
|
|
||||||
- Example: `src/agents/tatlock_core/`, `src/agents/librarian/` (future)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure Requirements
|
|
||||||
|
|
||||||
### Redis Setup
|
|
||||||
|
|
||||||
Development (quick start):
|
|
||||||
```bash
|
|
||||||
# Docker (recommended)
|
|
||||||
docker run -d -p 6379:6379 --name tatlock-redis redis:7-alpine
|
|
||||||
|
|
||||||
# Or local installation
|
|
||||||
# macOS: brew install redis && brew services start redis
|
|
||||||
# Linux: sudo apt install redis-server && sudo systemctl start redis
|
|
||||||
```
|
|
||||||
|
|
||||||
Production (docker-compose.yml):
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependencies Update
|
|
||||||
|
|
||||||
Add to `requirements.txt`:
|
|
||||||
```txt
|
|
||||||
redis[hiredis]>=5.0.0,<6.0.0
|
|
||||||
structlog>=24.1.0,<25.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Add to `.env`:
|
|
||||||
```env
|
|
||||||
# Redis Configuration
|
|
||||||
REDIS_URL=redis://localhost:6379/1
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FORMAT=json
|
|
||||||
ENABLE_BENCHMARKS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
**Week 1-2**: Household Registry + Logging Infrastructure
|
|
||||||
- Household registry with Toolsets
|
|
||||||
- Structured logging with structlog
|
|
||||||
- Redis benchmark storage
|
|
||||||
- Tatlock core reorganization
|
|
||||||
- Tests: Registry + benchmarking
|
|
||||||
|
|
||||||
**Week 3-4**: Steward Agent with Context Analysis
|
|
||||||
- Steward agent with conversation context
|
|
||||||
- ConversationContext in recommendations
|
|
||||||
- Missing capabilities handling
|
|
||||||
- Tests: Context analysis, missing capabilities
|
|
||||||
|
|
||||||
**Week 5-6**: Integration + Tool Tracking
|
|
||||||
- Request preprocessing with full conversation
|
|
||||||
- Tool usage tracking middleware
|
|
||||||
- Scoped toolset creation
|
|
||||||
- Streaming transparency
|
|
||||||
- Tests: Full flow + tool tracking
|
|
||||||
|
|
||||||
**Week 7**: Testing, Benchmarking & Refinement
|
|
||||||
- End-to-end integration tests
|
|
||||||
- Benchmark analysis tools
|
|
||||||
- Prompt refinement
|
|
||||||
- Performance validation
|
|
||||||
- Documentation updates
|
|
||||||
|
|
||||||
**Total: 4-5 weeks** (core implementation complete in 6 weeks, polish in week 7)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
### Technical
|
|
||||||
- ✅ Household registry operational with executive summaries
|
|
||||||
- ✅ Steward produces accurate recommendations (> 90%)
|
|
||||||
- ✅ Steward analyzes full conversation context
|
|
||||||
- ✅ Tool scoping enforced (Tatlock can't use non-recommended tools)
|
|
||||||
- ✅ Model efficiency preserved (no reload delays)
|
|
||||||
- ✅ Added latency < 3 seconds
|
|
||||||
- ✅ Performance benchmarks recorded to Redis
|
|
||||||
- ✅ Tool usage tracking (recommended vs. actual)
|
|
||||||
|
|
||||||
### Observability
|
|
||||||
- ✅ Structured logging (JSON format)
|
|
||||||
- ✅ Benchmark analysis tools available
|
|
||||||
- ✅ Tool recommendation accuracy measurable
|
|
||||||
- ✅ Cross-session performance trends visible
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- ✅ Missing capabilities explicitly communicated
|
|
||||||
- ✅ Steward can guide user toward needed resources
|
|
||||||
- ✅ Code errors properly surfaced (not suppressed)
|
|
||||||
|
|
||||||
### Architectural
|
|
||||||
- ✅ PydanticAI patterns followed (Toolsets, decorators, structured outputs)
|
|
||||||
- ✅ Clean separation: registry vs. agents vs. tools
|
|
||||||
- ✅ Two-tier abstraction working (summaries vs. details)
|
|
||||||
- ✅ Future-proof for expert agents (Phase 4)
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ Maintain 80%+ test coverage
|
|
||||||
- ✅ Integration tests for full flow
|
|
||||||
- ✅ Performance benchmarks established
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future-Proofing for Phase 4
|
|
||||||
|
|
||||||
### Expert Agent Pattern (Template)
|
|
||||||
|
|
||||||
When adding The Librarian, The Developer, etc., follow this structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/agents/librarian/
|
|
||||||
├── __init__.py
|
|
||||||
├── agent.py # Librarian PydanticAI agent
|
|
||||||
├── tools.py # Librarian-specific tools (wiki, research, etc.)
|
|
||||||
├── toolset.py # PydanticAI toolset creation
|
|
||||||
└── capability.py # Executive summary for registry
|
|
||||||
```
|
|
||||||
|
|
||||||
Example capability registration:
|
|
||||||
```python
|
|
||||||
# capability.py
|
|
||||||
LIBRARIAN_CAPABILITY = HouseholdCapability(
|
|
||||||
name="librarian",
|
|
||||||
role="The Librarian",
|
|
||||||
category="research",
|
|
||||||
description="Research assistance, knowledge management, and information synthesis",
|
|
||||||
domains=["research", "knowledge_base", "documentation"],
|
|
||||||
cost="medium",
|
|
||||||
requires_network=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_librarian():
|
|
||||||
household_registry.register(
|
|
||||||
name="librarian",
|
|
||||||
capability=LIBRARIAN_CAPABILITY,
|
|
||||||
toolset=librarian_toolset,
|
|
||||||
agent=librarian_agent # Expert agent for delegation
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Tatlock delegation pattern (Phase 4):
|
|
||||||
```python
|
|
||||||
@tatlock_agent.tool
|
|
||||||
async def consult_librarian(
|
|
||||||
ctx: RunContext[None],
|
|
||||||
research_query: str
|
|
||||||
) -> str:
|
|
||||||
"""Consult the Librarian for research assistance."""
|
|
||||||
from src.agents.librarian.agent import librarian_agent
|
|
||||||
|
|
||||||
result = await librarian_agent.run(
|
|
||||||
research_query,
|
|
||||||
usage=ctx.usage # Aggregate usage
|
|
||||||
)
|
|
||||||
return result.data
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risk Mitigation
|
|
||||||
|
|
||||||
### Identified Risks
|
|
||||||
|
|
||||||
1. **Steward recommendations too broad**
|
|
||||||
- Mitigation: Conservative prompt engineering, benchmark tracking, iterate based on false positives
|
|
||||||
|
|
||||||
2. **Added latency unacceptable**
|
|
||||||
- Mitigation: Stream Steward reasoning for transparency, optimize prompt, use same base model
|
|
||||||
|
|
||||||
3. **Tool registry becomes unwieldy**
|
|
||||||
- Mitigation: Good categorization, semantic search (future), regular pruning
|
|
||||||
|
|
||||||
4. **Model VRAM competition**
|
|
||||||
- Mitigation: Use same base model for Steward and Tatlock, sequential calls
|
|
||||||
|
|
||||||
5. **Redis dependency**
|
|
||||||
- Mitigation: Make benchmarking optional, graceful degradation if Redis unavailable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions - RESOLVED
|
|
||||||
|
|
||||||
All major design questions have been resolved. See "Design Decisions Summary" section above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### Immediate (Today/This Week)
|
|
||||||
1. Set up Redis (Docker or local)
|
|
||||||
2. Create `src/core/logging_config.py` with structured logging
|
|
||||||
3. Create `src/core/benchmarks.py` with Redis storage
|
|
||||||
4. Add `redis` and `structlog` to requirements.txt
|
|
||||||
5. Create household registry skeleton
|
|
||||||
|
|
||||||
### Week 1-2
|
|
||||||
1. Complete household registry with Toolset integration
|
|
||||||
2. Reorganize Tatlock core tools into domain directory
|
|
||||||
3. Implement logging infrastructure
|
|
||||||
4. Write tests for registry + benchmarking
|
|
||||||
|
|
||||||
### Week 3-4
|
|
||||||
1. Create Steward agent with conversation context
|
|
||||||
2. Implement missing capabilities handling
|
|
||||||
3. Test context analysis accuracy
|
|
||||||
4. Iterate on system prompt
|
|
||||||
|
|
||||||
### Week 5-6
|
|
||||||
1. Build preprocessing pipeline
|
|
||||||
2. Integrate with Responses API
|
|
||||||
3. Implement tool tracking
|
|
||||||
4. Add streaming transparency
|
|
||||||
|
|
||||||
### Week 7
|
|
||||||
1. End-to-end testing
|
|
||||||
2. Benchmark analysis
|
|
||||||
3. Performance optimization
|
|
||||||
4. Documentation updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Document Status
|
|
||||||
|
|
||||||
**Status**: Active Planning Document
|
|
||||||
**Created**: 2025-12-07
|
|
||||||
**Last Updated**: 2025-12-07
|
|
||||||
**Version**: 1.0
|
|
||||||
**Next Review**: After Milestone 1 completion
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Reference Documents**:
|
|
||||||
- [PHILOSOPHY.md](PHILOSOPHY.md) - System vision and architecture
|
|
||||||
- [IMPLEMENTATION_ROADMAP.md](IMPLEMENTATION_ROADMAP.md) - Full project roadmap
|
|
||||||
- [AGENTS.md](AGENTS.md) - Agent development guidelines
|
|
||||||
- [README.md](README.md) - User documentation
|
|
||||||
|
|
||||||
@@ -6,12 +6,25 @@ A privacy-first, offline-capable personal assistant system that coordinates spec
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
- ✅ **Production-ready testing API** with OpenAI Responses API format
|
- ✅ **Production-ready API** with OpenAI Responses API format
|
||||||
- ✅ **Open WebUI integration** with reasoning bubbles (`<think>` tags)
|
- ✅ **Open WebUI integration** with reasoning bubbles (`<think>` tags)
|
||||||
- ✅ **Conversation history** with auto-generated IDs and context management
|
- ✅ **Two-tier architecture** - The Steward analyzes requests, Tatlock coordinates execution
|
||||||
- ✅ **Tatlock PydanticAI Agent** - Real LLM integration with Ollama + permanent tools
|
- ✅ **Multi-agent coordination** - Expert household staff for specialized tasks
|
||||||
- ✅ **Permanent Tools** - Calculator, date/time toolkit, web search (SearXNG)
|
- ✅ **Memory system** - User profile, preferences, and semantic recall
|
||||||
- ✅ **Comprehensive testing** - 131 tests, 81.78% coverage
|
- ✅ **Comprehensive testing** - 399 tests with good coverage
|
||||||
|
|
||||||
|
### The Household Staff
|
||||||
|
|
||||||
|
| Agent | Role | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| **Tatlock** | The Butler - Primary interface with witty personality | ✅ Active |
|
||||||
|
| **The Steward** | Request analysis and capability recommendation | ✅ Active |
|
||||||
|
| **The Librarian** | Research, wiki management, knowledge synthesis | ✅ Active |
|
||||||
|
| **The Biographer** | User memory - profiles, preferences, facts | ✅ Active |
|
||||||
|
| **The Developer** | Code assistance, debugging, architecture | 🔜 Planned |
|
||||||
|
| **The Secretary** | Scheduling, calendars, reminders | 🔜 Planned |
|
||||||
|
| **The Handyman** | System administration, monitoring | 🔜 Planned |
|
||||||
|
| **The Housekeeper** | Home automation (Home Assistant) | 🔜 Planned |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -45,24 +58,27 @@ A privacy-first, offline-capable personal assistant system that coordinates spec
|
|||||||
- Error triggers for testing (rate_limit, context_overflow)
|
- Error triggers for testing (rate_limit, context_overflow)
|
||||||
|
|
||||||
- **Tatlock**: Real PydanticAI agent with butler personality
|
- **Tatlock**: Real PydanticAI agent with butler personality
|
||||||
- **LLM Backend**: Ollama (mistral-nemo:latest)
|
- **LLM Backend**: Ollama (mistral-nemo:latest by default)
|
||||||
- **Personality**: Witty British butler, research-oriented
|
- **Personality**: Witty British butler, research-oriented
|
||||||
- **Permanent Tools**:
|
- **Core Tools**:
|
||||||
- **Calculator**: Safe mathematical expression evaluation (arithmetic, algebra, trigonometry, logarithms)
|
- **Calculator**: Safe mathematical expression evaluation
|
||||||
- **Date/Time Toolkit**: Current time, relative dates ("1 week ago"), time differences
|
- **Date/Time Toolkit**: Current time, relative dates, time differences
|
||||||
- **Web Search**: Privacy-preserving search via SearXNG
|
- **Web Search**: Privacy-preserving search via SearXNG
|
||||||
- **Capabilities**: Streaming, reasoning, tool calling
|
- **Household Coordination**:
|
||||||
- **Phase**: Phase 1 - Basic Integration (full household coordination coming in future phases)
|
- **The Steward**: Analyzes requests and recommends capabilities
|
||||||
|
- **The Librarian**: Research via library-desk HybridRAG + wiki
|
||||||
|
- **The Biographer**: User memory and preference management
|
||||||
|
- **Capabilities**: Streaming, reasoning, tool calling, multi-agent delegation
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.12+ (Python 3.12.11 recommended)
|
- Python 3.12+ (Python 3.12.11 recommended)
|
||||||
- **Ollama** (for Tatlock agent): Running locally or network-accessible
|
- **External Services** (must be running separately):
|
||||||
- Download: https://ollama.ai/
|
- **Ollama**: LLM inference (mistral-nemo:latest, nomic-embed-text)
|
||||||
- Model: `ollama pull mistral-nemo:latest`
|
- **Redis**: Caching and session memory
|
||||||
- **SearXNG** (for web search tool): Optional but recommended
|
- **Qdrant**: Vector storage for The Biographer's memory
|
||||||
- Docker: `docker run -d -p 8087:8080 searxng/searxng`
|
- **SearXNG**: Web search (optional)
|
||||||
- Or use public instance (less private)
|
- **library-desk**: Research API for The Librarian (optional)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -251,15 +267,18 @@ Interactive documentation available at:
|
|||||||
# Run all tests
|
# Run all tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
|
# Run unit tests only (no external services needed)
|
||||||
|
pytest --ignore=tests/e2e --ignore=tests/integration
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
pytest --cov=src --cov-report=term-missing
|
pytest --cov=src --cov-report=term-missing
|
||||||
|
|
||||||
# Current: 131 tests, 81.78% coverage
|
# Current: ~400 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test Categories:**
|
**Test Categories:**
|
||||||
- Unit tests: Agent tools, streaming, schemas
|
- Unit tests: Agent tools, capabilities, schemas, memory service
|
||||||
- Integration tests: Full API stack with real Ollama calls
|
- Integration tests: Full API stack with real Ollama
|
||||||
- End-to-end tests: Chat completions, responses API
|
- End-to-end tests: Chat completions, responses API
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -291,9 +310,25 @@ API_PORT=8000
|
|||||||
# Ollama Configuration
|
# Ollama Configuration
|
||||||
OLLAMA_HOST=http://localhost:11434
|
OLLAMA_HOST=http://localhost:11434
|
||||||
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
OLLAMA_TIMEOUT=120
|
OLLAMA_TIMEOUT=120
|
||||||
|
|
||||||
# SearXNG Configuration (for web search tool)
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_MEMORY_DB=2
|
||||||
|
REDIS_MEMORY_TTL_HOURS=24
|
||||||
|
|
||||||
|
# Qdrant Configuration (for memory)
|
||||||
|
QDRANT_HOST=localhost
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_EMBEDDING_DIM=768
|
||||||
|
|
||||||
|
# Library-desk Configuration (for The Librarian)
|
||||||
|
LIBRARY_DESK_HOST=http://localhost:8089
|
||||||
|
LIBRARY_DESK_TIMEOUT=60
|
||||||
|
|
||||||
|
# SearXNG Configuration (for web search)
|
||||||
SEARXNG_HOST=http://localhost:8087
|
SEARXNG_HOST=http://localhost:8087
|
||||||
SEARXNG_TIMEOUT=30
|
SEARXNG_TIMEOUT=30
|
||||||
|
|
||||||
@@ -339,23 +374,32 @@ See `.env.example` for full configuration options.
|
|||||||
```
|
```
|
||||||
tatlock/
|
tatlock/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── agents/ # Agent interface and implementations
|
│ ├── agents/ # Agent implementations
|
||||||
│ │ ├── base.py # AgentInterface abstract class
|
│ │ ├── biographer/ # The Biographer - memory management
|
||||||
│ │ ├── lorem_tester.py # Mock agent for testing
|
│ │ ├── librarian/ # The Librarian - research & wiki
|
||||||
│ │ ├── tatlock.py # Real PydanticAI butler agent
|
│ │ ├── steward/ # The Steward - request analysis
|
||||||
│ │ ├── tools.py # Permanent tools (calculator, date/time, search)
|
│ │ ├── tatlock_core/ # Core butler tools
|
||||||
│ │ └── registry.py # Model registry
|
│ │ ├── tatlock.py # Tatlock PydanticAI agent
|
||||||
│ ├── responses/ # Responses API (primary endpoint)
|
│ │ ├── coordination.py # Multi-agent coordination
|
||||||
│ ├── chat/ # Chat Completions wrapper
|
│ │ ├── delegation.py # Expert delegation wrappers
|
||||||
│ ├── models/ # Models listing
|
│ │ └── protocol.py # Agent communication protocol
|
||||||
│ ├── core/ # Shared utilities and config
|
│ ├── responses/ # Responses API (primary endpoint)
|
||||||
│ └── main.py # Application entry point
|
│ ├── chat/ # Chat Completions wrapper
|
||||||
├── tests/ # Comprehensive test suite (131 tests)
|
│ ├── models/ # Models listing
|
||||||
├── AGENTS.md # LLM agent development guidelines
|
│ ├── core/ # Shared infrastructure
|
||||||
├── PHILOSOPHY.md # System vision and architecture
|
│ │ ├── config.py # Configuration management
|
||||||
├── IMPLEMENTATION_ROADMAP.md # Development phases
|
│ │ ├── context.py # Request context (ContextVar)
|
||||||
├── CHANGELOG.md # Version history
|
│ │ ├── memory_service.py # Direct memory access
|
||||||
└── README.md # This file
|
│ │ ├── memory_cache.py # Redis session cache
|
||||||
|
│ │ ├── embeddings.py # Ollama embedding client
|
||||||
|
│ │ ├── qdrant.py # Vector database client
|
||||||
|
│ │ └── multi_tenancy.py # User isolation utilities
|
||||||
|
│ └── main.py # Application entry point
|
||||||
|
├── tests/ # Comprehensive test suite
|
||||||
|
├── PHILOSOPHY.md # System vision and architecture
|
||||||
|
├── IMPLEMENTATION_ROADMAP.md # Development phases
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -388,8 +432,8 @@ For LLM agent development guidelines and architectural decisions, see [AGENTS.md
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
Current version: **0.2.5** - Phase 2: The Steward (Two-Tier Architecture)
|
Current version: **1.2.0** - Phase F: Memory System (The Biographer)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Note**: This is a production-ready testing API with mock responses. The architecture is designed for easy integration with real LLM backends (PydanticAI, Ollama, OpenAI, etc.).
|
**Note**: Tatlock is a production-ready homelab butler. All household staff use PydanticAI with Ollama for local LLM inference.
|
||||||
|
|||||||
@@ -1,424 +0,0 @@
|
|||||||
# Library-Desk API Requirements for Tatlock Integration
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Librarian agent in Tatlock needs additional endpoints in library-desk to support wiki page editing and content management. Currently, the API provides read operations but The Librarian needs write capabilities for:
|
|
||||||
|
|
||||||
- Creating new wiki pages
|
|
||||||
- Updating existing wiki pages (content, title, tags, description)
|
|
||||||
|
|
||||||
## Required Endpoints
|
|
||||||
|
|
||||||
### 1. Create Wiki Page (Already Exists)
|
|
||||||
|
|
||||||
**Endpoint:** `POST /wiki/pages`
|
|
||||||
|
|
||||||
This endpoint already exists and works correctly.
|
|
||||||
|
|
||||||
### 2. Update Wiki Page (Needs Enhancement)
|
|
||||||
|
|
||||||
**Endpoint:** `PUT /wiki/pages/{page_id}`
|
|
||||||
|
|
||||||
**Current Status:** May exist but needs verification that it supports partial updates.
|
|
||||||
|
|
||||||
**Required Behavior:**
|
|
||||||
- Accept partial updates (only provided fields should be updated)
|
|
||||||
- Support updating: `content`, `title`, `tags`, `description`
|
|
||||||
- Auto-update vector embeddings after content changes
|
|
||||||
- Auto-update knowledge graph after content changes
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": "# New Content\n\nOptional - only if changing content",
|
|
||||||
"title": "Optional - only if renaming",
|
|
||||||
"tags": ["optional", "list", "of", "new", "tags"],
|
|
||||||
"description": "Optional new description"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `user`: User identifier for multi-tenancy (required)
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"path": "/projects/example",
|
|
||||||
"title": "Updated Title",
|
|
||||||
"description": "Updated description",
|
|
||||||
"content": "# New Content...",
|
|
||||||
"tags": ["updated", "tags"],
|
|
||||||
"updated_at": "2024-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Should trigger background tasks to re-index vectors and refresh graph entities
|
|
||||||
- Should validate that user has access to the page (namespace check)
|
|
||||||
- Should preserve fields that are not provided in the request
|
|
||||||
|
|
||||||
## Use Cases for The Librarian
|
|
||||||
|
|
||||||
### Adding New Knowledge
|
|
||||||
When a user says "Add this to the wiki" or "Create a page about X":
|
|
||||||
- Librarian uses `POST /wiki/pages` to create the page
|
|
||||||
- Tags are assigned based on context (dossiers)
|
|
||||||
|
|
||||||
### Correcting Information
|
|
||||||
When a user says "Update the page about X" or "Fix this fact":
|
|
||||||
1. Librarian searches for the page with `GET /wiki/search`
|
|
||||||
2. Fetches full content with `GET /wiki/pages/{id}`
|
|
||||||
3. Updates with corrected content via `PUT /wiki/pages/{id}`
|
|
||||||
|
|
||||||
### Organizing Knowledge
|
|
||||||
When a user says "Add this page to the projects dossier":
|
|
||||||
- Librarian updates just the tags field via `PUT /wiki/pages/{id}`
|
|
||||||
|
|
||||||
## Integration Notes
|
|
||||||
|
|
||||||
- The Librarian will call these endpoints via HTTP from Tatlock
|
|
||||||
- Authentication uses Bearer token (LIBRARY_DESK_API_KEY)
|
|
||||||
- All operations are scoped to the user's namespace
|
|
||||||
- Background processing (vectors, graph) should not block the response
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] `PUT /wiki/pages/{page_id}` accepts partial updates
|
|
||||||
- [ ] Updating content triggers vector re-indexing
|
|
||||||
- [ ] Updating content triggers graph entity extraction
|
|
||||||
- [ ] Tags can be updated independently of content
|
|
||||||
- [ ] Description can be updated independently
|
|
||||||
- [ ] Title can be updated (with path remaining the same)
|
|
||||||
- [ ] User namespace validation works correctly
|
|
||||||
|
|
||||||
|
|
||||||
===== IMPLEMENTATION INSTRUCTIONS =========
|
|
||||||
# Librarian Wiki Integration Guide
|
|
||||||
|
|
||||||
This document provides implementation instructions for integrating the library-desk wiki endpoints into the Librarian agent (Tatlock).
|
|
||||||
|
|
||||||
## Available Endpoints
|
|
||||||
|
|
||||||
### 1. Create Wiki Page
|
|
||||||
|
|
||||||
**Endpoint:** `POST /wiki/pages`
|
|
||||||
|
|
||||||
Use this for simple page creation when the Librarian already has the content.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def create_wiki_page(
|
|
||||||
title: str,
|
|
||||||
path: str,
|
|
||||||
content: str,
|
|
||||||
tags: list[str],
|
|
||||||
description: str = "",
|
|
||||||
user: str = "default"
|
|
||||||
) -> dict:
|
|
||||||
"""Create a new wiki page."""
|
|
||||||
response = await http_client.post(
|
|
||||||
f"{LIBRARY_DESK_URL}/wiki/pages",
|
|
||||||
headers={"Authorization": f"Bearer {LIBRARY_DESK_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"title": title,
|
|
||||||
"path": path,
|
|
||||||
"content": content,
|
|
||||||
"tags": tags,
|
|
||||||
"description": description,
|
|
||||||
"user": user
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- User provides specific content to add
|
|
||||||
- Librarian has already composed the content
|
|
||||||
- Simple note-taking or quick additions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Smart Create Wiki Page (Recommended for Research)
|
|
||||||
|
|
||||||
**Endpoint:** `POST /wiki/pages/smart-create`
|
|
||||||
|
|
||||||
Use this when the Librarian should research a topic before creating the page. This endpoint:
|
|
||||||
1. Searches existing wiki, knowledge graph, and web for context
|
|
||||||
2. Uses LLM to synthesize findings into structured content
|
|
||||||
3. Creates the page with proper attribution
|
|
||||||
4. Automatically links entities bidirectionally
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def smart_create_wiki_page(
|
|
||||||
topic: str,
|
|
||||||
tags: list[str],
|
|
||||||
user: str = "default",
|
|
||||||
path: str | None = None,
|
|
||||||
include_web_research: bool = True,
|
|
||||||
include_wiki_search: bool = True
|
|
||||||
) -> dict:
|
|
||||||
"""Create a wiki page with HybridRAG research."""
|
|
||||||
response = await http_client.post(
|
|
||||||
f"{LIBRARY_DESK_URL}/wiki/pages/smart-create",
|
|
||||||
headers={"Authorization": f"Bearer {LIBRARY_DESK_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"topic": topic,
|
|
||||||
"path": path, # Optional - auto-generated from topic if not provided
|
|
||||||
"tags": tags,
|
|
||||||
"user": user,
|
|
||||||
"include_web_research": include_web_research,
|
|
||||||
"include_wiki_search": include_wiki_search
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response includes:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"page": {
|
|
||||||
"id": 123,
|
|
||||||
"path": "/users/jpmschweitzer/technology/docker-orchestration",
|
|
||||||
"title": "Docker orchestration",
|
|
||||||
"content": "# Docker Orchestration\n\n...",
|
|
||||||
"tags": ["technology", "devops"],
|
|
||||||
"created_at": "2024-01-15T10:30:00Z",
|
|
||||||
"updated_at": "2024-01-15T10:30:00Z"
|
|
||||||
},
|
|
||||||
"research_summary": {
|
|
||||||
"wiki_results": 3,
|
|
||||||
"web_results": 8,
|
|
||||||
"graph_entities": 5,
|
|
||||||
"keywords_extracted": 12,
|
|
||||||
"timing_ms": 4500
|
|
||||||
},
|
|
||||||
"sources_used": 11,
|
|
||||||
"search_id": "uuid-for-reference",
|
|
||||||
"entity_linking": {
|
|
||||||
"forward_links": 5,
|
|
||||||
"backward_links": 3,
|
|
||||||
"pages_updated": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- User says "Create a page about X"
|
|
||||||
- User says "Add information about X to the wiki"
|
|
||||||
- Librarian needs to research before writing
|
|
||||||
- Topic benefits from context from existing knowledge
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Update Wiki Page
|
|
||||||
|
|
||||||
**Endpoint:** `PUT /wiki/pages/{page_id}`
|
|
||||||
|
|
||||||
Use this for modifying existing pages. Supports partial updates.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def update_wiki_page(
|
|
||||||
page_id: int,
|
|
||||||
user: str = "default",
|
|
||||||
content: str | None = None,
|
|
||||||
title: str | None = None,
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
description: str | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""Update an existing wiki page (partial updates supported)."""
|
|
||||||
# Only include fields that are being updated
|
|
||||||
update_data = {}
|
|
||||||
if content is not None:
|
|
||||||
update_data["content"] = content
|
|
||||||
if title is not None:
|
|
||||||
update_data["title"] = title
|
|
||||||
if tags is not None:
|
|
||||||
update_data["tags"] = tags
|
|
||||||
if description is not None:
|
|
||||||
update_data["description"] = description
|
|
||||||
|
|
||||||
response = await http_client.put(
|
|
||||||
f"{LIBRARY_DESK_URL}/wiki/pages/{page_id}?user={user}",
|
|
||||||
headers={"Authorization": f"Bearer {LIBRARY_DESK_API_KEY}"},
|
|
||||||
json=update_data
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- User says "Update the page about X"
|
|
||||||
- User says "Fix this information"
|
|
||||||
- User says "Add this page to the projects dossier" (update tags only)
|
|
||||||
- Correcting or enhancing existing content
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Search Wiki Pages
|
|
||||||
|
|
||||||
**Endpoint:** `GET /wiki/search`
|
|
||||||
|
|
||||||
Use this to find existing pages before updating.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def search_wiki(
|
|
||||||
query: str,
|
|
||||||
user: str = "default"
|
|
||||||
) -> dict:
|
|
||||||
"""Search wiki pages."""
|
|
||||||
response = await http_client.get(
|
|
||||||
f"{LIBRARY_DESK_URL}/wiki/search",
|
|
||||||
headers={"Authorization": f"Bearer {LIBRARY_DESK_API_KEY}"},
|
|
||||||
params={"q": query, "user": user}
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Get Wiki Page
|
|
||||||
|
|
||||||
**Endpoint:** `GET /wiki/pages/{page_id}`
|
|
||||||
|
|
||||||
Use this to fetch full page content before editing.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_wiki_page(
|
|
||||||
page_id: int,
|
|
||||||
user: str = "default"
|
|
||||||
) -> dict:
|
|
||||||
"""Get a wiki page by ID."""
|
|
||||||
response = await http_client.get(
|
|
||||||
f"{LIBRARY_DESK_URL}/wiki/pages/{page_id}",
|
|
||||||
headers={"Authorization": f"Bearer {LIBRARY_DESK_API_KEY}"},
|
|
||||||
params={"user": user}
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision Flow for Librarian
|
|
||||||
|
|
||||||
```
|
|
||||||
User Request
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Does user want to CREATE or UPDATE a page? │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
CREATE UPDATE
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────┐ ┌──────────────────────┐
|
|
||||||
│ Does Librarian │ │ Search for the page │
|
|
||||||
│ need to research│ │ GET /wiki/search │
|
|
||||||
│ the topic? │ └──────────────────────┘
|
|
||||||
└─────────────────┘ │
|
|
||||||
│ │ ▼
|
|
||||||
▼ ▼ ┌──────────────────────┐
|
|
||||||
YES NO │ Get full page content│
|
|
||||||
│ │ │ GET /wiki/pages/{id} │
|
|
||||||
▼ ▼ └──────────────────────┘
|
|
||||||
┌─────────┐ ┌─────────┐ │
|
|
||||||
│ smart- │ │ POST │ ▼
|
|
||||||
│ create │ │ /wiki/ │ ┌──────────────────────┐
|
|
||||||
│ │ │ pages │ │ Update the page │
|
|
||||||
└─────────┘ └─────────┘ │ PUT /wiki/pages/{id} │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Use Cases
|
|
||||||
|
|
||||||
### 1. "Create a page about Docker Compose"
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Use smart-create for research-backed content
|
|
||||||
result = await smart_create_wiki_page(
|
|
||||||
topic="Docker Compose",
|
|
||||||
tags=["technology", "devops", "containers"],
|
|
||||||
user="jpmschweitzer"
|
|
||||||
)
|
|
||||||
# Returns page with synthesized content from wiki + web research
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. "Add this note to the wiki: Remember to renew SSL cert on Jan 15"
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Use simple create for user-provided content
|
|
||||||
result = await create_wiki_page(
|
|
||||||
title="SSL Certificate Renewal Reminder",
|
|
||||||
path="/reminders/ssl-renewal",
|
|
||||||
content="# SSL Certificate Renewal\n\nRemember to renew SSL cert on Jan 15",
|
|
||||||
tags=["reminders", "infrastructure"],
|
|
||||||
user="jpmschweitzer"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. "Update the page about my home server to add the new IP"
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 1. Search for the page
|
|
||||||
search_results = await search_wiki("home server", user="jpmschweitzer")
|
|
||||||
page_id = search_results["results"][0]["id"]
|
|
||||||
|
|
||||||
# 2. Get current content
|
|
||||||
page = await get_wiki_page(page_id, user="jpmschweitzer")
|
|
||||||
|
|
||||||
# 3. Modify content (Librarian edits the markdown)
|
|
||||||
new_content = page["content"] + "\n\n## Updated IP\n\nNew IP: 192.168.1.100"
|
|
||||||
|
|
||||||
# 4. Update the page
|
|
||||||
result = await update_wiki_page(
|
|
||||||
page_id=page_id,
|
|
||||||
content=new_content,
|
|
||||||
user="jpmschweitzer"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. "Add this page to the projects dossier"
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Update only tags (partial update)
|
|
||||||
result = await update_wiki_page(
|
|
||||||
page_id=page_id,
|
|
||||||
tags=["projects", "existing-tag"], # Add "projects" tag
|
|
||||||
user="jpmschweitzer"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background Processing
|
|
||||||
|
|
||||||
All write operations trigger background tasks that:
|
|
||||||
|
|
||||||
1. **Vector Indexing:** Chunks content and generates embeddings in Qdrant
|
|
||||||
2. **Graph Extraction:** Extracts entities and creates Neo4j relationships
|
|
||||||
3. **Entity Linking:** (smart-create only) Links entities bidirectionally
|
|
||||||
|
|
||||||
These run asynchronously and don't block the API response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All endpoints require Bearer token authentication:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {LIBRARY_DESK_API_KEY}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Multi-Tenancy
|
|
||||||
|
|
||||||
All operations are scoped to the user's namespace:
|
|
||||||
- Pages are stored under `/users/{user}/...`
|
|
||||||
- Vector collections are per-user: `library_desk_{user}`
|
|
||||||
- Graph nodes are labeled per-user: `User_{User}_Document`
|
|
||||||
|
|
||||||
Always pass the `user` parameter to ensure proper isolation.
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ starlette>=0.45,<0.46
|
|||||||
# hiredis: C parser for better performance
|
# hiredis: C parser for better performance
|
||||||
redis[hiredis]>=5.2,<6.0
|
redis[hiredis]>=5.2,<6.0
|
||||||
|
|
||||||
|
# Qdrant vector database client for memory storage
|
||||||
|
# Latest: 1.12.1 (Dec 2025) - No known CVEs
|
||||||
|
qdrant-client>=1.12,<2.0
|
||||||
|
|
||||||
# Structured logging for observability
|
# Structured logging for observability
|
||||||
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
||||||
structlog>=24.1,<25.0
|
structlog>=24.1,<25.0
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
The Biographer - Expert for recording and recalling the user's story.
|
||||||
|
|
||||||
|
The Biographer serves as the household's memory keeper, responsible for:
|
||||||
|
- Recording and recalling facts about the user's life
|
||||||
|
- Storing personal information, preferences, and insights
|
||||||
|
- Answering questions like "What car do I drive?", "Where do I work?"
|
||||||
|
- Managing what the household knows and remembers
|
||||||
|
|
||||||
|
For direct key-based lookups (location, timezone, preferences),
|
||||||
|
use the memory_service instead - it's faster and doesn't require LLM.
|
||||||
|
The Biographer handles semantic, fuzzy queries.
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import (
|
||||||
|
get_biographer_agent,
|
||||||
|
run_biographer,
|
||||||
|
run_biographer_stream,
|
||||||
|
)
|
||||||
|
from src.agents.biographer.capability import (
|
||||||
|
BIOGRAPHER_CAPABILITY,
|
||||||
|
get_biographer_capability,
|
||||||
|
register_biographer,
|
||||||
|
unregister_biographer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BIOGRAPHER_CAPABILITY",
|
||||||
|
"get_biographer_capability",
|
||||||
|
"get_biographer_agent",
|
||||||
|
"register_biographer",
|
||||||
|
"unregister_biographer",
|
||||||
|
"run_biographer",
|
||||||
|
"run_biographer_stream",
|
||||||
|
]
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
The Biographer - Expert for recording and recalling the user's story.
|
||||||
|
|
||||||
|
A PydanticAI agent that serves as the household's memory keeper:
|
||||||
|
- Records facts about the user's life, work, and preferences
|
||||||
|
- Recalls information semantically ("What car do I drive?")
|
||||||
|
- Manages user profile and preferences
|
||||||
|
- Forgets information when requested
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from src.agents.biographer.tools import (
|
||||||
|
forget_memory,
|
||||||
|
list_memories,
|
||||||
|
recall_semantic,
|
||||||
|
store_insight,
|
||||||
|
update_preference,
|
||||||
|
update_profile,
|
||||||
|
)
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# The Biographer's system prompt
|
||||||
|
BIOGRAPHER_SYSTEM_PROMPT = """You are The Biographer, the household's memory keeper in the Tatlock estate.
|
||||||
|
|
||||||
|
Your role is to record, recall, and manage the story of the user's life:
|
||||||
|
- Personal facts (vehicle, pets, family members, hobbies, interests)
|
||||||
|
- Life details (employer, occupation, significant events)
|
||||||
|
- Profile information (name, location, timezone)
|
||||||
|
- Preferences (units, theme, communication style)
|
||||||
|
|
||||||
|
## Your Character
|
||||||
|
|
||||||
|
You are a discreet and attentive chronicler. Like a personal biographer who has been
|
||||||
|
with the household for years, you:
|
||||||
|
- Listen carefully and remember important details
|
||||||
|
- Recall information accurately when asked
|
||||||
|
- Never gossip or volunteer unnecessary information
|
||||||
|
- Respect privacy absolutely
|
||||||
|
- Acknowledge when you don't know something rather than guessing
|
||||||
|
|
||||||
|
## Your Tools
|
||||||
|
|
||||||
|
### Recalling the Story
|
||||||
|
- **recall_semantic**: Your primary tool for answering questions about the user
|
||||||
|
- "What car do I drive?" → searches for car-related memories
|
||||||
|
- "Where do I work?" → finds employment information
|
||||||
|
- Finds relevant memories even without exact keywords
|
||||||
|
- **list_memories**: Browse all recorded memories of a type
|
||||||
|
- Use when user asks "What do you know about me?"
|
||||||
|
- Shows everything you've recorded
|
||||||
|
|
||||||
|
### Recording New Details
|
||||||
|
- **store_insight**: Record new facts from conversation
|
||||||
|
- User says "My car is a Tesla" → store_insight("car", "Tesla Model 3")
|
||||||
|
- User says "I work at Acme" → store_insight("employer", "Acme Corp")
|
||||||
|
- Use for facts that don't fit standard profile fields
|
||||||
|
- **update_profile**: Update core biographical fields
|
||||||
|
- name, location, timezone only
|
||||||
|
- "I live in Amsterdam" → update_profile("location", "Amsterdam")
|
||||||
|
- **update_preference**: Record user preferences
|
||||||
|
- temperature_unit, distance_unit, theme, etc.
|
||||||
|
- "Use Celsius please" → update_preference("temperature_unit", "celsius")
|
||||||
|
|
||||||
|
### Managing Records
|
||||||
|
- **forget_memory**: Remove specific records
|
||||||
|
- User asks to forget something → honor immediately
|
||||||
|
- Information becomes outdated → remove it
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### What to Record
|
||||||
|
- Explicit statements: "I drive a Tesla", "My wife is Sarah"
|
||||||
|
- Corrections: "Actually, I moved to Berlin"
|
||||||
|
- Preferences: "I prefer metric units"
|
||||||
|
|
||||||
|
### What NOT to Record
|
||||||
|
- Sensitive data: passwords, financial details, health information
|
||||||
|
- Temporary information: "I'm tired today"
|
||||||
|
- Speculation or assumptions
|
||||||
|
|
||||||
|
### Responding to Tatlock
|
||||||
|
Your responses go to Tatlock (the butler) who synthesizes the final answer. Be:
|
||||||
|
- Direct and factual
|
||||||
|
- Clear about what you found or didn't find
|
||||||
|
- Structured for easy integration with other responses
|
||||||
|
|
||||||
|
When you don't have information:
|
||||||
|
"I have no record of the user's [topic]. Would you like me to record this information?"
|
||||||
|
|
||||||
|
When recalling:
|
||||||
|
"According to my records, [information]. This was recorded [source/when if available]."
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lazy initialization to avoid connection issues during imports
|
||||||
|
_biographer_agent: Optional[Agent[None, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_biographer_agent() -> Agent[None, str]:
|
||||||
|
"""Create The Biographer PydanticAI agent."""
|
||||||
|
# Import required classes for Ollama configuration
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from pydantic_ai.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
# PydanticAI expects Ollama base URL to end with /v1
|
||||||
|
clean_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
# Create Ollama model with provider
|
||||||
|
model = OpenAIChatModel(
|
||||||
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
provider=OllamaProvider(base_url=base_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[None, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=BIOGRAPHER_SYSTEM_PROMPT,
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register recall tools
|
||||||
|
agent.tool_plain(recall_semantic)
|
||||||
|
agent.tool_plain(list_memories)
|
||||||
|
|
||||||
|
# Register recording tools
|
||||||
|
agent.tool_plain(store_insight)
|
||||||
|
agent.tool_plain(update_profile)
|
||||||
|
agent.tool_plain(update_preference)
|
||||||
|
|
||||||
|
# Register management tools
|
||||||
|
agent.tool_plain(forget_memory)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_agent_created",
|
||||||
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
|
tool_count=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def get_biographer_agent() -> Agent[None, str]:
|
||||||
|
"""
|
||||||
|
Get The Biographer agent instance (lazy initialization).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PydanticAI Agent configured for memory tasks
|
||||||
|
"""
|
||||||
|
global _biographer_agent
|
||||||
|
if _biographer_agent is None:
|
||||||
|
_biographer_agent = _create_biographer_agent()
|
||||||
|
return _biographer_agent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute a memory task with The Biographer.
|
||||||
|
|
||||||
|
This is the main entry point for delegating memory tasks
|
||||||
|
from Tatlock or other agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The memory task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory results or confirmation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await run_biographer(
|
||||||
|
task="What car do I drive?",
|
||||||
|
context="User is asking about their vehicle",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
agent = get_biographer_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_task_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
has_history=bool(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent.run(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_task_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(result.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"biographer_task_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return f"The Biographer encountered an error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_biographer_stream(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
message_history: Optional[list[Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Execute a memory task with streaming output.
|
||||||
|
|
||||||
|
Yields text deltas as The Biographer generates the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The memory task or question
|
||||||
|
context: Additional context from conversation
|
||||||
|
message_history: Optional conversation history
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Text deltas from the response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async for delta in run_biographer_stream("What do you know about me?"):
|
||||||
|
print(delta, end="", flush=True)
|
||||||
|
"""
|
||||||
|
agent = get_biographer_agent()
|
||||||
|
|
||||||
|
# Build prompt with context if provided
|
||||||
|
prompt = task
|
||||||
|
if context:
|
||||||
|
prompt = f"Context: {context}\n\nTask: {task}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_stream_started",
|
||||||
|
task=task[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with agent.run_stream(
|
||||||
|
prompt,
|
||||||
|
message_history=message_history,
|
||||||
|
) as response:
|
||||||
|
async for delta in response.stream_text(delta=True):
|
||||||
|
yield delta
|
||||||
|
|
||||||
|
logger.info("biographer_stream_completed", task=task[:50])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"biographer_stream_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield f"\n\nThe Biographer encountered an error: {str(e)}"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Biographer capability registration for the Household Registry.
|
||||||
|
|
||||||
|
Defines The Biographer's capabilities and registers it as a
|
||||||
|
household member for coordination by the Steward and Tatlock.
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import get_biographer_agent
|
||||||
|
from src.agents.biographer.tools import BIOGRAPHER_TOOLS
|
||||||
|
from src.core.household_registry import (
|
||||||
|
HouseholdCapability,
|
||||||
|
get_household_registry,
|
||||||
|
)
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The Biographer's capability summary for Steward coordination
|
||||||
|
BIOGRAPHER_CAPABILITY = HouseholdCapability(
|
||||||
|
name="biographer",
|
||||||
|
role="The Biographer",
|
||||||
|
category="context",
|
||||||
|
description=(
|
||||||
|
"Memory keeper for the user's story: can RECALL personal facts "
|
||||||
|
"(car, job, family, pets), RECORD new information learned from "
|
||||||
|
"conversation, UPDATE profile (name, location, timezone) and "
|
||||||
|
"preferences (units, theme), and FORGET information when requested. "
|
||||||
|
"Use for: 'what car do I drive?', 'remember that I...', "
|
||||||
|
"'forget my...', 'what do you know about me?'"
|
||||||
|
),
|
||||||
|
domains=[
|
||||||
|
"remember",
|
||||||
|
"recall",
|
||||||
|
"forget",
|
||||||
|
"memory",
|
||||||
|
"preferences",
|
||||||
|
"profile",
|
||||||
|
"personal",
|
||||||
|
"know",
|
||||||
|
"about me",
|
||||||
|
"my",
|
||||||
|
],
|
||||||
|
cost="low", # Mostly vector search, minimal LLM
|
||||||
|
requires_network=False, # All local (Qdrant, Redis)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_biographer_capability() -> HouseholdCapability:
|
||||||
|
"""Get The Biographer's capability definition."""
|
||||||
|
return BIOGRAPHER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
def register_biographer() -> None:
|
||||||
|
"""
|
||||||
|
Register The Biographer with the Household Registry.
|
||||||
|
|
||||||
|
This makes The Biographer available for:
|
||||||
|
- Steward recommendations (via capability summary)
|
||||||
|
- Tatlock delegation (via agent reference)
|
||||||
|
- Tool scoping (via tool list)
|
||||||
|
"""
|
||||||
|
registry = get_household_registry()
|
||||||
|
|
||||||
|
# Check if already registered
|
||||||
|
if "biographer" in registry:
|
||||||
|
logger.debug("biographer_already_registered")
|
||||||
|
return
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="biographer",
|
||||||
|
capability=BIOGRAPHER_CAPABILITY,
|
||||||
|
tools=BIOGRAPHER_TOOLS,
|
||||||
|
agent=get_biographer_agent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"biographer_registered",
|
||||||
|
role=BIOGRAPHER_CAPABILITY.role,
|
||||||
|
domains=BIOGRAPHER_CAPABILITY.domains,
|
||||||
|
tool_count=len(BIOGRAPHER_TOOLS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_biographer() -> None:
|
||||||
|
"""Unregister The Biographer from the Household Registry."""
|
||||||
|
registry = get_household_registry()
|
||||||
|
registry.unregister("biographer")
|
||||||
|
logger.info("biographer_unregistered")
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Biographer tools for PydanticAI agent.
|
||||||
|
|
||||||
|
These tools enable The Biographer to record and recall the user's story:
|
||||||
|
- recall_semantic: Find memories by meaning/concept
|
||||||
|
- store_insight: Record new facts about the user
|
||||||
|
- list_memories: Browse recorded memories by type
|
||||||
|
- forget_memory: Remove specific memories
|
||||||
|
|
||||||
|
For direct key-based access (get/set profile, preferences),
|
||||||
|
use memory_service directly - these tools are for semantic queries.
|
||||||
|
"""
|
||||||
|
from src.core.context import get_user
|
||||||
|
from src.core.embeddings import get_embedding_client
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.memory_service import MemoryType, memory_service
|
||||||
|
from src.core.qdrant import get_qdrant_client
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Semantic Recall
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def recall_semantic(
|
||||||
|
query: str,
|
||||||
|
memory_type: str | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search memories by semantic similarity.
|
||||||
|
|
||||||
|
Use this to find memories that are conceptually related to
|
||||||
|
the query, even if exact words don't match. This is the main
|
||||||
|
tool for answering questions like "What car do I drive?" or
|
||||||
|
"What did I mention about my job?"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language query to search for
|
||||||
|
memory_type: Optional filter: "user_profile", "preference", "learned_fact"
|
||||||
|
limit: Maximum memories to return (default: 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching memories with their content and relevance scores
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
recall_semantic("What is my car?")
|
||||||
|
recall_semantic("work preferences", memory_type="preference")
|
||||||
|
recall_semantic("family members")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_user()
|
||||||
|
embedding_client = get_embedding_client()
|
||||||
|
qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
# Generate embedding for query
|
||||||
|
query_vector = await embedding_client.embed(query)
|
||||||
|
if not query_vector:
|
||||||
|
return "Unable to process query - embedding generation failed"
|
||||||
|
|
||||||
|
# Search memories
|
||||||
|
results = await qdrant.search_memories(
|
||||||
|
user=user,
|
||||||
|
query_vector=query_vector,
|
||||||
|
limit=limit,
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No memories found related to '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Memories matching: {query}\n"]
|
||||||
|
|
||||||
|
for i, memory in enumerate(results, 1):
|
||||||
|
mem_type = memory.get("type", "unknown")
|
||||||
|
key = memory.get("key", "")
|
||||||
|
value = memory.get("value", "")
|
||||||
|
score = memory.get("score", 0.0)
|
||||||
|
source = memory.get("source", "unknown")
|
||||||
|
|
||||||
|
type_icon = {
|
||||||
|
"user_profile": "👤",
|
||||||
|
"preference": "⚙️",
|
||||||
|
"learned_fact": "💡",
|
||||||
|
}.get(mem_type, "📝")
|
||||||
|
|
||||||
|
output_parts.append(f"{i}. {type_icon} **{key}** (relevance: {score:.2f})")
|
||||||
|
output_parts.append(f" {value}")
|
||||||
|
output_parts.append(f" _Type: {mem_type}, Source: {source}_")
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_recall_semantic",
|
||||||
|
query=query[:50],
|
||||||
|
result_count=len(results),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_recall_semantic_error", error=str(e), query=query[:50])
|
||||||
|
return f"Error searching memories: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Store Memory
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def store_insight(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
importance: float = 0.5,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Store a new insight or learned fact about the user.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User explicitly asks to remember something
|
||||||
|
- User shares personal information worth remembering
|
||||||
|
- You learn something from conversation that should persist
|
||||||
|
|
||||||
|
The memory will be stored with vector embedding for semantic search
|
||||||
|
and can be recalled later using recall_semantic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Short identifier for the memory (e.g., "car", "employer", "pet")
|
||||||
|
value: The actual information to remember
|
||||||
|
keywords: Optional keywords for better search (auto-extracted if not provided)
|
||||||
|
importance: How important is this? 0.0 (trivial) to 1.0 (critical)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of stored memory
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
store_insight("car", "User drives a Tesla Model 3")
|
||||||
|
store_insight("employer", "Works at Acme Corp as software engineer", importance=0.8)
|
||||||
|
store_insight("coffee", "Prefers oat milk lattes", keywords=["coffee", "drink", "preference"])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Auto-generate keywords if not provided
|
||||||
|
if not keywords:
|
||||||
|
keywords = [key]
|
||||||
|
# Extract simple keywords from value
|
||||||
|
words = value.lower().split()
|
||||||
|
keywords.extend([w for w in words if len(w) > 4][:5])
|
||||||
|
|
||||||
|
success = await memory_service.store_fact(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords,
|
||||||
|
importance=importance,
|
||||||
|
source="conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Memory Stored",
|
||||||
|
f"**Key:** {key}",
|
||||||
|
f"**Value:** {value}",
|
||||||
|
f"**Keywords:** {', '.join(keywords)}",
|
||||||
|
f"**Importance:** {importance:.1f}",
|
||||||
|
"",
|
||||||
|
"_Memory is now searchable via semantic recall._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_store_insight",
|
||||||
|
key=key,
|
||||||
|
importance=importance,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to store memory for key '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_store_insight_error", error=str(e), key=key)
|
||||||
|
return f"Error storing memory: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Update user profile information.
|
||||||
|
|
||||||
|
Use this for core identity information:
|
||||||
|
- name, location, timezone
|
||||||
|
- language preferences
|
||||||
|
- occupation
|
||||||
|
|
||||||
|
Profile data has high importance and is used for context
|
||||||
|
by the Steward during request analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile field (e.g., "name", "location", "timezone")
|
||||||
|
value: The value to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of profile update
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
update_profile("location", "Amsterdam, Netherlands")
|
||||||
|
update_profile("timezone", "Europe/Amsterdam")
|
||||||
|
update_profile("name", "John")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await memory_service.set_profile(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=[key, "profile"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Profile Updated",
|
||||||
|
f"**{key}:** {value}",
|
||||||
|
"",
|
||||||
|
"_Profile data is automatically included in context._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_update_profile",
|
||||||
|
key=key,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to update profile field '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_update_profile_error", error=str(e), key=key)
|
||||||
|
return f"Error updating profile: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_preference(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Update user preferences.
|
||||||
|
|
||||||
|
Use this for settings and preferences:
|
||||||
|
- temperature_unit (celsius/fahrenheit)
|
||||||
|
- distance_unit (metric/imperial)
|
||||||
|
- theme, language, etc.
|
||||||
|
|
||||||
|
Preferences are used by agents to customize responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference name (e.g., "temperature_unit", "theme")
|
||||||
|
value: Preference value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of preference update
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
update_preference("temperature_unit", "celsius")
|
||||||
|
update_preference("distance_unit", "metric")
|
||||||
|
update_preference("theme", "dark")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await memory_service.set_preference(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Preference Updated",
|
||||||
|
f"**{key}:** {value}",
|
||||||
|
"",
|
||||||
|
"_Preference will be applied to future responses._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_update_preference",
|
||||||
|
key=key,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Failed to update preference '{key}'"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_update_preference_error", error=str(e), key=key)
|
||||||
|
return f"Error updating preference: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# List Memories
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def list_memories(
|
||||||
|
memory_type: str = "learned_fact",
|
||||||
|
limit: int = 20,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
List stored memories of a specific type.
|
||||||
|
|
||||||
|
Use this to browse what's stored in memory without
|
||||||
|
a specific search query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
memory_type: Type to list: "user_profile", "preference", "learned_fact"
|
||||||
|
limit: Maximum memories to return (default: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of memories with their keys and values
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
list_memories("user_profile")
|
||||||
|
list_memories("preference")
|
||||||
|
list_memories("learned_fact", limit=10)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_user()
|
||||||
|
qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
# Convert string to MemoryType
|
||||||
|
try:
|
||||||
|
mem_type = MemoryType(memory_type)
|
||||||
|
except ValueError:
|
||||||
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
||||||
|
|
||||||
|
# Get all memories of type
|
||||||
|
results = qdrant._client.scroll(
|
||||||
|
collection_name=f"memories_{user}",
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
if not points:
|
||||||
|
return f"No {memory_type} memories found"
|
||||||
|
|
||||||
|
type_icon = {
|
||||||
|
"user_profile": "👤",
|
||||||
|
"preference": "⚙️",
|
||||||
|
"learned_fact": "💡",
|
||||||
|
}.get(memory_type, "📝")
|
||||||
|
|
||||||
|
output_parts = [f"## {type_icon} {memory_type.replace('_', ' ').title()} Memories\n"]
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
payload = point.payload
|
||||||
|
key = payload.get("key", "unknown")
|
||||||
|
value = payload.get("value", "")
|
||||||
|
importance = payload.get("importance", 0.5)
|
||||||
|
|
||||||
|
output_parts.append(f"- **{key}**: {value}")
|
||||||
|
if importance > 0.7:
|
||||||
|
output_parts.append(f" _(importance: {importance:.1f})_")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_list",
|
||||||
|
memory_type=memory_type,
|
||||||
|
count=len(points),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_list_error", error=str(e), memory_type=memory_type)
|
||||||
|
return f"Error listing memories: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Forget Memory
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def forget_memory(
|
||||||
|
key: str,
|
||||||
|
memory_type: str = "learned_fact",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Remove a specific memory.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
- User asks to forget something
|
||||||
|
- Information is outdated or incorrect
|
||||||
|
- Privacy concerns
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Key of the memory to forget
|
||||||
|
memory_type: Type of memory: "user_profile", "preference", "learned_fact"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of deletion
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
forget_memory("old_car")
|
||||||
|
forget_memory("location", memory_type="user_profile")
|
||||||
|
forget_memory("theme", memory_type="preference")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert string to MemoryType
|
||||||
|
try:
|
||||||
|
mem_type = MemoryType(memory_type)
|
||||||
|
except ValueError:
|
||||||
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
||||||
|
|
||||||
|
success = await memory_service.delete_memory(
|
||||||
|
key=key,
|
||||||
|
memory_type=mem_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
output_parts = [
|
||||||
|
"## Memory Forgotten",
|
||||||
|
f"**Key:** {key}",
|
||||||
|
f"**Type:** {memory_type}",
|
||||||
|
"",
|
||||||
|
"_Memory has been removed._"
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_forget",
|
||||||
|
key=key,
|
||||||
|
memory_type=memory_type,
|
||||||
|
user=get_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
else:
|
||||||
|
return f"Memory '{key}' not found or already deleted"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_forget_error", error=str(e), key=key)
|
||||||
|
return f"Error forgetting memory: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tool Collection for Registration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# All tools available to The Biographer
|
||||||
|
BIOGRAPHER_TOOLS = [
|
||||||
|
# Recall
|
||||||
|
recall_semantic,
|
||||||
|
list_memories,
|
||||||
|
# Record
|
||||||
|
store_insight,
|
||||||
|
update_profile,
|
||||||
|
update_preference,
|
||||||
|
# Manage
|
||||||
|
forget_memory,
|
||||||
|
]
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
Delegation infrastructure for expert agent calls.
|
||||||
|
|
||||||
|
Provides delegation wrappers that Tatlock uses to call expert agents.
|
||||||
|
Each wrapper encapsulates the complexity of calling an expert and
|
||||||
|
returns a structured result for synthesis.
|
||||||
|
|
||||||
|
This implements the agent-as-tool pattern recommended by PydanticAI:
|
||||||
|
agents call other agents via tool wrappers, keeping each agent focused.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional, Any
|
||||||
|
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DelegationTask:
|
||||||
|
"""
|
||||||
|
A task to be delegated to an expert agent.
|
||||||
|
|
||||||
|
Represents a unit of work that Tatlock delegates to a specialist.
|
||||||
|
Used for tracking and orchestration of multi-expert workflows.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expert_name: Name of the expert agent (e.g., "librarian", "memory")
|
||||||
|
task: Clear description of what needs to be done
|
||||||
|
context: Additional context from the conversation
|
||||||
|
action: Specific action verb (create, search, update, etc.)
|
||||||
|
priority: Execution priority (lower = higher priority)
|
||||||
|
depends_on: List of task IDs this task depends on
|
||||||
|
result: Result from expert after execution
|
||||||
|
"""
|
||||||
|
expert_name: str
|
||||||
|
task: str
|
||||||
|
context: str = ""
|
||||||
|
action: str = ""
|
||||||
|
priority: int = 0
|
||||||
|
depends_on: list[str] = field(default_factory=list)
|
||||||
|
result: Optional[str] = None
|
||||||
|
task_id: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Generate task ID if not provided."""
|
||||||
|
if not self.task_id:
|
||||||
|
import uuid
|
||||||
|
self.task_id = f"{self.expert_name}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DelegationResult:
|
||||||
|
"""
|
||||||
|
Result from an expert agent delegation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expert_name: Which expert handled the task
|
||||||
|
task: Original task description
|
||||||
|
success: Whether the delegation succeeded
|
||||||
|
output: Expert's response/findings
|
||||||
|
error: Error message if failed
|
||||||
|
"""
|
||||||
|
expert_name: str
|
||||||
|
task: str
|
||||||
|
success: bool
|
||||||
|
output: str
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Delegate a research or wiki task to The Librarian.
|
||||||
|
|
||||||
|
The Librarian handles:
|
||||||
|
- Wiki creation (smart_create_wiki_page for topic-based)
|
||||||
|
- Wiki updates (update_wiki_page for modifications)
|
||||||
|
- Research queries (hybrid_search for comprehensive search)
|
||||||
|
- Knowledge graph exploration
|
||||||
|
- Document lookups and semantic search
|
||||||
|
|
||||||
|
This wrapper uses run() not run_stream() to avoid Ollama's
|
||||||
|
streaming + tool call bug (PydanticAI issues #1292, #2256).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear description of what needs to be done.
|
||||||
|
Include the action verb (create, search, update, etc.)
|
||||||
|
Example: "Create a wiki page about CI/CD pipelines"
|
||||||
|
Example: "Search for information about Docker networking"
|
||||||
|
context: Additional context from the user's request or
|
||||||
|
conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult with the Librarian's findings
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = await delegate_to_librarian(
|
||||||
|
... task="Create a wiki page about Kubernetes deployments",
|
||||||
|
... context="User is setting up a homelab cluster",
|
||||||
|
... )
|
||||||
|
>>> if result.success:
|
||||||
|
... print(result.output)
|
||||||
|
"""
|
||||||
|
from src.agents.librarian.agent import run_librarian
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_librarian_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_librarian(task=task, context=context)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_librarian_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task=task,
|
||||||
|
success=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_to_librarian_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delegate_to_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Delegate a memory task to The Biographer.
|
||||||
|
|
||||||
|
The Biographer handles:
|
||||||
|
- Semantic recall ("What car do I drive?", "What's my job?")
|
||||||
|
- Recording new facts from conversation
|
||||||
|
- Profile updates (name, location, timezone)
|
||||||
|
- Preference updates (units, theme)
|
||||||
|
- Memory management (forget, list)
|
||||||
|
|
||||||
|
For direct key-based lookups (get location, get timezone), use
|
||||||
|
memory_service directly - it's faster and doesn't require LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear description of what needs to be done.
|
||||||
|
Include the action verb (recall, remember, forget, etc.)
|
||||||
|
Example: "What car do I drive?"
|
||||||
|
Example: "Remember that I work at Acme Corp"
|
||||||
|
context: Additional context from the user's request or
|
||||||
|
conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult with The Biographer's response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = await delegate_to_biographer(
|
||||||
|
... task="What do you know about my preferences?",
|
||||||
|
... context="User is asking about stored information",
|
||||||
|
... )
|
||||||
|
>>> if result.success:
|
||||||
|
... print(result.output)
|
||||||
|
"""
|
||||||
|
from src.agents.biographer.agent import run_biographer
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_biographer_started",
|
||||||
|
task=task[:100],
|
||||||
|
has_context=bool(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_biographer(task=task, context=context)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_to_biographer_completed",
|
||||||
|
task=task[:50],
|
||||||
|
output_length=len(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="biographer",
|
||||||
|
task=task,
|
||||||
|
success=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"delegation_to_biographer_error",
|
||||||
|
task=task[:50],
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="biographer",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Future expert delegation wrappers will be added here:
|
||||||
|
# - delegate_to_home_automation(task, context) -> DelegationResult
|
||||||
|
# - delegate_to_developer(task, context) -> DelegationResult
|
||||||
@@ -21,8 +21,10 @@ LIBRARIAN_CAPABILITY = HouseholdCapability(
|
|||||||
role="The Librarian",
|
role="The Librarian",
|
||||||
category="research",
|
category="research",
|
||||||
description=(
|
description=(
|
||||||
"Research assistant providing knowledge search, wiki access, "
|
"Research and wiki management: can CREATE wiki pages about topics "
|
||||||
"semantic search, and knowledge graph exploration via library-desk API"
|
"(with automatic HybridRAG research), UPDATE existing pages, "
|
||||||
|
"SEARCH wiki/knowledge graph/web, and synthesize information. "
|
||||||
|
"Use for: 'create a page about X', 'update wiki', 'find info on X'"
|
||||||
),
|
),
|
||||||
domains=[
|
domains=[
|
||||||
"research",
|
"research",
|
||||||
@@ -32,6 +34,9 @@ LIBRARIAN_CAPABILITY = HouseholdCapability(
|
|||||||
"documents",
|
"documents",
|
||||||
"search",
|
"search",
|
||||||
"synthesis",
|
"synthesis",
|
||||||
|
"create",
|
||||||
|
"write",
|
||||||
|
"update",
|
||||||
],
|
],
|
||||||
cost="medium", # Multiple API calls to library-desk
|
cost="medium", # Multiple API calls to library-desk
|
||||||
requires_network=True, # Needs library-desk API access
|
requires_network=True, # Needs library-desk API access
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import httpx
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
|
from src.core.context import get_user
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -179,7 +180,7 @@ class LibraryDeskClient:
|
|||||||
async def hybrid_search(
|
async def hybrid_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
vector_limit: int = 10,
|
vector_limit: int = 10,
|
||||||
graph_limit: int = 10,
|
graph_limit: int = 10,
|
||||||
web_limit: int = 5,
|
web_limit: int = 5,
|
||||||
@@ -191,7 +192,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
user: User identifier for multi-tenancy
|
user: User identifier for multi-tenancy (defaults to request context)
|
||||||
vector_limit: Max results from vector search
|
vector_limit: Max results from vector search
|
||||||
graph_limit: Max results from graph search
|
graph_limit: Max results from graph search
|
||||||
web_limit: Max results from web search
|
web_limit: Max results from web search
|
||||||
@@ -201,6 +202,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
HybridRAGResponse with ranked results and context
|
HybridRAGResponse with ranked results and context
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -255,7 +257,7 @@ class LibraryDeskClient:
|
|||||||
async def search_wiki(
|
async def search_wiki(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
) -> list[WikiSearchResult]:
|
) -> list[WikiSearchResult]:
|
||||||
"""
|
"""
|
||||||
@@ -263,12 +265,13 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
limit: Maximum results
|
limit: Maximum results
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching wiki pages
|
List of matching wiki pages
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
logger.debug("library_desk_wiki_search", query=query, user=user)
|
logger.debug("library_desk_wiki_search", query=query, user=user)
|
||||||
@@ -285,18 +288,19 @@ class LibraryDeskClient:
|
|||||||
async def get_wiki_page(
|
async def get_wiki_page(
|
||||||
self,
|
self,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> WikiPage:
|
) -> WikiPage:
|
||||||
"""
|
"""
|
||||||
Get a wiki page by ID.
|
Get a wiki page by ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_id: Page ID
|
page_id: Page ID
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WikiPage with full content
|
WikiPage with full content
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -309,7 +313,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_wiki_pages(
|
async def list_wiki_pages(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[WikiPage]:
|
) -> list[WikiPage]:
|
||||||
@@ -317,13 +321,14 @@ class LibraryDeskClient:
|
|||||||
List wiki pages, optionally filtered by tag.
|
List wiki pages, optionally filtered by tag.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
tag: Optional tag (dossier) to filter by
|
tag: Optional tag (dossier) to filter by
|
||||||
limit: Maximum pages to return
|
limit: Maximum pages to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of wiki pages
|
List of wiki pages
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
@@ -341,7 +346,7 @@ class LibraryDeskClient:
|
|||||||
title: str,
|
title: str,
|
||||||
path: str,
|
path: str,
|
||||||
content: str,
|
content: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
) -> WikiPage:
|
) -> WikiPage:
|
||||||
@@ -352,13 +357,14 @@ class LibraryDeskClient:
|
|||||||
title: Page title
|
title: Page title
|
||||||
path: Page path (e.g., "/projects/my-project")
|
path: Page path (e.g., "/projects/my-project")
|
||||||
content: Markdown content
|
content: Markdown content
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
description: Short description
|
description: Short description
|
||||||
tags: List of tags (dossiers)
|
tags: List of tags (dossiers)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created WikiPage
|
Created WikiPage
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -380,7 +386,7 @@ class LibraryDeskClient:
|
|||||||
async def update_wiki_page(
|
async def update_wiki_page(
|
||||||
self,
|
self,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
@@ -394,7 +400,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_id: ID of the page to update
|
page_id: ID of the page to update
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
content: New content (optional)
|
content: New content (optional)
|
||||||
title: New title (optional)
|
title: New title (optional)
|
||||||
tags: New tags list (optional)
|
tags: New tags list (optional)
|
||||||
@@ -403,6 +409,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Updated WikiPage
|
Updated WikiPage
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
# Build update payload with only provided fields
|
# Build update payload with only provided fields
|
||||||
@@ -435,7 +442,7 @@ class LibraryDeskClient:
|
|||||||
self,
|
self,
|
||||||
topic: str,
|
topic: str,
|
||||||
tags: list[str],
|
tags: list[str],
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
path: Optional[str] = None,
|
path: Optional[str] = None,
|
||||||
include_web_research: bool = True,
|
include_web_research: bool = True,
|
||||||
include_wiki_search: bool = True,
|
include_wiki_search: bool = True,
|
||||||
@@ -460,6 +467,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
SmartCreateResponse with page and research metadata
|
SmartCreateResponse with page and research metadata
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -499,17 +507,18 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_dossiers(
|
async def list_dossiers(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> list[Dossier]:
|
) -> list[Dossier]:
|
||||||
"""
|
"""
|
||||||
List all dossiers (tag collections) for a user.
|
List all dossiers (tag collections) for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dossiers with page counts
|
List of dossiers with page counts
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -528,7 +537,7 @@ class LibraryDeskClient:
|
|||||||
async def semantic_search(
|
async def semantic_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
score_threshold: float = 0.5,
|
score_threshold: float = 0.5,
|
||||||
) -> list[VectorSearchResult]:
|
) -> list[VectorSearchResult]:
|
||||||
@@ -537,13 +546,14 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Natural language query
|
query: Natural language query
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
limit: Maximum results
|
limit: Maximum results
|
||||||
score_threshold: Minimum similarity score
|
score_threshold: Minimum similarity score
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching document chunks with scores
|
List of matching document chunks with scores
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -568,7 +578,7 @@ class LibraryDeskClient:
|
|||||||
async def query_graph(
|
async def query_graph(
|
||||||
self,
|
self,
|
||||||
cypher_query: str,
|
cypher_query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
parameters: Optional[dict[str, Any]] = None,
|
parameters: Optional[dict[str, Any]] = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -578,12 +588,13 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
cypher_query: Cypher query string
|
cypher_query: Cypher query string
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
parameters: Query parameters
|
parameters: Query parameters
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of result records
|
List of result records
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -601,7 +612,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_graph_nodes(
|
async def list_graph_nodes(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
node_type: Optional[str] = None,
|
node_type: Optional[str] = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> list[GraphNode]:
|
) -> list[GraphNode]:
|
||||||
@@ -609,13 +620,14 @@ class LibraryDeskClient:
|
|||||||
List nodes in the knowledge graph.
|
List nodes in the knowledge graph.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
||||||
limit: Maximum nodes
|
limit: Maximum nodes
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of graph nodes
|
List of graph nodes
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
@@ -631,18 +643,19 @@ class LibraryDeskClient:
|
|||||||
async def get_graph_node(
|
async def get_graph_node(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get detailed information about a graph node.
|
Get detailed information about a graph node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
node_id: Node ID
|
node_id: Node ID
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Node with relationships and connected nodes
|
Node with relationships and connected nodes
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""
|
||||||
|
Orchestration module for multi-expert agent coordination.
|
||||||
|
|
||||||
|
Provides infrastructure for Tatlock to orchestrate expert agents
|
||||||
|
with streaming think updates to keep users informed of progress.
|
||||||
|
|
||||||
|
Key pattern: Stream user-facing interactions, use run() internally
|
||||||
|
to avoid Ollama streaming+tool call bugs.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single expert delegation with think updates
|
||||||
|
- Sequential multi-expert execution (task A → task B → task C)
|
||||||
|
- Parallel multi-expert execution (tasks A, B, C concurrently)
|
||||||
|
- Result aggregation from multiple experts
|
||||||
|
- Partial failure handling
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, Optional, Callable, Any
|
||||||
|
|
||||||
|
from src.agents.delegation import DelegationTask, DelegationResult, delegate_to_librarian
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionMode(str, Enum):
|
||||||
|
"""Execution mode for multi-expert coordination."""
|
||||||
|
SEQUENTIAL = "sequential" # One at a time, in order
|
||||||
|
PARALLEL = "parallel" # All at once, concurrently
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrchestrationContext:
|
||||||
|
"""
|
||||||
|
Context for an orchestration session.
|
||||||
|
|
||||||
|
Tracks the user's request, delegation tasks, and results.
|
||||||
|
"""
|
||||||
|
user_message: str
|
||||||
|
steward_note: str
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_delegation_from_steward_note(steward_note: str) -> Optional[DelegationTask]:
|
||||||
|
"""
|
||||||
|
Parse a delegation task from Steward's note.
|
||||||
|
|
||||||
|
Looks for the DELEGATE: pattern in the Steward's recommendation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steward_note: Formatted note from Steward
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationTask if delegation found, None otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> note = "DELEGATE: librarian to create a wiki page about CI/CD"
|
||||||
|
>>> task = parse_delegation_from_steward_note(note)
|
||||||
|
>>> task.expert_name
|
||||||
|
'librarian'
|
||||||
|
>>> task.task
|
||||||
|
'create a wiki page about CI/CD'
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Look for DELEGATE: pattern
|
||||||
|
# Match: "DELEGATE: expert_name to action description"
|
||||||
|
match = re.search(
|
||||||
|
r'DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)',
|
||||||
|
steward_note,
|
||||||
|
re.IGNORECASE | re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
expert_name = match.group(1).lower()
|
||||||
|
task_description = match.group(2).strip()
|
||||||
|
|
||||||
|
# Handle "none" case
|
||||||
|
if expert_name == "none":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return DelegationTask(
|
||||||
|
expert_name=expert_name,
|
||||||
|
task=task_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_delegation(
|
||||||
|
task: DelegationTask,
|
||||||
|
) -> DelegationResult:
|
||||||
|
"""
|
||||||
|
Execute a delegation task.
|
||||||
|
|
||||||
|
Routes to the appropriate expert agent based on expert_name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Delegation task to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DelegationResult from the expert agent
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"executing_delegation",
|
||||||
|
expert=task.expert_name,
|
||||||
|
task=task.task[:50],
|
||||||
|
)
|
||||||
|
|
||||||
|
if task.expert_name == "librarian":
|
||||||
|
return await delegate_to_librarian(
|
||||||
|
task=task.task,
|
||||||
|
context=task.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Future experts would be added here:
|
||||||
|
# elif task.expert_name == "memory":
|
||||||
|
# return await delegate_to_memory(task.task, task.context)
|
||||||
|
# elif task.expert_name == "home_automation":
|
||||||
|
# return await delegate_to_home_automation(task.task, task.context)
|
||||||
|
|
||||||
|
# Unknown expert - return error result
|
||||||
|
logger.warning("unknown_expert", expert=task.expert_name)
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name=task.expert_name,
|
||||||
|
task=task.task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=f"Unknown expert: {task.expert_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def orchestrate_with_think_updates(
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
delegation_task: Optional[DelegationTask] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Orchestrate expert delegation with streaming think updates.
|
||||||
|
|
||||||
|
Emits <think> updates before and after delegation calls to
|
||||||
|
keep the user informed of progress. Expert calls use run()
|
||||||
|
internally to avoid Ollama streaming bugs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: Original user message
|
||||||
|
steward_note: Steward's analysis and instructions
|
||||||
|
delegation_task: Optional pre-parsed delegation task
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Think update strings and final expert output
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> async for update in orchestrate_with_think_updates(
|
||||||
|
... "Create a wiki page about CI/CD",
|
||||||
|
... "DELEGATE: librarian to create wiki page",
|
||||||
|
... ):
|
||||||
|
... print(update)
|
||||||
|
<think>Consulting The Librarian...</think>
|
||||||
|
<think>Delegation complete.</think>
|
||||||
|
[Wiki page created successfully...]
|
||||||
|
"""
|
||||||
|
# Parse delegation if not provided
|
||||||
|
if delegation_task is None:
|
||||||
|
delegation_task = parse_delegation_from_steward_note(steward_note)
|
||||||
|
|
||||||
|
if delegation_task is None:
|
||||||
|
# No delegation needed - nothing to orchestrate
|
||||||
|
logger.debug("no_delegation_needed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream: About to delegate
|
||||||
|
expert_display_name = delegation_task.expert_name.title()
|
||||||
|
if delegation_task.expert_name == "librarian":
|
||||||
|
expert_display_name = "The Librarian"
|
||||||
|
|
||||||
|
yield f"<think>🤝 Consulting {expert_display_name}...</think>\n"
|
||||||
|
|
||||||
|
# Execute delegation (uses run() internally)
|
||||||
|
result = await execute_delegation(delegation_task)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield f"<think>✅ {expert_display_name} completed research.</think>\n"
|
||||||
|
|
||||||
|
# Yield the expert's findings
|
||||||
|
if result.output:
|
||||||
|
yield f"\n{result.output}"
|
||||||
|
else:
|
||||||
|
yield f"<think>⚠️ {expert_display_name} encountered an issue: {result.error}</think>\n"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"orchestration_complete",
|
||||||
|
expert=delegation_task.expert_name,
|
||||||
|
success=result.success,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_delegation_context(
|
||||||
|
steward_note: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Extract context fields from Steward's note.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
steward_note: Formatted note from Steward
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with reason, complexity, and context
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"reason": "",
|
||||||
|
"complexity": "",
|
||||||
|
"context": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract REASON:
|
||||||
|
reason_match = re.search(r'REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||||
|
if reason_match:
|
||||||
|
result["reason"] = reason_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract COMPLEXITY:
|
||||||
|
complexity_match = re.search(r'COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||||
|
if complexity_match:
|
||||||
|
result["complexity"] = complexity_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract CONTEXT:
|
||||||
|
context_match = re.search(r'CONTEXT:\s*(.+?)$', steward_note, re.IGNORECASE | re.MULTILINE)
|
||||||
|
if context_match:
|
||||||
|
result["context"] = context_match.group(1).strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Multi-Expert Coordination
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Aggregated result from multiple expert delegations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
results: Dict mapping expert name to their result
|
||||||
|
all_succeeded: True if all delegations succeeded
|
||||||
|
failed_experts: List of expert names that failed
|
||||||
|
combined_output: Aggregated output from all successful experts
|
||||||
|
"""
|
||||||
|
results: dict[str, DelegationResult] = field(default_factory=dict)
|
||||||
|
all_succeeded: bool = True
|
||||||
|
failed_experts: list[str] = field(default_factory=list)
|
||||||
|
combined_output: str = ""
|
||||||
|
|
||||||
|
def add_result(self, result: DelegationResult) -> None:
|
||||||
|
"""Add a result and update aggregation state."""
|
||||||
|
self.results[result.expert_name] = result
|
||||||
|
if not result.success:
|
||||||
|
self.all_succeeded = False
|
||||||
|
self.failed_experts.append(result.expert_name)
|
||||||
|
|
||||||
|
def aggregate_outputs(self, separator: str = "\n\n---\n\n") -> str:
|
||||||
|
"""Combine all successful outputs into one string."""
|
||||||
|
outputs = []
|
||||||
|
for expert_name, result in self.results.items():
|
||||||
|
if result.success and result.output:
|
||||||
|
outputs.append(f"**{expert_name.title()}**: {result.output}")
|
||||||
|
|
||||||
|
self.combined_output = separator.join(outputs)
|
||||||
|
return self.combined_output
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_sequential(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
stop_on_failure: bool = False,
|
||||||
|
) -> MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Execute multiple delegation tasks sequentially.
|
||||||
|
|
||||||
|
Tasks run one after another in order. Later tasks can depend on
|
||||||
|
earlier results (though this function doesn't handle passing
|
||||||
|
results between tasks - that's the orchestrator's job).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks to execute in order
|
||||||
|
stop_on_failure: If True, stop execution if any task fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MultiExpertResult with all task results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="memory", task="get user location"),
|
||||||
|
... DelegationTask(expert_name="librarian", task="search weather"),
|
||||||
|
... ]
|
||||||
|
>>> result = await execute_sequential(tasks)
|
||||||
|
>>> result.all_succeeded
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
multi_result = MultiExpertResult()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sequential_execution_started",
|
||||||
|
task_count=len(tasks),
|
||||||
|
experts=[t.expert_name for t in tasks],
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
logger.debug(
|
||||||
|
"sequential_task_executing",
|
||||||
|
index=i,
|
||||||
|
expert=task.expert_name,
|
||||||
|
task=task.task[:50],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
multi_result.add_result(result)
|
||||||
|
|
||||||
|
if not result.success and stop_on_failure:
|
||||||
|
logger.warning(
|
||||||
|
"sequential_execution_stopped",
|
||||||
|
failed_at=i,
|
||||||
|
expert=task.expert_name,
|
||||||
|
error=result.error,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
multi_result.aggregate_outputs()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sequential_execution_complete",
|
||||||
|
total_tasks=len(tasks),
|
||||||
|
succeeded=len(tasks) - len(multi_result.failed_experts),
|
||||||
|
failed=len(multi_result.failed_experts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return multi_result
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_parallel(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
) -> MultiExpertResult:
|
||||||
|
"""
|
||||||
|
Execute multiple delegation tasks in parallel.
|
||||||
|
|
||||||
|
All tasks run concurrently using asyncio.gather. Use this when
|
||||||
|
tasks are independent and don't depend on each other's results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks to execute concurrently
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MultiExpertResult with all task results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="librarian", task="search wiki"),
|
||||||
|
... DelegationTask(expert_name="memory", task="get preferences"),
|
||||||
|
... ]
|
||||||
|
>>> result = await execute_parallel(tasks)
|
||||||
|
>>> len(result.results)
|
||||||
|
2
|
||||||
|
"""
|
||||||
|
multi_result = MultiExpertResult()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"parallel_execution_started",
|
||||||
|
task_count=len(tasks),
|
||||||
|
experts=[t.expert_name for t in tasks],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute all tasks concurrently
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[execute_delegation(task) for task in tasks],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
# Handle exceptions as failed delegations
|
||||||
|
error_result = DelegationResult(
|
||||||
|
expert_name=tasks[i].expert_name,
|
||||||
|
task=tasks[i].task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(result),
|
||||||
|
)
|
||||||
|
multi_result.add_result(error_result)
|
||||||
|
logger.error(
|
||||||
|
"parallel_task_exception",
|
||||||
|
expert=tasks[i].expert_name,
|
||||||
|
error=str(result),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
multi_result.add_result(result)
|
||||||
|
|
||||||
|
multi_result.aggregate_outputs()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"parallel_execution_complete",
|
||||||
|
total_tasks=len(tasks),
|
||||||
|
succeeded=len(tasks) - len(multi_result.failed_experts),
|
||||||
|
failed=len(multi_result.failed_experts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return multi_result
|
||||||
|
|
||||||
|
|
||||||
|
async def orchestrate_multi_expert(
|
||||||
|
tasks: list[DelegationTask],
|
||||||
|
mode: ExecutionMode = ExecutionMode.SEQUENTIAL,
|
||||||
|
stop_on_failure: bool = False,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Orchestrate multiple expert delegations with streaming think updates.
|
||||||
|
|
||||||
|
Emits <think> updates for each delegation phase and yields
|
||||||
|
combined results at the end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of delegation tasks
|
||||||
|
mode: SEQUENTIAL or PARALLEL execution
|
||||||
|
stop_on_failure: For sequential mode, stop if a task fails
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Think updates and combined expert output
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tasks = [
|
||||||
|
... DelegationTask(expert_name="memory", task="get location"),
|
||||||
|
... DelegationTask(expert_name="librarian", task="search weather"),
|
||||||
|
... ]
|
||||||
|
>>> async for update in orchestrate_multi_expert(tasks):
|
||||||
|
... print(update)
|
||||||
|
<think>Starting multi-expert coordination (2 tasks)...</think>
|
||||||
|
<think>Consulting Memory...</think>
|
||||||
|
<think>Memory completed.</think>
|
||||||
|
<think>Consulting The Librarian...</think>
|
||||||
|
<think>The Librarian completed.</think>
|
||||||
|
<think>All experts completed successfully.</think>
|
||||||
|
[Combined output from all experts...]
|
||||||
|
"""
|
||||||
|
if not tasks:
|
||||||
|
logger.debug("no_tasks_to_orchestrate")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream: Starting multi-expert coordination
|
||||||
|
yield f"<think>🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...</think>\n"
|
||||||
|
|
||||||
|
if mode == ExecutionMode.PARALLEL:
|
||||||
|
# Parallel execution - emit one update then run all at once
|
||||||
|
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
|
||||||
|
yield f"<think>🔄 Consulting in parallel: {expert_names}...</think>\n"
|
||||||
|
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
# Emit completion updates for each
|
||||||
|
for expert_name, expert_result in result.results.items():
|
||||||
|
display_name = _get_display_name(expert_name)
|
||||||
|
if expert_result.success:
|
||||||
|
yield f"<think>✅ {display_name} completed.</think>\n"
|
||||||
|
else:
|
||||||
|
yield f"<think>⚠️ {display_name} failed: {expert_result.error}</think>\n"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Sequential execution - emit updates for each task
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
display_name = _get_display_name(task.expert_name)
|
||||||
|
yield f"<think>🤝 Consulting {display_name}...</think>\n"
|
||||||
|
|
||||||
|
task_result = await execute_delegation(task)
|
||||||
|
result.add_result(task_result)
|
||||||
|
|
||||||
|
if task_result.success:
|
||||||
|
yield f"<think>✅ {display_name} completed.</think>\n"
|
||||||
|
else:
|
||||||
|
yield f"<think>⚠️ {display_name} failed: {task_result.error}</think>\n"
|
||||||
|
if stop_on_failure:
|
||||||
|
yield "<think>🛑 Stopping due to failure.</think>\n"
|
||||||
|
break
|
||||||
|
|
||||||
|
result.aggregate_outputs()
|
||||||
|
|
||||||
|
# Stream: Summary
|
||||||
|
if result.all_succeeded:
|
||||||
|
yield "<think>🎉 All experts completed successfully.</think>\n"
|
||||||
|
else:
|
||||||
|
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
|
||||||
|
yield f"<think>⚠️ Some experts failed: {failed_names}</think>\n"
|
||||||
|
|
||||||
|
# Yield combined output
|
||||||
|
if result.combined_output:
|
||||||
|
yield f"\n{result.combined_output}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"multi_expert_orchestration_complete",
|
||||||
|
task_count=len(tasks),
|
||||||
|
mode=mode.value,
|
||||||
|
all_succeeded=result.all_succeeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display_name(expert_name: str) -> str:
|
||||||
|
"""Get user-friendly display name for an expert."""
|
||||||
|
display_names = {
|
||||||
|
"librarian": "The Librarian",
|
||||||
|
"memory": "Memory",
|
||||||
|
"home_automation": "Home Automation",
|
||||||
|
"tatlock_core": "Core Tools",
|
||||||
|
}
|
||||||
|
return display_names.get(expert_name, expert_name.title())
|
||||||
@@ -48,7 +48,7 @@ AVAILABLE HOUSEHOLD CAPABILITIES:
|
|||||||
{capabilities_text}
|
{capabilities_text}
|
||||||
|
|
||||||
YOUR TASK:
|
YOUR TASK:
|
||||||
Analyze the user's query and recommend which capabilities are needed.
|
Analyze the user's query and recommend which capabilities are needed, with specific delegation instructions.
|
||||||
{history_text}
|
{history_text}
|
||||||
|
|
||||||
USER QUERY: {query}
|
USER QUERY: {query}
|
||||||
@@ -56,19 +56,30 @@ USER QUERY: {query}
|
|||||||
GUIDELINES:
|
GUIDELINES:
|
||||||
- Be conservative - only recommend truly necessary capabilities
|
- Be conservative - only recommend truly necessary capabilities
|
||||||
- Simple greetings/chat → no capabilities needed (conversational response only)
|
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||||
|
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||||
- Math/calculations → tatlock_core
|
- Math/calculations → tatlock_core
|
||||||
- Web searches → tatlock_core
|
- Quick web searches → tatlock_core
|
||||||
- Time/date queries → tatlock_core
|
- Time/date queries → tatlock_core
|
||||||
|
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
||||||
|
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
||||||
|
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
||||||
|
- In-depth research, knowledge synthesis, document lookup → librarian with hybrid_search
|
||||||
- If conversation history is relevant, note which previous turns matter
|
- If conversation history is relevant, note which previous turns matter
|
||||||
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
|
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
|
||||||
- If capabilities are missing, mention what would be needed
|
|
||||||
|
|
||||||
RESPOND WITH 2-3 SENTENCES:
|
RESPOND IN THIS FORMAT:
|
||||||
1. Which capabilities (if any) are needed and why
|
DELEGATE: [capability name] to [action] [specific task]
|
||||||
2. Complexity assessment (simple/moderate/complex)
|
REASON: [why this capability handles the request]
|
||||||
3. Any conversation context or missing capabilities
|
COMPLEXITY: [simple/moderate/complex]
|
||||||
|
CONTEXT: [any relevant conversation context, or "none"]
|
||||||
|
|
||||||
Use capability names in your response (e.g., "tatlock_core for calculations").
|
EXAMPLES:
|
||||||
|
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
||||||
|
- "DELEGATE: librarian to search for information about Docker networking"
|
||||||
|
- "DELEGATE: tatlock_core to calculate the result"
|
||||||
|
- "DELEGATE: none (conversational response only)"
|
||||||
|
|
||||||
|
Be specific about what Tatlock should delegate - include the action verb (create, update, search, etc.).
|
||||||
Plain text only - no JSON, no special formatting."""
|
Plain text only - no JSON, no special formatting."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Steward agent schemas.
|
|||||||
Defines the structured output models for Steward's request analysis
|
Defines the structured output models for Steward's request analysis
|
||||||
and capability recommendations.
|
and capability recommendations.
|
||||||
"""
|
"""
|
||||||
from typing import Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -56,6 +56,10 @@ class StewardRecommendation(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Description of capabilities that would be helpful but aren't available"
|
description="Description of capabilities that would be helpful but aren't available"
|
||||||
)
|
)
|
||||||
|
memory_context: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Pre-fetched user context from memory (profile, preferences)"
|
||||||
|
)
|
||||||
|
|
||||||
def format_for_butler(self) -> str:
|
def format_for_butler(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -88,6 +92,23 @@ class StewardRecommendation(BaseModel):
|
|||||||
if self.missing_capabilities:
|
if self.missing_capabilities:
|
||||||
lines.append(f"⚠️ Missing: {self.missing_capabilities}")
|
lines.append(f"⚠️ Missing: {self.missing_capabilities}")
|
||||||
|
|
||||||
|
# Memory context (user profile and preferences)
|
||||||
|
if self.memory_context:
|
||||||
|
profile = self.memory_context.get("profile", {})
|
||||||
|
preferences = self.memory_context.get("preferences", {})
|
||||||
|
|
||||||
|
if profile or preferences:
|
||||||
|
lines.append("-" * 40)
|
||||||
|
lines.append("User Context:")
|
||||||
|
|
||||||
|
if profile:
|
||||||
|
for key, value in profile.items():
|
||||||
|
lines.append(f" • {key}: {value}")
|
||||||
|
|
||||||
|
if preferences:
|
||||||
|
prefs_str = ", ".join(f"{k}={v}" for k, v in preferences.items())
|
||||||
|
lines.append(f" • preferences: {prefs_str}")
|
||||||
|
|
||||||
lines.append("=" * 40)
|
lines.append("=" * 40)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ Provides high-level interface for request analysis with logging,
|
|||||||
benchmarking, and error handling.
|
benchmarking, and error handling.
|
||||||
|
|
||||||
Parses plain text recommendations into structured data.
|
Parses plain text recommendations into structured data.
|
||||||
|
Includes memory pre-fetch for user context injection.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger, log_operation
|
from src.core.logging_config import get_logger, log_operation
|
||||||
|
from src.core.memory_service import memory_service
|
||||||
from .agent import get_steward_agent
|
from .agent import get_steward_agent
|
||||||
from .schemas import ConversationContext, StewardRecommendation
|
from .schemas import ConversationContext, StewardRecommendation
|
||||||
|
|
||||||
@@ -147,6 +149,69 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pre-fetch user context that might be needed for this request.
|
||||||
|
|
||||||
|
This is the "direct access" layer - fast lookups without LLM overhead.
|
||||||
|
Uses simple keyword matching to determine what context to fetch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: The user's request text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with profile and/or preferences data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> ctx = await _prefetch_memory_context("What's the weather?")
|
||||||
|
>>> ctx
|
||||||
|
{"profile": {"location": "Amsterdam"}}
|
||||||
|
"""
|
||||||
|
request_lower = user_request.lower()
|
||||||
|
|
||||||
|
# Determine what context might be needed based on keywords
|
||||||
|
profile_keys = []
|
||||||
|
|
||||||
|
# Location-related queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"weather", "temperature", "forecast", "nearby", "local",
|
||||||
|
"directions", "distance", "map", "here"
|
||||||
|
]):
|
||||||
|
profile_keys.append("location")
|
||||||
|
|
||||||
|
# Time-related queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"time", "schedule", "meeting", "appointment", "reminder",
|
||||||
|
"alarm", "when", "today", "tomorrow"
|
||||||
|
]):
|
||||||
|
profile_keys.append("timezone")
|
||||||
|
|
||||||
|
# Personal queries
|
||||||
|
if any(word in request_lower for word in [
|
||||||
|
"my name", "who am i", "about me"
|
||||||
|
]):
|
||||||
|
profile_keys.append("name")
|
||||||
|
|
||||||
|
# Always fetch preferences if they might affect response format
|
||||||
|
include_preferences = any(word in request_lower for word in [
|
||||||
|
"temperature", "weather", "convert", "unit", "format",
|
||||||
|
"celsius", "fahrenheit", "metric", "imperial"
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await memory_service.prefetch_context(
|
||||||
|
include_profile=bool(profile_keys),
|
||||||
|
include_preferences=include_preferences,
|
||||||
|
profile_keys=profile_keys if profile_keys else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"steward_prefetch_memory_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def analyze_request(
|
async def analyze_request(
|
||||||
user_request: str,
|
user_request: str,
|
||||||
conversation_history: list[dict],
|
conversation_history: list[dict],
|
||||||
@@ -186,6 +251,10 @@ async def analyze_request(
|
|||||||
}
|
}
|
||||||
) as log_ctx:
|
) as log_ctx:
|
||||||
try:
|
try:
|
||||||
|
# Pre-fetch user context from memory (fast, no LLM)
|
||||||
|
memory_context = await _prefetch_memory_context(user_request)
|
||||||
|
log_ctx["memory_context_keys"] = list(memory_context.keys())
|
||||||
|
|
||||||
# Get Steward agent
|
# Get Steward agent
|
||||||
steward = get_steward_agent()
|
steward = get_steward_agent()
|
||||||
|
|
||||||
@@ -193,6 +262,7 @@ async def analyze_request(
|
|||||||
"steward_analyzing_request",
|
"steward_analyzing_request",
|
||||||
request=user_request,
|
request=user_request,
|
||||||
history_turns=len(conversation_history),
|
history_turns=len(conversation_history),
|
||||||
|
memory_context=bool(memory_context),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get plain text analysis from Steward
|
# Get plain text analysis from Steward
|
||||||
@@ -212,7 +282,8 @@ async def analyze_request(
|
|||||||
reasoning=analysis_text,
|
reasoning=analysis_text,
|
||||||
estimated_complexity=complexity,
|
estimated_complexity=complexity,
|
||||||
conversation_context=context,
|
conversation_context=context,
|
||||||
missing_capabilities=missing
|
missing_capabilities=missing,
|
||||||
|
memory_context=memory_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update log context with results
|
# Update log context with results
|
||||||
|
|||||||
+13
-6
@@ -587,16 +587,23 @@ class TatlockAgent(AgentInterface):
|
|||||||
ModelResponse(parts=[TextPart(content=content)])
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream with scoped tools and tracker
|
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
||||||
async with scoped_agent.run_stream(
|
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
||||||
|
# We yield the final response in chunks to maintain streaming interface
|
||||||
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker
|
deps=tool_tracker
|
||||||
) as stream:
|
)
|
||||||
async for chunk in stream.stream_text(delta=True):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
logger.info("tatlock_stream_complete")
|
# Stream the final response in chunks to maintain UX
|
||||||
|
response_text = result.output
|
||||||
|
chunk_size = 50 # characters per chunk
|
||||||
|
|
||||||
|
for i in range(0, len(response_text), chunk_size):
|
||||||
|
yield response_text[i:i + chunk_size]
|
||||||
|
|
||||||
|
logger.info("tatlock_scoped_run_complete")
|
||||||
|
|
||||||
async def get_capabilities(self) -> dict:
|
async def get_capabilities(self) -> dict:
|
||||||
"""Return current capabilities."""
|
"""Return current capabilities."""
|
||||||
|
|||||||
+41
-1
@@ -124,6 +124,36 @@ class Config(BaseSettings):
|
|||||||
description="Library-Desk request timeout in seconds"
|
description="Library-Desk request timeout in seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Qdrant Configuration (Memory vector storage)
|
||||||
|
QDRANT_HOST: str = Field(
|
||||||
|
default="localhost",
|
||||||
|
description="Qdrant server host"
|
||||||
|
)
|
||||||
|
QDRANT_PORT: int = Field(
|
||||||
|
default=6333,
|
||||||
|
description="Qdrant server port"
|
||||||
|
)
|
||||||
|
QDRANT_EMBEDDING_DIM: int = Field(
|
||||||
|
default=768,
|
||||||
|
description="Embedding dimension (768 for nomic-embed-text)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ollama Embedding Configuration
|
||||||
|
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||||
|
default="nomic-embed-text",
|
||||||
|
description="Ollama model for embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis Memory Database (separate from benchmarks)
|
||||||
|
REDIS_MEMORY_DB: int = Field(
|
||||||
|
default=2,
|
||||||
|
description="Redis database number for memory cache"
|
||||||
|
)
|
||||||
|
REDIS_MEMORY_TTL_HOURS: int = Field(
|
||||||
|
default=24,
|
||||||
|
description="TTL for session context in hours"
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
||||||
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
||||||
@@ -139,9 +169,19 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def redis_url(self) -> str:
|
def redis_url(self) -> str:
|
||||||
"""Construct Redis connection URL."""
|
"""Construct Redis connection URL for benchmarks."""
|
||||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_memory_url(self) -> str:
|
||||||
|
"""Construct Redis connection URL for memory cache."""
|
||||||
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_MEMORY_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qdrant_url(self) -> str:
|
||||||
|
"""Construct Qdrant server URL."""
|
||||||
|
return f"http://{self.QDRANT_HOST}:{self.QDRANT_PORT}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def log_format(self) -> str:
|
def log_format(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Request context using ContextVar for async-safe user/conversation tracking.
|
||||||
|
|
||||||
|
ContextVar provides task-local storage that automatically propagates through
|
||||||
|
async calls, eliminating the need to thread user identity through every function.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# At request entry (router):
|
||||||
|
token = current_user.set(request.user or "jpmschweitzer")
|
||||||
|
try:
|
||||||
|
await service.process(request)
|
||||||
|
finally:
|
||||||
|
current_user.reset(token)
|
||||||
|
|
||||||
|
# Anywhere in the codebase:
|
||||||
|
from src.core.context import get_user
|
||||||
|
user = get_user() # Returns current request's user
|
||||||
|
"""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
# Default user for single-user homelab setup
|
||||||
|
DEFAULT_USER = "jpmschweitzer"
|
||||||
|
|
||||||
|
# Request-scoped context variables (async-safe, isolated per request)
|
||||||
|
current_user: ContextVar[str] = ContextVar("current_user", default=DEFAULT_USER)
|
||||||
|
current_conversation: ContextVar[str | None] = ContextVar(
|
||||||
|
"current_conversation", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user() -> str:
|
||||||
|
"""
|
||||||
|
Get current user from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User identifier for the current request.
|
||||||
|
Falls back to DEFAULT_USER if not set.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
user = get_user() # "jpmschweitzer" or whatever was set in router
|
||||||
|
"""
|
||||||
|
return current_user.get()
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_id() -> str | None:
|
||||||
|
"""
|
||||||
|
Get current conversation ID from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation ID if set, None otherwise.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
conv_id = get_conversation_id() # "conv_abc123" or None
|
||||||
|
"""
|
||||||
|
return current_conversation.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
"""
|
||||||
|
Context manager for setting request-scoped context.
|
||||||
|
|
||||||
|
Provides a cleaner alternative to manual token management.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with RequestContext(user="alice", conversation_id="conv_123"):
|
||||||
|
# All code here sees user="alice"
|
||||||
|
result = await some_service.process()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize request context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to DEFAULT_USER if None)
|
||||||
|
conversation_id: Conversation ID (optional)
|
||||||
|
"""
|
||||||
|
self.user = user or DEFAULT_USER
|
||||||
|
self.conversation_id = conversation_id
|
||||||
|
self._user_token = None
|
||||||
|
self._conv_token = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "RequestContext":
|
||||||
|
"""Set context variables on entry."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Reset context variables on exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
|
|
||||||
|
def __enter__(self) -> "RequestContext":
|
||||||
|
"""Sync context manager entry (for non-async code)."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Sync context manager exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Ollama client for embeddings generation.
|
||||||
|
|
||||||
|
Provides async embedding operations via Ollama API:
|
||||||
|
- Text embedding generation
|
||||||
|
- Batch embedding support
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Ollama API client for embeddings.
|
||||||
|
|
||||||
|
Uses the Ollama embeddings endpoint to generate vector representations
|
||||||
|
of text using the nomic-embed-text model (768 dimensions).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = OllamaEmbeddingClient()
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
Or with context manager:
|
||||||
|
async with OllamaEmbeddingClient() as client:
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Ollama embedding client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama server URL (defaults to config.OLLAMA_HOST)
|
||||||
|
model: Embedding model name (defaults to config.OLLAMA_EMBEDDING_MODEL)
|
||||||
|
timeout: Request timeout in seconds (embeddings can be slow)
|
||||||
|
"""
|
||||||
|
self.base_url = (base_url or str(config.OLLAMA_HOST)).rstrip("/")
|
||||||
|
self.model = model or config.OLLAMA_EMBEDDING_MODEL
|
||||||
|
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
||||||
|
self.tags_url = f"{self.base_url}/api/tags"
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ollama_embedding_client_initialized",
|
||||||
|
base_url=self.base_url,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create HTTP client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=self._timeout)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "OllamaEmbeddingClient":
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self._get_client()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def embed(self, text: str) -> list[float] | None:
|
||||||
|
"""
|
||||||
|
Generate embedding for single text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to embed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding vector (768-dimensional for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embedding = await client.embed("Hello world")
|
||||||
|
>>> len(embedding)
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(self.embeddings_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
embedding = data.get("embedding")
|
||||||
|
if not embedding:
|
||||||
|
logger.error("ollama_embed_no_embedding", response_data=data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
"ollama_embed_http_error",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=e.response.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embed_failed", error=str(e), exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def embed_batch(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float] | None]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts.
|
||||||
|
|
||||||
|
Note: Ollama doesn't support native batch embeddings, so this
|
||||||
|
sequentially calls embed() for each text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of embedding vectors (same order as input)
|
||||||
|
None entries for texts that failed to embed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> texts = ["Hello", "World", "Test"]
|
||||||
|
>>> embeddings = await client.embed_batch(texts)
|
||||||
|
>>> len(embeddings)
|
||||||
|
3
|
||||||
|
"""
|
||||||
|
embeddings = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if show_progress and i % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_progress",
|
||||||
|
current=i,
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding = await self.embed(text)
|
||||||
|
embeddings.append(embedding)
|
||||||
|
|
||||||
|
if show_progress:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_complete",
|
||||||
|
successful=sum(1 for e in embeddings if e is not None),
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
async def embed_batch_filtered(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts, filtering out failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of successful embedding vectors (may be shorter than input)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embeddings = await client.embed_batch_filtered(texts)
|
||||||
|
>>> all(e is not None for e in embeddings)
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
all_embeddings = await self.embed_batch(texts, show_progress)
|
||||||
|
return [e for e in all_embeddings if e is not None]
|
||||||
|
|
||||||
|
async def get_embedding_dimension(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Get embedding dimension for current model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding dimension (e.g., 768 for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dim = await client.get_embedding_dimension()
|
||||||
|
>>> dim
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
test_embedding = await self.embed("test")
|
||||||
|
if test_embedding:
|
||||||
|
return len(test_embedding)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Ollama server is reachable and model is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(self.tags_url, timeout=5.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
models = data.get("models", [])
|
||||||
|
|
||||||
|
# Check if our embedding model is available
|
||||||
|
model_found = False
|
||||||
|
for m in models:
|
||||||
|
name = m.get("name", "")
|
||||||
|
if name == self.model or name.startswith(f"{self.model}:"):
|
||||||
|
model_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_found:
|
||||||
|
logger.warning(
|
||||||
|
"ollama_embedding_model_not_found",
|
||||||
|
model=self.model,
|
||||||
|
available=[m.get("name") for m in models],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embedding_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_embedding_client: OllamaEmbeddingClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_client() -> OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Get global embedding client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OllamaEmbeddingClient instance
|
||||||
|
"""
|
||||||
|
global _embedding_client
|
||||||
|
if _embedding_client is None:
|
||||||
|
_embedding_client = OllamaEmbeddingClient()
|
||||||
|
return _embedding_client
|
||||||
@@ -200,6 +200,75 @@ class HouseholdRegistry:
|
|||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
def get_delegation_tools(self, names: list[str]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Get delegation wrapper tools for specified capabilities.
|
||||||
|
|
||||||
|
Instead of returning raw tools (which overloads the LLM),
|
||||||
|
returns wrapper functions that delegate to expert agents.
|
||||||
|
This implements the agent-as-tool pattern.
|
||||||
|
|
||||||
|
For members WITH an agent: returns delegation wrapper
|
||||||
|
For members WITHOUT an agent (e.g., tatlock_core): returns raw tools
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of member names to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of delegation wrappers and/or raw tools
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # Steward recommends librarian + tatlock_core
|
||||||
|
>>> tools = registry.get_delegation_tools(["librarian", "tatlock_core"])
|
||||||
|
>>> # Returns: [delegate_to_librarian, calculate, datetime, ...]
|
||||||
|
>>> # Instead of: [hybrid_search, search_wiki, create_wiki_page, ... (16 tools)]
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import delegate_to_librarian
|
||||||
|
|
||||||
|
# Map of expert names to their delegation wrappers
|
||||||
|
delegation_wrappers = {
|
||||||
|
"librarian": delegate_to_librarian,
|
||||||
|
# Future: "memory": delegate_to_memory,
|
||||||
|
# Future: "home_automation": delegate_to_home_automation,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = []
|
||||||
|
for name in names:
|
||||||
|
member = self._members.get(name)
|
||||||
|
if not member:
|
||||||
|
logger.warning(
|
||||||
|
"household_member_not_found",
|
||||||
|
requested_name=name,
|
||||||
|
available_names=list(self._members.keys()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this member has a delegation wrapper
|
||||||
|
if name in delegation_wrappers and member.agent is not None:
|
||||||
|
# Use delegation wrapper instead of raw tools
|
||||||
|
tools.append(delegation_wrappers[name])
|
||||||
|
logger.debug(
|
||||||
|
"delegation_wrapper_added",
|
||||||
|
member=name,
|
||||||
|
wrapper=delegation_wrappers[name].__name__,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No agent = direct tools (e.g., tatlock_core)
|
||||||
|
tools.extend(member.tools)
|
||||||
|
logger.debug(
|
||||||
|
"raw_tools_added",
|
||||||
|
member=name,
|
||||||
|
tool_count=len(member.tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"delegation_tools_created",
|
||||||
|
requested_members=names,
|
||||||
|
total_tools=len(tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
def list_members(self) -> list[str]:
|
def list_members(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all registered member names.
|
List all registered member names.
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""
|
||||||
|
Redis-backed memory cache for session context.
|
||||||
|
|
||||||
|
Provides short-term memory storage with TTL:
|
||||||
|
- Session context (24h TTL)
|
||||||
|
- Recent entities mentioned in conversation
|
||||||
|
- User-scoped with conversation isolation
|
||||||
|
|
||||||
|
Uses Redis DB 2 (separate from benchmarks in DB 1).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_session_key, get_entities_key
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""
|
||||||
|
Redis-backed cache for session memory.
|
||||||
|
|
||||||
|
Stores ephemeral context that doesn't need vector search:
|
||||||
|
- Session context (recent topics, user state)
|
||||||
|
- Recent entities (people, places, things mentioned)
|
||||||
|
- Conversation metadata
|
||||||
|
|
||||||
|
All data expires after REDIS_MEMORY_TTL_HOURS (default 24h).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cache = MemoryCache()
|
||||||
|
await cache.set_session_context(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
conversation_id="conv_123",
|
||||||
|
context={"topic": "docker", "mood": "curious"}
|
||||||
|
)
|
||||||
|
context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_url: str | None = None,
|
||||||
|
ttl_hours: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_url: Redis connection URL (defaults to config.redis_memory_url)
|
||||||
|
ttl_hours: TTL for cached data (defaults to config.REDIS_MEMORY_TTL_HOURS)
|
||||||
|
"""
|
||||||
|
self._redis_url = redis_url or config.redis_memory_url
|
||||||
|
self._ttl_seconds = (ttl_hours or config.REDIS_MEMORY_TTL_HOURS) * 3600
|
||||||
|
self._client: redis.Redis | None = None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_cache_initialized",
|
||||||
|
redis_url=self._redis_url,
|
||||||
|
ttl_hours=ttl_hours or config.REDIS_MEMORY_TTL_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> redis.Redis:
|
||||||
|
"""Get or create Redis client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = redis.from_url(
|
||||||
|
self._redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=config.REDIS_TIMEOUT,
|
||||||
|
socket_connect_timeout=config.REDIS_TIMEOUT,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close Redis connection."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Context
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session context dict or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
>>> context
|
||||||
|
{"topic": "docker", "mood": "curious", "last_tool": "librarian"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
data = await client.get(key)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
context: Context data to store
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.set_session_context(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... {"topic": "docker", "mood": "curious"}
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
await client.setex(
|
||||||
|
key,
|
||||||
|
self._ttl_seconds,
|
||||||
|
json.dumps(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_set_session",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
context_keys=list(context.keys()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_set_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update session context (merge with existing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
updates: Fields to update/add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
existing = await self.get_session_context(user, conversation_id) or {}
|
||||||
|
existing.update(updates)
|
||||||
|
return await self.set_session_context(user, conversation_id, existing)
|
||||||
|
|
||||||
|
async def delete_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_delete_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Recent Entities
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get recently mentioned entities in a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entity names/identifiers
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> entities = await cache.get_recent_entities("jpmschweitzer", "conv_123")
|
||||||
|
>>> entities
|
||||||
|
["Docker", "Kubernetes", "nginx"]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Get all members of the set
|
||||||
|
entities = await client.smembers(key)
|
||||||
|
return list(entities)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
entities: list[str],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Add entities to the recent entities set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
entities: Entity names to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.add_recent_entities(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... ["Docker", "Kubernetes"]
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
if not entities:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Add to set
|
||||||
|
await client.sadd(key, *entities)
|
||||||
|
|
||||||
|
# Refresh TTL
|
||||||
|
await client.expire(key, self._ttl_seconds)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_add_entities",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
entities=entities,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_add_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def clear_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear all recent entities for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cleared, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_clear_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Health Check
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Redis is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
await client.ping()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_cache_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global cache instance (lazy initialization)
|
||||||
|
_memory_cache: MemoryCache | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_cache() -> MemoryCache:
|
||||||
|
"""
|
||||||
|
Get global memory cache instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryCache instance
|
||||||
|
"""
|
||||||
|
global _memory_cache
|
||||||
|
if _memory_cache is None:
|
||||||
|
_memory_cache = MemoryCache()
|
||||||
|
return _memory_cache
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""
|
||||||
|
Memory service for direct key-based access.
|
||||||
|
|
||||||
|
Provides fast, LLM-free access to user memories for:
|
||||||
|
- Known-key lookups (location, timezone, preferences)
|
||||||
|
- Session context (current topic, recent entities)
|
||||||
|
- Structured storage (explicit user instructions)
|
||||||
|
|
||||||
|
This is the "direct access layer" - no LLM interpretation.
|
||||||
|
For semantic/fuzzy queries, use the Memory Agent instead.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.core.memory_service import memory_service
|
||||||
|
|
||||||
|
# Get user's location (fast, no LLM)
|
||||||
|
location = await memory_service.get_profile("location")
|
||||||
|
|
||||||
|
# Set a preference
|
||||||
|
await memory_service.set_preference("temperature_unit", "celsius")
|
||||||
|
|
||||||
|
# Get session context
|
||||||
|
ctx = await memory_service.get_session_context(conversation_id)
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .context import get_user, get_conversation_id
|
||||||
|
from .embeddings import get_embedding_client
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .memory_cache import get_memory_cache
|
||||||
|
from .multi_tenancy import get_memory_collection_name
|
||||||
|
from .qdrant import get_qdrant_client
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryType(str, Enum):
|
||||||
|
"""Types of memories stored in Qdrant."""
|
||||||
|
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||||
|
PREFERENCE = "preference" # Units, language, theme
|
||||||
|
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRecord(BaseModel):
|
||||||
|
"""A memory record stored in Qdrant."""
|
||||||
|
id: str
|
||||||
|
type: MemoryType
|
||||||
|
key: str # e.g., "location", "timezone", "car"
|
||||||
|
value: str # The actual content
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
importance: float = 0.5 # 0.0 - 1.0
|
||||||
|
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||||
|
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
"""
|
||||||
|
Direct access to user memories without LLM overhead.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Known-key lookups: get_profile("location"), get_preference("units")
|
||||||
|
- Explicit storage: set_preference("theme", "dark")
|
||||||
|
- Session context: get_session_context(), update_session_context()
|
||||||
|
|
||||||
|
Do NOT use for:
|
||||||
|
- Fuzzy queries: "What car do I drive?" → Use Memory Agent
|
||||||
|
- Semantic recall: "What did I mention about X?" → Use Memory Agent
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize memory service with lazy client loading."""
|
||||||
|
self._qdrant = None
|
||||||
|
self._embedding = None
|
||||||
|
self._cache = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qdrant(self):
|
||||||
|
"""Lazy-load Qdrant client."""
|
||||||
|
if self._qdrant is None:
|
||||||
|
self._qdrant = get_qdrant_client()
|
||||||
|
return self._qdrant
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embedding(self):
|
||||||
|
"""Lazy-load embedding client."""
|
||||||
|
if self._embedding is None:
|
||||||
|
self._embedding = get_embedding_client()
|
||||||
|
return self._embedding
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache(self):
|
||||||
|
"""Lazy-load Redis cache."""
|
||||||
|
if self._cache is None:
|
||||||
|
self._cache = get_memory_cache()
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Profile Methods (user_profile type)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_profile(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a user profile value by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile key (e.g., "location", "timezone", "name")
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Profile value or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> location = await memory_service.get_profile("location")
|
||||||
|
>>> location
|
||||||
|
"Amsterdam, Netherlands"
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.USER_PROFILE, key)
|
||||||
|
|
||||||
|
async def set_profile(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set a user profile value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Profile key (e.g., "location", "timezone")
|
||||||
|
value: Profile value
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
keywords: Optional keywords for semantic search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await memory_service.set_profile("location", "Amsterdam, Netherlands")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.USER_PROFILE,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords or [key],
|
||||||
|
importance=0.9, # Profile data is important
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Preference Methods (preference type)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_preference(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a user preference by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference key (e.g., "temperature_unit", "language", "theme")
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Preference value or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> units = await memory_service.get_preference("temperature_unit")
|
||||||
|
>>> units
|
||||||
|
"celsius"
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.PREFERENCE, key)
|
||||||
|
|
||||||
|
async def set_preference(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set a user preference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Preference key
|
||||||
|
value: Preference value
|
||||||
|
user: User ID (defaults to current request context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await memory_service.set_preference("theme", "dark")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.PREFERENCE,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=[key, "preference"],
|
||||||
|
importance=0.7,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_all_preferences(self, user: str | None = None) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get all preferences for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of key -> value for all preferences
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
memories = await self._get_all_by_type(user, MemoryType.PREFERENCE)
|
||||||
|
return {m["key"]: m["value"] for m in memories}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Learned Facts (learned_fact type) - for direct storage only
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def store_fact(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
user: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
importance: float = 0.5,
|
||||||
|
source: str = "explicit",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Store a learned fact about the user.
|
||||||
|
|
||||||
|
Use this for explicit user statements like:
|
||||||
|
- "Remember that my car is a Tesla"
|
||||||
|
- "I work at Acme Corp"
|
||||||
|
|
||||||
|
For semantic extraction from conversation, use the Memory Agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Fact identifier (e.g., "car", "employer")
|
||||||
|
value: The fact content
|
||||||
|
user: User ID
|
||||||
|
keywords: Keywords for semantic search
|
||||||
|
importance: 0.0-1.0 importance score
|
||||||
|
source: "explicit" | "inferred" | "conversation"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._set_memory(
|
||||||
|
user=user,
|
||||||
|
memory_type=MemoryType.LEARNED_FACT,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
keywords=keywords or [key],
|
||||||
|
importance=importance,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_fact(self, key: str, user: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a specific fact by key.
|
||||||
|
|
||||||
|
For semantic/fuzzy queries, use the Memory Agent.
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
return await self._get_memory(user, MemoryType.LEARNED_FACT, key)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Context (Redis-backed, 24h TTL)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_session_context(
|
||||||
|
self,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get session context for current conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation ID (defaults to current context)
|
||||||
|
user: User ID (defaults to current context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session context dict or None
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self.cache.get_session_context(user, conversation_id)
|
||||||
|
|
||||||
|
async def set_session_context(
|
||||||
|
self,
|
||||||
|
context: dict[str, Any],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set session context for current conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Context data to store
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
logger.warning("memory_service_no_conversation_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.set_session_context(user, conversation_id, context)
|
||||||
|
|
||||||
|
async def update_session_context(
|
||||||
|
self,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update session context (merge with existing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
updates: Fields to update
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.update_session_context(user, conversation_id, updates)
|
||||||
|
|
||||||
|
async def get_recent_entities(
|
||||||
|
self,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get recently mentioned entities in conversation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entity names
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return await self.cache.get_recent_entities(user, conversation_id)
|
||||||
|
|
||||||
|
async def add_recent_entities(
|
||||||
|
self,
|
||||||
|
entities: list[str],
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Add entities to recent entities set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities: Entity names to add
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
conversation_id = conversation_id or get_conversation_id()
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.cache.add_recent_entities(user, conversation_id, entities)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Bulk / Pre-fetch Methods (for Steward)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def prefetch_context(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
include_profile: bool = True,
|
||||||
|
include_preferences: bool = True,
|
||||||
|
profile_keys: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Pre-fetch commonly needed context for Steward.
|
||||||
|
|
||||||
|
This is the main entry point for Steward to get user context
|
||||||
|
before analyzing a request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User ID
|
||||||
|
include_profile: Include profile data
|
||||||
|
include_preferences: Include preferences
|
||||||
|
profile_keys: Specific profile keys to fetch (None = common ones)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with profile and preferences data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> ctx = await memory_service.prefetch_context()
|
||||||
|
>>> ctx
|
||||||
|
{
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if include_profile:
|
||||||
|
profile_keys = profile_keys or ["location", "timezone", "name"]
|
||||||
|
profile = {}
|
||||||
|
for key in profile_keys:
|
||||||
|
value = await self.get_profile(key, user)
|
||||||
|
if value:
|
||||||
|
profile[key] = value
|
||||||
|
if profile:
|
||||||
|
result["profile"] = profile
|
||||||
|
|
||||||
|
if include_preferences:
|
||||||
|
preferences = await self.get_all_preferences(user)
|
||||||
|
if preferences:
|
||||||
|
result["preferences"] = preferences
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_service_prefetch",
|
||||||
|
user=user,
|
||||||
|
profile_keys=list(result.get("profile", {}).keys()),
|
||||||
|
preference_keys=list(result.get("preferences", {}).keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Internal Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _get_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
key: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Get a memory by type and key (exact match)."""
|
||||||
|
collection = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search with filter for exact type + key match
|
||||||
|
# We use a dummy vector since we're filtering by payload
|
||||||
|
results = self.qdrant._client.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type.value}},
|
||||||
|
{"key": "key", "match": {"value": key}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=1,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
if points:
|
||||||
|
return points[0].payload.get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_service_get_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _set_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
keywords: list[str],
|
||||||
|
importance: float = 0.5,
|
||||||
|
source: str = "explicit",
|
||||||
|
) -> bool:
|
||||||
|
"""Set a memory (upsert by type + key)."""
|
||||||
|
try:
|
||||||
|
# Generate embedding for semantic search
|
||||||
|
embedding = await self.embedding.embed(f"{key}: {value}")
|
||||||
|
if not embedding:
|
||||||
|
logger.error("memory_service_embedding_failed", key=key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create memory ID from type + key for idempotent upserts
|
||||||
|
memory_id = f"{memory_type.value}:{key}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": memory_type.value,
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"keywords": keywords,
|
||||||
|
"importance": importance,
|
||||||
|
"source": source,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.qdrant.upsert_memory(
|
||||||
|
user=user,
|
||||||
|
memory_id=memory_id,
|
||||||
|
vector=embedding,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.debug(
|
||||||
|
"memory_service_set",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"memory_service_set_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
key=key,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _get_all_by_type(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get all memories of a specific type."""
|
||||||
|
collection = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = self.qdrant._client.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
scroll_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "type", "match": {"value": memory_type.value}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, _ = results
|
||||||
|
return [p.payload for p in points]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_service_get_all_failed",
|
||||||
|
user=user,
|
||||||
|
type=memory_type.value,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete_memory(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
memory_type: MemoryType,
|
||||||
|
user: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a specific memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Memory key
|
||||||
|
memory_type: Type of memory
|
||||||
|
user: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
memory_id = f"{memory_type.value}:{key}"
|
||||||
|
|
||||||
|
return await self.qdrant.delete_memory(user, memory_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance
|
||||||
|
memory_service = MemoryService()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Multi-tenancy helpers for Tatlock.
|
||||||
|
|
||||||
|
Provides utilities for user namespace management across:
|
||||||
|
- Qdrant (collection per user for memories)
|
||||||
|
- Redis (user-scoped keys for session context)
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_id(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize user ID for use in collection names, keys, and paths.
|
||||||
|
|
||||||
|
Converts special characters to underscores and ensures alphanumeric safety.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Raw user identifier (email, username, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized user ID safe for use in identifiers
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> sanitize_user_id("john@example.com")
|
||||||
|
'john_at_example_com'
|
||||||
|
>>> sanitize_user_id("user.name")
|
||||||
|
'user_name'
|
||||||
|
>>> sanitize_user_id("User Name")
|
||||||
|
'user_name'
|
||||||
|
"""
|
||||||
|
sanitized = user_id.lower()
|
||||||
|
|
||||||
|
# Convert @ to _at_
|
||||||
|
sanitized = sanitized.replace("@", "_at_")
|
||||||
|
|
||||||
|
# Convert dots to underscores
|
||||||
|
sanitized = sanitized.replace(".", "_")
|
||||||
|
|
||||||
|
# Replace any non-alphanumeric characters with underscores
|
||||||
|
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove consecutive underscores
|
||||||
|
sanitized = re.sub(r'_+', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove leading/trailing underscores
|
||||||
|
sanitized = sanitized.strip('_')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_collection_name(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Qdrant collection name for user's memories.
|
||||||
|
|
||||||
|
Pattern: memories_{sanitized_user_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Qdrant collection name
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_memory_collection_name("jpmschweitzer")
|
||||||
|
'memories_jpmschweitzer'
|
||||||
|
>>> get_memory_collection_name("john@example.com")
|
||||||
|
'memories_john_at_example_com'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"memories_{sanitized}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for session context.
|
||||||
|
|
||||||
|
Pattern: session:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for session context
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_session_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'session:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"session:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_entities_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for recent entities in a conversation.
|
||||||
|
|
||||||
|
Pattern: entities:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for recent entities
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_entities_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'entities:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"entities:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_user_id(user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a user ID is acceptable.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Not empty
|
||||||
|
- Not too long (max 100 chars)
|
||||||
|
- Contains some alphanumeric characters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> validate_user_id("jpmschweitzer")
|
||||||
|
True
|
||||||
|
>>> validate_user_id("")
|
||||||
|
False
|
||||||
|
>>> validate_user_id("a" * 101)
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
if not user_id or len(user_id) > 100:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Must contain at least one alphanumeric character
|
||||||
|
if not re.search(r'[a-zA-Z0-9]', user_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -4,6 +4,7 @@ Request preprocessing pipeline.
|
|||||||
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
|
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from src.agents.steward import analyze_request, format_steward_note
|
from src.agents.steward import analyze_request, format_steward_note
|
||||||
@@ -14,6 +15,23 @@ from src.core.logging_config import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_temporal_context(request: str) -> str:
|
||||||
|
"""
|
||||||
|
Append current time context to user request.
|
||||||
|
|
||||||
|
Provides Tatlock with temporal awareness for time-sensitive queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Original user request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Request with appended time context
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
time_str = now.strftime("%Y-%m-%d %H:%M")
|
||||||
|
return f"{request}\n\n[Current time: {time_str}]"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EnrichedRequest:
|
class EnrichedRequest:
|
||||||
"""
|
"""
|
||||||
@@ -65,6 +83,9 @@ async def preprocess_request(
|
|||||||
>>> print(len(enriched.scoped_tools))
|
>>> print(len(enriched.scoped_tools))
|
||||||
5 # All tatlock_core tools
|
5 # All tatlock_core tools
|
||||||
"""
|
"""
|
||||||
|
# Inject temporal context for time-aware processing
|
||||||
|
enriched_request = _inject_temporal_context(user_request)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"preprocessing_request",
|
"preprocessing_request",
|
||||||
request_preview=user_request[:100],
|
request_preview=user_request[:100],
|
||||||
@@ -74,7 +95,7 @@ async def preprocess_request(
|
|||||||
|
|
||||||
# Call Steward with full conversation history
|
# Call Steward with full conversation history
|
||||||
recommendation = await analyze_request(
|
recommendation = await analyze_request(
|
||||||
user_request,
|
enriched_request,
|
||||||
conversation_history=conversation_history,
|
conversation_history=conversation_history,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
@@ -82,9 +103,11 @@ async def preprocess_request(
|
|||||||
# Format note for Tatlock (includes conversation context)
|
# Format note for Tatlock (includes conversation context)
|
||||||
steward_note = await format_steward_note(recommendation)
|
steward_note = await format_steward_note(recommendation)
|
||||||
|
|
||||||
# Get scoped tools from household registry
|
# Get delegation tools from household registry
|
||||||
|
# Uses agent-as-tool pattern: expert agents get delegation wrappers,
|
||||||
|
# core tools are returned directly
|
||||||
registry = get_household_registry()
|
registry = get_household_registry()
|
||||||
scoped_tools = registry.get_scoped_tools(
|
scoped_tools = registry.get_delegation_tools(
|
||||||
recommendation.recommended_capabilities
|
recommendation.recommended_capabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,7 +120,7 @@ async def preprocess_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return EnrichedRequest(
|
return EnrichedRequest(
|
||||||
original_request=user_request,
|
original_request=enriched_request,
|
||||||
steward_note=steward_note,
|
steward_note=steward_note,
|
||||||
scoped_tools=scoped_tools,
|
scoped_tools=scoped_tools,
|
||||||
recommendation=recommendation,
|
recommendation=recommendation,
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory vector storage.
|
||||||
|
|
||||||
|
Provides async operations for storing and retrieving memory embeddings:
|
||||||
|
- Collection management (per-user collections)
|
||||||
|
- Memory upsert/search/delete
|
||||||
|
- Filtering by memory type
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qdrant_models
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_memory_collection_name
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory storage.
|
||||||
|
|
||||||
|
Manages per-user collections with the pattern: memories_{user}
|
||||||
|
Stores memory embeddings with metadata (type, content, timestamps).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = MemoryQdrantClient()
|
||||||
|
await client.ensure_collection("jpmschweitzer")
|
||||||
|
await client.upsert_memory(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
memory_id="mem_123",
|
||||||
|
vector=[0.1, 0.2, ...],
|
||||||
|
payload={"type": "fact", "content": "User prefers dark mode"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | None = None,
|
||||||
|
embedding_dim: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Qdrant client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Qdrant server URL (defaults to config.qdrant_url)
|
||||||
|
embedding_dim: Vector dimension (defaults to config.QDRANT_EMBEDDING_DIM)
|
||||||
|
"""
|
||||||
|
self.url = url or config.qdrant_url
|
||||||
|
self.embedding_dim = embedding_dim or config.QDRANT_EMBEDDING_DIM
|
||||||
|
self._client = QdrantClient(url=self.url)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_client_initialized",
|
||||||
|
url=self.url,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close Qdrant client."""
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
async def ensure_collection(self, user: str) -> bool:
|
||||||
|
"""
|
||||||
|
Ensure collection exists for user, create if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if collection exists or was created successfully
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.ensure_collection("jpmschweitzer")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if collection exists
|
||||||
|
collections = self._client.get_collections()
|
||||||
|
existing = [c.name for c in collections.collections]
|
||||||
|
|
||||||
|
if collection_name in existing:
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_collection_exists",
|
||||||
|
collection=collection_name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create collection with cosine distance
|
||||||
|
self._client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=qdrant_models.VectorParams(
|
||||||
|
size=self.embedding_dim,
|
||||||
|
distance=qdrant_models.Distance.COSINE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_collection_created",
|
||||||
|
collection=collection_name,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_ensure_collection_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def upsert_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_id: str | None,
|
||||||
|
vector: list[float],
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Upsert a memory point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID (generated if None)
|
||||||
|
vector: Embedding vector
|
||||||
|
payload: Memory metadata (should include 'type', 'content', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory ID if successful, None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memory_id = await client.upsert_memory(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... memory_id=None,
|
||||||
|
... vector=[0.1, 0.2, ...],
|
||||||
|
... payload={
|
||||||
|
... "type": "fact",
|
||||||
|
... "content": "User prefers dark mode",
|
||||||
|
... "created_at": "2024-01-01T00:00:00Z"
|
||||||
|
... }
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
memory_id = memory_id or f"mem_{uuid4().hex[:16]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure collection exists
|
||||||
|
await self.ensure_collection(user)
|
||||||
|
|
||||||
|
# Create point
|
||||||
|
point = qdrant_models.PointStruct(
|
||||||
|
id=memory_id,
|
||||||
|
vector=vector,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert
|
||||||
|
self._client.upsert(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points=[point],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_upserted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
memory_type=payload.get("type"),
|
||||||
|
)
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_upsert_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_memories(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
query_vector: list[float],
|
||||||
|
limit: int = 10,
|
||||||
|
memory_type: str | None = None,
|
||||||
|
score_threshold: float = 0.5,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search memories by vector similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
query_vector: Query embedding vector
|
||||||
|
limit: Maximum results
|
||||||
|
memory_type: Filter by memory type (e.g., "fact", "preference", "profile")
|
||||||
|
score_threshold: Minimum similarity score (0-1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching memories with scores
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memories = await client.search_memories(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... query_vector=[0.1, 0.2, ...],
|
||||||
|
... limit=5,
|
||||||
|
... memory_type="fact"
|
||||||
|
... )
|
||||||
|
>>> memories[0]
|
||||||
|
{"id": "mem_123", "score": 0.89, "type": "fact", "content": "..."}
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build filter if memory_type specified
|
||||||
|
query_filter = None
|
||||||
|
if memory_type:
|
||||||
|
query_filter = qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
results = self._client.search(
|
||||||
|
collection_name=collection_name,
|
||||||
|
query_vector=query_vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
|
score_threshold=score_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
memories = []
|
||||||
|
for hit in results:
|
||||||
|
memory = {
|
||||||
|
"id": hit.id,
|
||||||
|
"score": hit.score,
|
||||||
|
**hit.payload,
|
||||||
|
}
|
||||||
|
memories.append(memory)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_search_memories",
|
||||||
|
collection=collection_name,
|
||||||
|
results_count=len(memories),
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return memories
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_search_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_memory(self, user: str, memory_id: str) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get a specific memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory data or None if not found
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
points = self._client.retrieve(
|
||||||
|
collection_name=collection_name,
|
||||||
|
ids=[memory_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
point = points[0]
|
||||||
|
return {
|
||||||
|
"id": point.id,
|
||||||
|
**point.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_get_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_memory(self, user: str, memory_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.delete_memory("jpmschweitzer", "mem_123")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.PointIdsList(
|
||||||
|
points=[memory_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_deleted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_memories_by_type(self, user: str, memory_type: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all memories of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_type: Type of memories to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories deleted (approximate)
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete by filter
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.FilterSelector(
|
||||||
|
filter=qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_memories_deleted_by_type",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return -1 # Qdrant doesn't return count for filter deletes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memories_by_type_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def count_memories(self, user: str) -> int:
|
||||||
|
"""
|
||||||
|
Count total memories for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories in user's collection
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self._client.get_collection(collection_name)
|
||||||
|
return info.points_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_count_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Qdrant server is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._client.get_collections()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("qdrant_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_qdrant_client: MemoryQdrantClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_qdrant_client() -> MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Get global Qdrant client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryQdrantClient instance
|
||||||
|
"""
|
||||||
|
global _qdrant_client
|
||||||
|
if _qdrant_client is None:
|
||||||
|
_qdrant_client = MemoryQdrantClient()
|
||||||
|
return _qdrant_client
|
||||||
@@ -5,6 +5,7 @@ Handles initialization of household registry and other startup tasks.
|
|||||||
This module should be called during application startup to register
|
This module should be called during application startup to register
|
||||||
all household members.
|
all household members.
|
||||||
"""
|
"""
|
||||||
|
from src.agents.biographer import register_biographer
|
||||||
from src.agents.librarian import register_librarian
|
from src.agents.librarian import register_librarian
|
||||||
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
@@ -23,6 +24,7 @@ def register_household_members():
|
|||||||
Currently registers:
|
Currently registers:
|
||||||
- tatlock_core: Butler's core tools (calculator, datetime, web search)
|
- tatlock_core: Butler's core tools (calculator, datetime, web search)
|
||||||
- librarian: Research and knowledge management (Phase 3)
|
- librarian: Research and knowledge management (Phase 3)
|
||||||
|
- biographer: User memory and context management (Phase F)
|
||||||
"""
|
"""
|
||||||
registry = get_household_registry()
|
registry = get_household_registry()
|
||||||
|
|
||||||
@@ -52,6 +54,16 @@ def register_household_members():
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register The Biographer (Phase F)
|
||||||
|
try:
|
||||||
|
register_biographer()
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail startup if Biographer registration fails
|
||||||
|
logger.warning(
|
||||||
|
"biographer_registration_failed",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"household_registration_complete",
|
"household_registration_complete",
|
||||||
total_members=len(registry),
|
total_members=len(registry),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sse_starlette.sse import EventSourceResponse
|
|||||||
from src.responses import service
|
from src.responses import service
|
||||||
from src.responses.schemas import ResponseRequest, Response
|
from src.responses.schemas import ResponseRequest, Response
|
||||||
from src.core.exceptions import ModelNotFoundError, AppException
|
from src.core.exceptions import ModelNotFoundError, AppException
|
||||||
|
from src.core.context import current_user, current_conversation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -94,6 +95,11 @@ async def create_response(
|
|||||||
"""
|
"""
|
||||||
logger.info(f"Response request for model: {request.model}")
|
logger.info(f"Response request for model: {request.model}")
|
||||||
|
|
||||||
|
# Set request context (propagates through all async calls)
|
||||||
|
user_token = current_user.set(request.user or "jpmschweitzer")
|
||||||
|
conv_id = request.metadata.get("conversation_id") if request.metadata else None
|
||||||
|
conv_token = current_conversation.set(conv_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
||||||
model_id = request.model
|
model_id = request.model
|
||||||
@@ -136,3 +142,8 @@ async def create_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Reset context (important for connection reuse)
|
||||||
|
current_user.reset(user_token)
|
||||||
|
current_conversation.reset(conv_token)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class ResponseRequest(CustomBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Stop sequences"
|
description="Stop sequences"
|
||||||
)
|
)
|
||||||
|
user: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Unique identifier for end-user (OpenAI standard)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('reasoning')
|
@field_validator('reasoning')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for The Biographer agent."""
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Tests for Biographer capability registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.biographer.capability import (
|
||||||
|
BIOGRAPHER_CAPABILITY,
|
||||||
|
get_biographer_capability,
|
||||||
|
register_biographer,
|
||||||
|
unregister_biographer,
|
||||||
|
)
|
||||||
|
from src.core.household_registry import HouseholdCapability
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBiographerCapability:
|
||||||
|
"""Tests for the Biographer capability definition."""
|
||||||
|
|
||||||
|
def test_capability_is_household_capability(self):
|
||||||
|
"""Test capability is correct type."""
|
||||||
|
assert isinstance(BIOGRAPHER_CAPABILITY, HouseholdCapability)
|
||||||
|
|
||||||
|
def test_capability_name(self):
|
||||||
|
"""Test capability has correct name."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.name == "biographer"
|
||||||
|
|
||||||
|
def test_capability_role(self):
|
||||||
|
"""Test capability has correct role."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.role == "The Biographer"
|
||||||
|
|
||||||
|
def test_capability_category(self):
|
||||||
|
"""Test capability is in context category."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.category == "context"
|
||||||
|
|
||||||
|
def test_capability_domains(self):
|
||||||
|
"""Test capability covers expected domains."""
|
||||||
|
domains = BIOGRAPHER_CAPABILITY.domains
|
||||||
|
|
||||||
|
assert "remember" in domains
|
||||||
|
assert "recall" in domains
|
||||||
|
assert "forget" in domains
|
||||||
|
assert "memory" in domains
|
||||||
|
assert "preferences" in domains
|
||||||
|
assert "profile" in domains
|
||||||
|
|
||||||
|
def test_capability_does_not_require_network(self):
|
||||||
|
"""Test capability does not require network access."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.requires_network is False
|
||||||
|
|
||||||
|
def test_capability_low_cost(self):
|
||||||
|
"""Test capability has low cost (vector search, minimal LLM)."""
|
||||||
|
assert BIOGRAPHER_CAPABILITY.cost == "low"
|
||||||
|
|
||||||
|
def test_get_biographer_capability(self):
|
||||||
|
"""Test getter returns same capability."""
|
||||||
|
cap = get_biographer_capability()
|
||||||
|
|
||||||
|
assert cap is BIOGRAPHER_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBiographerRegistration:
|
||||||
|
"""Tests for Biographer registration functions."""
|
||||||
|
|
||||||
|
def test_register_biographer(self):
|
||||||
|
"""Test registering biographer with registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_biographer_agent"
|
||||||
|
) as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
register_biographer()
|
||||||
|
|
||||||
|
mock_registry.register.assert_called_once()
|
||||||
|
call_kwargs = mock_registry.register.call_args[1]
|
||||||
|
|
||||||
|
assert call_kwargs["name"] == "biographer"
|
||||||
|
assert call_kwargs["capability"] is BIOGRAPHER_CAPABILITY
|
||||||
|
assert call_kwargs["agent"] is mock_agent
|
||||||
|
|
||||||
|
def test_register_biographer_already_registered(self):
|
||||||
|
"""Test registering when already registered does nothing."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
mock_registry.__contains__ = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
register_biographer()
|
||||||
|
|
||||||
|
# Should not call register since already registered
|
||||||
|
mock_registry.register.assert_not_called()
|
||||||
|
|
||||||
|
def test_unregister_biographer(self):
|
||||||
|
"""Test unregistering biographer from registry."""
|
||||||
|
mock_registry = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.biographer.capability.get_household_registry",
|
||||||
|
return_value=mock_registry,
|
||||||
|
):
|
||||||
|
unregister_biographer()
|
||||||
|
|
||||||
|
mock_registry.unregister.assert_called_once_with("biographer")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCapabilityDescription:
|
||||||
|
"""Tests for capability description."""
|
||||||
|
|
||||||
|
def test_description_mentions_recall(self):
|
||||||
|
"""Test description mentions recall capabilities."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "recall" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_record(self):
|
||||||
|
"""Test description mentions recording capability."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "record" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_forget(self):
|
||||||
|
"""Test description mentions forget capability."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "forget" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_profile(self):
|
||||||
|
"""Test description mentions profile updates."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "profile" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_preferences(self):
|
||||||
|
"""Test description mentions preferences."""
|
||||||
|
desc = BIOGRAPHER_CAPABILITY.description.lower()
|
||||||
|
assert "preferences" in desc
|
||||||
@@ -113,9 +113,12 @@ class TestLibrarianRegistration:
|
|||||||
class TestCapabilityDescription:
|
class TestCapabilityDescription:
|
||||||
"""Tests for capability description."""
|
"""Tests for capability description."""
|
||||||
|
|
||||||
def test_description_mentions_library_desk(self):
|
def test_description_mentions_wiki_capabilities(self):
|
||||||
"""Test description mentions library-desk API."""
|
"""Test description mentions wiki read/write capabilities."""
|
||||||
assert "library-desk" in LIBRARIAN_CAPABILITY.description.lower()
|
desc = LIBRARIAN_CAPABILITY.description.lower()
|
||||||
|
assert "create" in desc
|
||||||
|
assert "update" in desc
|
||||||
|
assert "search" in desc
|
||||||
|
|
||||||
def test_description_mentions_search(self):
|
def test_description_mentions_search(self):
|
||||||
"""Test description mentions search capability."""
|
"""Test description mentions search capability."""
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Tests for delegation infrastructure.
|
||||||
|
|
||||||
|
Tests the DelegationTask dataclass and delegation wrapper functions
|
||||||
|
that implement the agent-as-tool pattern.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from src.agents.delegation import (
|
||||||
|
DelegationTask,
|
||||||
|
DelegationResult,
|
||||||
|
delegate_to_librarian,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationTask:
|
||||||
|
"""Tests for the DelegationTask dataclass."""
|
||||||
|
|
||||||
|
def test_delegation_task_creation(self):
|
||||||
|
"""Test basic DelegationTask creation."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Create a wiki page about CI/CD",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
action="create",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert task.task == "Create a wiki page about CI/CD"
|
||||||
|
assert task.context == "User is setting up a homelab"
|
||||||
|
assert task.action == "create"
|
||||||
|
|
||||||
|
def test_delegation_task_default_values(self):
|
||||||
|
"""Test DelegationTask default values."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.context == ""
|
||||||
|
assert task.action == ""
|
||||||
|
assert task.priority == 0
|
||||||
|
assert task.depends_on == []
|
||||||
|
assert task.result is None
|
||||||
|
|
||||||
|
def test_delegation_task_auto_generates_id(self):
|
||||||
|
"""Test DelegationTask auto-generates unique IDs."""
|
||||||
|
task1 = DelegationTask(expert_name="librarian", task="Task 1")
|
||||||
|
task2 = DelegationTask(expert_name="librarian", task="Task 2")
|
||||||
|
|
||||||
|
assert task1.task_id.startswith("librarian_")
|
||||||
|
assert task2.task_id.startswith("librarian_")
|
||||||
|
assert task1.task_id != task2.task_id
|
||||||
|
|
||||||
|
def test_delegation_task_preserves_custom_id(self):
|
||||||
|
"""Test DelegationTask preserves custom ID if provided."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Custom task",
|
||||||
|
task_id="custom_id_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.task_id == "custom_id_123"
|
||||||
|
|
||||||
|
def test_delegation_task_with_dependencies(self):
|
||||||
|
"""Test DelegationTask with dependencies."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Update wiki page",
|
||||||
|
depends_on=["memory_abc123", "search_def456"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(task.depends_on) == 2
|
||||||
|
assert "memory_abc123" in task.depends_on
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegationResult:
|
||||||
|
"""Tests for the DelegationResult dataclass."""
|
||||||
|
|
||||||
|
def test_delegation_result_success(self):
|
||||||
|
"""Test successful DelegationResult."""
|
||||||
|
result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
success=True,
|
||||||
|
output="Found 5 relevant documents about Docker...",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.expert_name == "librarian"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output.startswith("Found")
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
def test_delegation_result_failure(self):
|
||||||
|
"""Test failed DelegationResult."""
|
||||||
|
result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="Search for Docker info",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection timeout to library-desk API",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.output == ""
|
||||||
|
assert result.error == "Connection timeout to library-desk API"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDelegateToLibrarian:
|
||||||
|
"""Tests for the delegate_to_librarian wrapper."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_success(self):
|
||||||
|
"""Test successful delegation to Librarian."""
|
||||||
|
mock_output = "Successfully created wiki page about CI/CD pipelines..."
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_output,
|
||||||
|
) as mock_run:
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Create a wiki page about CI/CD pipelines",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify run_librarian was called correctly
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
task="Create a wiki page about CI/CD pipelines",
|
||||||
|
context="User is setting up a homelab",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert isinstance(result, DelegationResult)
|
||||||
|
assert result.expert_name == "librarian"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output == mock_output
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_without_context(self):
|
||||||
|
"""Test delegation to Librarian without context."""
|
||||||
|
mock_output = "Found information about Docker networking..."
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_output,
|
||||||
|
) as mock_run:
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Search for information about Docker networking",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
task="Search for information about Docker networking",
|
||||||
|
context="",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output == mock_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_handles_error(self):
|
||||||
|
"""Test delegation handles Librarian errors gracefully."""
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Connection refused"),
|
||||||
|
):
|
||||||
|
result = await delegate_to_librarian(
|
||||||
|
task="Search for information",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, DelegationResult)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.output == ""
|
||||||
|
assert result.error == "Connection refused"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_to_librarian_preserves_task(self):
|
||||||
|
"""Test delegation result preserves original task."""
|
||||||
|
original_task = "Create a wiki page about Kubernetes deployments"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.agent.run_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="Page created",
|
||||||
|
):
|
||||||
|
result = await delegate_to_librarian(task=original_task)
|
||||||
|
|
||||||
|
assert result.task == original_task
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
"""
|
||||||
|
Tests for orchestration module.
|
||||||
|
|
||||||
|
Tests the multi-expert coordination infrastructure including
|
||||||
|
delegation parsing, think updates, result handling, and
|
||||||
|
multi-expert sequential/parallel execution.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from src.agents.orchestration import (
|
||||||
|
OrchestrationContext,
|
||||||
|
parse_delegation_from_steward_note,
|
||||||
|
execute_delegation,
|
||||||
|
orchestrate_with_think_updates,
|
||||||
|
extract_delegation_context,
|
||||||
|
ExecutionMode,
|
||||||
|
MultiExpertResult,
|
||||||
|
execute_sequential,
|
||||||
|
execute_parallel,
|
||||||
|
orchestrate_multi_expert,
|
||||||
|
_get_display_name,
|
||||||
|
)
|
||||||
|
from src.agents.delegation import DelegationTask, DelegationResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestParseDelegation:
|
||||||
|
"""Tests for parsing delegation from Steward's note."""
|
||||||
|
|
||||||
|
def test_parse_librarian_create(self):
|
||||||
|
"""Test parsing librarian create delegation."""
|
||||||
|
note = """DELEGATE: librarian to create a wiki page about CI/CD pipelines
|
||||||
|
REASON: User wants to document CI/CD concepts
|
||||||
|
COMPLEXITY: moderate
|
||||||
|
CONTEXT: none"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert "create a wiki page about CI/CD pipelines" in task.task
|
||||||
|
|
||||||
|
def test_parse_librarian_search(self):
|
||||||
|
"""Test parsing librarian search delegation."""
|
||||||
|
note = """DELEGATE: librarian to search for information about Docker networking
|
||||||
|
REASON: User needs Docker documentation
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
assert "search for information about Docker networking" in task.task
|
||||||
|
|
||||||
|
def test_parse_no_delegation(self):
|
||||||
|
"""Test parsing when no delegation needed."""
|
||||||
|
note = """DELEGATE: none (conversational response only)
|
||||||
|
REASON: Simple greeting requires no tools
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is None
|
||||||
|
|
||||||
|
def test_parse_tatlock_core(self):
|
||||||
|
"""Test parsing tatlock_core delegation."""
|
||||||
|
note = """DELEGATE: tatlock_core to calculate the result
|
||||||
|
REASON: Math calculation needed
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "tatlock_core"
|
||||||
|
assert "calculate the result" in task.task
|
||||||
|
|
||||||
|
def test_parse_case_insensitive(self):
|
||||||
|
"""Test parsing is case insensitive."""
|
||||||
|
note = """delegate: LIBRARIAN to search docs
|
||||||
|
reason: Research query"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.expert_name == "librarian"
|
||||||
|
|
||||||
|
def test_parse_missing_delegate(self):
|
||||||
|
"""Test parsing when DELEGATE line is missing."""
|
||||||
|
note = """REASON: This has no delegation
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
task = parse_delegation_from_steward_note(note)
|
||||||
|
|
||||||
|
assert task is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractDelegationContext:
|
||||||
|
"""Tests for extracting context from Steward's note."""
|
||||||
|
|
||||||
|
def test_extract_all_fields(self):
|
||||||
|
"""Test extracting all context fields."""
|
||||||
|
note = """DELEGATE: librarian to create wiki page
|
||||||
|
REASON: User wants documentation
|
||||||
|
COMPLEXITY: moderate
|
||||||
|
CONTEXT: Related to previous discussion about DevOps"""
|
||||||
|
|
||||||
|
context = extract_delegation_context(note)
|
||||||
|
|
||||||
|
assert context["reason"] == "User wants documentation"
|
||||||
|
assert context["complexity"] == "moderate"
|
||||||
|
assert "Related to previous discussion" in context["context"]
|
||||||
|
|
||||||
|
def test_extract_partial_fields(self):
|
||||||
|
"""Test extracting when some fields missing."""
|
||||||
|
note = """DELEGATE: librarian to search
|
||||||
|
REASON: Research query
|
||||||
|
COMPLEXITY: simple"""
|
||||||
|
|
||||||
|
context = extract_delegation_context(note)
|
||||||
|
|
||||||
|
assert context["reason"] == "Research query"
|
||||||
|
assert context["complexity"] == "simple"
|
||||||
|
assert context["context"] == ""
|
||||||
|
|
||||||
|
def test_extract_empty_note(self):
|
||||||
|
"""Test extracting from empty note."""
|
||||||
|
context = extract_delegation_context("")
|
||||||
|
|
||||||
|
assert context["reason"] == ""
|
||||||
|
assert context["complexity"] == ""
|
||||||
|
assert context["context"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteDelegation:
|
||||||
|
"""Tests for executing delegation tasks."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_librarian_delegation(self):
|
||||||
|
"""Test executing delegation to librarian."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search for Docker docs",
|
||||||
|
context="User learning Docker",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search for Docker docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker documentation...",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
) as mock_delegate:
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
|
||||||
|
mock_delegate.assert_called_once_with(
|
||||||
|
task="search for Docker docs",
|
||||||
|
context="User learning Docker",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert "Docker" in result.output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_unknown_expert(self):
|
||||||
|
"""Test executing delegation to unknown expert."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="unknown_expert",
|
||||||
|
task="do something",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await execute_delegation(task)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "Unknown expert" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrateWithThinkUpdates:
|
||||||
|
"""Tests for orchestration with think updates."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_emits_think_before_delegation(self):
|
||||||
|
"""Test that think update is emitted before delegation."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found results",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# First update should be think tag about consulting
|
||||||
|
assert any("<think>" in u and "Consulting" in u for u in updates)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_emits_think_after_delegation(self):
|
||||||
|
"""Test that think update is emitted after delegation."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found results",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should have think tag about completion
|
||||||
|
assert any("<think>" in u and "completed" in u for u in updates)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_yields_expert_output(self):
|
||||||
|
"""Test that expert output is yielded."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker documentation with networking details",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for Docker info",
|
||||||
|
steward_note="DELEGATE: librarian to search for Docker info",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should include expert output
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "Docker documentation" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_handles_delegation_failure(self):
|
||||||
|
"""Test that delegation failure emits warning think update."""
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Search for info",
|
||||||
|
steward_note="DELEGATE: librarian to search",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
# Should have warning think update
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "⚠️" in all_output or "issue" in all_output.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_no_delegation_returns_empty(self):
|
||||||
|
"""Test that no delegation yields nothing."""
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Hello",
|
||||||
|
steward_note="DELEGATE: none (conversational)",
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_with_preparsed_task(self):
|
||||||
|
"""Test orchestration with pre-parsed delegation task."""
|
||||||
|
task = DelegationTask(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="create wiki page",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="create wiki page",
|
||||||
|
success=True,
|
||||||
|
output="Wiki page created",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.delegate_to_librarian",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_with_think_updates(
|
||||||
|
user_message="Create wiki page",
|
||||||
|
steward_note="", # Empty note since task is pre-parsed
|
||||||
|
delegation_task=task,
|
||||||
|
):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) > 0
|
||||||
|
all_output = "".join(updates)
|
||||||
|
assert "Wiki page created" in all_output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrationContext:
|
||||||
|
"""Tests for OrchestrationContext dataclass."""
|
||||||
|
|
||||||
|
def test_context_creation(self):
|
||||||
|
"""Test creating orchestration context."""
|
||||||
|
ctx = OrchestrationContext(
|
||||||
|
user_message="Test message",
|
||||||
|
steward_note="Test note",
|
||||||
|
conversation_id="conv_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.user_message == "Test message"
|
||||||
|
assert ctx.steward_note == "Test note"
|
||||||
|
assert ctx.conversation_id == "conv_123"
|
||||||
|
|
||||||
|
def test_context_defaults(self):
|
||||||
|
"""Test orchestration context default values."""
|
||||||
|
ctx = OrchestrationContext(
|
||||||
|
user_message="Test",
|
||||||
|
steward_note="Note",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.conversation_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Multi-Expert Coordination Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMultiExpertResult:
|
||||||
|
"""Tests for MultiExpertResult aggregation."""
|
||||||
|
|
||||||
|
def test_result_creation(self):
|
||||||
|
"""Test creating empty MultiExpertResult."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
assert result.results == {}
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert result.failed_experts == []
|
||||||
|
assert result.combined_output == ""
|
||||||
|
|
||||||
|
def test_add_successful_result(self):
|
||||||
|
"""Test adding a successful result."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
delegation_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found docs",
|
||||||
|
)
|
||||||
|
result.add_result(delegation_result)
|
||||||
|
|
||||||
|
assert "librarian" in result.results
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert result.failed_experts == []
|
||||||
|
|
||||||
|
def test_add_failed_result(self):
|
||||||
|
"""Test adding a failed result."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
delegation_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Connection error",
|
||||||
|
)
|
||||||
|
result.add_result(delegation_result)
|
||||||
|
|
||||||
|
assert "librarian" in result.results
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert "librarian" in result.failed_experts
|
||||||
|
|
||||||
|
def test_aggregate_outputs(self):
|
||||||
|
"""Test aggregating outputs from multiple experts."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search docs",
|
||||||
|
success=True,
|
||||||
|
output="Found Docker docs",
|
||||||
|
))
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="memory",
|
||||||
|
task="get preferences",
|
||||||
|
success=True,
|
||||||
|
output="User prefers dark mode",
|
||||||
|
))
|
||||||
|
|
||||||
|
combined = result.aggregate_outputs()
|
||||||
|
|
||||||
|
assert "Librarian" in combined
|
||||||
|
assert "Found Docker docs" in combined
|
||||||
|
assert "Memory" in combined
|
||||||
|
assert "dark mode" in combined
|
||||||
|
|
||||||
|
def test_aggregate_excludes_failed(self):
|
||||||
|
"""Test that failed results are excluded from aggregate."""
|
||||||
|
result = MultiExpertResult()
|
||||||
|
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="search",
|
||||||
|
success=True,
|
||||||
|
output="Success output",
|
||||||
|
))
|
||||||
|
result.add_result(DelegationResult(
|
||||||
|
expert_name="memory",
|
||||||
|
task="get",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Failed",
|
||||||
|
))
|
||||||
|
|
||||||
|
combined = result.aggregate_outputs()
|
||||||
|
|
||||||
|
assert "Success output" in combined
|
||||||
|
assert "Failed" not in combined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteSequential:
|
||||||
|
"""Tests for sequential multi-expert execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_all_succeed(self):
|
||||||
|
"""Test sequential execution when all tasks succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert result.failed_experts == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_with_failure(self):
|
||||||
|
"""Test sequential execution when a task fails."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Failed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_stop_on_failure(self):
|
||||||
|
"""Test sequential execution stops on failure when configured."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
DelegationTask(expert_name="librarian", task="task 3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=False, output="", error="Error"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_sequential(tasks, stop_on_failure=True)
|
||||||
|
|
||||||
|
# Should only have 1 result (stopped after first failure)
|
||||||
|
assert len(result.results) == 1
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExecuteParallel:
|
||||||
|
"""Tests for parallel multi-expert execution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_all_succeed(self):
|
||||||
|
"""Test parallel execution when all tasks succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is True
|
||||||
|
assert len(result.results) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_with_failure(self):
|
||||||
|
"""Test parallel execution with partial failure."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Timeout"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert len(result.results) == 2
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_handles_exception(self):
|
||||||
|
"""Test parallel execution handles exceptions gracefully."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mock_execute(task):
|
||||||
|
if task.expert_name == "memory":
|
||||||
|
raise RuntimeError("Connection lost")
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name=task.expert_name,
|
||||||
|
task=task.task,
|
||||||
|
success=True,
|
||||||
|
output="OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_execute,
|
||||||
|
):
|
||||||
|
result = await execute_parallel(tasks)
|
||||||
|
|
||||||
|
assert result.all_succeeded is False
|
||||||
|
assert "memory" in result.failed_experts
|
||||||
|
assert "Connection lost" in result.results["memory"].error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOrchestrateMultiExpert:
|
||||||
|
"""Tests for multi-expert orchestration with think updates."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_sequential_emits_think_updates(self):
|
||||||
|
"""Test sequential orchestration emits think updates for each task."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.SEQUENTIAL):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should have think updates for both experts
|
||||||
|
assert "Consulting" in all_output
|
||||||
|
assert "completed" in all_output
|
||||||
|
assert "Librarian" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_parallel_emits_think_updates(self):
|
||||||
|
"""Test parallel orchestration emits appropriate think updates."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
DelegationTask(expert_name="memory", task="task 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_results = [
|
||||||
|
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||||
|
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=mock_results,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.PARALLEL):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should mention parallel execution
|
||||||
|
assert "parallel" in all_output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_empty_tasks_yields_nothing(self):
|
||||||
|
"""Test orchestration with empty tasks yields nothing."""
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert([]):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
assert len(updates) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_success_summary(self):
|
||||||
|
"""Test orchestration emits success summary when all succeed."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="task 1",
|
||||||
|
success=True,
|
||||||
|
output="Done",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should have success message
|
||||||
|
assert "🎉" in all_output or "successfully" in all_output.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_orchestrate_failure_summary(self):
|
||||||
|
"""Test orchestration emits failure summary when some fail."""
|
||||||
|
tasks = [
|
||||||
|
DelegationTask(expert_name="librarian", task="task 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_result = DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task="task 1",
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error="Failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.orchestration.execute_delegation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
updates = []
|
||||||
|
async for update in orchestrate_multi_expert(tasks):
|
||||||
|
updates.append(update)
|
||||||
|
|
||||||
|
all_output = "".join(updates)
|
||||||
|
|
||||||
|
# Should mention failure
|
||||||
|
assert "⚠️" in all_output or "failed" in all_output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetDisplayName:
|
||||||
|
"""Tests for _get_display_name helper."""
|
||||||
|
|
||||||
|
def test_librarian_display_name(self):
|
||||||
|
"""Test librarian gets 'The Librarian' display name."""
|
||||||
|
assert _get_display_name("librarian") == "The Librarian"
|
||||||
|
|
||||||
|
def test_memory_display_name(self):
|
||||||
|
"""Test memory gets 'Memory' display name."""
|
||||||
|
assert _get_display_name("memory") == "Memory"
|
||||||
|
|
||||||
|
def test_unknown_expert_title_case(self):
|
||||||
|
"""Test unknown expert gets title-cased name."""
|
||||||
|
assert _get_display_name("some_expert") == "Some_Expert"
|
||||||
|
assert _get_display_name("newagent") == "Newagent"
|
||||||
@@ -168,9 +168,11 @@ async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
||||||
"""
|
"""
|
||||||
Test that calculator tool calls are logged to reasoning output.
|
Test that calculator requests are handled correctly.
|
||||||
|
|
||||||
Verifies that mathematical calculations show what expression was evaluated.
|
Verifies that mathematical calculations produce correct results.
|
||||||
|
Note: Tool call logging visibility depends on execution path
|
||||||
|
(streaming vs run, scoped tools vs delegation).
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"model": "Tatlock",
|
"model": "Tatlock",
|
||||||
@@ -190,18 +192,30 @@ async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
full_response = data["choices"][0]["message"]["content"]
|
full_response = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
# Should have calculator emoji in the response
|
# Should have reasoning in <think> tags (from Steward analysis)
|
||||||
assert "🧮" in full_response, \
|
assert "<think>" in full_response, \
|
||||||
f"Response should show calculator was used. Got: {full_response}"
|
f"Should have reasoning output in <think> tags. Got: {full_response}"
|
||||||
|
|
||||||
# Should show the calculation expression
|
# Should reference the calculation in some form
|
||||||
assert "sqrt(144)" in full_response or "144" in full_response, \
|
has_calculation_reference = (
|
||||||
f"Should show what was calculated. Got: {full_response}"
|
"144" in full_response or
|
||||||
|
"sqrt" in full_response.lower() or
|
||||||
|
"square root" in full_response.lower()
|
||||||
|
)
|
||||||
|
assert has_calculation_reference, \
|
||||||
|
f"Should reference the calculation. Got: {full_response}"
|
||||||
|
|
||||||
# Should have the correct answer (37)
|
# Should have the correct answer (37)
|
||||||
assert "37" in full_response, \
|
assert "37" in full_response, \
|
||||||
f"Should contain the answer 37. Got: {full_response}"
|
f"Should contain the answer 37. Got: {full_response}"
|
||||||
|
|
||||||
|
# Tool emoji is optional - depends on whether tool was used directly
|
||||||
|
# or computation was delegated to capability
|
||||||
|
if "🧮" in full_response:
|
||||||
|
print(f"\nCalculator tool was used directly")
|
||||||
|
else:
|
||||||
|
print(f"\nCalculation handled via tatlock_core capability")
|
||||||
|
|
||||||
print(f"\nCalculator response: {full_response}")
|
print(f"\nCalculator response: {full_response}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,101 @@ class TestHouseholdRegistry:
|
|||||||
assert research_caps[0].name == "research_tools"
|
assert research_caps[0].name == "research_tools"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDelegationTools:
|
||||||
|
"""Test get_delegation_tools() method for agent-as-tool pattern."""
|
||||||
|
|
||||||
|
def test_delegation_tools_returns_wrapper_for_member_with_agent(self, registry, sample_tools):
|
||||||
|
"""Test delegation tools returns wrapper when member has an agent."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
cap = HouseholdCapability(
|
||||||
|
name="librarian",
|
||||||
|
role="The Librarian",
|
||||||
|
category="research",
|
||||||
|
description="Research and wiki management",
|
||||||
|
domains=["research", "wiki"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_agent = Mock()
|
||||||
|
registry.register("librarian", cap, sample_tools, agent=mock_agent)
|
||||||
|
|
||||||
|
tools = registry.get_delegation_tools(["librarian"])
|
||||||
|
|
||||||
|
# Should return delegation wrapper, not raw tools
|
||||||
|
assert len(tools) == 1
|
||||||
|
# The wrapper should be the delegate_to_librarian function
|
||||||
|
assert callable(tools[0])
|
||||||
|
assert tools[0].__name__ == "delegate_to_librarian"
|
||||||
|
|
||||||
|
def test_delegation_tools_returns_raw_tools_for_member_without_agent(self, registry, sample_capability, sample_tools):
|
||||||
|
"""Test delegation tools returns raw tools when member has no agent."""
|
||||||
|
registry.register("test_tools", sample_capability, sample_tools)
|
||||||
|
|
||||||
|
tools = registry.get_delegation_tools(["test_tools"])
|
||||||
|
|
||||||
|
# Should return raw tools since no agent
|
||||||
|
assert len(tools) == 2
|
||||||
|
assert tools[0].name == "test_tool_1"
|
||||||
|
assert tools[1].name == "test_tool_2"
|
||||||
|
|
||||||
|
def test_delegation_tools_mixed_members(self, registry, sample_tools):
|
||||||
|
"""Test delegation tools handles mix of agent and non-agent members."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
# Member with agent (librarian)
|
||||||
|
librarian_cap = HouseholdCapability(
|
||||||
|
name="librarian",
|
||||||
|
role="The Librarian",
|
||||||
|
category="research",
|
||||||
|
description="Research and wiki",
|
||||||
|
domains=["research"],
|
||||||
|
cost="medium",
|
||||||
|
requires_network=True,
|
||||||
|
)
|
||||||
|
mock_agent = Mock()
|
||||||
|
registry.register("librarian", librarian_cap, sample_tools, agent=mock_agent)
|
||||||
|
|
||||||
|
# Member without agent (tatlock_core)
|
||||||
|
core_cap = HouseholdCapability(
|
||||||
|
name="tatlock_core",
|
||||||
|
role="Butler's Core Tools",
|
||||||
|
category="core",
|
||||||
|
description="Basic tools",
|
||||||
|
domains=["computation"],
|
||||||
|
cost="low",
|
||||||
|
requires_network=False,
|
||||||
|
)
|
||||||
|
registry.register("tatlock_core", core_cap, sample_tools)
|
||||||
|
|
||||||
|
# Request both
|
||||||
|
tools = registry.get_delegation_tools(["librarian", "tatlock_core"])
|
||||||
|
|
||||||
|
# Should get 1 delegation wrapper + 2 raw tools = 3 total
|
||||||
|
assert len(tools) == 3
|
||||||
|
|
||||||
|
# First should be delegation wrapper
|
||||||
|
assert callable(tools[0])
|
||||||
|
assert tools[0].__name__ == "delegate_to_librarian"
|
||||||
|
|
||||||
|
# Rest should be raw tools
|
||||||
|
assert hasattr(tools[1], 'name')
|
||||||
|
assert hasattr(tools[2], 'name')
|
||||||
|
|
||||||
|
def test_delegation_tools_nonexistent_member(self, registry):
|
||||||
|
"""Test delegation tools handles non-existent member gracefully."""
|
||||||
|
tools = registry.get_delegation_tools(["nonexistent"])
|
||||||
|
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
def test_delegation_tools_empty_list(self, registry):
|
||||||
|
"""Test delegation tools handles empty list."""
|
||||||
|
tools = registry.get_delegation_tools([])
|
||||||
|
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
|
||||||
class TestGlobalRegistry:
|
class TestGlobalRegistry:
|
||||||
"""Test the global registry instance."""
|
"""Test the global registry instance."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Tests for the memory service (direct access layer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
|
|
||||||
|
from src.core.memory_service import (
|
||||||
|
MemoryService,
|
||||||
|
MemoryType,
|
||||||
|
MemoryRecord,
|
||||||
|
memory_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryType:
|
||||||
|
"""Tests for MemoryType enum."""
|
||||||
|
|
||||||
|
def test_user_profile_type(self):
|
||||||
|
"""Test user_profile type exists."""
|
||||||
|
assert MemoryType.USER_PROFILE.value == "user_profile"
|
||||||
|
|
||||||
|
def test_preference_type(self):
|
||||||
|
"""Test preference type exists."""
|
||||||
|
assert MemoryType.PREFERENCE.value == "preference"
|
||||||
|
|
||||||
|
def test_learned_fact_type(self):
|
||||||
|
"""Test learned_fact type exists."""
|
||||||
|
assert MemoryType.LEARNED_FACT.value == "learned_fact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryRecord:
|
||||||
|
"""Tests for MemoryRecord model."""
|
||||||
|
|
||||||
|
def test_create_minimal_record(self):
|
||||||
|
"""Test creating record with minimal fields."""
|
||||||
|
record = MemoryRecord(
|
||||||
|
id="test_1",
|
||||||
|
type=MemoryType.USER_PROFILE,
|
||||||
|
key="location",
|
||||||
|
value="Amsterdam",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.id == "test_1"
|
||||||
|
assert record.type == MemoryType.USER_PROFILE
|
||||||
|
assert record.key == "location"
|
||||||
|
assert record.value == "Amsterdam"
|
||||||
|
assert record.importance == 0.5 # Default
|
||||||
|
assert record.source == "explicit" # Default
|
||||||
|
|
||||||
|
def test_create_full_record(self):
|
||||||
|
"""Test creating record with all fields."""
|
||||||
|
record = MemoryRecord(
|
||||||
|
id="test_2",
|
||||||
|
type=MemoryType.LEARNED_FACT,
|
||||||
|
key="car",
|
||||||
|
value="Tesla Model 3",
|
||||||
|
keywords=["car", "vehicle", "tesla"],
|
||||||
|
importance=0.8,
|
||||||
|
source="conversation",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.keywords == ["car", "vehicle", "tesla"]
|
||||||
|
assert record.importance == 0.8
|
||||||
|
assert record.source == "conversation"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceInit:
|
||||||
|
"""Tests for MemoryService initialization."""
|
||||||
|
|
||||||
|
def test_service_has_lazy_clients(self):
|
||||||
|
"""Test service initializes with lazy client loading."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
assert service._qdrant is None
|
||||||
|
assert service._embedding is None
|
||||||
|
assert service._cache is None
|
||||||
|
|
||||||
|
def test_global_instance_exists(self):
|
||||||
|
"""Test global memory_service instance exists."""
|
||||||
|
assert memory_service is not None
|
||||||
|
assert isinstance(memory_service, MemoryService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceProfileMethods:
|
||||||
|
"""Tests for profile-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_uses_context(self):
|
||||||
|
"""Test get_profile uses request context for user."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Amsterdam"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_profile("location")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.USER_PROFILE, "location")
|
||||||
|
assert result == "Amsterdam"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_explicit_user(self):
|
||||||
|
"""Test get_profile with explicit user parameter."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Berlin"
|
||||||
|
|
||||||
|
result = await service.get_profile("location", user="otheruser")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("otheruser", MemoryType.USER_PROFILE, "location")
|
||||||
|
assert result == "Berlin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_profile_high_importance(self):
|
||||||
|
"""Test set_profile uses high importance (0.9)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.set_profile("timezone", "Europe/Amsterdam")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.9
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServicePreferenceMethods:
|
||||||
|
"""Tests for preference-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preference(self):
|
||||||
|
"""Test get_preference retrieves correctly."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "celsius"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_preference("temperature_unit")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.PREFERENCE, "temperature_unit")
|
||||||
|
assert result == "celsius"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_preference_medium_importance(self):
|
||||||
|
"""Test set_preference uses medium importance (0.7)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.set_preference("theme", "dark")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServiceFactMethods:
|
||||||
|
"""Tests for fact-related methods."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fact_default_importance(self):
|
||||||
|
"""Test store_fact uses default importance (0.5)."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.store_fact("car", "Tesla Model 3")
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fact_custom_importance(self):
|
||||||
|
"""Test store_fact with custom importance."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
||||||
|
mock_set.return_value = True
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.store_fact(
|
||||||
|
"employer",
|
||||||
|
"Acme Corp",
|
||||||
|
importance=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_set.call_args[1]
|
||||||
|
assert call_kwargs["importance"] == 0.8
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_fact(self):
|
||||||
|
"""Test get_fact retrieves correctly."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
||||||
|
mock_get.return_value = "Tesla Model 3"
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.get_fact("car")
|
||||||
|
|
||||||
|
mock_get.assert_called_once_with("testuser", MemoryType.LEARNED_FACT, "car")
|
||||||
|
assert result == "Tesla Model 3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMemoryServicePrefetch:
|
||||||
|
"""Tests for prefetch_context method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_default_keys(self):
|
||||||
|
"""Test prefetch with default profile keys."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_profile.side_effect = [
|
||||||
|
"Amsterdam", # location
|
||||||
|
"Europe/Amsterdam", # timezone
|
||||||
|
"John", # name
|
||||||
|
]
|
||||||
|
mock_prefs.return_value = {"temperature_unit": "celsius"}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context()
|
||||||
|
|
||||||
|
assert result["profile"]["location"] == "Amsterdam"
|
||||||
|
assert result["profile"]["timezone"] == "Europe/Amsterdam"
|
||||||
|
assert result["profile"]["name"] == "John"
|
||||||
|
assert result["preferences"]["temperature_unit"] == "celsius"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_specific_keys(self):
|
||||||
|
"""Test prefetch with specific profile keys."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_profile.return_value = "Amsterdam"
|
||||||
|
mock_prefs.return_value = {}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context(
|
||||||
|
profile_keys=["location"],
|
||||||
|
include_preferences=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only fetch location
|
||||||
|
mock_profile.assert_called_once()
|
||||||
|
mock_prefs.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefetch_no_profile(self):
|
||||||
|
"""Test prefetch without profile data."""
|
||||||
|
service = MemoryService()
|
||||||
|
|
||||||
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
||||||
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
||||||
|
mock_prefs.return_value = {"theme": "dark"}
|
||||||
|
|
||||||
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
||||||
|
result = await service.prefetch_context(include_profile=False)
|
||||||
|
|
||||||
|
mock_profile.assert_not_called()
|
||||||
|
assert "profile" not in result
|
||||||
|
assert result["preferences"]["theme"] == "dark"
|
||||||
Reference in New Issue
Block a user