Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb54887c03 | ||
|
|
d5e5fc1ad8 | ||
|
|
74f47097c2 | ||
|
|
add9b74207 | ||
|
|
100ebeae52 | ||
|
|
3e432d662e | ||
|
|
49f0da8068 |
@@ -27,7 +27,17 @@ This document contains instructions and documentation references for AI assistan
|
|||||||
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
* **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`
|
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||||
* Public repos are readable without authentication
|
* Public repos are readable without authentication
|
||||||
* Related repos: `library-desk`, `scheduler`
|
* Related repos: `library-desk`, `scheduler`, `core-api`, `portainer-core`
|
||||||
|
|
||||||
|
### 🐳 Deployment & Infrastructure
|
||||||
|
* **Full stack documentation**: Available in the `portainer-core` repo
|
||||||
|
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
||||||
|
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
||||||
|
* **Tatlock deployment**:
|
||||||
|
* LAN: `http://192.168.86.149:8000`
|
||||||
|
* External: `tatlock.schweitz.net` (behind Authentik SSO)
|
||||||
|
* Redis DBs: 1 (memory), 6 (benchmarks)
|
||||||
|
* **Health check**: `curl http://192.168.86.149:8000/health`
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
### 🛡️ Git Discipline
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||||
|
|||||||
+125
-1
@@ -7,6 +7,128 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.8.2] - 2025-12-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **HybridRAG keywords schema mismatch** - library-desk now returns `keywords` as dict with `core_keywords`, client now handles both formats
|
||||||
|
|
||||||
|
## [1.8.1] - 2025-12-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Ollama Message Sanitization
|
||||||
|
- **Fixed `invalid message content type: <nil>` error** from Ollama
|
||||||
|
- Created custom `TatlockOllamaProvider` that sanitizes messages before sending to Ollama
|
||||||
|
- Ollama rejects assistant messages with `content: null` (tool-only messages from PydanticAI)
|
||||||
|
- Provider converts `null` content to empty string `""` for compatibility
|
||||||
|
- Updated all agents (Librarian, Biographer, Housekeeper, Tatlock) to use sanitized provider
|
||||||
|
- Added `src/ollama/provider.py` with reusable provider pattern
|
||||||
|
|
||||||
|
#### Streaming Think Message Accumulation
|
||||||
|
- **Fixed repeating think messages in frontend** (e.g., 10x "The Librarian has compiled...")
|
||||||
|
- Frontend was accumulating `ReasoningSummaryDelta` events expecting concatenation
|
||||||
|
- Added `ReasoningSummaryDone()` signal after each think message to indicate completion
|
||||||
|
- Each think slug is now treated as a complete message, not a continuation
|
||||||
|
|
||||||
|
## [1.8.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Steward Routing for Web Search
|
||||||
|
- Updated Steward guidelines to route web searches, weather, news → Librarian with `search_web`
|
||||||
|
- Added URL/article reading → Librarian with `read_url` to routing guidelines
|
||||||
|
- Added examples showing `search_web` and `read_url` tool usage
|
||||||
|
|
||||||
|
#### Librarian Agent Tool Registration
|
||||||
|
- Registered `search_web`, `read_url`, `read_urls_batch` tools with the Librarian PydanticAI agent
|
||||||
|
- Updated Librarian system prompt with Web Search & Content Extraction section
|
||||||
|
- Fixed tool count in agent logger (11 → 14 tools)
|
||||||
|
|
||||||
|
#### Query Enrichment Integration
|
||||||
|
- Fixed enriched query (with location/timezone context) not being passed to delegations
|
||||||
|
- Response service now uses `enriched_query` from Steward recommendation for all delegations
|
||||||
|
- Weather queries now automatically include user's stored location
|
||||||
|
|
||||||
|
#### Action Type Detection
|
||||||
|
- Added "read", "fetch", "url", "http" keywords to RESEARCH action type for Librarian
|
||||||
|
- Ensures proper think messages for URL reading tasks
|
||||||
|
|
||||||
|
## [1.7.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Web Search Migration to Librarian
|
||||||
|
- **`search_web()`** tool in Librarian for web search via library-desk `/rag/search` endpoint
|
||||||
|
- **`read_url()`** tool for single URL content extraction via Trafilatura
|
||||||
|
- **`read_urls_batch()`** tool for parallel batch URL extraction (max 20 URLs)
|
||||||
|
- `WebSearchResult`, `WebSearchResponse` models in LibraryDeskClient
|
||||||
|
- `ContentExtractionResult`, `BatchExtractionResponse` models for content extraction
|
||||||
|
- `search_web()`, `extract_content()`, `extract_content_batch()` methods in LibraryDeskClient
|
||||||
|
- Comprehensive unit tests for new Librarian tools (`tests/agents/librarian/test_tools.py`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Librarian capability updated with web search domains: "web", "url", "internet"
|
||||||
|
- Tatlock system prompt now delegates web search to Librarian
|
||||||
|
- `tatlock_core` capability reduced to computation/datetime only (no longer requires network)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `search_web` function from `src/agents/tatlock_core/tools.py`
|
||||||
|
- `web_search_tool` from `tatlock_core_tools` list
|
||||||
|
- `search_web` from legacy `src/agents/tools.py`
|
||||||
|
- Search tests from `tests/agents/test_tools.py` (moved to Librarian tests)
|
||||||
|
|
||||||
|
## [1.6.0] - 2025-12-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Two-Phase Tatlock Execution
|
||||||
|
- **Phase 1: Orchestration** - Executes tool calls and expert delegations, returns structured results
|
||||||
|
- **Phase 2: Synthesis** - Synthesizes butler-toned response from gathered results
|
||||||
|
- `orchestrate_tool_calls()` method in TatlockAgent for coordination phase
|
||||||
|
- `synthesize_from_results()` method in TatlockAgent for synthesis phase
|
||||||
|
- Guarantees butler personality in all responses by separating coordination from response generation
|
||||||
|
|
||||||
|
#### Automatic Think Slugs
|
||||||
|
- **Deterministic butler-perspective messages** during expert delegation (no LLM involved)
|
||||||
|
- `ActionType` enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
|
||||||
|
- `HOUSEHOLD_THINK_MESSAGES` mapping with butler-perspective messages for all experts:
|
||||||
|
- Librarian: "Allow me to consult the archives, sir." / "I'm having the Librarian prepare a new entry."
|
||||||
|
- Biographer: "Let me consult the household records." / "I've asked the Biographer to take note, sir."
|
||||||
|
- Housekeeper: "I'm instructing the household staff now, sir." / "Allow me to inquire with the household staff."
|
||||||
|
- `_detect_action_type()` function for keyword-based action detection
|
||||||
|
- `get_think_message()` helper for retrieving appropriate messages
|
||||||
|
- Streaming delegation wrappers: `stream_delegate_to_librarian()`, `stream_delegate_to_biographer()`, `stream_delegate_to_housekeeper()`
|
||||||
|
- `STREAMING_DELEGATION_WRAPPERS` mapping in delegation.py
|
||||||
|
- `get_streaming_delegation_tools()` method in HouseholdRegistry
|
||||||
|
|
||||||
|
#### Steward Query Enrichment
|
||||||
|
- **Auto-fill user context** (location, timezone) when not specified in query
|
||||||
|
- `_build_enriched_query()` function in steward service
|
||||||
|
- Regex word boundary matching for accurate location detection (avoids false positives)
|
||||||
|
- `enriched_query` field added to `StewardRecommendation` schema
|
||||||
|
- Automatic enrichment for weather queries (location), time queries (timezone), temperature preferences
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- **ORCHESTRATION_SCENARIOS.md** completely rewritten with:
|
||||||
|
- Mermaid flow diagrams for two-phase execution
|
||||||
|
- 4 new Housekeeper scenarios (light control, device status, parallel delegation)
|
||||||
|
- Biographer memory recording scenario
|
||||||
|
- Complete think slug reference tables
|
||||||
|
- Action type detection tables
|
||||||
|
- Updated architecture mindmap
|
||||||
|
- **TESTING_IMPROVEMENTS.md** - LLM testing best practices for future implementation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `create_response_with_steward()` now uses two-phase execution
|
||||||
|
- `_direct_delegation()` routes through synthesis phase for consistent butler tone
|
||||||
|
- `_execute_single_delegation()` now supports housekeeper
|
||||||
|
- Streaming response handler integrated with think slug system
|
||||||
|
- All 326 unit tests passing
|
||||||
|
|
||||||
## [1.5.0] - 2025-12-15
|
## [1.5.0] - 2025-12-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -608,7 +730,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- CORS middleware
|
- CORS middleware
|
||||||
- Exception handlers (OpenAI-compatible error format)
|
- Exception handlers (OpenAI-compatible error format)
|
||||||
|
|
||||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...main
|
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.6.0...main
|
||||||
|
[1.6.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.5.0...v1.6.0
|
||||||
|
[1.5.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...v1.5.0
|
||||||
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
|
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
|
||||||
[1.3.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.2...v1.3.3
|
[1.3.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.2...v1.3.3
|
||||||
[1.3.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.1...v1.3.2
|
[1.3.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.1...v1.3.2
|
||||||
|
|||||||
+677
-216
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
# Testing Improvements for LLM Outputs
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
LLM outputs are non-deterministic. Tests checking for exact string matches fail when the LLM writes "thirty-seven" instead of "37".
|
||||||
|
|
||||||
|
## Proposed Solutions
|
||||||
|
|
||||||
|
### 1. LLM-as-Judge Pattern
|
||||||
|
|
||||||
|
Use a smaller/faster model to evaluate semantic correctness:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def llm_judge(output: str, criteria: str) -> bool:
|
||||||
|
"""Use LLM to evaluate if output meets criteria."""
|
||||||
|
prompt = f"""
|
||||||
|
Evaluate if this output is correct:
|
||||||
|
Output: {output}
|
||||||
|
Criteria: {criteria}
|
||||||
|
Answer only YES or NO.
|
||||||
|
"""
|
||||||
|
result = await judge_model.run(prompt)
|
||||||
|
return "YES" in result.output.upper()
|
||||||
|
|
||||||
|
# Usage in test:
|
||||||
|
assert await llm_judge(
|
||||||
|
response,
|
||||||
|
"The answer correctly states that sqrt(144) + 25 = 37"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fuzzy/Regex Matching
|
||||||
|
|
||||||
|
For numeric answers, accept multiple representations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
def contains_number(text: str, number: int) -> bool:
|
||||||
|
"""Check if text contains number in any form."""
|
||||||
|
patterns = [
|
||||||
|
rf'\b{number}\b', # Digit form
|
||||||
|
number_to_words(number), # Word form
|
||||||
|
]
|
||||||
|
return any(re.search(p, text, re.I) for p in patterns)
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
assert contains_number(response, 37) # Matches "37" or "thirty-seven"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DeepEval Framework
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deepeval.metrics import AnswerRelevancyMetric
|
||||||
|
from deepeval.test_case import LLMTestCase
|
||||||
|
|
||||||
|
def test_calculation():
|
||||||
|
test_case = LLMTestCase(
|
||||||
|
input="What is sqrt(144) + 25?",
|
||||||
|
actual_output=response,
|
||||||
|
expected_output="37"
|
||||||
|
)
|
||||||
|
metric = AnswerRelevancyMetric(threshold=0.7)
|
||||||
|
assert metric.measure(test_case)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. pytest-evals Plugin
|
||||||
|
|
||||||
|
Minimal pytest plugin for LLM testing with metrics collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest-evals
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Multiple Runs with Threshold
|
||||||
|
|
||||||
|
Run flaky tests multiple times and require majority pass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.flaky(reruns=3, reruns_delay=1)
|
||||||
|
def test_llm_response():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or custom:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.parametrize("run", range(3))
|
||||||
|
def test_llm_response(run):
|
||||||
|
...
|
||||||
|
# Aggregate results across runs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [DeepEval](https://github.com/confident-ai/deepeval) - LLM evaluation framework
|
||||||
|
- [pytest-evals](https://github.com/AlmogBaku/pytest-evals) - pytest plugin for LLM evals
|
||||||
|
- [LLM Testing Guide 2025](https://www.confident-ai.com/blog/llm-testing-in-2024-top-methods-and-strategies)
|
||||||
|
- [Testing LLM Applications - Langfuse](https://langfuse.com/blog/2025-10-21-testing-llm-applications)
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. Add fuzzy number matching helper (quick win)
|
||||||
|
2. Evaluate DeepEval for complex output testing
|
||||||
|
3. Consider LLM-as-judge for semantic correctness
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Tatlock Integration Guide
|
||||||
|
|
||||||
|
Implementation instructions for integrating Library Desk search and content extraction endpoints into the Tatlock project.
|
||||||
|
|
||||||
|
## Base Configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
BASE_URL: http://library-desk:8089 (or your deployment URL)
|
||||||
|
AUTH_HEADER: Authorization: Bearer <LIBRARY_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RAG Search Endpoint
|
||||||
|
|
||||||
|
**Use case:** Librarian needs to research a topic by searching the web.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /rag/search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "Python async programming best practices",
|
||||||
|
"search_type": "web",
|
||||||
|
"limit": 10,
|
||||||
|
"user": "tatlock-librarian"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `query` | string | required | Search query (1-500 chars) |
|
||||||
|
| `search_type` | enum | `"web"` | `"web"`, `"news"`, or `"images"` |
|
||||||
|
| `limit` | int | 10 | Results to return (1-20) |
|
||||||
|
| `user` | string | `"default"` | User identifier for tracking |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "Python async programming best practices",
|
||||||
|
"search_type": "web",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Async IO in Python: A Complete Walkthrough",
|
||||||
|
"url": "https://realpython.com/async-io-python/",
|
||||||
|
"content": "Full extracted article text via Trafilatura (~2000 chars max)...",
|
||||||
|
"snippet": "Original search engine snippet (150-300 chars)...",
|
||||||
|
"source": "realpython.com",
|
||||||
|
"published_date": "2023-05-15"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_results": 10,
|
||||||
|
"search_time_ms": 2340,
|
||||||
|
"sources_summary": "## Sources\n- [Async IO in Python](https://realpython.com/async-io-python/)\n- ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Fields for Tatlock
|
||||||
|
|
||||||
|
| Field | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `results[].content` | Full extracted text - use this for LLM context |
|
||||||
|
| `results[].snippet` | Fallback if content extraction failed |
|
||||||
|
| `sources_summary` | Pre-formatted markdown for citations |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
| HTTP Code | Meaning | Action |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| 400 | Invalid query | Check query length/format |
|
||||||
|
| 502 | SearXNG unavailable | Retry with backoff |
|
||||||
|
| 504 | Search timeout | Retry or reduce limit |
|
||||||
|
| 500 | Internal error | Log and notify |
|
||||||
|
|
||||||
|
### Example Usage (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def search_web(query: str, limit: int = 10) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/rag/search",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"search_type": "web",
|
||||||
|
"limit": limit,
|
||||||
|
"user": "tatlock-librarian"
|
||||||
|
},
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
results = await search_web("machine learning transformers")
|
||||||
|
for r in results["results"]:
|
||||||
|
# Prefer full content, fall back to snippet
|
||||||
|
text = r["content"] or r["snippet"]
|
||||||
|
print(f"{r['title']}: {len(text)} chars")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Content Extraction Endpoint
|
||||||
|
|
||||||
|
**Use case:** Librarian has a specific URL and needs to read its content.
|
||||||
|
|
||||||
|
### Single URL Extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /content/extract
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article",
|
||||||
|
"include_metadata": true,
|
||||||
|
"max_length": 2000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"url": "https://example.com/article",
|
||||||
|
"title": "Article Title",
|
||||||
|
"content": "Extracted main text content...",
|
||||||
|
"author": "John Doe",
|
||||||
|
"date": "2024-01-15",
|
||||||
|
"language": "en",
|
||||||
|
"success": true,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"extraction_time_ms": 1250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch URL Extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /content/extract/batch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"urls": [
|
||||||
|
"https://example.com/article1",
|
||||||
|
"https://example.com/article2",
|
||||||
|
"https://example.com/article3"
|
||||||
|
],
|
||||||
|
"include_metadata": true,
|
||||||
|
"max_length": 2000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article1",
|
||||||
|
"title": "Article 1",
|
||||||
|
"content": "Extracted content...",
|
||||||
|
"success": true,
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.com/article2",
|
||||||
|
"title": null,
|
||||||
|
"content": "",
|
||||||
|
"success": false,
|
||||||
|
"error": "Connection timeout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_urls": 3,
|
||||||
|
"successful": 2,
|
||||||
|
"failed": 1,
|
||||||
|
"extraction_time_ms": 3500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Error Pattern: Soft Failures
|
||||||
|
|
||||||
|
> **Important:** Content extraction uses a **soft failure pattern** - individual URL failures do NOT throw HTTP errors.
|
||||||
|
|
||||||
|
### Why Soft Failures?
|
||||||
|
|
||||||
|
When extracting content from multiple URLs (batch) or even single URLs:
|
||||||
|
- Some sites block bots
|
||||||
|
- Some URLs are temporarily down
|
||||||
|
- Some pages have no extractable content
|
||||||
|
|
||||||
|
Instead of failing the entire request, we return:
|
||||||
|
- `success: true/false` per result
|
||||||
|
- `error: "reason"` when failed
|
||||||
|
- Empty `content: ""` on failure
|
||||||
|
|
||||||
|
### Handling Soft Failures
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def extract_with_fallback(url: str) -> str:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"url": url}
|
||||||
|
)
|
||||||
|
response.raise_for_status() # Only throws on 4xx/5xx
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
result = data["result"]
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return result["content"]
|
||||||
|
else:
|
||||||
|
# Log the failure, return empty or handle gracefully
|
||||||
|
logger.warning(f"Extraction failed for {url}: {result['error']}")
|
||||||
|
return "" # Or raise, or use cached version, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Processing Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def extract_batch_with_stats(urls: list[str]) -> dict:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract/batch",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"urls": urls, "max_length": 3000}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Separate successful and failed
|
||||||
|
successful = [r for r in data["results"] if r["success"]]
|
||||||
|
failed = [r for r in data["results"] if not r["success"]]
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
logger.warning(f"{len(failed)} URLs failed extraction:")
|
||||||
|
for f in failed:
|
||||||
|
logger.warning(f" {f['url']}: {f['error']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contents": {r["url"]: r["content"] for r in successful},
|
||||||
|
"failed_urls": [f["url"] for f in failed],
|
||||||
|
"success_rate": data["successful"] / data["total_urls"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended Patterns for Tatlock
|
||||||
|
|
||||||
|
### Research Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def librarian_research(topic: str) -> dict:
|
||||||
|
"""
|
||||||
|
Full research flow: search + extract additional context.
|
||||||
|
"""
|
||||||
|
# 1. Search for relevant pages
|
||||||
|
search_results = await search_web(topic, limit=10)
|
||||||
|
|
||||||
|
# 2. RAG search already includes extracted content
|
||||||
|
# Only extract more if you need deeper content
|
||||||
|
|
||||||
|
# 3. Build context for LLM
|
||||||
|
context_parts = []
|
||||||
|
for r in search_results["results"]:
|
||||||
|
content = r["content"] or r["snippet"]
|
||||||
|
if content:
|
||||||
|
context_parts.append(f"## {r['title']}\nSource: {r['url']}\n\n{content}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"context": "\n\n---\n\n".join(context_parts),
|
||||||
|
"sources": search_results["sources_summary"],
|
||||||
|
"result_count": search_results["total_results"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading a Specific Page
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def librarian_read_page(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Read a specific URL the user provided.
|
||||||
|
"""
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/content/extract",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"url": url, "max_length": 5000} # Longer for deep reads
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()["result"]
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise ValueError(f"Could not read page: {result['error']}")
|
||||||
|
|
||||||
|
# Format for LLM
|
||||||
|
header = f"# {result['title'] or 'Untitled'}\n"
|
||||||
|
if result["author"]:
|
||||||
|
header += f"Author: {result['author']}\n"
|
||||||
|
if result["date"]:
|
||||||
|
header += f"Date: {result['date']}\n"
|
||||||
|
|
||||||
|
return header + "\n" + result["content"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rate Limits & Best Practices
|
||||||
|
|
||||||
|
| Recommendation | Reason |
|
||||||
|
|----------------|--------|
|
||||||
|
| Use `limit: 5-10` for searches | More results = longer extraction time |
|
||||||
|
| Batch URLs when possible | More efficient than sequential calls |
|
||||||
|
| Max 20 URLs per batch | Server limit |
|
||||||
|
| Set reasonable timeouts (30s) | Content extraction can be slow |
|
||||||
|
| Cache results client-side | Same URL rarely changes content |
|
||||||
|
| Use `user` parameter | Helps with debugging and rate limiting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference
|
||||||
|
|
||||||
|
| Endpoint | Method | Use Case |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| `/rag/search` | POST | Search web + get extracted content |
|
||||||
|
| `/content/extract` | POST | Read a single URL |
|
||||||
|
| `/content/extract/batch` | POST | Read multiple URLs |
|
||||||
|
| `/health` | GET | Check service status |
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "1.5.0"
|
version = "1.8.2"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -102,18 +102,15 @@ _biographer_agent: Optional[Agent[None, str]] = None
|
|||||||
|
|
||||||
def _create_biographer_agent() -> Agent[None, str]:
|
def _create_biographer_agent() -> Agent[None, str]:
|
||||||
"""Create The Biographer PydanticAI agent."""
|
"""Create The Biographer PydanticAI agent."""
|
||||||
# Import required classes for Ollama configuration
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
from src.ollama.provider import get_ollama_provider
|
||||||
clean_host = str(config.OLLAMA_HOST).rstrip('/')
|
|
||||||
base_url = f"{clean_host}/v1"
|
|
||||||
|
|
||||||
# Create Ollama model with provider
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
agent: Agent[None, str] = Agent(
|
agent: Agent[None, str] = Agent(
|
||||||
|
|||||||
+224
-1
@@ -9,13 +9,139 @@ This implements the agent-as-tool pattern recommended by PydanticAI:
|
|||||||
agents call other agents via tool wrappers, keeping each agent focused.
|
agents call other agents via tool wrappers, keeping each agent focused.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable, Optional, Any
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, Callable, Optional, Any
|
||||||
|
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Action Types for Think Slug Selection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ActionType(Enum):
|
||||||
|
"""
|
||||||
|
Categories of actions for selecting appropriate think messages.
|
||||||
|
|
||||||
|
Each expert has different action types that warrant different
|
||||||
|
butler-perspective messages to the user.
|
||||||
|
"""
|
||||||
|
RETRIEVE = "retrieve" # Looking up existing information
|
||||||
|
RESEARCH = "research" # Conducting new research (web search, etc.)
|
||||||
|
CREATE = "create" # Creating new content (pages, notes)
|
||||||
|
CONTROL = "control" # Controlling devices/automations
|
||||||
|
RECORD = "record" # Recording memories/notes
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Household Think Messages (Butler's Perspective)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
|
||||||
|
"librarian": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "<think>Allow me to consult the archives, sir.</think>",
|
||||||
|
"success": "<think>The Librarian has compiled the relevant findings.</think>",
|
||||||
|
"error": "<think>I'm afraid the archives proved difficult to access.</think>",
|
||||||
|
},
|
||||||
|
ActionType.RESEARCH: {
|
||||||
|
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>",
|
||||||
|
"success": "<think>The Librarian has returned with findings, sir.</think>",
|
||||||
|
"error": "<think>The research proved inconclusive, I'm afraid.</think>",
|
||||||
|
},
|
||||||
|
ActionType.CREATE: {
|
||||||
|
"start": "<think>I'm having the Librarian prepare a new entry.</think>",
|
||||||
|
"success": "<think>The new material has been properly catalogued, sir.</think>",
|
||||||
|
"error": "<think>I'm afraid there was difficulty filing the entry.</think>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"biographer": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "<think>Let me consult the household records.</think>",
|
||||||
|
"success": "<think>The Biographer has located the relevant information, sir.</think>",
|
||||||
|
"error": "<think>I'm unable to locate those particular records.</think>",
|
||||||
|
},
|
||||||
|
ActionType.RECORD: {
|
||||||
|
"start": "<think>I've asked the Biographer to take note of this, sir.</think>",
|
||||||
|
"success": "<think>The household records have been updated accordingly.</think>",
|
||||||
|
"error": "<think>I'm afraid there was difficulty recording the entry.</think>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"housekeeper": {
|
||||||
|
ActionType.RETRIEVE: {
|
||||||
|
"start": "<think>Allow me to inquire with the household staff.</think>",
|
||||||
|
"success": "<think>The staff reports the current status, sir.</think>",
|
||||||
|
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>",
|
||||||
|
},
|
||||||
|
ActionType.CONTROL: {
|
||||||
|
"start": "<think>I'm instructing the household staff now, sir.</think>",
|
||||||
|
"success": "<think>The household has been configured as requested.</think>",
|
||||||
|
"error": "<think>I'm afraid the staff reports an issue with that request.</think>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_action_type(expert: str, task: str) -> ActionType:
|
||||||
|
"""
|
||||||
|
Detect action type from expert name and task description.
|
||||||
|
|
||||||
|
Used to select appropriate butler-perspective think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expert: Name of the expert (librarian, biographer, housekeeper)
|
||||||
|
task: Task description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ActionType: Detected action type for message selection
|
||||||
|
"""
|
||||||
|
task_lower = task.lower()
|
||||||
|
|
||||||
|
if expert == "librarian":
|
||||||
|
# Web search, URL reading = RESEARCH (fresh external data)
|
||||||
|
if any(w in task_lower for w in ["search", "find", "look up", "research"]):
|
||||||
|
if any(w in task_lower for w in ["web", "online", "internet"]):
|
||||||
|
return ActionType.RESEARCH
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
if any(w in task_lower for w in ["read", "fetch", "url", "http"]):
|
||||||
|
return ActionType.RESEARCH # Reading URLs is research
|
||||||
|
if any(w in task_lower for w in ["create", "write", "add", "make", "new"]):
|
||||||
|
return ActionType.CREATE
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
elif expert == "biographer":
|
||||||
|
if any(w in task_lower for w in ["remember", "note", "record", "save", "store"]):
|
||||||
|
return ActionType.RECORD
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
elif expert == "housekeeper":
|
||||||
|
if any(w in task_lower for w in ["turn", "set", "activate", "enable", "disable", "toggle"]):
|
||||||
|
return ActionType.CONTROL
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
return ActionType.RETRIEVE
|
||||||
|
|
||||||
|
|
||||||
|
def get_think_message(expert: str, task: str, phase: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the appropriate think message for an expert delegation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expert: Name of the expert
|
||||||
|
task: Task description (used to detect action type)
|
||||||
|
phase: One of "start", "success", "error"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Butler-perspective think message
|
||||||
|
"""
|
||||||
|
action_type = _detect_action_type(expert, task)
|
||||||
|
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
|
||||||
|
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
|
||||||
|
return action_messages.get(phase, f"<think>Consulting {expert}...</think>")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DelegationTask:
|
class DelegationTask:
|
||||||
"""
|
"""
|
||||||
@@ -301,6 +427,103 @@ async def delegate_to_housekeeper(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Streaming Delegation Wrappers (with Think Messages)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def stream_delegate_to_librarian(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Librarian with automatic think messages.
|
||||||
|
|
||||||
|
Yields butler-perspective think messages before and after the delegation,
|
||||||
|
allowing the UI to show progress to the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
# Yield start message (deterministic)
|
||||||
|
yield get_think_message("librarian", task, "start") + "\n"
|
||||||
|
|
||||||
|
# Execute delegation
|
||||||
|
result = await delegate_to_librarian(task, context)
|
||||||
|
|
||||||
|
# Yield completion message (deterministic)
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("librarian", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("librarian", task, "error") + "\n"
|
||||||
|
|
||||||
|
# Yield result marker for extraction
|
||||||
|
yield f"__DELEGATION_RESULT__:librarian:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_delegate_to_biographer(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Biographer with automatic think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
yield get_think_message("biographer", task, "start") + "\n"
|
||||||
|
|
||||||
|
result = await delegate_to_biographer(task, context)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("biographer", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("biographer", task, "error") + "\n"
|
||||||
|
|
||||||
|
yield f"__DELEGATION_RESULT__:biographer:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_delegate_to_housekeeper(
|
||||||
|
task: str,
|
||||||
|
context: str = "",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream delegation to Housekeeper with automatic think messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task description
|
||||||
|
context: Additional context
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: Think messages and final result marker
|
||||||
|
"""
|
||||||
|
yield get_think_message("housekeeper", task, "start") + "\n"
|
||||||
|
|
||||||
|
result = await delegate_to_housekeeper(task, context)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
yield get_think_message("housekeeper", task, "success") + "\n"
|
||||||
|
else:
|
||||||
|
yield get_think_message("housekeeper", task, "error") + "\n"
|
||||||
|
|
||||||
|
yield f"__DELEGATION_RESULT__:housekeeper:{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of streaming delegation wrappers
|
||||||
|
STREAMING_DELEGATION_WRAPPERS = {
|
||||||
|
"librarian": stream_delegate_to_librarian,
|
||||||
|
"biographer": stream_delegate_to_biographer,
|
||||||
|
"housekeeper": stream_delegate_to_housekeeper,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Future expert delegation wrappers will be added here:
|
# Future expert delegation wrappers will be added here:
|
||||||
# - delegate_to_developer(task, context) -> DelegationResult
|
# - delegate_to_developer(task, context) -> DelegationResult
|
||||||
# - delegate_to_secretary(task, context) -> DelegationResult
|
# - delegate_to_secretary(task, context) -> DelegationResult
|
||||||
|
|||||||
@@ -113,18 +113,15 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
|
|||||||
|
|
||||||
def _create_housekeeper_agent() -> Agent[None, str]:
|
def _create_housekeeper_agent() -> Agent[None, str]:
|
||||||
"""Create the Housekeeper PydanticAI agent."""
|
"""Create the Housekeeper PydanticAI agent."""
|
||||||
# Import required classes for Ollama configuration
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
from src.ollama.provider import get_ollama_provider
|
||||||
clean_host = str(config.OLLAMA_HOST).rstrip("/")
|
|
||||||
base_url = f"{clean_host}/v1"
|
|
||||||
|
|
||||||
# Create Ollama model with provider
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
provider=OllamaProvider(base_url=base_url),
|
provider=get_ollama_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
agent: Agent[None, str] = Agent(
|
agent: Agent[None, str] = Agent(
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from src.agents.librarian.tools import (
|
|||||||
get_wiki_page,
|
get_wiki_page,
|
||||||
hybrid_search,
|
hybrid_search,
|
||||||
list_dossiers,
|
list_dossiers,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
search_web,
|
||||||
search_wiki,
|
search_wiki,
|
||||||
semantic_search,
|
semantic_search,
|
||||||
smart_create_wiki_page,
|
smart_create_wiki_page,
|
||||||
@@ -47,8 +50,17 @@ Your role is to help users find, understand, synthesize, and manage information
|
|||||||
|
|
||||||
## Your Tools
|
## Your Tools
|
||||||
|
|
||||||
### Research Tools
|
### Web Search & Content Extraction
|
||||||
- **hybrid_search**: Your primary research tool - searches all sources at once
|
- **search_web**: Search the internet for current information (weather, news, facts)
|
||||||
|
- Use for: weather forecasts, current events, recent developments, external facts
|
||||||
|
- Returns extracted content from search results, not just snippets
|
||||||
|
- **read_url**: Read and extract content from a specific URL
|
||||||
|
- Use when: user provides a URL or you need to read a specific webpage
|
||||||
|
- **read_urls_batch**: Read multiple URLs in parallel (up to 20)
|
||||||
|
- Use for: comparing multiple sources, gathering info from several pages
|
||||||
|
|
||||||
|
### Internal Research Tools
|
||||||
|
- **hybrid_search**: Your primary research tool - searches wiki, graph, and web at once
|
||||||
- **search_wiki**: Find specific wiki pages by keyword
|
- **search_wiki**: Find specific wiki pages by keyword
|
||||||
- **semantic_search**: Find conceptually similar content
|
- **semantic_search**: Find conceptually similar content
|
||||||
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
||||||
@@ -110,18 +122,15 @@ _librarian_agent: Optional[Agent[None, str]] = None
|
|||||||
|
|
||||||
def _create_librarian_agent() -> Agent[None, str]:
|
def _create_librarian_agent() -> Agent[None, str]:
|
||||||
"""Create the Librarian PydanticAI agent."""
|
"""Create the Librarian PydanticAI agent."""
|
||||||
# Import required classes for Ollama configuration
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
from src.ollama.provider import get_ollama_provider
|
||||||
clean_host = str(config.OLLAMA_HOST).rstrip('/')
|
|
||||||
base_url = f"{clean_host}/v1"
|
|
||||||
|
|
||||||
# Create Ollama model with provider
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
agent: Agent[None, str] = Agent(
|
agent: Agent[None, str] = Agent(
|
||||||
@@ -130,7 +139,7 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
retries=2,
|
retries=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register research tools
|
# Register research tools (internal knowledge)
|
||||||
agent.tool_plain(hybrid_search)
|
agent.tool_plain(hybrid_search)
|
||||||
agent.tool_plain(search_wiki)
|
agent.tool_plain(search_wiki)
|
||||||
agent.tool_plain(semantic_search)
|
agent.tool_plain(semantic_search)
|
||||||
@@ -139,6 +148,11 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
agent.tool_plain(explore_knowledge_graph)
|
agent.tool_plain(explore_knowledge_graph)
|
||||||
agent.tool_plain(find_related_entities)
|
agent.tool_plain(find_related_entities)
|
||||||
|
|
||||||
|
# Register web search & content extraction tools
|
||||||
|
agent.tool_plain(search_web)
|
||||||
|
agent.tool_plain(read_url)
|
||||||
|
agent.tool_plain(read_urls_batch)
|
||||||
|
|
||||||
# Register wiki read tools
|
# Register wiki read tools
|
||||||
agent.tool_plain(get_wiki_page)
|
agent.tool_plain(get_wiki_page)
|
||||||
|
|
||||||
@@ -150,7 +164,7 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"librarian_agent_created",
|
"librarian_agent_created",
|
||||||
model=config.OLLAMA_DEFAULT_MODEL,
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
tool_count=11,
|
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
|
||||||
)
|
)
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ LIBRARIAN_CAPABILITY = HouseholdCapability(
|
|||||||
role="The Librarian",
|
role="The Librarian",
|
||||||
category="research",
|
category="research",
|
||||||
description=(
|
description=(
|
||||||
"Research and wiki management: can CREATE wiki pages about topics "
|
"Research, web search, and wiki management: can SEARCH the web for current "
|
||||||
|
"information, READ URLs/articles, CREATE wiki pages about topics "
|
||||||
"(with automatic HybridRAG research), UPDATE existing pages, "
|
"(with automatic HybridRAG research), UPDATE existing pages, "
|
||||||
"SEARCH wiki/knowledge graph/web, and synthesize information. "
|
"and synthesize information from multiple sources. "
|
||||||
"Use for: 'create a page about X', 'update wiki', 'find info on X'"
|
"Use for: 'search for X', 'what is X', 'create a page about X', 'read this URL'"
|
||||||
),
|
),
|
||||||
domains=[
|
domains=[
|
||||||
"research",
|
"research",
|
||||||
@@ -33,6 +34,9 @@ LIBRARIAN_CAPABILITY = HouseholdCapability(
|
|||||||
"wiki",
|
"wiki",
|
||||||
"documents",
|
"documents",
|
||||||
"search",
|
"search",
|
||||||
|
"web",
|
||||||
|
"url",
|
||||||
|
"internet",
|
||||||
"synthesis",
|
"synthesis",
|
||||||
"create",
|
"create",
|
||||||
"write",
|
"write",
|
||||||
|
|||||||
@@ -98,6 +98,47 @@ class ResearchSummary(BaseModel):
|
|||||||
timing_ms: int = 0
|
timing_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchResult(BaseModel):
|
||||||
|
"""Result from web search via /rag/search."""
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
content: str = "" # Full extracted text via Trafilatura
|
||||||
|
snippet: str = "" # Original search engine snippet
|
||||||
|
source: str = "" # Domain name
|
||||||
|
published_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchResponse(BaseModel):
|
||||||
|
"""Response from /rag/search endpoint."""
|
||||||
|
query: str
|
||||||
|
search_type: str
|
||||||
|
results: list[WebSearchResult] = Field(default_factory=list)
|
||||||
|
total_results: int = 0
|
||||||
|
search_time_ms: int = 0
|
||||||
|
sources_summary: str = "" # Pre-formatted markdown citations
|
||||||
|
|
||||||
|
|
||||||
|
class ContentExtractionResult(BaseModel):
|
||||||
|
"""Result from content extraction."""
|
||||||
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
author: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
success: bool = True
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExtractionResponse(BaseModel):
|
||||||
|
"""Response from batch content extraction."""
|
||||||
|
results: list[ContentExtractionResult] = Field(default_factory=list)
|
||||||
|
total_urls: int = 0
|
||||||
|
successful: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
extraction_time_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
class EntityLinking(BaseModel):
|
class EntityLinking(BaseModel):
|
||||||
"""Entity linking results from smart-create."""
|
"""Entity linking results from smart-create."""
|
||||||
forward_links: int = 0
|
forward_links: int = 0
|
||||||
@@ -240,9 +281,16 @@ class LibraryDeskClient:
|
|||||||
metadata=r.get("metadata", {}),
|
metadata=r.get("metadata", {}),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Handle keywords being either a list or a dict with core_keywords
|
||||||
|
raw_keywords = data.get("keywords", [])
|
||||||
|
if isinstance(raw_keywords, dict):
|
||||||
|
keywords = raw_keywords.get("core_keywords", [])
|
||||||
|
else:
|
||||||
|
keywords = raw_keywords
|
||||||
|
|
||||||
return HybridRAGResponse(
|
return HybridRAGResponse(
|
||||||
results=results,
|
results=results,
|
||||||
keywords=data.get("keywords", []),
|
keywords=keywords,
|
||||||
synonyms=data.get("synonyms", []),
|
synonyms=data.get("synonyms", []),
|
||||||
related_dossiers=data.get("related_dossiers", []),
|
related_dossiers=data.get("related_dossiers", []),
|
||||||
formatted_context=data.get("formatted_context", ""),
|
formatted_context=data.get("formatted_context", ""),
|
||||||
@@ -685,6 +733,186 @@ class LibraryDeskClient:
|
|||||||
logger.warning("library_desk_health_check_failed", error=str(e))
|
logger.warning("library_desk_health_check_failed", error=str(e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# RAG Search (Web Search with Content Extraction)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def search_web(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
user: str | None = None,
|
||||||
|
search_type: str = "web",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> WebSearchResponse:
|
||||||
|
"""
|
||||||
|
Search the web and extract content from results.
|
||||||
|
|
||||||
|
Uses SearXNG for search and Trafilatura for content extraction.
|
||||||
|
Returns both snippets and full extracted text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (1-500 chars)
|
||||||
|
user: User identifier for tracking
|
||||||
|
search_type: "web", "news", or "images"
|
||||||
|
limit: Number of results (1-20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WebSearchResponse with results and pre-formatted sources
|
||||||
|
"""
|
||||||
|
user = user or get_user()
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"search_type": search_type,
|
||||||
|
"limit": limit,
|
||||||
|
"user": user or "tatlock-librarian",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_web_search", query=query, limit=limit)
|
||||||
|
|
||||||
|
response = await client.post("/rag/search", json=payload, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = [
|
||||||
|
WebSearchResult(
|
||||||
|
title=r.get("title", ""),
|
||||||
|
url=r.get("url", ""),
|
||||||
|
content=r.get("content", ""),
|
||||||
|
snippet=r.get("snippet", ""),
|
||||||
|
source=r.get("source", ""),
|
||||||
|
published_date=r.get("published_date"),
|
||||||
|
)
|
||||||
|
for r in data.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return WebSearchResponse(
|
||||||
|
query=data.get("query", query),
|
||||||
|
search_type=data.get("search_type", search_type),
|
||||||
|
results=results,
|
||||||
|
total_results=data.get("total_results", len(results)),
|
||||||
|
search_time_ms=data.get("search_time_ms", 0),
|
||||||
|
sources_summary=data.get("sources_summary", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Content Extraction
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def extract_content(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
include_metadata: bool = True,
|
||||||
|
max_length: int = 5000,
|
||||||
|
) -> ContentExtractionResult:
|
||||||
|
"""
|
||||||
|
Extract main content from a URL.
|
||||||
|
|
||||||
|
Uses Trafilatura for intelligent content extraction,
|
||||||
|
removing boilerplate, ads, and navigation.
|
||||||
|
|
||||||
|
Note: Uses soft failure pattern - check result.success field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to extract content from
|
||||||
|
include_metadata: Whether to extract author, date, etc.
|
||||||
|
max_length: Maximum content length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContentExtractionResult (check .success and .error fields)
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"url": url,
|
||||||
|
"include_metadata": include_metadata,
|
||||||
|
"max_length": max_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("library_desk_extract_content", url=url)
|
||||||
|
|
||||||
|
response = await client.post("/content/extract", json=payload, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
result = data.get("result", {})
|
||||||
|
|
||||||
|
return ContentExtractionResult(
|
||||||
|
url=result.get("url", url),
|
||||||
|
title=result.get("title"),
|
||||||
|
content=result.get("content", ""),
|
||||||
|
author=result.get("author"),
|
||||||
|
date=result.get("date"),
|
||||||
|
language=result.get("language"),
|
||||||
|
success=result.get("success", False),
|
||||||
|
error=result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def extract_content_batch(
|
||||||
|
self,
|
||||||
|
urls: list[str],
|
||||||
|
include_metadata: bool = True,
|
||||||
|
max_length: int = 2000,
|
||||||
|
) -> BatchExtractionResponse:
|
||||||
|
"""
|
||||||
|
Extract content from multiple URLs in parallel.
|
||||||
|
|
||||||
|
More efficient than sequential calls. Max 20 URLs per batch.
|
||||||
|
|
||||||
|
Note: Uses soft failure pattern - individual failures don't
|
||||||
|
throw errors, check each result's .success field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to extract (max 20)
|
||||||
|
include_metadata: Whether to extract author, date, etc.
|
||||||
|
max_length: Maximum content length per URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BatchExtractionResponse with results and stats
|
||||||
|
"""
|
||||||
|
client = self._ensure_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"urls": urls[:20], # Server limit
|
||||||
|
"include_metadata": include_metadata,
|
||||||
|
"max_length": max_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("library_desk_extract_batch", url_count=len(urls))
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/content/extract/batch",
|
||||||
|
json=payload,
|
||||||
|
timeout=60.0, # Longer timeout for batch
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = [
|
||||||
|
ContentExtractionResult(
|
||||||
|
url=r.get("url", ""),
|
||||||
|
title=r.get("title"),
|
||||||
|
content=r.get("content", ""),
|
||||||
|
author=r.get("author"),
|
||||||
|
date=r.get("date"),
|
||||||
|
language=r.get("language"),
|
||||||
|
success=r.get("success", False),
|
||||||
|
error=r.get("error"),
|
||||||
|
)
|
||||||
|
for r in data.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return BatchExtractionResponse(
|
||||||
|
results=results,
|
||||||
|
total_urls=data.get("total_urls", len(urls)),
|
||||||
|
successful=data.get("successful", 0),
|
||||||
|
failed=data.get("failed", 0),
|
||||||
|
extraction_time_ms=data.get("extraction_time_ms", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global client factory
|
# Global client factory
|
||||||
async def get_library_client() -> LibraryDeskClient:
|
async def get_library_client() -> LibraryDeskClient:
|
||||||
|
|||||||
@@ -432,6 +432,239 @@ async def find_related_entities(
|
|||||||
return f"Error finding related entities: {str(e)}"
|
return f"Error finding related entities: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Web Search & Content Extraction
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def search_web(
|
||||||
|
query: str,
|
||||||
|
limit: int = 10,
|
||||||
|
search_type: str = "web",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search the web and extract content from results.
|
||||||
|
|
||||||
|
This is the primary tool for finding current information online.
|
||||||
|
Results include both snippets and full extracted text from pages.
|
||||||
|
|
||||||
|
Search types:
|
||||||
|
- "web": General web search (default)
|
||||||
|
- "news": News articles
|
||||||
|
- "images": Image search
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (1-500 chars)
|
||||||
|
limit: Number of results (1-20, default: 10)
|
||||||
|
search_type: Type of search ("web", "news", or "images")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted search results with sources and extracted content
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
search_web("Python 3.12 new features")
|
||||||
|
search_web("latest tech news", search_type="news", limit=5)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.search_web(
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
search_type=search_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.results:
|
||||||
|
return f"No results found for '{query}'"
|
||||||
|
|
||||||
|
output_parts = [f"## Web Search: {query}\n"]
|
||||||
|
output_parts.append(f"*Found {response.total_results} results in {response.search_time_ms}ms*\n")
|
||||||
|
|
||||||
|
for i, result in enumerate(response.results, 1):
|
||||||
|
output_parts.append(f"### {i}. {result.title}")
|
||||||
|
output_parts.append(f"**Source:** {result.source}")
|
||||||
|
output_parts.append(f"**URL:** {result.url}")
|
||||||
|
|
||||||
|
if result.published_date:
|
||||||
|
output_parts.append(f"**Date:** {result.published_date}")
|
||||||
|
|
||||||
|
# Use full content if available, otherwise snippet
|
||||||
|
content = result.content or result.snippet
|
||||||
|
if content:
|
||||||
|
# Truncate for readability
|
||||||
|
if len(content) > 500:
|
||||||
|
content = content[:500] + "..."
|
||||||
|
output_parts.append(f"\n{content}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Add pre-formatted sources for citations
|
||||||
|
if response.sources_summary:
|
||||||
|
output_parts.append("---")
|
||||||
|
output_parts.append(response.sources_summary)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_web_search",
|
||||||
|
query=query,
|
||||||
|
result_count=response.total_results,
|
||||||
|
search_type=search_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_web_search_error", error=str(e), query=query)
|
||||||
|
return f"Error searching web: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_url(
|
||||||
|
url: str,
|
||||||
|
max_length: int = 5000,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read and extract the main content from a URL.
|
||||||
|
|
||||||
|
Use this when you have a specific URL to read, such as:
|
||||||
|
- A link the user provided
|
||||||
|
- A URL from search results you want to read in full
|
||||||
|
- Documentation or article pages
|
||||||
|
|
||||||
|
Extracts the main content, removing ads, navigation, and boilerplate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to read
|
||||||
|
max_length: Maximum content length (default: 5000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted page content with metadata
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
read_url("https://docs.python.org/3/library/asyncio.html")
|
||||||
|
read_url("https://example.com/article", max_length=10000)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
result = await client.extract_content(
|
||||||
|
url=url,
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=max_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return f"Could not read page: {result.error or 'Unknown error'}"
|
||||||
|
|
||||||
|
output_parts = []
|
||||||
|
|
||||||
|
# Header with metadata
|
||||||
|
if result.title:
|
||||||
|
output_parts.append(f"# {result.title}")
|
||||||
|
else:
|
||||||
|
output_parts.append(f"# Content from {url}")
|
||||||
|
|
||||||
|
output_parts.append(f"**URL:** {url}")
|
||||||
|
|
||||||
|
if result.author:
|
||||||
|
output_parts.append(f"**Author:** {result.author}")
|
||||||
|
|
||||||
|
if result.date:
|
||||||
|
output_parts.append(f"**Date:** {result.date}")
|
||||||
|
|
||||||
|
if result.language and result.language != "en":
|
||||||
|
output_parts.append(f"**Language:** {result.language}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Main content
|
||||||
|
if result.content:
|
||||||
|
output_parts.append(result.content)
|
||||||
|
else:
|
||||||
|
output_parts.append("(No content could be extracted)")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_read_url",
|
||||||
|
url=url,
|
||||||
|
content_length=len(result.content) if result.content else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_read_url_error", error=str(e), url=url)
|
||||||
|
return f"Error reading URL: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_urls_batch(
|
||||||
|
urls: list[str],
|
||||||
|
max_length: int = 2000,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read and extract content from multiple URLs in parallel.
|
||||||
|
|
||||||
|
More efficient than calling read_url multiple times.
|
||||||
|
Max 20 URLs per batch.
|
||||||
|
|
||||||
|
Note: Individual failures don't fail the entire batch -
|
||||||
|
failed URLs are reported but other content is still returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to read (max 20)
|
||||||
|
max_length: Maximum content length per URL (default: 2000)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted content from all successful URLs with failure report
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
read_urls_batch(["https://example.com/1", "https://example.com/2"])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with LibraryDeskClient() as client:
|
||||||
|
response = await client.extract_content_batch(
|
||||||
|
urls=urls,
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=max_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_parts = [
|
||||||
|
f"## Batch Content Extraction",
|
||||||
|
f"*Extracted {response.successful}/{response.total_urls} URLs in {response.extraction_time_ms}ms*\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Show successful extractions
|
||||||
|
for result in response.results:
|
||||||
|
if result.success:
|
||||||
|
title = result.title or result.url
|
||||||
|
output_parts.append(f"### {title}")
|
||||||
|
output_parts.append(f"**URL:** {result.url}")
|
||||||
|
|
||||||
|
if result.content:
|
||||||
|
# Truncate for readability in batch mode
|
||||||
|
content = result.content
|
||||||
|
if len(content) > max_length:
|
||||||
|
content = content[:max_length] + "..."
|
||||||
|
output_parts.append(f"\n{content}")
|
||||||
|
|
||||||
|
output_parts.append("")
|
||||||
|
|
||||||
|
# Report failures
|
||||||
|
failed = [r for r in response.results if not r.success]
|
||||||
|
if failed:
|
||||||
|
output_parts.append("---")
|
||||||
|
output_parts.append("### Failed Extractions")
|
||||||
|
for result in failed:
|
||||||
|
output_parts.append(f"- {result.url}: {result.error}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"librarian_read_urls_batch",
|
||||||
|
total=response.total_urls,
|
||||||
|
successful=response.successful,
|
||||||
|
failed=response.failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(output_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("librarian_read_urls_batch_error", error=str(e))
|
||||||
|
return f"Error reading URLs: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Wiki Write Operations
|
# Wiki Write Operations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -685,7 +918,7 @@ async def smart_create_wiki_page(
|
|||||||
|
|
||||||
# All tools available to The Librarian
|
# All tools available to The Librarian
|
||||||
LIBRARIAN_TOOLS = [
|
LIBRARIAN_TOOLS = [
|
||||||
# Research tools
|
# Research tools (internal knowledge)
|
||||||
hybrid_search,
|
hybrid_search,
|
||||||
search_wiki,
|
search_wiki,
|
||||||
get_wiki_page,
|
get_wiki_page,
|
||||||
@@ -694,6 +927,10 @@ LIBRARIAN_TOOLS = [
|
|||||||
semantic_search,
|
semantic_search,
|
||||||
explore_knowledge_graph,
|
explore_knowledge_graph,
|
||||||
find_related_entities,
|
find_related_entities,
|
||||||
|
# Web search & content extraction
|
||||||
|
search_web,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
# Write tools
|
# Write tools
|
||||||
create_wiki_page,
|
create_wiki_page,
|
||||||
update_wiki_page,
|
update_wiki_page,
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ GUIDELINES:
|
|||||||
- Simple greetings/chat → no capabilities needed (conversational response only)
|
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||||
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||||
- Math/calculations → tatlock_core
|
- Math/calculations → tatlock_core
|
||||||
- Quick web searches → tatlock_core
|
|
||||||
- Time/date queries → tatlock_core
|
- Time/date queries → tatlock_core
|
||||||
|
- Web searches, weather, news, current information → librarian with search_web
|
||||||
|
- Read a URL or article → librarian with read_url
|
||||||
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
||||||
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
||||||
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
||||||
@@ -74,8 +75,10 @@ COMPLEXITY: [simple/moderate/complex]
|
|||||||
CONTEXT: [any relevant conversation context, or "none"]
|
CONTEXT: [any relevant conversation context, or "none"]
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
|
- "DELEGATE: librarian to search_web for tomorrow's weather forecast"
|
||||||
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
||||||
- "DELEGATE: librarian to search for information about Docker networking"
|
- "DELEGATE: librarian to hybrid_search for information about Docker networking"
|
||||||
|
- "DELEGATE: librarian to read_url https://example.com/article"
|
||||||
- "DELEGATE: tatlock_core to calculate the result"
|
- "DELEGATE: tatlock_core to calculate the result"
|
||||||
- "DELEGATE: none (conversational response only)"
|
- "DELEGATE: none (conversational response only)"
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ class StewardRecommendation(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Pre-fetched user context from memory (profile, preferences)"
|
description="Pre-fetched user context from memory (profile, preferences)"
|
||||||
)
|
)
|
||||||
|
enriched_query: str = Field(
|
||||||
|
default="",
|
||||||
|
description="User query with auto-filled context (location, timezone) when not specified"
|
||||||
|
)
|
||||||
|
|
||||||
def format_for_butler(self) -> str:
|
def format_for_butler(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -149,6 +149,68 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Build an enriched query by appending user context when not specified.
|
||||||
|
|
||||||
|
When the user asks location-dependent questions (weather, nearby, etc.)
|
||||||
|
without specifying a location, this appends their known location.
|
||||||
|
Similarly for timezone-dependent queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_request: The user's original request
|
||||||
|
memory_context: Pre-fetched memory context with profile/preferences
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Query with context appended, or original query if no enrichment needed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> query = _build_enriched_query(
|
||||||
|
... "What's the weather?",
|
||||||
|
... {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
|
||||||
|
... )
|
||||||
|
>>> query
|
||||||
|
"What's the weather?\n\n[User Context: location=Amsterdam, timezone=Europe/Amsterdam]"
|
||||||
|
"""
|
||||||
|
if not memory_context:
|
||||||
|
return user_request
|
||||||
|
|
||||||
|
request_lower = user_request.lower()
|
||||||
|
profile = memory_context.get("profile", {})
|
||||||
|
preferences = memory_context.get("preferences", {})
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# Check if location is needed and not specified
|
||||||
|
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
|
||||||
|
# Use word boundary pattern to avoid false positives like "at" in "what"
|
||||||
|
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
|
||||||
|
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
|
||||||
|
|
||||||
|
if any(word in request_lower for word in location_keywords):
|
||||||
|
if not location_specified and profile.get("location"):
|
||||||
|
context_parts.append(f"location={profile['location']}")
|
||||||
|
|
||||||
|
# Check if timezone is needed and not specified
|
||||||
|
time_keywords = ["time", "schedule", "meeting", "appointment", "when", "today", "tomorrow"]
|
||||||
|
timezone_specified = any(word in request_lower for word in ["timezone", "tz", "utc", "gmt"])
|
||||||
|
|
||||||
|
if any(word in request_lower for word in time_keywords):
|
||||||
|
if not timezone_specified and profile.get("timezone"):
|
||||||
|
context_parts.append(f"timezone={profile['timezone']}")
|
||||||
|
|
||||||
|
# Add preferences if relevant
|
||||||
|
if preferences.get("temperature_unit") and "weather" in request_lower:
|
||||||
|
context_parts.append(f"temperature_unit={preferences['temperature_unit']}")
|
||||||
|
|
||||||
|
# Build enriched query
|
||||||
|
if context_parts:
|
||||||
|
context_str = ", ".join(context_parts)
|
||||||
|
return f"{user_request}\n\n[User Context: {context_str}]"
|
||||||
|
|
||||||
|
return user_request
|
||||||
|
|
||||||
|
|
||||||
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Pre-fetch user context that might be needed for this request.
|
Pre-fetch user context that might be needed for this request.
|
||||||
@@ -277,6 +339,9 @@ async def analyze_request(
|
|||||||
context = _extract_conversation_context(analysis_text, conversation_history)
|
context = _extract_conversation_context(analysis_text, conversation_history)
|
||||||
missing = _extract_missing_capabilities(analysis_text)
|
missing = _extract_missing_capabilities(analysis_text)
|
||||||
|
|
||||||
|
# Build enriched query with auto-filled context
|
||||||
|
enriched_query = _build_enriched_query(user_request, memory_context)
|
||||||
|
|
||||||
recommendation = StewardRecommendation(
|
recommendation = StewardRecommendation(
|
||||||
recommended_capabilities=capabilities,
|
recommended_capabilities=capabilities,
|
||||||
reasoning=analysis_text,
|
reasoning=analysis_text,
|
||||||
@@ -284,6 +349,7 @@ async def analyze_request(
|
|||||||
conversation_context=context,
|
conversation_context=context,
|
||||||
missing_capabilities=missing,
|
missing_capabilities=missing,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
|
enriched_query=enriched_query,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update log context with results
|
# Update log context with results
|
||||||
|
|||||||
+248
-35
@@ -17,7 +17,6 @@ from src.agents.tatlock_core.tools import (
|
|||||||
get_current_datetime,
|
get_current_datetime,
|
||||||
calculate_time_offset,
|
calculate_time_offset,
|
||||||
time_difference,
|
time_difference,
|
||||||
search_web,
|
|
||||||
)
|
)
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
@@ -76,18 +75,17 @@ You have direct access to several permanent tools that you should USE whenever a
|
|||||||
- time_difference: Calculate the time between two dates
|
- time_difference: Calculate the time between two dates
|
||||||
- Use these for ANY date/time queries - never guess at dates or times
|
- Use these for ANY date/time queries - never guess at dates or times
|
||||||
|
|
||||||
3. **Web Search** (search_web): Search for current, volatile, or factual information
|
3. **Web Search** (via Librarian): For current, volatile, or factual information
|
||||||
- Use this for ANY information that might be current, factual, or outside your training data
|
- Delegate to the Librarian for web searches and research
|
||||||
- Examples: news, current events, recent developments, specific facts, technical documentation
|
- Examples: news, current events, recent developments, specific facts, technical documentation
|
||||||
- Always prefer searching over guessing or using potentially outdated knowledge
|
- Use: delegate_to_librarian(task="search the web for ...")
|
||||||
- For extensive research questions, note that this will later be delegated to the librarian
|
|
||||||
|
|
||||||
## Tool Usage Guidelines
|
## Tool Usage Guidelines
|
||||||
|
|
||||||
- **Mathematics**: ALWAYS use the calculator tool, even for simple arithmetic
|
- **Mathematics**: ALWAYS use the calculator tool, even for simple arithmetic
|
||||||
- **Dates/Times**: ALWAYS use the date/time tools, never guess or estimate
|
- **Dates/Times**: ALWAYS use the date/time tools, never guess or estimate
|
||||||
- **Current Information**: ALWAYS search for facts, news, or volatile information
|
- **Current Information**: Delegate web searches to the Librarian
|
||||||
- **Verification**: When facts are important, use search to verify rather than rely on memory alone
|
- **Verification**: When facts are important, delegate to Librarian for research
|
||||||
- When you use a tool, explain what you're doing in a butler-appropriate manner
|
- When you use a tool, explain what you're doing in a butler-appropriate manner
|
||||||
- Present tool results naturally in your response
|
- Present tool results naturally in your response
|
||||||
|
|
||||||
@@ -145,7 +143,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
|
|
||||||
# Import required classes for Ollama configuration
|
# Import required classes for Ollama configuration
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
# PydanticAI expects Ollama base URL to end with /v1
|
||||||
# Remove trailing slash from ollama_host if present
|
# Remove trailing slash from ollama_host if present
|
||||||
@@ -155,7 +153,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
# Create Ollama model with provider
|
# Create Ollama model with provider
|
||||||
ollama_model = OpenAIChatModel(
|
ollama_model = OpenAIChatModel(
|
||||||
model_name=self.model_name,
|
model_name=self.model_name,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create PydanticAI agent with Ollama model
|
# Create PydanticAI agent with Ollama model
|
||||||
@@ -238,28 +236,8 @@ class TatlockAgent(AgentInterface):
|
|||||||
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
||||||
return time_difference(date1_str, date2_str)
|
return time_difference(date1_str, date2_str)
|
||||||
|
|
||||||
# Web search tool
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
@self._agent.tool
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
async def web_search(ctx: RunContext[ToolCallTracker], query: str, num_results: int = 5) -> str:
|
|
||||||
"""
|
|
||||||
Search the web using SearXNG for current information.
|
|
||||||
|
|
||||||
Use this tool for ANY information that might be:
|
|
||||||
- Current or time-sensitive (news, events, recent developments)
|
|
||||||
- Factual and verifiable (statistics, technical specs, definitions)
|
|
||||||
- Outside your training data or knowledge cutoff
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
num_results: Number of results to return (default: 5, max: 10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted search results with titles, URLs, and snippets
|
|
||||||
"""
|
|
||||||
# Log the search query to reasoning output
|
|
||||||
if ctx.deps:
|
|
||||||
ctx.deps.log_call(f"🔍 Searching for: '{query}'")
|
|
||||||
return await search_web(query, num_results)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def agent(self):
|
def agent(self):
|
||||||
@@ -470,7 +448,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
... )
|
... )
|
||||||
"""
|
"""
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"tatlock_run_with_scoped_tools",
|
"tatlock_run_with_scoped_tools",
|
||||||
@@ -486,7 +464,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
|
|
||||||
ollama_model = OpenAIChatModel(
|
ollama_model = OpenAIChatModel(
|
||||||
model_name=self.model_name,
|
model_name=self.model_name,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create agent with scoped tools
|
# Create agent with scoped tools
|
||||||
@@ -564,7 +542,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
Text chunks from the streaming response
|
Text chunks from the streaming response
|
||||||
"""
|
"""
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"tatlock_run_with_scoped_tools_stream",
|
"tatlock_run_with_scoped_tools_stream",
|
||||||
@@ -579,7 +557,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
|
|
||||||
ollama_model = OpenAIChatModel(
|
ollama_model = OpenAIChatModel(
|
||||||
model_name=self.model_name,
|
model_name=self.model_name,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create agent with scoped tools
|
# Create agent with scoped tools
|
||||||
@@ -630,6 +608,241 @@ class TatlockAgent(AgentInterface):
|
|||||||
|
|
||||||
logger.info("tatlock_scoped_run_complete")
|
logger.info("tatlock_scoped_run_complete")
|
||||||
|
|
||||||
|
async def orchestrate_tool_calls(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
steward_note: str,
|
||||||
|
scoped_tools: list[Any],
|
||||||
|
message_history: list[dict],
|
||||||
|
tool_tracker: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 1: Execute tool calls and delegations, return structured results.
|
||||||
|
|
||||||
|
This is the coordination phase where Tatlock orchestrates tool calls
|
||||||
|
and expert delegations. The raw output is captured for Phase 2 synthesis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's original message
|
||||||
|
steward_note: Note from Steward (invisible to user)
|
||||||
|
scoped_tools: List of tool definitions from household registry
|
||||||
|
message_history: Conversation history
|
||||||
|
tool_tracker: Optional tool call tracker for benchmarking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- tools_called: List of tool names that were called
|
||||||
|
- expert_results: Dict mapping expert names to their outputs
|
||||||
|
- tool_outputs: Dict mapping tool names to their outputs
|
||||||
|
- raw_output: The agent's raw text output
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
from pydantic_ai.messages import (
|
||||||
|
ModelRequest,
|
||||||
|
ModelResponse,
|
||||||
|
UserPromptPart,
|
||||||
|
TextPart,
|
||||||
|
ToolCallPart,
|
||||||
|
ToolReturnPart,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_orchestrate_tool_calls",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
scoped_tool_count=len(scoped_tools),
|
||||||
|
history_length=len(message_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a fresh agent instance with scoped tools only
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create agent with scoped tools
|
||||||
|
scoped_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
tools=scoped_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepend Steward's note to the request
|
||||||
|
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run with scoped tools and tracker
|
||||||
|
result = await scoped_agent.run(
|
||||||
|
enriched_message,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
deps=tool_tracker,
|
||||||
|
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract tool calls and results from the agent's messages
|
||||||
|
tools_called = []
|
||||||
|
expert_results = {}
|
||||||
|
tool_outputs = {}
|
||||||
|
|
||||||
|
# Parse through new messages to find tool calls and returns
|
||||||
|
for msg in result.new_messages():
|
||||||
|
if isinstance(msg, ModelResponse):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolCallPart):
|
||||||
|
tools_called.append(part.tool_name)
|
||||||
|
elif isinstance(msg, ModelRequest):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolReturnPart):
|
||||||
|
tool_name = part.tool_name
|
||||||
|
content = part.content
|
||||||
|
|
||||||
|
# Categorize as expert result or tool output
|
||||||
|
if tool_name.startswith("delegate_to_"):
|
||||||
|
expert_name = tool_name.replace("delegate_to_", "")
|
||||||
|
expert_results[expert_name] = content
|
||||||
|
else:
|
||||||
|
tool_outputs[tool_name] = content
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_orchestration_complete",
|
||||||
|
tools_called=tools_called,
|
||||||
|
expert_count=len(expert_results),
|
||||||
|
tool_output_count=len(tool_outputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": tool_outputs,
|
||||||
|
"raw_output": result.output,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def synthesize_from_results(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
orchestration_results: dict[str, Any],
|
||||||
|
message_history: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Phase 2: Synthesize butler-toned response from gathered results.
|
||||||
|
|
||||||
|
This is the synthesis phase where Tatlock takes the coordination
|
||||||
|
results and produces a properly butler-toned response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's original message
|
||||||
|
orchestration_results: Results from orchestrate_tool_calls()
|
||||||
|
message_history: Conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Butler-toned response synthesized from all results
|
||||||
|
"""
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_synthesize_from_results",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
|
expert_count=len(orchestration_results.get("expert_results", {})),
|
||||||
|
tool_count=len(orchestration_results.get("tool_outputs", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build synthesis prompt with all available information
|
||||||
|
synthesis_parts = []
|
||||||
|
synthesis_parts.append(f"The user asked: {user_message}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
# Add expert findings if any
|
||||||
|
if orchestration_results.get("expert_results"):
|
||||||
|
synthesis_parts.append("Expert findings:")
|
||||||
|
for expert, result in orchestration_results["expert_results"].items():
|
||||||
|
synthesis_parts.append(f"- {expert.title()}: {result}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
# Add tool outputs if any
|
||||||
|
if orchestration_results.get("tool_outputs"):
|
||||||
|
synthesis_parts.append("Tool results:")
|
||||||
|
for tool, result in orchestration_results["tool_outputs"].items():
|
||||||
|
synthesis_parts.append(f"- {tool}: {result}")
|
||||||
|
synthesis_parts.append("")
|
||||||
|
|
||||||
|
synthesis_parts.append(
|
||||||
|
"Based on this information, provide a response to the user. "
|
||||||
|
"Maintain your butler personality - address them as 'sir', "
|
||||||
|
"use formal but personable language, and be helpful."
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_prompt = "\n".join(synthesis_parts)
|
||||||
|
|
||||||
|
# Create synthesis agent (no tools needed)
|
||||||
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
ollama_model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=get_ollama_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Synthesis agent uses butler prompt but no tools
|
||||||
|
synthesis_agent = Agent(
|
||||||
|
ollama_model,
|
||||||
|
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||||
|
# No tools for synthesis phase
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert message history to PydanticAI format
|
||||||
|
pydantic_history = []
|
||||||
|
for msg in message_history:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||||
|
)
|
||||||
|
elif role == "assistant":
|
||||||
|
pydantic_history.append(
|
||||||
|
ModelResponse(parts=[TextPart(content=content)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run synthesis
|
||||||
|
result = await synthesis_agent.run(
|
||||||
|
synthesis_prompt,
|
||||||
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tatlock_synthesis_complete",
|
||||||
|
response_preview=result.output[:100],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
|
||||||
async def get_capabilities(self) -> dict:
|
async def get_capabilities(self) -> dict:
|
||||||
"""Return current capabilities."""
|
"""Return current capabilities."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Tatlock's core tools package.
|
Tatlock's core tools package.
|
||||||
|
|
||||||
Provides calculator, date/time, and web search capabilities.
|
Provides calculator and date/time capabilities.
|
||||||
|
Web search has been moved to The Librarian agent.
|
||||||
Organized as a household member with toolset and capability registration.
|
Organized as a household member with toolset and capability registration.
|
||||||
"""
|
"""
|
||||||
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
|
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
|
||||||
@@ -10,7 +11,6 @@ from .tools import (
|
|||||||
calculate,
|
calculate,
|
||||||
calculate_time_offset,
|
calculate_time_offset,
|
||||||
get_current_datetime,
|
get_current_datetime,
|
||||||
search_web,
|
|
||||||
time_difference,
|
time_difference,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ __all__ = [
|
|||||||
"get_current_datetime",
|
"get_current_datetime",
|
||||||
"calculate_time_offset",
|
"calculate_time_offset",
|
||||||
"time_difference",
|
"time_difference",
|
||||||
"search_web",
|
|
||||||
# Toolset
|
# Toolset
|
||||||
"tatlock_core_tools",
|
"tatlock_core_tools",
|
||||||
"get_core_tools",
|
"get_core_tools",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ TATLOCK_CORE_CAPABILITY = HouseholdCapability(
|
|||||||
name="tatlock_core",
|
name="tatlock_core",
|
||||||
role="Butler's Core Tools",
|
role="Butler's Core Tools",
|
||||||
category="core",
|
category="core",
|
||||||
description="Essential tools for computation, date/time operations, and web searches",
|
description="Essential tools for computation and date/time operations",
|
||||||
domains=["computation", "datetime", "information", "research"],
|
domains=["computation", "datetime", "math", "calculator"],
|
||||||
cost="low",
|
cost="low",
|
||||||
requires_network=True, # For web search
|
requires_network=False, # Web search moved to Librarian
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -256,96 +256,5 @@ def time_difference(date1_str: str, date2_str: str = "now") -> str:
|
|||||||
return f"Error calculating time difference: {str(e)}"
|
return f"Error calculating time difference: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
# SearXNG Search Tool
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
async def search_web(query: str, num_results: int = 5) -> str:
|
|
||||||
"""
|
|
||||||
Search the web using SearXNG.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
num_results: Number of results to return (default: 5, max: 10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted search results as a string with titles, URLs, and snippets
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
search_web("Python async programming") -> "1. Title: ...\n URL: ...\n ..."
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Limit results
|
|
||||||
num_results = min(num_results, 10)
|
|
||||||
|
|
||||||
# Get SearXNG host with fallback logic
|
|
||||||
searxng_host = str(config.SEARXNG_HOST)
|
|
||||||
|
|
||||||
# Try production host first, fall back to localhost in development
|
|
||||||
hosts_to_try = [searxng_host]
|
|
||||||
if config.ENVIRONMENT.value == "development" and "localhost" not in searxng_host:
|
|
||||||
# Add localhost fallback for development
|
|
||||||
hosts_to_try.append("http://localhost:8087")
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for host in hosts_to_try:
|
|
||||||
try:
|
|
||||||
logger.debug("searxng_search_attempt", host=host, query=query)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=config.SEARXNG_TIMEOUT) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{host}/search",
|
|
||||||
params={
|
|
||||||
"q": query,
|
|
||||||
"format": "json",
|
|
||||||
"pageno": 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
results = data.get("results", [])
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
return f"No results found for '{query}'"
|
|
||||||
|
|
||||||
# Format results
|
|
||||||
formatted_results = []
|
|
||||||
for i, result in enumerate(results[:num_results], 1):
|
|
||||||
title = result.get("title", "No title")
|
|
||||||
url = result.get("url", "")
|
|
||||||
content = result.get("content", "No description available")
|
|
||||||
|
|
||||||
formatted_results.append(
|
|
||||||
f"{i}. {title}\n"
|
|
||||||
f" URL: {url}\n"
|
|
||||||
f" {content}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"searxng_search_success",
|
|
||||||
host=host,
|
|
||||||
query=query,
|
|
||||||
result_count=len(results),
|
|
||||||
)
|
|
||||||
return "\n".join(formatted_results)
|
|
||||||
else:
|
|
||||||
last_error = f"SearXNG returned status {response.status_code}"
|
|
||||||
|
|
||||||
except httpx.ConnectError:
|
|
||||||
last_error = f"Cannot connect to SearXNG at {host}"
|
|
||||||
logger.warning("searxng_connection_failed", host=host)
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
last_error = str(e)
|
|
||||||
logger.warning("searxng_error", host=host, error=str(e))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# All hosts failed
|
|
||||||
logger.error("searxng_all_hosts_failed", error=last_error)
|
|
||||||
return f"Error searching: {last_error}. Please check that SearXNG is running."
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("searxng_unexpected_error", error=str(e), exc_info=True)
|
|
||||||
return f"Error searching: {str(e)}"
|
|
||||||
|
|||||||
@@ -55,17 +55,8 @@ time_difference_tool = Tool(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
web_search_tool = Tool(
|
# NOTE: Web search has been moved to The Librarian agent.
|
||||||
function=tools.search_web,
|
# Use delegate_to_librarian(task="search web for ...") for web search.
|
||||||
name="search_web",
|
|
||||||
description=(
|
|
||||||
"Search the web using SearXNG for current information. "
|
|
||||||
"Use this to find recent events, current data, or verify facts. "
|
|
||||||
"Returns formatted results with titles, URLs, and snippets. "
|
|
||||||
"Useful for information that may have changed since training data."
|
|
||||||
),
|
|
||||||
takes_ctx=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Combined toolset of all core tools
|
# Combined toolset of all core tools
|
||||||
@@ -74,7 +65,6 @@ tatlock_core_tools = [
|
|||||||
current_datetime_tool,
|
current_datetime_tool,
|
||||||
time_offset_tool,
|
time_offset_tool,
|
||||||
time_difference_tool,
|
time_difference_tool,
|
||||||
web_search_tool,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-97
@@ -4,20 +4,14 @@ Tatlock's permanent tools.
|
|||||||
These tools are always available to the butler agent:
|
These tools are always available to the butler agent:
|
||||||
- Calculator: For all mathematical operations
|
- Calculator: For all mathematical operations
|
||||||
- Date/Time toolkit: For current time and time calculations
|
- Date/Time toolkit: For current time and time calculations
|
||||||
- SearXNG search: For searching the web for current information
|
|
||||||
|
Note: Web search has been moved to The Librarian agent.
|
||||||
|
See src/agents/librarian/tools.py for search_web functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from src.core.config import config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -256,91 +250,3 @@ def time_difference(date1_str: str, date2_str: str = "now") -> str:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error calculating time difference: {str(e)}"
|
return f"Error calculating time difference: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SearXNG Search Tool
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
async def search_web(query: str, num_results: int = 5) -> str:
|
|
||||||
"""
|
|
||||||
Search the web using SearXNG.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
num_results: Number of results to return (default: 5, max: 10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted search results as a string with titles, URLs, and snippets
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
search_web("Python async programming") -> "1. Title: ...\n URL: ...\n ..."
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Limit results
|
|
||||||
num_results = min(num_results, 10)
|
|
||||||
|
|
||||||
# Get SearXNG host with fallback logic
|
|
||||||
searxng_host = str(config.SEARXNG_HOST)
|
|
||||||
|
|
||||||
# Try production host first, fall back to localhost in development
|
|
||||||
hosts_to_try = [searxng_host]
|
|
||||||
if config.ENVIRONMENT.value == "development" and "localhost" not in searxng_host:
|
|
||||||
# Add localhost fallback for development
|
|
||||||
hosts_to_try.append("http://localhost:8087")
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for host in hosts_to_try:
|
|
||||||
try:
|
|
||||||
logger.info(f"Attempting SearXNG search at {host}")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=config.SEARXNG_TIMEOUT) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{host}/search",
|
|
||||||
params={
|
|
||||||
"q": query,
|
|
||||||
"format": "json",
|
|
||||||
"pageno": 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
results = data.get("results", [])
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
return f"No results found for '{query}'"
|
|
||||||
|
|
||||||
# Format results
|
|
||||||
formatted_results = []
|
|
||||||
for i, result in enumerate(results[:num_results], 1):
|
|
||||||
title = result.get("title", "No title")
|
|
||||||
url = result.get("url", "")
|
|
||||||
content = result.get("content", "No description available")
|
|
||||||
|
|
||||||
formatted_results.append(
|
|
||||||
f"{i}. {title}\n"
|
|
||||||
f" URL: {url}\n"
|
|
||||||
f" {content}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(formatted_results)
|
|
||||||
else:
|
|
||||||
last_error = f"SearXNG returned status {response.status_code}"
|
|
||||||
|
|
||||||
except httpx.ConnectError:
|
|
||||||
last_error = f"Cannot connect to SearXNG at {host}"
|
|
||||||
logger.warning(f"SearXNG connection failed at {host}, trying next host if available")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
last_error = str(e)
|
|
||||||
logger.warning(f"SearXNG error at {host}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# All hosts failed
|
|
||||||
return f"Error searching: {last_error}. Please check that SearXNG is running."
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error in search_web: {e}", exc_info=True)
|
|
||||||
return f"Error searching: {str(e)}"
|
|
||||||
|
|||||||
@@ -273,6 +273,65 @@ class HouseholdRegistry:
|
|||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
def get_streaming_delegation_tools(self, names: list[str]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
Get streaming delegation wrapper tools for specified capabilities.
|
||||||
|
|
||||||
|
Similar to get_delegation_tools() but returns streaming wrappers
|
||||||
|
that yield butler-perspective think messages during execution.
|
||||||
|
|
||||||
|
These wrappers emit think slugs like:
|
||||||
|
- "Allow me to consult the archives, sir."
|
||||||
|
- "The Librarian has compiled the relevant findings."
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: List of member names to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of streaming delegation wrappers and/or raw tools
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> tools = registry.get_streaming_delegation_tools(["librarian"])
|
||||||
|
>>> async for chunk in tools[0](task="Search for Docker"):
|
||||||
|
... print(chunk) # Yields think messages then result
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import STREAMING_DELEGATION_WRAPPERS
|
||||||
|
|
||||||
|
tools = []
|
||||||
|
for name in names:
|
||||||
|
member = self._members.get(name)
|
||||||
|
if not member:
|
||||||
|
logger.warning(
|
||||||
|
"household_member_not_found",
|
||||||
|
requested_name=name,
|
||||||
|
available_names=list(self._members.keys()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this member has a streaming delegation wrapper
|
||||||
|
if name in STREAMING_DELEGATION_WRAPPERS and member.agent is not None:
|
||||||
|
tools.append(STREAMING_DELEGATION_WRAPPERS[name])
|
||||||
|
logger.debug(
|
||||||
|
"streaming_delegation_wrapper_added",
|
||||||
|
member=name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No agent = direct tools (e.g., tatlock_core)
|
||||||
|
tools.extend(member.tools)
|
||||||
|
logger.debug(
|
||||||
|
"raw_tools_added",
|
||||||
|
member=name,
|
||||||
|
tool_count=len(member.tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"streaming_delegation_tools_created",
|
||||||
|
requested_members=names,
|
||||||
|
total_tools=len(tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
def list_members(self) -> list[str]:
|
def list_members(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all registered member names.
|
List all registered member names.
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
PydanticAI provider for Ollama with message sanitization.
|
||||||
|
|
||||||
|
Ollama's OpenAI-compatible API rejects messages with `content: null`,
|
||||||
|
which PydanticAI sends for assistant messages that only contain tool calls.
|
||||||
|
This provider sanitizes messages to use empty strings instead of null.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from pydantic_ai.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TatlockOllamaProvider(OllamaProvider):
|
||||||
|
"""
|
||||||
|
Custom OllamaProvider with message sanitization for Tatlock agents.
|
||||||
|
|
||||||
|
Fixes the 'invalid message content type: <nil>' error that occurs
|
||||||
|
when assistant messages have `content: null` with tool calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None):
|
||||||
|
"""
|
||||||
|
Initialize provider with Ollama base URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama API URL (defaults to config.OLLAMA_HOST/v1)
|
||||||
|
"""
|
||||||
|
if base_url is None:
|
||||||
|
clean_host = str(config.OLLAMA_HOST).rstrip("/")
|
||||||
|
base_url = f"{clean_host}/v1"
|
||||||
|
|
||||||
|
super().__init__(base_url=base_url)
|
||||||
|
|
||||||
|
# Override the client with our sanitized version
|
||||||
|
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
|
||||||
|
|
||||||
|
logger.debug("tatlock_ollama_provider_created", base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedAsyncOpenAI(AsyncOpenAI):
|
||||||
|
"""AsyncOpenAI client that sanitizes messages before sending."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any):
|
||||||
|
# Ollama doesn't need an API key
|
||||||
|
super().__init__(api_key="ollama", **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chat(self) -> "_SanitizedChat":
|
||||||
|
"""Return sanitized chat interface."""
|
||||||
|
return _SanitizedChat(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedChat:
|
||||||
|
"""Chat interface wrapper with sanitized completions."""
|
||||||
|
|
||||||
|
def __init__(self, client: _SanitizedAsyncOpenAI):
|
||||||
|
self._client = client
|
||||||
|
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completions(self) -> "_SanitizedCompletions":
|
||||||
|
"""Return sanitized completions interface."""
|
||||||
|
return _SanitizedCompletions(self._original_chat.completions)
|
||||||
|
|
||||||
|
|
||||||
|
class _SanitizedCompletions:
|
||||||
|
"""Completions wrapper that sanitizes messages before API calls."""
|
||||||
|
|
||||||
|
def __init__(self, original_completions: Any):
|
||||||
|
self._original = original_completions
|
||||||
|
|
||||||
|
async def create(self, **kwargs: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Create chat completion with sanitized messages.
|
||||||
|
|
||||||
|
Converts `content: null` to `content: ""` in assistant messages
|
||||||
|
to prevent Ollama's 'invalid message content type: <nil>' error.
|
||||||
|
"""
|
||||||
|
if "messages" in kwargs:
|
||||||
|
kwargs["messages"] = _sanitize_messages(kwargs["messages"])
|
||||||
|
|
||||||
|
return await self._original.create(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Sanitize messages to fix null content issues.
|
||||||
|
|
||||||
|
When an assistant message has tool_calls but no text content,
|
||||||
|
PydanticAI sets content to None. Ollama rejects this.
|
||||||
|
We convert None to empty string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of chat messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized messages with null content replaced by empty strings
|
||||||
|
"""
|
||||||
|
sanitized = []
|
||||||
|
for msg in messages:
|
||||||
|
msg_copy = dict(msg)
|
||||||
|
|
||||||
|
# Fix null content in assistant messages with tool calls
|
||||||
|
if msg_copy.get("role") == "assistant":
|
||||||
|
if msg_copy.get("content") is None and msg_copy.get("tool_calls"):
|
||||||
|
msg_copy["content"] = ""
|
||||||
|
logger.debug(
|
||||||
|
"sanitized_null_content",
|
||||||
|
tool_call_count=len(msg_copy["tool_calls"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
sanitized.append(msg_copy)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def get_ollama_provider() -> TatlockOllamaProvider:
|
||||||
|
"""
|
||||||
|
Get a configured Ollama provider for PydanticAI agents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TatlockOllamaProvider configured with sanitization
|
||||||
|
"""
|
||||||
|
return TatlockOllamaProvider()
|
||||||
+110
-26
@@ -43,7 +43,7 @@ async def _execute_single_delegation(
|
|||||||
Execute a single delegation to an agent.
|
Execute a single delegation to an agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_name: Name of agent (biographer, librarian)
|
agent_name: Name of agent (biographer, librarian, housekeeper)
|
||||||
task: Task description
|
task: Task description
|
||||||
tracker: Tool call tracker
|
tracker: Tool call tracker
|
||||||
|
|
||||||
@@ -67,6 +67,13 @@ async def _execute_single_delegation(
|
|||||||
await tracker.track_call("delegate_to_librarian", duration)
|
await tracker.track_call("delegate_to_librarian", duration)
|
||||||
return (agent_name, result.output)
|
return (agent_name, result.output)
|
||||||
|
|
||||||
|
elif agent_name == "housekeeper":
|
||||||
|
from src.agents.delegation import delegate_to_housekeeper
|
||||||
|
result = await delegate_to_housekeeper(task=task)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await tracker.track_call("delegate_to_housekeeper", duration)
|
||||||
|
return (agent_name, result.output)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return (agent_name, f"Unknown agent: {agent_name}")
|
return (agent_name, f"Unknown agent: {agent_name}")
|
||||||
|
|
||||||
@@ -244,6 +251,68 @@ async def _direct_delegation(
|
|||||||
return "\n\n".join(results) if results else "I apologize, sir. No delegation results available."
|
return "\n\n".join(results) if results else "I apologize, sir. No delegation results available."
|
||||||
|
|
||||||
|
|
||||||
|
async def _direct_delegation_with_results(
|
||||||
|
user_message: str,
|
||||||
|
recommendation: "StewardRecommendation",
|
||||||
|
tracker: "ToolCallTracker",
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Directly delegate to expert agents and return structured results.
|
||||||
|
|
||||||
|
This is the Phase 1 variant of direct delegation that returns results
|
||||||
|
in the same format as TatlockAgent.orchestrate_tool_calls() for
|
||||||
|
consistent Phase 2 synthesis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: User's request
|
||||||
|
recommendation: Steward's recommendation
|
||||||
|
tracker: Tool call tracker
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Orchestration results with expert_results, tool_outputs, etc.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_with_results",
|
||||||
|
agents=recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
expert_results = {}
|
||||||
|
tools_called = []
|
||||||
|
|
||||||
|
for agent in recommendation.recommended_capabilities:
|
||||||
|
try:
|
||||||
|
agent_name, result = await _execute_single_delegation(
|
||||||
|
agent, user_message, tracker
|
||||||
|
)
|
||||||
|
expert_results[agent_name] = result
|
||||||
|
tools_called.append(f"delegate_to_{agent_name}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"direct_delegation_result",
|
||||||
|
agent=agent_name,
|
||||||
|
result_preview=result[:100] if result else "empty",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"direct_delegation_failed",
|
||||||
|
agent=agent,
|
||||||
|
error=str(e),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
expert_results[agent] = f"Error: {e}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": {}, # No tool outputs for direct delegation
|
||||||
|
"raw_output": "", # No raw output for direct delegation
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global conversation history tracker
|
# Global conversation history tracker
|
||||||
# In production, this would be backed by a database or Redis
|
# In production, this would be backed by a database or Redis
|
||||||
_conversation_history = ConversationHistory(max_turns=20)
|
_conversation_history = ConversationHistory(max_turns=20)
|
||||||
@@ -379,12 +448,13 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
|
|
||||||
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||||
"""
|
"""
|
||||||
Create response using Steward preprocessing (Phase 2 flow).
|
Create response using Steward preprocessing and two-phase Tatlock execution.
|
||||||
|
|
||||||
This is the two-tier architecture where:
|
This is the two-tier architecture with two-phase synthesis:
|
||||||
1. Steward analyzes the request and recommends capabilities
|
1. Steward analyzes the request and recommends capabilities
|
||||||
2. Tatlock runs with scoped tools based on recommendations
|
2. Phase 1: Tatlock orchestrates tool calls and expert delegations
|
||||||
3. Tool usage is tracked for benchmarking
|
3. Phase 2: Tatlock synthesizes butler-toned response from results
|
||||||
|
4. Tool usage is tracked for benchmarking
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Response request
|
request: Response request
|
||||||
@@ -420,52 +490,66 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 1: Steward preprocessing
|
# Steward preprocessing
|
||||||
enriched = await preprocess_request(
|
enriched = await preprocess_request(
|
||||||
user_message,
|
user_message,
|
||||||
conversation_history=conversation_history,
|
conversation_history=conversation_history,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 2: Initialize tool tracker
|
# Initialize tool tracker
|
||||||
tracker = ToolCallTracker(
|
tracker = ToolCallTracker(
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 3: Check if direct delegation is recommended
|
# Check if direct delegation is recommended
|
||||||
# If Steward recommends ONLY delegation agents (biographer/librarian),
|
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
||||||
# skip Tatlock and delegate directly
|
# we still use two-phase but delegate directly in Phase 1
|
||||||
|
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||||
delegation_only = all(
|
delegation_only = all(
|
||||||
cap in ("biographer", "librarian")
|
cap in delegation_agents
|
||||||
for cap in enriched.recommendation.recommended_capabilities
|
for cap in enriched.recommendation.recommended_capabilities
|
||||||
) and enriched.recommendation.recommended_capabilities
|
) and enriched.recommendation.recommended_capabilities
|
||||||
|
|
||||||
|
from src.agents.tatlock import TatlockAgent
|
||||||
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
# Use enriched query (with location/timezone context) if available
|
||||||
|
effective_query = enriched.recommendation.enriched_query or user_message
|
||||||
|
|
||||||
if delegation_only:
|
if delegation_only:
|
||||||
tatlock_response = await _direct_delegation(
|
# Direct delegation path - collect results then synthesize
|
||||||
user_message, enriched.recommendation, tracker, conversation_id
|
orchestration_results = await _direct_delegation_with_results(
|
||||||
|
effective_query, enriched.recommendation, tracker, conversation_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Phase 3a: Run Tatlock with scoped tools
|
# Phase 1: Orchestrate tool calls
|
||||||
from src.agents.tatlock import TatlockAgent
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
tatlock = TatlockAgent()
|
user_message=effective_query,
|
||||||
|
|
||||||
tatlock_response = await tatlock.run_with_scoped_tools(
|
|
||||||
user_message=user_message,
|
|
||||||
steward_note=enriched.steward_note,
|
steward_note=enriched.steward_note,
|
||||||
scoped_tools=enriched.scoped_tools,
|
scoped_tools=enriched.scoped_tools,
|
||||||
message_history=conversation_history,
|
message_history=conversation_history,
|
||||||
tool_tracker=tracker,
|
tool_tracker=tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 3b: Check for text-based delegation fallback
|
# Handle text-based delegation fallback if present
|
||||||
# If Tatlock outputs [DELEGATE:...] instead of calling the function,
|
if "[DELEGATE:" in orchestration_results.get("raw_output", ""):
|
||||||
# we parse and execute it here
|
text_delegation_results = await _handle_text_delegation(
|
||||||
tatlock_response = await _handle_text_delegation(
|
orchestration_results["raw_output"], tracker, conversation_id
|
||||||
tatlock_response, tracker, conversation_id
|
)
|
||||||
)
|
# Add text delegation results to expert_results
|
||||||
|
if text_delegation_results != orchestration_results["raw_output"]:
|
||||||
|
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
||||||
|
|
||||||
# Phase 4: Finalize tool tracking
|
# Phase 2: Synthesize butler-toned response from all results
|
||||||
|
tatlock_response = await tatlock.synthesize_from_results(
|
||||||
|
user_message=user_message,
|
||||||
|
orchestration_results=orchestration_results,
|
||||||
|
message_history=conversation_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finalize tool tracking
|
||||||
await tracker.finalize()
|
await tracker.finalize()
|
||||||
|
|
||||||
# Build response output items
|
# Build response output items
|
||||||
|
|||||||
+136
-19
@@ -118,11 +118,12 @@ class StreamingCoordinator:
|
|||||||
request: "ResponseRequest" # type: ignore # Forward reference
|
request: "ResponseRequest" # type: ignore # Forward reference
|
||||||
) -> AsyncGenerator[StreamEvent, None]:
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
"""
|
"""
|
||||||
Stream response with Steward preprocessing (Phase 2 flow).
|
Stream response with Steward preprocessing and two-phase Tatlock execution.
|
||||||
|
|
||||||
Streams in order:
|
Streams in order:
|
||||||
1. Steward's analysis as reasoning summary
|
1. Steward's analysis as reasoning summary
|
||||||
2. Tatlock's response as output text
|
2. Think slugs during expert delegation (butler-perspective messages)
|
||||||
|
3. Synthesized butler-toned response as output text
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Response request
|
request: Response request
|
||||||
@@ -130,11 +131,17 @@ class StreamingCoordinator:
|
|||||||
Yields:
|
Yields:
|
||||||
StreamEvent: Stream of SSE events
|
StreamEvent: Stream of SSE events
|
||||||
"""
|
"""
|
||||||
from src.responses.service import _calculate_usage, generate_id, _conversation_history
|
from src.responses.service import (
|
||||||
|
_calculate_usage,
|
||||||
|
generate_id,
|
||||||
|
_conversation_history,
|
||||||
|
_direct_delegation_with_results,
|
||||||
|
)
|
||||||
from src.core.preprocessing import preprocess_request
|
from src.core.preprocessing import preprocess_request
|
||||||
from src.core.tool_tracking import ToolCallTracker
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
|
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
|
||||||
from src.agents.tatlock import TatlockAgent
|
from src.agents.tatlock import TatlockAgent
|
||||||
|
from src.agents.delegation import get_think_message, STREAMING_DELEGATION_WRAPPERS
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
output_items = []
|
output_items = []
|
||||||
@@ -152,7 +159,7 @@ class StreamingCoordinator:
|
|||||||
|
|
||||||
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
||||||
|
|
||||||
# Phase 1: Steward preprocessing
|
# Steward preprocessing
|
||||||
enriched = await preprocess_request(
|
enriched = await preprocess_request(
|
||||||
user_message,
|
user_message,
|
||||||
conversation_history=conversation_history,
|
conversation_history=conversation_history,
|
||||||
@@ -179,31 +186,62 @@ class StreamingCoordinator:
|
|||||||
)
|
)
|
||||||
output_items.append(reasoning_item)
|
output_items.append(reasoning_item)
|
||||||
|
|
||||||
# Phase 2: Initialize tool tracker
|
# Initialize tool tracker
|
||||||
tracker = ToolCallTracker(
|
tracker = ToolCallTracker(
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 3: Stream Tatlock's response with scoped tools
|
# Check if direct delegation is recommended
|
||||||
tatlock = TatlockAgent()
|
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||||
tatlock_response_parts = []
|
delegation_only = all(
|
||||||
|
cap in delegation_agents
|
||||||
|
for cap in enriched.recommendation.recommended_capabilities
|
||||||
|
) and enriched.recommendation.recommended_capabilities
|
||||||
|
|
||||||
async for chunk in tatlock.run_with_scoped_tools_stream(
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
if delegation_only:
|
||||||
|
# Direct delegation path with streaming think slugs
|
||||||
|
orchestration_results = await self._stream_direct_delegation(
|
||||||
|
user_message=user_message,
|
||||||
|
recommendation=enriched.recommendation,
|
||||||
|
tracker=tracker,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream think slugs that were collected during delegation
|
||||||
|
# Each think message is complete, so we signal done after each
|
||||||
|
for think_msg in orchestration_results.get("think_messages", []):
|
||||||
|
yield ReasoningSummaryDelta(delta=think_msg)
|
||||||
|
yield ReasoningSummaryDone()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Phase 1: Orchestrate tool calls
|
||||||
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
|
user_message=user_message,
|
||||||
|
steward_note=enriched.steward_note,
|
||||||
|
scoped_tools=enriched.scoped_tools,
|
||||||
|
message_history=conversation_history,
|
||||||
|
tool_tracker=tracker,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2: Synthesize butler-toned response
|
||||||
|
tatlock_response = await tatlock.synthesize_from_results(
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
steward_note=enriched.steward_note,
|
orchestration_results=orchestration_results,
|
||||||
scoped_tools=enriched.scoped_tools,
|
|
||||||
message_history=conversation_history,
|
message_history=conversation_history,
|
||||||
tool_tracker=tracker,
|
)
|
||||||
):
|
|
||||||
tatlock_response_parts.append(chunk)
|
# Stream the synthesized response
|
||||||
yield OutputTextDelta(delta=chunk)
|
chunk_size = 50
|
||||||
|
for i in range(0, len(tatlock_response), chunk_size):
|
||||||
|
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
yield OutputTextDone()
|
yield OutputTextDone()
|
||||||
|
|
||||||
# Combine response for output item
|
|
||||||
tatlock_response = "".join(tatlock_response_parts)
|
|
||||||
|
|
||||||
# Add Tatlock message to output items
|
# Add Tatlock message to output items
|
||||||
message_item = MessageOutputItem(
|
message_item = MessageOutputItem(
|
||||||
id=f"msg_{generate_id()}",
|
id=f"msg_{generate_id()}",
|
||||||
@@ -217,7 +255,7 @@ class StreamingCoordinator:
|
|||||||
)
|
)
|
||||||
output_items.append(message_item)
|
output_items.append(message_item)
|
||||||
|
|
||||||
# Phase 4: Finalize tool tracking
|
# Finalize tool tracking
|
||||||
await tracker.finalize()
|
await tracker.finalize()
|
||||||
|
|
||||||
# Calculate usage and build final response
|
# Calculate usage and build final response
|
||||||
@@ -241,6 +279,85 @@ class StreamingCoordinator:
|
|||||||
# Stream error event
|
# Stream error event
|
||||||
yield self._create_error_event(e)
|
yield self._create_error_event(e)
|
||||||
|
|
||||||
|
async def _stream_direct_delegation(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
recommendation: "StewardRecommendation", # type: ignore
|
||||||
|
tracker: "ToolCallTracker", # type: ignore
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Execute direct delegation with streaming think messages.
|
||||||
|
|
||||||
|
Collects think messages as delegations execute for streaming to client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: User's request
|
||||||
|
recommendation: Steward's recommendation
|
||||||
|
tracker: Tool call tracker
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Orchestration results with think_messages list
|
||||||
|
"""
|
||||||
|
from src.agents.delegation import (
|
||||||
|
get_think_message,
|
||||||
|
delegate_to_librarian,
|
||||||
|
delegate_to_biographer,
|
||||||
|
delegate_to_housekeeper,
|
||||||
|
)
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
expert_results = {}
|
||||||
|
tools_called = []
|
||||||
|
think_messages = []
|
||||||
|
|
||||||
|
for agent in recommendation.recommended_capabilities:
|
||||||
|
# Emit start think message
|
||||||
|
start_msg = get_think_message(agent, user_message, "start")
|
||||||
|
think_messages.append(start_msg + "\n")
|
||||||
|
|
||||||
|
start_time = time_module.time()
|
||||||
|
try:
|
||||||
|
# Execute delegation
|
||||||
|
if agent == "librarian":
|
||||||
|
result = await delegate_to_librarian(task=user_message)
|
||||||
|
elif agent == "biographer":
|
||||||
|
result = await delegate_to_biographer(task=user_message)
|
||||||
|
elif agent == "housekeeper":
|
||||||
|
result = await delegate_to_housekeeper(task=user_message)
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
|
||||||
|
duration = time_module.time() - start_time
|
||||||
|
await tracker.track_call(f"delegate_to_{agent}", duration)
|
||||||
|
|
||||||
|
if result and result.success:
|
||||||
|
expert_results[agent] = result.output
|
||||||
|
tools_called.append(f"delegate_to_{agent}")
|
||||||
|
# Emit success think message
|
||||||
|
success_msg = get_think_message(agent, user_message, "success")
|
||||||
|
think_messages.append(success_msg + "\n")
|
||||||
|
else:
|
||||||
|
error_msg = result.error if result else "Unknown error"
|
||||||
|
expert_results[agent] = f"Error: {error_msg}"
|
||||||
|
# Emit error think message
|
||||||
|
error_think = get_think_message(agent, user_message, "error")
|
||||||
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
expert_results[agent] = f"Error: {e}"
|
||||||
|
error_think = get_think_message(agent, user_message, "error")
|
||||||
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_results": expert_results,
|
||||||
|
"tool_outputs": {},
|
||||||
|
"raw_output": "",
|
||||||
|
"think_messages": think_messages,
|
||||||
|
}
|
||||||
|
|
||||||
async def stream_response(
|
async def stream_response(
|
||||||
self,
|
self,
|
||||||
request: "ResponseRequest" # type: ignore # Forward reference
|
request: "ResponseRequest" # type: ignore # Forward reference
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
Tests for Librarian tools.
|
||||||
|
|
||||||
|
Tests the tool functions that wrap the Library-Desk API,
|
||||||
|
including the new web search and content extraction tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.agents.librarian.tools import (
|
||||||
|
search_web,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
hybrid_search,
|
||||||
|
search_wiki,
|
||||||
|
)
|
||||||
|
from src.agents.librarian.client import (
|
||||||
|
WebSearchResult,
|
||||||
|
WebSearchResponse,
|
||||||
|
ContentExtractionResult,
|
||||||
|
BatchExtractionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client():
|
||||||
|
"""Create a mock LibraryDeskClient."""
|
||||||
|
client = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Web Search Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSearchWeb:
|
||||||
|
"""Tests for search_web tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_success(self, mock_client):
|
||||||
|
"""Test successful web search."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="Python async programming",
|
||||||
|
search_type="web",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(
|
||||||
|
title="Async Python Tutorial",
|
||||||
|
url="https://example.com/async",
|
||||||
|
content="Full content about async programming...",
|
||||||
|
snippet="Learn async programming in Python",
|
||||||
|
source="example.com",
|
||||||
|
),
|
||||||
|
WebSearchResult(
|
||||||
|
title="AsyncIO Documentation",
|
||||||
|
url="https://docs.python.org/asyncio",
|
||||||
|
content="Official asyncio docs content...",
|
||||||
|
snippet="Python asyncio library reference",
|
||||||
|
source="docs.python.org",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_results=2,
|
||||||
|
search_time_ms=150,
|
||||||
|
sources_summary="**Sources:**\n- example.com\n- docs.python.org",
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("Python async programming")
|
||||||
|
|
||||||
|
assert "Python async programming" in result
|
||||||
|
assert "Async Python Tutorial" in result
|
||||||
|
assert "https://example.com/async" in result
|
||||||
|
assert "example.com" in result
|
||||||
|
assert "150ms" in result or "2 results" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_no_results(self, mock_client):
|
||||||
|
"""Test web search with no results."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="nonexistent query xyz123",
|
||||||
|
search_type="web",
|
||||||
|
results=[],
|
||||||
|
total_results=0,
|
||||||
|
search_time_ms=50,
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("nonexistent query xyz123")
|
||||||
|
|
||||||
|
assert "No results found" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_error_handling(self, mock_client):
|
||||||
|
"""Test web search error handling."""
|
||||||
|
mock_client.search_web.side_effect = Exception("Connection failed")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("test query")
|
||||||
|
|
||||||
|
assert "Error" in result
|
||||||
|
assert "Connection failed" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_web_with_news_type(self, mock_client):
|
||||||
|
"""Test web search with news search type."""
|
||||||
|
mock_response = WebSearchResponse(
|
||||||
|
query="latest tech news",
|
||||||
|
search_type="news",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(
|
||||||
|
title="Tech News Today",
|
||||||
|
url="https://news.example.com/tech",
|
||||||
|
snippet="Breaking tech news",
|
||||||
|
source="news.example.com",
|
||||||
|
published_date="2024-01-15",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_results=1,
|
||||||
|
search_time_ms=100,
|
||||||
|
)
|
||||||
|
mock_client.search_web.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await search_web("latest tech news", search_type="news")
|
||||||
|
|
||||||
|
assert "Tech News Today" in result
|
||||||
|
mock_client.search_web.assert_called_with(
|
||||||
|
query="latest tech news",
|
||||||
|
limit=10,
|
||||||
|
search_type="news",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Read URL Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReadUrl:
|
||||||
|
"""Tests for read_url tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_success(self, mock_client):
|
||||||
|
"""Test successful URL content extraction."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/article",
|
||||||
|
title="Great Article Title",
|
||||||
|
content="This is the full article content extracted from the page.",
|
||||||
|
author="John Doe",
|
||||||
|
date="2024-01-10",
|
||||||
|
language="en",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/article")
|
||||||
|
|
||||||
|
assert "Great Article Title" in result
|
||||||
|
assert "https://example.com/article" in result
|
||||||
|
assert "John Doe" in result
|
||||||
|
assert "full article content" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_failure(self, mock_client):
|
||||||
|
"""Test URL extraction failure."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/blocked",
|
||||||
|
success=False,
|
||||||
|
error="403 Forbidden",
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/blocked")
|
||||||
|
|
||||||
|
assert "Could not read page" in result
|
||||||
|
assert "403 Forbidden" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_url_with_max_length(self, mock_client):
|
||||||
|
"""Test URL extraction with custom max length."""
|
||||||
|
mock_result = ContentExtractionResult(
|
||||||
|
url="https://example.com/long",
|
||||||
|
title="Long Article",
|
||||||
|
content="X" * 10000,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
mock_client.extract_content.return_value = mock_result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_url("https://example.com/long", max_length=2000)
|
||||||
|
|
||||||
|
mock_client.extract_content.assert_called_with(
|
||||||
|
url="https://example.com/long",
|
||||||
|
include_metadata=True,
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Batch URL Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReadUrlsBatch:
|
||||||
|
"""Tests for read_urls_batch tool."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_success(self, mock_client):
|
||||||
|
"""Test successful batch extraction."""
|
||||||
|
mock_response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/1",
|
||||||
|
title="Article 1",
|
||||||
|
content="Content from article 1",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/2",
|
||||||
|
title="Article 2",
|
||||||
|
content="Content from article 2",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=2,
|
||||||
|
failed=0,
|
||||||
|
extraction_time_ms=300,
|
||||||
|
)
|
||||||
|
mock_client.extract_content_batch.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_urls_batch([
|
||||||
|
"https://example.com/1",
|
||||||
|
"https://example.com/2",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert "Article 1" in result
|
||||||
|
assert "Article 2" in result
|
||||||
|
assert "2/2" in result or "Extracted 2" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_partial_failure(self, mock_client):
|
||||||
|
"""Test batch extraction with some failures."""
|
||||||
|
mock_response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/good",
|
||||||
|
title="Good Article",
|
||||||
|
content="Content extracted successfully",
|
||||||
|
success=True,
|
||||||
|
),
|
||||||
|
ContentExtractionResult(
|
||||||
|
url="https://example.com/bad",
|
||||||
|
success=False,
|
||||||
|
error="Connection timeout",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=1,
|
||||||
|
failed=1,
|
||||||
|
extraction_time_ms=500,
|
||||||
|
)
|
||||||
|
mock_client.extract_content_batch.return_value = mock_response
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
|
) as mock_client_class:
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
mock_client_class.return_value.__aexit__.return_value = None
|
||||||
|
|
||||||
|
result = await read_urls_batch([
|
||||||
|
"https://example.com/good",
|
||||||
|
"https://example.com/bad",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Should contain successful result
|
||||||
|
assert "Good Article" in result
|
||||||
|
# Should report failure
|
||||||
|
assert "Failed" in result
|
||||||
|
assert "Connection timeout" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Response Model Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestWebSearchModels:
|
||||||
|
"""Tests for web search response models."""
|
||||||
|
|
||||||
|
def test_web_search_result_model(self):
|
||||||
|
"""Test WebSearchResult model."""
|
||||||
|
result = WebSearchResult(
|
||||||
|
title="Test Title",
|
||||||
|
url="https://example.com",
|
||||||
|
content="Full content here",
|
||||||
|
snippet="Short snippet",
|
||||||
|
source="example.com",
|
||||||
|
published_date="2024-01-15",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.title == "Test Title"
|
||||||
|
assert result.url == "https://example.com"
|
||||||
|
assert result.content == "Full content here"
|
||||||
|
assert result.source == "example.com"
|
||||||
|
|
||||||
|
def test_web_search_result_defaults(self):
|
||||||
|
"""Test WebSearchResult default values."""
|
||||||
|
result = WebSearchResult(
|
||||||
|
title="Title",
|
||||||
|
url="https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.content == ""
|
||||||
|
assert result.snippet == ""
|
||||||
|
assert result.source == ""
|
||||||
|
assert result.published_date is None
|
||||||
|
|
||||||
|
def test_web_search_response_model(self):
|
||||||
|
"""Test WebSearchResponse model."""
|
||||||
|
response = WebSearchResponse(
|
||||||
|
query="test query",
|
||||||
|
search_type="web",
|
||||||
|
results=[
|
||||||
|
WebSearchResult(title="R1", url="https://example.com/1"),
|
||||||
|
WebSearchResult(title="R2", url="https://example.com/2"),
|
||||||
|
],
|
||||||
|
total_results=2,
|
||||||
|
search_time_ms=100,
|
||||||
|
sources_summary="**Sources:** example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.query == "test query"
|
||||||
|
assert len(response.results) == 2
|
||||||
|
assert response.total_results == 2
|
||||||
|
|
||||||
|
def test_content_extraction_result_model(self):
|
||||||
|
"""Test ContentExtractionResult model."""
|
||||||
|
result = ContentExtractionResult(
|
||||||
|
url="https://example.com",
|
||||||
|
title="Title",
|
||||||
|
content="Content",
|
||||||
|
author="Author",
|
||||||
|
date="2024-01-01",
|
||||||
|
language="en",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.url == "https://example.com"
|
||||||
|
assert result.success is True
|
||||||
|
assert result.author == "Author"
|
||||||
|
|
||||||
|
def test_content_extraction_failure(self):
|
||||||
|
"""Test ContentExtractionResult for failed extraction."""
|
||||||
|
result = ContentExtractionResult(
|
||||||
|
url="https://example.com",
|
||||||
|
success=False,
|
||||||
|
error="404 Not Found",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "404 Not Found"
|
||||||
|
assert result.content == ""
|
||||||
|
|
||||||
|
def test_batch_extraction_response_model(self):
|
||||||
|
"""Test BatchExtractionResponse model."""
|
||||||
|
response = BatchExtractionResponse(
|
||||||
|
results=[
|
||||||
|
ContentExtractionResult(url="https://1.com", success=True),
|
||||||
|
ContentExtractionResult(url="https://2.com", success=False),
|
||||||
|
],
|
||||||
|
total_urls=2,
|
||||||
|
successful=1,
|
||||||
|
failed=1,
|
||||||
|
extraction_time_ms=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.total_urls == 2
|
||||||
|
assert response.successful == 1
|
||||||
|
assert response.failed == 1
|
||||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||||
from src.agents.steward.service import analyze_request, format_steward_note
|
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
|
||||||
from src.core.startup import initialize_application
|
from src.core.startup import initialize_application
|
||||||
|
|
||||||
|
|
||||||
@@ -199,3 +199,102 @@ class TestFormatStewardNote:
|
|||||||
|
|
||||||
assert "⚠️ Missing:" in note
|
assert "⚠️ Missing:" in note
|
||||||
assert "Advanced research" in note
|
assert "Advanced research" in note
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBuildEnrichedQuery:
|
||||||
|
"""Tests for _build_enriched_query function."""
|
||||||
|
|
||||||
|
def test_no_enrichment_without_context(self):
|
||||||
|
"""Test no enrichment when memory context is empty."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
result = _build_enriched_query(query, {})
|
||||||
|
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_location(self):
|
||||||
|
"""Test location is appended for weather queries."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "location=Amsterdam" in result
|
||||||
|
assert query in result
|
||||||
|
assert "[User Context:" in result
|
||||||
|
|
||||||
|
def test_no_location_when_specified(self):
|
||||||
|
"""Test location is not appended when already specified."""
|
||||||
|
query = "What's the weather in London?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
# Should not add Amsterdam since location is specified
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_timezone(self):
|
||||||
|
"""Test timezone is appended for time queries."""
|
||||||
|
query = "What time is it?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "timezone=Europe/Amsterdam" in result
|
||||||
|
|
||||||
|
def test_no_timezone_when_specified(self):
|
||||||
|
"""Test timezone is not appended when already specified."""
|
||||||
|
query = "What time is it in UTC?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert result == query
|
||||||
|
|
||||||
|
def test_enrichment_adds_temperature_unit(self):
|
||||||
|
"""Test temperature unit is appended for weather queries."""
|
||||||
|
query = "What's the weather?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam"},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "temperature_unit=celsius" in result
|
||||||
|
|
||||||
|
def test_multiple_context_fields(self):
|
||||||
|
"""Test multiple context fields are appended."""
|
||||||
|
query = "What time and weather today?"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {
|
||||||
|
"location": "Amsterdam",
|
||||||
|
"timezone": "Europe/Amsterdam"
|
||||||
|
},
|
||||||
|
"preferences": {"temperature_unit": "celsius"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert "location=Amsterdam" in result
|
||||||
|
assert "timezone=Europe/Amsterdam" in result
|
||||||
|
assert "temperature_unit=celsius" in result
|
||||||
|
|
||||||
|
def test_no_enrichment_for_unrelated_query(self):
|
||||||
|
"""Test no enrichment for queries that don't need context."""
|
||||||
|
query = "Tell me a joke"
|
||||||
|
memory_context = {
|
||||||
|
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _build_enriched_query(query, memory_context)
|
||||||
|
|
||||||
|
assert result == query
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import pytest
|
|||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
from src.agents.delegation import (
|
from src.agents.delegation import (
|
||||||
|
ActionType,
|
||||||
DelegationTask,
|
DelegationTask,
|
||||||
DelegationResult,
|
DelegationResult,
|
||||||
|
HOUSEHOLD_THINK_MESSAGES,
|
||||||
|
STREAMING_DELEGATION_WRAPPERS,
|
||||||
delegate_to_librarian,
|
delegate_to_librarian,
|
||||||
|
get_think_message,
|
||||||
|
_detect_action_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,3 +198,158 @@ class TestDelegateToLibrarian:
|
|||||||
result = await delegate_to_librarian(task=original_task)
|
result = await delegate_to_librarian(task=original_task)
|
||||||
|
|
||||||
assert result.task == original_task
|
assert result.task == original_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestActionType:
|
||||||
|
"""Tests for the ActionType enum."""
|
||||||
|
|
||||||
|
def test_action_type_values(self):
|
||||||
|
"""Test ActionType enum values."""
|
||||||
|
assert ActionType.RETRIEVE.value == "retrieve"
|
||||||
|
assert ActionType.RESEARCH.value == "research"
|
||||||
|
assert ActionType.CREATE.value == "create"
|
||||||
|
assert ActionType.CONTROL.value == "control"
|
||||||
|
assert ActionType.RECORD.value == "record"
|
||||||
|
|
||||||
|
def test_action_type_is_enum(self):
|
||||||
|
"""Test ActionType is proper enum."""
|
||||||
|
assert len(ActionType) == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHouseholdThinkMessages:
|
||||||
|
"""Tests for HOUSEHOLD_THINK_MESSAGES mapping."""
|
||||||
|
|
||||||
|
def test_librarian_has_messages(self):
|
||||||
|
"""Test librarian has think messages."""
|
||||||
|
assert "librarian" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
assert ActionType.RESEARCH in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
assert ActionType.CREATE in HOUSEHOLD_THINK_MESSAGES["librarian"]
|
||||||
|
|
||||||
|
def test_biographer_has_messages(self):
|
||||||
|
"""Test biographer has think messages."""
|
||||||
|
assert "biographer" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["biographer"]
|
||||||
|
assert ActionType.RECORD in HOUSEHOLD_THINK_MESSAGES["biographer"]
|
||||||
|
|
||||||
|
def test_housekeeper_has_messages(self):
|
||||||
|
"""Test housekeeper has think messages."""
|
||||||
|
assert "housekeeper" in HOUSEHOLD_THINK_MESSAGES
|
||||||
|
assert ActionType.RETRIEVE in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
|
||||||
|
assert ActionType.CONTROL in HOUSEHOLD_THINK_MESSAGES["housekeeper"]
|
||||||
|
|
||||||
|
def test_messages_have_phases(self):
|
||||||
|
"""Test each action type has start/success/error messages."""
|
||||||
|
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
|
||||||
|
for action_type, messages in action_types.items():
|
||||||
|
assert "start" in messages, f"{expert}/{action_type} missing 'start'"
|
||||||
|
assert "success" in messages, f"{expert}/{action_type} missing 'success'"
|
||||||
|
assert "error" in messages, f"{expert}/{action_type} missing 'error'"
|
||||||
|
|
||||||
|
def test_messages_are_think_tags(self):
|
||||||
|
"""Test messages are wrapped in <think> tags."""
|
||||||
|
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
|
||||||
|
for action_type, messages in action_types.items():
|
||||||
|
for phase, msg in messages.items():
|
||||||
|
assert msg.startswith("<think>"), f"{expert}/{action_type}/{phase}"
|
||||||
|
assert msg.endswith("</think>"), f"{expert}/{action_type}/{phase}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDetectActionType:
|
||||||
|
"""Tests for _detect_action_type function."""
|
||||||
|
|
||||||
|
def test_librarian_search_is_retrieve(self):
|
||||||
|
"""Test librarian search tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("librarian", "search for Docker info") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("librarian", "find information about CI/CD") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("librarian", "look up Kubernetes docs") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_librarian_web_search_is_research(self):
|
||||||
|
"""Test librarian web search tasks are RESEARCH."""
|
||||||
|
assert _detect_action_type("librarian", "search the web for news") == ActionType.RESEARCH
|
||||||
|
assert _detect_action_type("librarian", "find online resources") == ActionType.RESEARCH
|
||||||
|
assert _detect_action_type("librarian", "research internet sources") == ActionType.RESEARCH
|
||||||
|
|
||||||
|
def test_librarian_create_is_create(self):
|
||||||
|
"""Test librarian creation tasks are CREATE."""
|
||||||
|
assert _detect_action_type("librarian", "create a wiki page") == ActionType.CREATE
|
||||||
|
assert _detect_action_type("librarian", "write a new article") == ActionType.CREATE
|
||||||
|
assert _detect_action_type("librarian", "add a new entry") == ActionType.CREATE
|
||||||
|
|
||||||
|
def test_biographer_recall_is_retrieve(self):
|
||||||
|
"""Test biographer recall tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("biographer", "what car do I drive?") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("biographer", "what is my job?") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_biographer_record_is_record(self):
|
||||||
|
"""Test biographer record tasks are RECORD."""
|
||||||
|
assert _detect_action_type("biographer", "remember that I work at Acme") == ActionType.RECORD
|
||||||
|
assert _detect_action_type("biographer", "note that my car is a Tesla") == ActionType.RECORD
|
||||||
|
assert _detect_action_type("biographer", "save my preference for dark mode") == ActionType.RECORD
|
||||||
|
|
||||||
|
def test_housekeeper_status_is_retrieve(self):
|
||||||
|
"""Test housekeeper status tasks are RETRIEVE."""
|
||||||
|
assert _detect_action_type("housekeeper", "what devices are in the bedroom?") == ActionType.RETRIEVE
|
||||||
|
assert _detect_action_type("housekeeper", "is the living room light on?") == ActionType.RETRIEVE
|
||||||
|
|
||||||
|
def test_housekeeper_control_is_control(self):
|
||||||
|
"""Test housekeeper control tasks are CONTROL."""
|
||||||
|
assert _detect_action_type("housekeeper", "turn on the lights") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "set brightness to 50%") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "activate the movie scene") == ActionType.CONTROL
|
||||||
|
assert _detect_action_type("housekeeper", "toggle the fan") == ActionType.CONTROL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetThinkMessage:
|
||||||
|
"""Tests for get_think_message function."""
|
||||||
|
|
||||||
|
def test_librarian_retrieve_start(self):
|
||||||
|
"""Test getting librarian retrieve start message."""
|
||||||
|
msg = get_think_message("librarian", "search for Docker", "start")
|
||||||
|
assert "<think>" in msg
|
||||||
|
assert "</think>" in msg
|
||||||
|
|
||||||
|
def test_librarian_create_success(self):
|
||||||
|
"""Test getting librarian create success message."""
|
||||||
|
msg = get_think_message("librarian", "create a wiki page", "success")
|
||||||
|
assert "<think>" in msg
|
||||||
|
assert "catalogued" in msg.lower()
|
||||||
|
|
||||||
|
def test_biographer_record_start(self):
|
||||||
|
"""Test getting biographer record start message."""
|
||||||
|
msg = get_think_message("biographer", "remember my preference", "start")
|
||||||
|
assert "<think>" in msg
|
||||||
|
assert "note" in msg.lower() or "biographer" in msg.lower()
|
||||||
|
|
||||||
|
def test_housekeeper_control_success(self):
|
||||||
|
"""Test getting housekeeper control success message."""
|
||||||
|
msg = get_think_message("housekeeper", "turn on the lights", "success")
|
||||||
|
assert "<think>" in msg
|
||||||
|
assert "configured" in msg.lower()
|
||||||
|
|
||||||
|
def test_unknown_expert_fallback(self):
|
||||||
|
"""Test unknown expert gets fallback message."""
|
||||||
|
msg = get_think_message("unknown_expert", "some task", "start")
|
||||||
|
assert "<think>" in msg
|
||||||
|
assert "unknown_expert" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestStreamingDelegationWrappers:
|
||||||
|
"""Tests for streaming delegation wrapper mapping."""
|
||||||
|
|
||||||
|
def test_streaming_wrappers_exist(self):
|
||||||
|
"""Test streaming wrappers mapping has all experts."""
|
||||||
|
assert "librarian" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
assert "biographer" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
assert "housekeeper" in STREAMING_DELEGATION_WRAPPERS
|
||||||
|
|
||||||
|
def test_streaming_wrappers_are_async_generators(self):
|
||||||
|
"""Test streaming wrappers are async generator functions."""
|
||||||
|
import inspect
|
||||||
|
for name, wrapper in STREAMING_DELEGATION_WRAPPERS.items():
|
||||||
|
assert inspect.isasyncgenfunction(wrapper), f"{name} is not an async generator"
|
||||||
|
|||||||
+4
-184
@@ -1,17 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Tests for Tatlock's permanent tools (calculator, date/time, search).
|
Tests for Tatlock's permanent tools (calculator, date/time).
|
||||||
|
|
||||||
|
Note: Web search has been moved to The Librarian agent.
|
||||||
|
See tests/agents/librarian/test_tools.py for search tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
from src.agents.tools import (
|
from src.agents.tools import (
|
||||||
calculate,
|
calculate,
|
||||||
get_current_datetime,
|
get_current_datetime,
|
||||||
calculate_time_offset,
|
calculate_time_offset,
|
||||||
time_difference,
|
time_difference,
|
||||||
search_web,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,184 +189,3 @@ class TestDateTime:
|
|||||||
"""Test error handling for invalid dates."""
|
"""Test error handling for invalid dates."""
|
||||||
result = time_difference("invalid-date", "now")
|
result = time_difference("invalid-date", "now")
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Search Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestSearch:
|
|
||||||
"""Tests for web search tool."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_web_success(self):
|
|
||||||
"""Test successful web search."""
|
|
||||||
mock_response = {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"title": "Test Result 1",
|
|
||||||
"url": "https://example.com/1",
|
|
||||||
"content": "This is a test result"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Test Result 2",
|
|
||||||
"url": "https://example.com/2",
|
|
||||||
"content": "Another test result"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("src.agents.tools.httpx.AsyncClient") as mock_client_class:
|
|
||||||
# Create mock response
|
|
||||||
mock_response_obj = type('MockResponse', (), {
|
|
||||||
'status_code': 200,
|
|
||||||
'json': lambda *args, **kwargs: mock_response
|
|
||||||
})()
|
|
||||||
|
|
||||||
# Create mock client with async get method
|
|
||||||
async def mock_get(*args, **kwargs):
|
|
||||||
return mock_response_obj
|
|
||||||
|
|
||||||
mock_client_instance = type('MockClient', (), {
|
|
||||||
'get': mock_get
|
|
||||||
})()
|
|
||||||
|
|
||||||
# Setup async context manager
|
|
||||||
async def mock_aenter(*args, **kwargs):
|
|
||||||
return mock_client_instance
|
|
||||||
|
|
||||||
async def mock_aexit(*args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
mock_client_class.return_value.__aenter__ = mock_aenter
|
|
||||||
mock_client_class.return_value.__aexit__ = mock_aexit
|
|
||||||
|
|
||||||
result = await search_web("test query", num_results=2)
|
|
||||||
|
|
||||||
assert "Test Result 1" in result
|
|
||||||
assert "https://example.com/1" in result
|
|
||||||
assert "Test Result 2" in result
|
|
||||||
assert "https://example.com/2" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_web_no_results(self):
|
|
||||||
"""Test web search with no results."""
|
|
||||||
mock_response_data = {"results": []}
|
|
||||||
|
|
||||||
with patch("src.agents.tools.httpx.AsyncClient") as mock_client_class:
|
|
||||||
mock_response_obj = type('MockResponse', (), {
|
|
||||||
'status_code': 200,
|
|
||||||
'json': lambda *args, **kwargs: mock_response_data
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_get(*args, **kwargs):
|
|
||||||
return mock_response_obj
|
|
||||||
|
|
||||||
mock_client_instance = type('MockClient', (), {
|
|
||||||
'get': mock_get
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_aenter(*args, **kwargs):
|
|
||||||
return mock_client_instance
|
|
||||||
|
|
||||||
async def mock_aexit(*args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
mock_client_class.return_value.__aenter__ = mock_aenter
|
|
||||||
mock_client_class.return_value.__aexit__ = mock_aexit
|
|
||||||
|
|
||||||
result = await search_web("test query")
|
|
||||||
|
|
||||||
assert "No results found" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_web_connection_error(self):
|
|
||||||
"""Test web search with connection error."""
|
|
||||||
with patch("httpx.AsyncClient") as mock_client:
|
|
||||||
mock_client_instance = AsyncMock()
|
|
||||||
mock_client_instance.get.side_effect = Exception("Connection failed")
|
|
||||||
mock_client.return_value.__aenter__.return_value = mock_client_instance
|
|
||||||
|
|
||||||
result = await search_web("test query")
|
|
||||||
|
|
||||||
assert "Error searching" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_web_limits_results(self):
|
|
||||||
"""Test that search limits results to max 10."""
|
|
||||||
mock_response_data = {
|
|
||||||
"results": [
|
|
||||||
{"title": f"Result {i}", "url": f"https://example.com/{i}", "content": "Test"}
|
|
||||||
for i in range(20)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("src.agents.tools.httpx.AsyncClient") as mock_client_class:
|
|
||||||
mock_response_obj = type('MockResponse', (), {
|
|
||||||
'status_code': 200,
|
|
||||||
'json': lambda *args, **kwargs: mock_response_data
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_get(*args, **kwargs):
|
|
||||||
return mock_response_obj
|
|
||||||
|
|
||||||
mock_client_instance = type('MockClient', (), {
|
|
||||||
'get': mock_get
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_aenter(*args, **kwargs):
|
|
||||||
return mock_client_instance
|
|
||||||
|
|
||||||
async def mock_aexit(*args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
mock_client_class.return_value.__aenter__ = mock_aenter
|
|
||||||
mock_client_class.return_value.__aexit__ = mock_aexit
|
|
||||||
|
|
||||||
result = await search_web("test query", num_results=15)
|
|
||||||
|
|
||||||
# Should only return 10 results (max limit)
|
|
||||||
result_count = result.count("URL:")
|
|
||||||
assert result_count == 10
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_web_formats_results(self):
|
|
||||||
"""Test that search results are properly formatted."""
|
|
||||||
mock_response_data = {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"title": "Test Title",
|
|
||||||
"url": "https://example.com",
|
|
||||||
"content": "Test content description"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("src.agents.tools.httpx.AsyncClient") as mock_client_class:
|
|
||||||
mock_response_obj = type('MockResponse', (), {
|
|
||||||
'status_code': 200,
|
|
||||||
'json': lambda *args, **kwargs: mock_response_data
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_get(*args, **kwargs):
|
|
||||||
return mock_response_obj
|
|
||||||
|
|
||||||
mock_client_instance = type('MockClient', (), {
|
|
||||||
'get': mock_get
|
|
||||||
})()
|
|
||||||
|
|
||||||
async def mock_aenter(*args, **kwargs):
|
|
||||||
return mock_client_instance
|
|
||||||
|
|
||||||
async def mock_aexit(*args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
mock_client_class.return_value.__aenter__ = mock_aenter
|
|
||||||
mock_client_class.return_value.__aexit__ = mock_aexit
|
|
||||||
|
|
||||||
result = await search_web("test query")
|
|
||||||
|
|
||||||
# Check formatting
|
|
||||||
assert "1. Test Title" in result
|
|
||||||
assert "URL: https://example.com" in result
|
|
||||||
assert "Test content description" in result
|
|
||||||
|
|||||||
Reference in New Issue
Block a user