Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6ac89808 | ||
|
|
c049c1e354 | ||
|
|
1e4bba2422 | ||
|
|
3d11b7ae4f | ||
|
|
1970751b2f | ||
|
|
40ebd565d8 | ||
|
|
6cc0bd78b2 | ||
|
|
a077121b39 | ||
|
|
51cee74912 | ||
|
|
7a1d94ca78 | ||
|
|
1a2e6392d2 | ||
|
|
54b6fcd7cc | ||
|
|
b5ee1f3e44 |
@@ -15,6 +15,12 @@ This document contains instructions and documentation references for AI assistan
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **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
|
||||
* **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.
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +41,10 @@ starlette>=0.45,<0.46
|
||||
# hiredis: C parser for better performance
|
||||
redis[hiredis]>=5.2,<6.0
|
||||
|
||||
# Qdrant vector database client for memory storage
|
||||
# Latest: 1.12.1 (Dec 2025) - No known CVEs
|
||||
qdrant-client>=1.12,<2.0
|
||||
|
||||
# Structured logging for observability
|
||||
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
||||
structlog>=24.1,<25.0
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
# Future expert delegation wrappers will be added here:
|
||||
# - delegate_to_memory(task, context) -> DelegationResult
|
||||
# - delegate_to_home_automation(task, context) -> DelegationResult
|
||||
# - delegate_to_developer(task, context) -> DelegationResult
|
||||
@@ -13,6 +13,7 @@ import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.core.config import config
|
||||
from src.core.context import get_user
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -179,7 +180,7 @@ class LibraryDeskClient:
|
||||
async def hybrid_search(
|
||||
self,
|
||||
query: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
vector_limit: int = 10,
|
||||
graph_limit: int = 10,
|
||||
web_limit: int = 5,
|
||||
@@ -191,7 +192,7 @@ class LibraryDeskClient:
|
||||
|
||||
Args:
|
||||
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
|
||||
graph_limit: Max results from graph search
|
||||
web_limit: Max results from web search
|
||||
@@ -201,6 +202,7 @@ class LibraryDeskClient:
|
||||
Returns:
|
||||
HybridRAGResponse with ranked results and context
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
@@ -255,7 +257,7 @@ class LibraryDeskClient:
|
||||
async def search_wiki(
|
||||
self,
|
||||
query: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[WikiSearchResult]:
|
||||
"""
|
||||
@@ -263,12 +265,13 @@ class LibraryDeskClient:
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of matching wiki pages
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
logger.debug("library_desk_wiki_search", query=query, user=user)
|
||||
@@ -285,18 +288,19 @@ class LibraryDeskClient:
|
||||
async def get_wiki_page(
|
||||
self,
|
||||
page_id: int,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
) -> WikiPage:
|
||||
"""
|
||||
Get a wiki page by ID.
|
||||
|
||||
Args:
|
||||
page_id: Page ID
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
|
||||
Returns:
|
||||
WikiPage with full content
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
response = await client.get(
|
||||
@@ -309,7 +313,7 @@ class LibraryDeskClient:
|
||||
|
||||
async def list_wiki_pages(
|
||||
self,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> list[WikiPage]:
|
||||
@@ -317,13 +321,14 @@ class LibraryDeskClient:
|
||||
List wiki pages, optionally filtered by tag.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
tag: Optional tag (dossier) to filter by
|
||||
limit: Maximum pages to return
|
||||
|
||||
Returns:
|
||||
List of wiki pages
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||
@@ -341,7 +346,7 @@ class LibraryDeskClient:
|
||||
title: str,
|
||||
path: str,
|
||||
content: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
description: str = "",
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> WikiPage:
|
||||
@@ -352,13 +357,14 @@ class LibraryDeskClient:
|
||||
title: Page title
|
||||
path: Page path (e.g., "/projects/my-project")
|
||||
content: Markdown content
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
description: Short description
|
||||
tags: List of tags (dossiers)
|
||||
|
||||
Returns:
|
||||
Created WikiPage
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
@@ -380,7 +386,7 @@ class LibraryDeskClient:
|
||||
async def update_wiki_page(
|
||||
self,
|
||||
page_id: int,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
content: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
@@ -394,7 +400,7 @@ class LibraryDeskClient:
|
||||
|
||||
Args:
|
||||
page_id: ID of the page to update
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
content: New content (optional)
|
||||
title: New title (optional)
|
||||
tags: New tags list (optional)
|
||||
@@ -403,6 +409,7 @@ class LibraryDeskClient:
|
||||
Returns:
|
||||
Updated WikiPage
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
# Build update payload with only provided fields
|
||||
@@ -435,7 +442,7 @@ class LibraryDeskClient:
|
||||
self,
|
||||
topic: str,
|
||||
tags: list[str],
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
path: Optional[str] = None,
|
||||
include_web_research: bool = True,
|
||||
include_wiki_search: bool = True,
|
||||
@@ -460,6 +467,7 @@ class LibraryDeskClient:
|
||||
Returns:
|
||||
SmartCreateResponse with page and research metadata
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -499,17 +507,18 @@ class LibraryDeskClient:
|
||||
|
||||
async def list_dossiers(
|
||||
self,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
) -> list[Dossier]:
|
||||
"""
|
||||
List all dossiers (tag collections) for a user.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
|
||||
Returns:
|
||||
List of dossiers with page counts
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
response = await client.get(
|
||||
@@ -528,7 +537,7 @@ class LibraryDeskClient:
|
||||
async def semantic_search(
|
||||
self,
|
||||
query: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.5,
|
||||
) -> list[VectorSearchResult]:
|
||||
@@ -537,13 +546,14 @@ class LibraryDeskClient:
|
||||
|
||||
Args:
|
||||
query: Natural language query
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
limit: Maximum results
|
||||
score_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
List of matching document chunks with scores
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
@@ -568,7 +578,7 @@ class LibraryDeskClient:
|
||||
async def query_graph(
|
||||
self,
|
||||
cypher_query: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
parameters: Optional[dict[str, Any]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -578,12 +588,13 @@ class LibraryDeskClient:
|
||||
|
||||
Args:
|
||||
cypher_query: Cypher query string
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
parameters: Query parameters
|
||||
|
||||
Returns:
|
||||
List of result records
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
@@ -601,7 +612,7 @@ class LibraryDeskClient:
|
||||
|
||||
async def list_graph_nodes(
|
||||
self,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
node_type: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> list[GraphNode]:
|
||||
@@ -609,13 +620,14 @@ class LibraryDeskClient:
|
||||
List nodes in the knowledge graph.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
||||
limit: Maximum nodes
|
||||
|
||||
Returns:
|
||||
List of graph nodes
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||
@@ -631,18 +643,19 @@ class LibraryDeskClient:
|
||||
async def get_graph_node(
|
||||
self,
|
||||
node_id: str,
|
||||
user: str = "jpmschweitzer",
|
||||
user: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about a graph node.
|
||||
|
||||
Args:
|
||||
node_id: Node ID
|
||||
user: User identifier
|
||||
user: User identifier (defaults to request context)
|
||||
|
||||
Returns:
|
||||
Node with relationships and connected nodes
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
response = await client.get(
|
||||
|
||||
@@ -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())
|
||||
@@ -56,6 +56,7 @@ USER QUERY: {query}
|
||||
GUIDELINES:
|
||||
- Be conservative - only recommend truly necessary capabilities
|
||||
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||
- Math/calculations → tatlock_core
|
||||
- Quick web searches → tatlock_core
|
||||
- Time/date queries → tatlock_core
|
||||
@@ -66,11 +67,17 @@ GUIDELINES:
|
||||
- If conversation history is relevant, note which previous turns matter
|
||||
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
|
||||
|
||||
RESPOND WITH 2-4 SENTENCES:
|
||||
1. Capability needed: name the capability and the specific action (e.g., "librarian to create a wiki page about CI/CD using smart_create")
|
||||
2. Reason: brief explanation of why this capability handles the request
|
||||
3. Complexity assessment (simple/moderate/complex)
|
||||
4. Any relevant conversation context
|
||||
RESPOND IN THIS FORMAT:
|
||||
DELEGATE: [capability name] to [action] [specific task]
|
||||
REASON: [why this capability handles the request]
|
||||
COMPLEXITY: [simple/moderate/complex]
|
||||
CONTEXT: [any relevant conversation context, or "none"]
|
||||
|
||||
EXAMPLES:
|
||||
- "DELEGATE: librarian to 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."""
|
||||
|
||||
+13
-6
@@ -587,16 +587,23 @@ class TatlockAgent(AgentInterface):
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
|
||||
# Stream with scoped tools and tracker
|
||||
async with scoped_agent.run_stream(
|
||||
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
||||
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
||||
# We yield the final response in chunks to maintain streaming interface
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker
|
||||
) 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:
|
||||
"""Return current capabilities."""
|
||||
|
||||
+41
-1
@@ -124,6 +124,36 @@ class Config(BaseSettings):
|
||||
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
|
||||
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
||||
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
||||
@@ -139,9 +169,19 @@ class Config(BaseSettings):
|
||||
|
||||
@property
|
||||
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}"
|
||||
|
||||
@property
|
||||
def redis_memory_url(self) -> str:
|
||||
"""Construct Redis connection URL for memory cache."""
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_MEMORY_DB}"
|
||||
|
||||
@property
|
||||
def qdrant_url(self) -> str:
|
||||
"""Construct Qdrant server URL."""
|
||||
return f"http://{self.QDRANT_HOST}:{self.QDRANT_PORT}"
|
||||
|
||||
@property
|
||||
def log_format(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
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]:
|
||||
"""
|
||||
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,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
|
||||
@@ -103,9 +103,11 @@ async def preprocess_request(
|
||||
# Format note for Tatlock (includes conversation context)
|
||||
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()
|
||||
scoped_tools = registry.get_scoped_tools(
|
||||
scoped_tools = registry.get_delegation_tools(
|
||||
recommendation.recommended_capabilities
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ from sse_starlette.sse import EventSourceResponse
|
||||
from src.responses import service
|
||||
from src.responses.schemas import ResponseRequest, Response
|
||||
from src.core.exceptions import ModelNotFoundError, AppException
|
||||
from src.core.context import current_user, current_conversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +95,11 @@ async def create_response(
|
||||
"""
|
||||
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:
|
||||
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
||||
model_id = request.model
|
||||
@@ -136,3 +142,8 @@ async def create_response(
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
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,
|
||||
description="Stop sequences"
|
||||
)
|
||||
user: str | None = Field(
|
||||
default=None,
|
||||
description="Unique identifier for end-user (OpenAI standard)"
|
||||
)
|
||||
|
||||
@field_validator('reasoning')
|
||||
@classmethod
|
||||
|
||||
@@ -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
|
||||
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 = {
|
||||
"model": "Tatlock",
|
||||
@@ -190,18 +192,30 @@ async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
||||
data = response.json()
|
||||
full_response = data["choices"][0]["message"]["content"]
|
||||
|
||||
# Should have calculator emoji in the response
|
||||
assert "🧮" in full_response, \
|
||||
f"Response should show calculator was used. Got: {full_response}"
|
||||
# Should have reasoning in <think> tags (from Steward analysis)
|
||||
assert "<think>" in full_response, \
|
||||
f"Should have reasoning output in <think> tags. Got: {full_response}"
|
||||
|
||||
# Should show the calculation expression
|
||||
assert "sqrt(144)" in full_response or "144" in full_response, \
|
||||
f"Should show what was calculated. Got: {full_response}"
|
||||
# Should reference the calculation in some form
|
||||
has_calculation_reference = (
|
||||
"144" in full_response or
|
||||
"sqrt" in full_response.lower() or
|
||||
"square root" in full_response.lower()
|
||||
)
|
||||
assert has_calculation_reference, \
|
||||
f"Should reference the calculation. Got: {full_response}"
|
||||
|
||||
# Should have the correct answer (37)
|
||||
assert "37" in full_response, \
|
||||
f"Should contain the answer 37. Got: {full_response}"
|
||||
|
||||
# Tool emoji is optional - depends on whether tool was used directly
|
||||
# or computation was delegated to capability
|
||||
if "🧮" in full_response:
|
||||
print(f"\nCalculator tool was used directly")
|
||||
else:
|
||||
print(f"\nCalculation handled via tatlock_core capability")
|
||||
|
||||
print(f"\nCalculator response: {full_response}")
|
||||
|
||||
|
||||
|
||||
@@ -294,6 +294,101 @@ class TestHouseholdRegistry:
|
||||
assert research_caps[0].name == "research_tools"
|
||||
|
||||
|
||||
class TestGetDelegationTools:
|
||||
"""Test get_delegation_tools() method for agent-as-tool pattern."""
|
||||
|
||||
def test_delegation_tools_returns_wrapper_for_member_with_agent(self, registry, sample_tools):
|
||||
"""Test delegation tools returns wrapper when member has an agent."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
cap = HouseholdCapability(
|
||||
name="librarian",
|
||||
role="The Librarian",
|
||||
category="research",
|
||||
description="Research and wiki management",
|
||||
domains=["research", "wiki"],
|
||||
cost="medium",
|
||||
requires_network=True,
|
||||
)
|
||||
|
||||
mock_agent = Mock()
|
||||
registry.register("librarian", cap, sample_tools, agent=mock_agent)
|
||||
|
||||
tools = registry.get_delegation_tools(["librarian"])
|
||||
|
||||
# Should return delegation wrapper, not raw tools
|
||||
assert len(tools) == 1
|
||||
# The wrapper should be the delegate_to_librarian function
|
||||
assert callable(tools[0])
|
||||
assert tools[0].__name__ == "delegate_to_librarian"
|
||||
|
||||
def test_delegation_tools_returns_raw_tools_for_member_without_agent(self, registry, sample_capability, sample_tools):
|
||||
"""Test delegation tools returns raw tools when member has no agent."""
|
||||
registry.register("test_tools", sample_capability, sample_tools)
|
||||
|
||||
tools = registry.get_delegation_tools(["test_tools"])
|
||||
|
||||
# Should return raw tools since no agent
|
||||
assert len(tools) == 2
|
||||
assert tools[0].name == "test_tool_1"
|
||||
assert tools[1].name == "test_tool_2"
|
||||
|
||||
def test_delegation_tools_mixed_members(self, registry, sample_tools):
|
||||
"""Test delegation tools handles mix of agent and non-agent members."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
# Member with agent (librarian)
|
||||
librarian_cap = HouseholdCapability(
|
||||
name="librarian",
|
||||
role="The Librarian",
|
||||
category="research",
|
||||
description="Research and wiki",
|
||||
domains=["research"],
|
||||
cost="medium",
|
||||
requires_network=True,
|
||||
)
|
||||
mock_agent = Mock()
|
||||
registry.register("librarian", librarian_cap, sample_tools, agent=mock_agent)
|
||||
|
||||
# Member without agent (tatlock_core)
|
||||
core_cap = HouseholdCapability(
|
||||
name="tatlock_core",
|
||||
role="Butler's Core Tools",
|
||||
category="core",
|
||||
description="Basic tools",
|
||||
domains=["computation"],
|
||||
cost="low",
|
||||
requires_network=False,
|
||||
)
|
||||
registry.register("tatlock_core", core_cap, sample_tools)
|
||||
|
||||
# Request both
|
||||
tools = registry.get_delegation_tools(["librarian", "tatlock_core"])
|
||||
|
||||
# Should get 1 delegation wrapper + 2 raw tools = 3 total
|
||||
assert len(tools) == 3
|
||||
|
||||
# First should be delegation wrapper
|
||||
assert callable(tools[0])
|
||||
assert tools[0].__name__ == "delegate_to_librarian"
|
||||
|
||||
# Rest should be raw tools
|
||||
assert hasattr(tools[1], 'name')
|
||||
assert hasattr(tools[2], 'name')
|
||||
|
||||
def test_delegation_tools_nonexistent_member(self, registry):
|
||||
"""Test delegation tools handles non-existent member gracefully."""
|
||||
tools = registry.get_delegation_tools(["nonexistent"])
|
||||
|
||||
assert tools == []
|
||||
|
||||
def test_delegation_tools_empty_list(self, registry):
|
||||
"""Test delegation tools handles empty list."""
|
||||
tools = registry.get_delegation_tools([])
|
||||
|
||||
assert tools == []
|
||||
|
||||
|
||||
class TestGlobalRegistry:
|
||||
"""Test the global registry instance."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user