Compare commits

...
5 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 583c407edd fix: Redis bool storage, tool tracking matching, e2e fixture scope
Build and Push / build (release) Successful in 53s
- Convert booleans to strings for Redis hset (Redis doesn't accept bool)
- Extract capability from delegate_to_X tool names for tracking
- Use loop_scope="module" for pytest-asyncio module-scoped fixtures
- Add note about using venv for tests in AGENTS.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:53:51 +01:00
jpmschweitzer 404e8fc106 add pre deploy check 2025-12-16 09:36:17 +01:00
jpmschweitzerandClaude Opus 4.5 54a27b481a docs: add release flow section to AGENTS.md
Documents the version bump, changelog update, tagging, and
deployment verification steps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:33:52 +01:00
jpmschweitzerandClaude Opus 4.5 9980e4764c fix: remove <think> wrappers from think messages
Build and Push / build (release) Successful in 1m49s
Messages in reasoning_content should be plain text, not wrapped
in <think> tags. Removed wrappers from:
- delegation.py household think messages
- orchestration.py status messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:12:17 +01:00
jpmschweitzerandClaude Opus 4.5 4907798e74 fix: use reasoning_content for Open WebUI streaming
Build and Push / build (release) Successful in 51s
Use DeepSeek R1 format (reasoning_content field) instead of <think>
tags in content. Open WebUI now renders thinking as proper
collapsible blocks instead of broken escaped HTML.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:56:45 +01:00
15 changed files with 284 additions and 140 deletions
+31
View File
@@ -22,6 +22,11 @@ This document contains instructions and documentation references for AI assistan
* **Test REST endpoints** against `http://localhost:8777` using curl or similar tools * **Test REST endpoints** against `http://localhost:8777` using curl or similar tools
* **Only deploy** when a phase or feature is complete and tested locally * **Only deploy** when a phase or feature is complete and tested locally
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts) * **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts)
* **Running tests**: Always use the venv explicitly to avoid environment mismatches:
```bash
.venv/bin/python -m pytest tests/ # All tests
.venv/bin/python -m pytest tests/core/ -v # Core tests only
```
### 🌐 Internal Service Access ### 🌐 Internal Service Access
* **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
@@ -51,6 +56,32 @@ This document contains instructions and documentation references for AI assistan
* **Update `CHANGELOG.md`** with every user-facing change. * **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`. * Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🚀 Release Flow
When changes are ready for deployment:
1. **Ask user if deploy cycle is desired**
2. **Update version** in `pyproject.toml`:
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
- New features: bump minor version (1.8.4 → 1.9.0)
3. **Update CHANGELOG.md**:
- Move items from `[Unreleased]` to new version section
- Add release date: `## [1.8.4] - 2025-12-16`
4. **Commit and tag**:
```bash
git add -A
git commit -m "fix: description of changes"
git tag v1.8.4
git push origin main --tags
```
5. **CI/CD triggers automatically**:
- Gitea CI builds Docker image on new tag
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8000/health`
--- ---
## 2. FastAPI Architecture & Best Practices ## 2. FastAPI Architecture & Best Practices
+24
View File
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.8.5] - 2025-12-16
### Fixed
- **Redis benchmark boolean storage** - Convert booleans to strings for Redis `hset` (Redis doesn't accept bool type directly)
- **Tool tracking capability matching** - `delegate_to_librarian` now correctly recognized as using "librarian" capability when checking Steward recommendations
- **E2E test fixture scope** - Fixed pytest-asyncio ScopeMismatch error by using `loop_scope="module"` for module-scoped async fixtures
## [1.8.4] - 2025-12-16
### Fixed
- **Remove `<think>` wrappers from think messages** - Messages in `reasoning_content` should be plain text
- Removed `<think>` wrappers from delegation.py household think messages
- Removed `<think>` wrappers from orchestration.py status messages
- Think messages now appear cleanly in Open WebUI's reasoning block
## [1.8.3] - 2025-12-16
### Fixed
- **Open WebUI streaming rendering** - Use `reasoning_content` field for thinking (DeepSeek R1 format) instead of `<think>` tags in `content`
- Open WebUI now renders thinking as proper collapsible blocks instead of broken HTML
## [1.8.2] - 2025-12-16 ## [1.8.2] - 2025-12-16
### Fixed ### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tatlock" name = "tatlock"
version = "1.8.2" version = "1.8.5"
description = "OpenAI-compatible API with Ollama backend" description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [] dependencies = []
+23 -22
View File
@@ -40,45 +40,46 @@ class ActionType(Enum):
# ============================================================================= # =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = { HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
# Note: No <think> wrappers needed - these go to reasoning_content field
"librarian": { "librarian": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Allow me to consult the archives, sir.</think>", "start": "Allow me to consult the archives, sir.",
"success": "<think>The Librarian has compiled the relevant findings.</think>", "success": "The Librarian has compiled the relevant findings.",
"error": "<think>I'm afraid the archives proved difficult to access.</think>", "error": "I'm afraid the archives proved difficult to access.",
}, },
ActionType.RESEARCH: { ActionType.RESEARCH: {
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>", "start": "I've dispatched the Librarian to conduct some fresh research.",
"success": "<think>The Librarian has returned with findings, sir.</think>", "success": "The Librarian has returned with findings, sir.",
"error": "<think>The research proved inconclusive, I'm afraid.</think>", "error": "The research proved inconclusive, I'm afraid.",
}, },
ActionType.CREATE: { ActionType.CREATE: {
"start": "<think>I'm having the Librarian prepare a new entry.</think>", "start": "I'm having the Librarian prepare a new entry.",
"success": "<think>The new material has been properly catalogued, sir.</think>", "success": "The new material has been properly catalogued, sir.",
"error": "<think>I'm afraid there was difficulty filing the entry.</think>", "error": "I'm afraid there was difficulty filing the entry.",
}, },
}, },
"biographer": { "biographer": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Let me consult the household records.</think>", "start": "Let me consult the household records.",
"success": "<think>The Biographer has located the relevant information, sir.</think>", "success": "The Biographer has located the relevant information, sir.",
"error": "<think>I'm unable to locate those particular records.</think>", "error": "I'm unable to locate those particular records.",
}, },
ActionType.RECORD: { ActionType.RECORD: {
"start": "<think>I've asked the Biographer to take note of this, sir.</think>", "start": "I've asked the Biographer to take note of this, sir.",
"success": "<think>The household records have been updated accordingly.</think>", "success": "The household records have been updated accordingly.",
"error": "<think>I'm afraid there was difficulty recording the entry.</think>", "error": "I'm afraid there was difficulty recording the entry.",
}, },
}, },
"housekeeper": { "housekeeper": {
ActionType.RETRIEVE: { ActionType.RETRIEVE: {
"start": "<think>Allow me to inquire with the household staff.</think>", "start": "Allow me to inquire with the household staff.",
"success": "<think>The staff reports the current status, sir.</think>", "success": "The staff reports the current status, sir.",
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>", "error": "The household staff is momentarily unavailable, I'm afraid.",
}, },
ActionType.CONTROL: { ActionType.CONTROL: {
"start": "<think>I'm instructing the household staff now, sir.</think>", "start": "I'm instructing the household staff now, sir.",
"success": "<think>The household has been configured as requested.</think>", "success": "The household has been configured as requested.",
"error": "<think>I'm afraid the staff reports an issue with that request.</think>", "error": "I'm afraid the staff reports an issue with that request.",
}, },
}, },
} }
@@ -139,7 +140,7 @@ def get_think_message(expert: str, task: str, phase: str) -> str:
action_type = _detect_action_type(expert, task) action_type = _detect_action_type(expert, task)
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {}) expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {})) action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
return action_messages.get(phase, f"<think>Consulting {expert}...</think>") return action_messages.get(phase, f"Consulting {expert}...")
@dataclass @dataclass
+13 -13
View File
@@ -176,19 +176,19 @@ async def orchestrate_with_think_updates(
if delegation_task.expert_name == "librarian": if delegation_task.expert_name == "librarian":
expert_display_name = "The Librarian" expert_display_name = "The Librarian"
yield f"<think>🤝 Consulting {expert_display_name}...</think>\n" yield f"🤝 Consulting {expert_display_name}...\n"
# Execute delegation (uses run() internally) # Execute delegation (uses run() internally)
result = await execute_delegation(delegation_task) result = await execute_delegation(delegation_task)
if result.success: if result.success:
yield f"<think>{expert_display_name} completed research.</think>\n" yield f"{expert_display_name} completed research.\n"
# Yield the expert's findings # Yield the expert's findings
if result.output: if result.output:
yield f"\n{result.output}" yield f"\n{result.output}"
else: else:
yield f"<think>⚠️ {expert_display_name} encountered an issue: {result.error}</think>\n" yield f"⚠️ {expert_display_name} encountered an issue: {result.error}\n"
logger.info( logger.info(
"orchestration_complete", "orchestration_complete",
@@ -449,12 +449,12 @@ async def orchestrate_multi_expert(
return return
# Stream: Starting multi-expert coordination # Stream: Starting multi-expert coordination
yield f"<think>🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...</think>\n" yield f"🎯 Starting multi-expert coordination ({len(tasks)} tasks, {mode.value})...\n"
if mode == ExecutionMode.PARALLEL: if mode == ExecutionMode.PARALLEL:
# Parallel execution - emit one update then run all at once # Parallel execution - emit one update then run all at once
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks) expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
yield f"<think>🔄 Consulting in parallel: {expert_names}...</think>\n" yield f"🔄 Consulting in parallel: {expert_names}...\n"
result = await execute_parallel(tasks) result = await execute_parallel(tasks)
@@ -462,9 +462,9 @@ async def orchestrate_multi_expert(
for expert_name, expert_result in result.results.items(): for expert_name, expert_result in result.results.items():
display_name = _get_display_name(expert_name) display_name = _get_display_name(expert_name)
if expert_result.success: if expert_result.success:
yield f"<think>{display_name} completed.</think>\n" yield f"{display_name} completed.\n"
else: else:
yield f"<think>⚠️ {display_name} failed: {expert_result.error}</think>\n" yield f"⚠️ {display_name} failed: {expert_result.error}\n"
else: else:
# Sequential execution - emit updates for each task # Sequential execution - emit updates for each task
@@ -472,27 +472,27 @@ async def orchestrate_multi_expert(
for task in tasks: for task in tasks:
display_name = _get_display_name(task.expert_name) display_name = _get_display_name(task.expert_name)
yield f"<think>🤝 Consulting {display_name}...</think>\n" yield f"🤝 Consulting {display_name}...\n"
task_result = await execute_delegation(task) task_result = await execute_delegation(task)
result.add_result(task_result) result.add_result(task_result)
if task_result.success: if task_result.success:
yield f"<think>{display_name} completed.</think>\n" yield f"{display_name} completed.\n"
else: else:
yield f"<think>⚠️ {display_name} failed: {task_result.error}</think>\n" yield f"⚠️ {display_name} failed: {task_result.error}\n"
if stop_on_failure: if stop_on_failure:
yield "<think>🛑 Stopping due to failure.</think>\n" yield "🛑 Stopping due to failure.\n"
break break
result.aggregate_outputs() result.aggregate_outputs()
# Stream: Summary # Stream: Summary
if result.all_succeeded: if result.all_succeeded:
yield "<think>🎉 All experts completed successfully.</think>\n" yield "🎉 All experts completed successfully.\n"
else: else:
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts) failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
yield f"<think>⚠️ Some experts failed: {failed_names}</think>\n" yield f"⚠️ Some experts failed: {failed_names}\n"
# Yield combined output # Yield combined output
if result.combined_output: if result.combined_output:
+1
View File
@@ -55,6 +55,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
"""Delta in streaming chunk.""" """Delta in streaming chunk."""
role: str | None = None role: str | None = None
content: str | None = None content: str | None = None
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
class ChatCompletionChunkChoice(CustomBaseModel): class ChatCompletionChunkChoice(CustomBaseModel):
+6 -35
View File
@@ -172,24 +172,9 @@ async def create_chat_completion_stream(
async for event in stream_generator: async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA: if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed # Stream reasoning via reasoning_content field (DeepSeek R1 format)
if not in_reasoning: # Open WebUI renders this as collapsible thinking block
yield ChatCompletionChunk( in_reasoning = True
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="<think>\n"),
finish_reason=None,
)
],
)
in_reasoning = True
# Stream reasoning delta
yield ChatCompletionChunk( yield ChatCompletionChunk(
id=completion_id, id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT, object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -198,29 +183,15 @@ async def create_chat_completion_stream(
choices=[ choices=[
ChatCompletionChunkChoice( ChatCompletionChunkChoice(
index=0, index=0,
delta=ChatCompletionChunkDelta(content=event.delta), delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
finish_reason=None, finish_reason=None,
) )
], ],
) )
elif event.event == StreamEventType.REASONING_SUMMARY_DONE: elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block # Signal end of reasoning block (no content needed)
if in_reasoning: in_reasoning = False
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
finish_reason=None,
)
],
)
in_reasoning = False
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA: elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content # Stream message content
+8
View File
@@ -47,6 +47,10 @@ class PerformanceBenchmark(BaseModel):
data = self.model_dump() data = self.model_dump()
data["timestamp"] = self.timestamp.isoformat() data["timestamp"] = self.timestamp.isoformat()
data["metadata"] = json.dumps(self.metadata) data["metadata"] = json.dumps(self.metadata)
# Convert booleans to strings (Redis doesn't accept bool type)
for key, value in data.items():
if isinstance(value, bool):
data[key] = str(value)
return data return data
@classmethod @classmethod
@@ -54,6 +58,10 @@ class PerformanceBenchmark(BaseModel):
"""Reconstruct from Redis dict.""" """Reconstruct from Redis dict."""
data["timestamp"] = datetime.fromisoformat(data["timestamp"]) data["timestamp"] = datetime.fromisoformat(data["timestamp"])
data["metadata"] = json.loads(data.get("metadata", "{}")) data["metadata"] = json.loads(data.get("metadata", "{}"))
# Convert string booleans back to bool
for key in ["success", "was_recommended", "was_actually_used"]:
if key in data and isinstance(data[key], str):
data[key] = data[key] == "True"
return cls(**data) return cls(**data)
+25 -6
View File
@@ -43,6 +43,16 @@ class ToolCallTracker:
conversation_id=conversation_id, conversation_id=conversation_id,
) )
def _extract_capability(self, tool_name: str) -> str:
"""
Extract capability name from tool name.
Tool names like 'delegate_to_librarian' map to capability 'librarian'.
"""
if tool_name.startswith("delegate_to_"):
return tool_name.replace("delegate_to_", "")
return tool_name
async def track_call(self, tool_name: str, duration: float): async def track_call(self, tool_name: str, duration: float):
""" """
Record a tool call with timing. Record a tool call with timing.
@@ -56,8 +66,9 @@ class ToolCallTracker:
self.actual_calls[tool_name] = [] self.actual_calls[tool_name] = []
self.actual_calls[tool_name].append(duration) self.actual_calls[tool_name].append(duration)
# Check if tool was recommended # Check if tool was recommended (normalize tool name to capability)
was_recommended = tool_name in self.recommended_capabilities capability = self._extract_capability(tool_name)
was_recommended = capability in self.recommended_capabilities
if not was_recommended: if not was_recommended:
logger.warning( logger.warning(
@@ -98,8 +109,12 @@ class ToolCallTracker:
Called after Tatlock completes its response to identify Called after Tatlock completes its response to identify
tools that were recommended but never used. tools that were recommended but never used.
""" """
# Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
# Find tools that were recommended but not used # Find tools that were recommended but not used
unused_tools = self.recommended_capabilities - set(self.actual_calls.keys()) unused_tools = self.recommended_capabilities - used_capabilities
if unused_tools: if unused_tools:
logger.info( logger.info(
@@ -145,7 +160,11 @@ class ToolCallTracker:
Dict with tracking statistics Dict with tracking statistics
""" """
total_calls = sum(len(durations) for durations in self.actual_calls.values()) total_calls = sum(len(durations) for durations in self.actual_calls.values())
unused = self.recommended_capabilities - set(self.actual_calls.keys()) # Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
unused = self.recommended_capabilities - used_capabilities
return { return {
"recommended_capabilities": list(self.recommended_capabilities), "recommended_capabilities": list(self.recommended_capabilities),
@@ -154,11 +173,11 @@ class ToolCallTracker:
"total_calls": total_calls, "total_calls": total_calls,
"accuracy": { "accuracy": {
"recommended_and_used": len( "recommended_and_used": len(
self.recommended_capabilities & set(self.actual_calls.keys()) self.recommended_capabilities & used_capabilities
), ),
"recommended_but_unused": len(unused), "recommended_but_unused": len(unused),
"not_recommended_but_used": len( "not_recommended_but_used": len(
set(self.actual_calls.keys()) - self.recommended_capabilities used_capabilities - self.recommended_capabilities
), ),
}, },
} }
+14 -10
View File
@@ -248,13 +248,16 @@ class TestHouseholdThinkMessages:
assert "success" in messages, f"{expert}/{action_type} missing 'success'" assert "success" in messages, f"{expert}/{action_type} missing 'success'"
assert "error" in messages, f"{expert}/{action_type} missing 'error'" assert "error" in messages, f"{expert}/{action_type} missing 'error'"
def test_messages_are_think_tags(self): def test_messages_are_plain_text(self):
"""Test messages are wrapped in <think> tags.""" """Test messages are plain text (no <think> wrappers - those go to reasoning_content)."""
for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items(): for expert, action_types in HOUSEHOLD_THINK_MESSAGES.items():
for action_type, messages in action_types.items(): for action_type, messages in action_types.items():
for phase, msg in messages.items(): for phase, msg in messages.items():
assert msg.startswith("<think>"), f"{expert}/{action_type}/{phase}" # Messages should NOT have <think> wrappers - they go to reasoning_content field
assert msg.endswith("</think>"), f"{expert}/{action_type}/{phase}" assert "<think>" not in msg, f"{expert}/{action_type}/{phase} should not have <think> wrapper"
assert "</think>" not in msg, f"{expert}/{action_type}/{phase} should not have </think> wrapper"
# Messages should be non-empty strings
assert isinstance(msg, str) and len(msg) > 0, f"{expert}/{action_type}/{phase}"
@pytest.mark.unit @pytest.mark.unit
@@ -310,31 +313,32 @@ class TestGetThinkMessage:
def test_librarian_retrieve_start(self): def test_librarian_retrieve_start(self):
"""Test getting librarian retrieve start message.""" """Test getting librarian retrieve start message."""
msg = get_think_message("librarian", "search for Docker", "start") msg = get_think_message("librarian", "search for Docker", "start")
assert "<think>" in msg # No <think> wrappers - messages go to reasoning_content field
assert "</think>" in msg assert "<think>" not in msg
assert "archives" in msg.lower() or "consult" in msg.lower()
def test_librarian_create_success(self): def test_librarian_create_success(self):
"""Test getting librarian create success message.""" """Test getting librarian create success message."""
msg = get_think_message("librarian", "create a wiki page", "success") msg = get_think_message("librarian", "create a wiki page", "success")
assert "<think>" in msg assert "<think>" not in msg
assert "catalogued" in msg.lower() assert "catalogued" in msg.lower()
def test_biographer_record_start(self): def test_biographer_record_start(self):
"""Test getting biographer record start message.""" """Test getting biographer record start message."""
msg = get_think_message("biographer", "remember my preference", "start") msg = get_think_message("biographer", "remember my preference", "start")
assert "<think>" in msg assert "<think>" not in msg
assert "note" in msg.lower() or "biographer" in msg.lower() assert "note" in msg.lower() or "biographer" in msg.lower()
def test_housekeeper_control_success(self): def test_housekeeper_control_success(self):
"""Test getting housekeeper control success message.""" """Test getting housekeeper control success message."""
msg = get_think_message("housekeeper", "turn on the lights", "success") msg = get_think_message("housekeeper", "turn on the lights", "success")
assert "<think>" in msg assert "<think>" not in msg
assert "configured" in msg.lower() assert "configured" in msg.lower()
def test_unknown_expert_fallback(self): def test_unknown_expert_fallback(self):
"""Test unknown expert gets fallback message.""" """Test unknown expert gets fallback message."""
msg = get_think_message("unknown_expert", "some task", "start") msg = get_think_message("unknown_expert", "some task", "start")
assert "<think>" in msg assert "<think>" not in msg
assert "unknown_expert" in msg.lower() assert "unknown_expert" in msg.lower()
+4 -4
View File
@@ -208,8 +208,8 @@ class TestOrchestrateWithThinkUpdates:
): ):
updates.append(update) updates.append(update)
# First update should be think tag about consulting # First update should be about consulting (no <think> wrappers anymore)
assert any("<think>" in u and "Consulting" in u for u in updates) assert any("Consulting" in u for u in updates)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orchestrate_emits_think_after_delegation(self): async def test_orchestrate_emits_think_after_delegation(self):
@@ -233,8 +233,8 @@ class TestOrchestrateWithThinkUpdates:
): ):
updates.append(update) updates.append(update)
# Should have think tag about completion # Should have message about completion (no <think> wrappers anymore)
assert any("<think>" in u and "completed" in u for u in updates) assert any("completed" in u for u in updates)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orchestrate_yields_expert_output(self): async def test_orchestrate_yields_expert_output(self):
+22 -31
View File
@@ -4,7 +4,7 @@ Tests for chat completions streaming wrapper.
Tests that the wrapper correctly: Tests that the wrapper correctly:
- Wraps Responses API - Wraps Responses API
- Enables reasoning automatically - Enables reasoning automatically
- Converts reasoning to <think> tags - Streams reasoning via reasoning_content field (DeepSeek R1 format)
- Streams both reasoning and content - Streams both reasoning and content
""" """
import json import json
@@ -17,7 +17,7 @@ from src.chat import constants
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient): async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
"""Test that streaming wrapper automatically enables reasoning.""" """Test that streaming wrapper automatically enables reasoning via reasoning_content."""
request_data = { request_data = {
"model": "lorem-tester", "model": "lorem-tester",
"messages": [ "messages": [
@@ -27,7 +27,7 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
} }
chunks_received = [] chunks_received = []
think_tags_found = False reasoning_content_found = False
async with async_client.stream( async with async_client.stream(
"POST", "POST",
@@ -51,12 +51,12 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
chunk = json.loads(data_str) chunk = json.loads(data_str)
chunks_received.append(chunk) chunks_received.append(chunk)
# Check for <think> tags in delta content # Check for reasoning_content in delta (DeepSeek R1 format)
if "choices" in chunk and len(chunk["choices"]) > 0: if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {}) delta = chunk["choices"][0].get("delta", {})
content = delta.get("content") reasoning = delta.get("reasoning_content")
if content and ("<think>" in content or "</think>" in content): if reasoning:
think_tags_found = True reasoning_content_found = True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@@ -64,14 +64,14 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
# Should have received chunks # Should have received chunks
assert len(chunks_received) > 0 assert len(chunks_received) > 0
# Should have found <think> tags (reasoning enabled automatically) # Should have found reasoning_content (reasoning enabled automatically)
assert think_tags_found, "Expected <think> tags in streaming output" assert reasoning_content_found, "Expected reasoning_content in streaming output"
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient): async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncClient):
"""Test that reasoning (<think> tags) comes before actual content.""" """Test that reasoning_content comes before regular content."""
request_data = { request_data = {
"model": "lorem-tester", "model": "lorem-tester",
"messages": [ "messages": [
@@ -80,10 +80,7 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
"stream": True "stream": True
} }
all_content = [] chunk_types = [] # Track order: 'reasoning' or 'content'
found_think_opening = False
found_think_closing = False
found_content_after_think = False
async with async_client.stream( async with async_client.stream(
"POST", "POST",
@@ -106,28 +103,22 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
chunk = json.loads(data_str) chunk = json.loads(data_str)
if "choices" in chunk and len(chunk["choices"]) > 0: if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {}) delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "") reasoning = delta.get("reasoning_content")
if content: content = delta.get("content")
all_content.append(content)
if "<think>" in content: if reasoning:
found_think_opening = True chunk_types.append("reasoning")
if "</think>" in content: if content:
found_think_closing = True chunk_types.append("content")
# Content after closing think tag
if found_think_closing and content.strip() and "<think>" not in content and "</think>" not in content:
found_content_after_think = True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
# Verify ordering # Verify reasoning comes before content
full_text = "".join(all_content) if "reasoning" in chunk_types and "content" in chunk_types:
if found_think_opening and found_think_closing: first_reasoning = chunk_types.index("reasoning")
# Reasoning should come before main content first_content = chunk_types.index("content")
think_start = full_text.index("<think>") assert first_reasoning < first_content, "reasoning_content should come before content"
think_end = full_text.index("</think>")
assert think_start < think_end, "Opening <think> should come before closing </think>"
@pytest.mark.unit @pytest.mark.unit
+9 -8
View File
@@ -61,7 +61,7 @@ class TestPerformanceBenchmark:
redis_dict = benchmark.to_redis_dict() redis_dict = benchmark.to_redis_dict()
assert redis_dict["operation"] == "test_op" assert redis_dict["operation"] == "test_op"
assert redis_dict["duration_seconds"] == 1.0 assert redis_dict["duration_seconds"] == 1.0
assert redis_dict["success"] is True assert redis_dict["success"] == "True" # Booleans stored as strings in Redis
assert isinstance(redis_dict["timestamp"], str) assert isinstance(redis_dict["timestamp"], str)
assert isinstance(redis_dict["metadata"], str) assert isinstance(redis_dict["metadata"], str)
@@ -72,7 +72,7 @@ class TestPerformanceBenchmark:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": 1.5, "duration_seconds": 1.5,
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": json.dumps({"test": "data"}), "metadata": json.dumps({"test": "data"}),
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -85,6 +85,7 @@ class TestPerformanceBenchmark:
benchmark = PerformanceBenchmark.from_redis_dict(redis_dict) benchmark = PerformanceBenchmark.from_redis_dict(redis_dict)
assert benchmark.operation == "test_op" assert benchmark.operation == "test_op"
assert benchmark.duration_seconds == 1.5 assert benchmark.duration_seconds == 1.5
assert benchmark.success is True # Converted back to bool
assert benchmark.metadata == {"test": "data"} assert benchmark.metadata == {"test": "data"}
@@ -162,12 +163,12 @@ class TestBenchmarkStore:
mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}" mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}"
mock_redis.zrevrangebyscore.return_value = [mock_key] mock_redis.zrevrangebyscore.return_value = [mock_key]
# Mock hgetall to return proper data # Mock hgetall to return proper data (booleans as strings, like Redis)
mock_redis.hgetall.return_value = { mock_redis.hgetall.return_value = {
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": 1.5, # Numeric, not string "duration_seconds": 1.5, # Numeric, not string
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -237,7 +238,7 @@ class TestBenchmarkStore:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "test_op", "operation": "test_op",
"duration_seconds": float(data["duration_seconds"]), "duration_seconds": float(data["duration_seconds"]),
"success": data["success"] == "True", "success": data["success"], # Pass string through, from_redis_dict converts
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
@@ -296,14 +297,14 @@ class TestBenchmarkStore:
"timestamp": now.isoformat(), "timestamp": now.isoformat(),
"operation": "tool_call", "operation": "tool_call",
"duration_seconds": 1.0, "duration_seconds": 1.0,
"success": True, "success": "True", # Booleans stored as strings in Redis
"metadata": "{}", "metadata": "{}",
"recommendation_count": None, "recommendation_count": None,
"confidence": None, "confidence": None,
"tool_name": "test_tool", "tool_name": "test_tool",
"conversation_id": None, "conversation_id": None,
"was_recommended": data["was_recommended"] == "True", "was_recommended": data["was_recommended"], # Already strings
"was_actually_used": data["was_actually_used"] == "True", "was_actually_used": data["was_actually_used"], # Already strings
} }
mock_redis.hgetall.side_effect = mock_hgetall mock_redis.hgetall.side_effect = mock_hgetall
+101
View File
@@ -0,0 +1,101 @@
"""
Tests for tool call tracking.
Tests capability extraction and recommendation matching.
"""
from unittest.mock import AsyncMock, patch
import pytest
from src.core.tool_tracking import ToolCallTracker
class TestToolCallTracker:
"""Test ToolCallTracker functionality."""
def test_extract_capability_delegation_tool(self):
"""Test extracting capability from delegation tool name."""
tracker = ToolCallTracker(recommended_capabilities=["librarian"])
assert tracker._extract_capability("delegate_to_librarian") == "librarian"
assert tracker._extract_capability("delegate_to_biographer") == "biographer"
assert tracker._extract_capability("delegate_to_housekeeper") == "housekeeper"
def test_extract_capability_non_delegation_tool(self):
"""Test that non-delegation tools return unchanged."""
tracker = ToolCallTracker(recommended_capabilities=[])
assert tracker._extract_capability("calculate") == "calculate"
assert tracker._extract_capability("search_web") == "search_web"
@pytest.mark.asyncio
async def test_track_call_recognizes_delegation_as_recommended(self):
"""Test that delegate_to_X is recognized when X is recommended."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.track_call("delegate_to_librarian", 1.0)
# Should NOT log warning since librarian was recommended
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.was_recommended is True
@pytest.mark.asyncio
async def test_track_call_detects_not_recommended(self):
"""Test that unrecommended tools are flagged."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian"]
)
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.track_call("delegate_to_housekeeper", 1.0)
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.was_recommended is False
def test_get_summary_with_delegation_tools(self):
"""Test summary correctly maps delegation tools to capabilities."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
tracker.actual_calls = {
"delegate_to_librarian": [1.0, 2.0],
"delegate_to_housekeeper": [0.5], # Not recommended
}
summary = tracker.get_summary()
assert summary["accuracy"]["recommended_and_used"] == 1 # librarian
assert summary["accuracy"]["recommended_but_unused"] == 1 # biographer
assert summary["accuracy"]["not_recommended_but_used"] == 1 # housekeeper
@pytest.mark.asyncio
async def test_finalize_with_delegation_tools(self):
"""Test finalize correctly identifies unused recommendations."""
tracker = ToolCallTracker(
recommended_capabilities=["librarian", "biographer"]
)
tracker.actual_calls = {
"delegate_to_librarian": [1.0],
}
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
mock_store.return_value.record = AsyncMock()
await tracker.finalize()
# Should record benchmark for unused biographer
assert mock_store.return_value.record.called
call_args = mock_store.return_value.record.call_args
benchmark = call_args[0][0]
assert benchmark.tool_name == "biographer"
assert benchmark.was_recommended is True
assert benchmark.was_actually_used is False
+2 -10
View File
@@ -8,8 +8,8 @@ These tests hit the actual running server and test the full stack:
- Response formatting - Response formatting
""" """
import pytest import pytest
import pytest_asyncio
import httpx import httpx
import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh) # Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
@@ -17,15 +17,7 @@ BASE_URL = "http://localhost:8777"
API_TIMEOUT = 120.0 # 120 second timeout for LLM calls API_TIMEOUT = 120.0 # 120 second timeout for LLM calls
@pytest.fixture(scope="module") @pytest_asyncio.fixture(loop_scope="module", scope="module")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="module")
async def client() -> AsyncGenerator[httpx.AsyncClient, None]: async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
"""HTTP client for making requests.""" """HTTP client for making requests."""
async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client: async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client: