Compare commits

...
5 Commits
Author SHA1 Message Date
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
jpmschweitzerandClaude Opus 4.5 fb54887c03 fix: handle HybridRAG keywords schema change
Build and Push / build (release) Successful in 52s
library-desk now returns keywords as dict with core_keywords field.
Client now handles both list and dict formats for backwards compat.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:32:36 +01:00
jpmschweitzerandClaude Opus 4.5 d5e5fc1ad8 fix: Ollama message sanitization and streaming think slugs
Build and Push / build (release) Successful in 52s
- Fix `invalid message content type: <nil>` error from Ollama
- Create TatlockOllamaProvider that sanitizes messages (null → "")
- Update all agents to use sanitized provider
- Fix repeating think messages by adding ReasoningSummaryDone signal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 00:19:03 +01:00
jpmschweitzerandClaude Opus 4.5 74f47097c2 fix: complete web search integration with query enrichment
Build and Push / build (release) Successful in 52s
Fixes several issues with the web search migration to Librarian:

- Update Steward routing guidelines for web search/weather → Librarian
- Register search_web, read_url, read_urls_batch tools with Librarian agent
- Update Librarian system prompt with web search documentation
- Fix query enrichment not being passed to delegations (location context)
- Add URL reading keywords to RESEARCH action type detection

Weather queries now automatically include user's stored location from
the Biographer, enabling location-aware search results.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:51:18 +01:00
18 changed files with 343 additions and 156 deletions
+63
View File
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### 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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tatlock"
version = "1.7.0"
version = "1.8.4"
description = "OpenAI-compatible API with Ollama backend"
requires-python = ">=3.12"
dependencies = []
+4 -7
View File
@@ -102,18 +102,15 @@ _biographer_agent: Optional[Agent[None, str]] = None
def _create_biographer_agent() -> Agent[None, str]:
"""Create The Biographer PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
+26 -22
View File
@@ -40,45 +40,46 @@ class ActionType(Enum):
# =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
# Note: No <think> wrappers needed - these go to reasoning_content field
"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>",
"start": "Allow me to consult the archives, sir.",
"success": "The Librarian has compiled the relevant findings.",
"error": "I'm afraid the archives proved difficult to access.",
},
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>",
"start": "I've dispatched the Librarian to conduct some fresh research.",
"success": "The Librarian has returned with findings, sir.",
"error": "The research proved inconclusive, I'm afraid.",
},
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>",
"start": "I'm having the Librarian prepare a new entry.",
"success": "The new material has been properly catalogued, sir.",
"error": "I'm afraid there was difficulty filing the entry.",
},
},
"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>",
"start": "Let me consult the household records.",
"success": "The Biographer has located the relevant information, sir.",
"error": "I'm unable to locate those particular records.",
},
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>",
"start": "I've asked the Biographer to take note of this, sir.",
"success": "The household records have been updated accordingly.",
"error": "I'm afraid there was difficulty recording the entry.",
},
},
"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>",
"start": "Allow me to inquire with the household staff.",
"success": "The staff reports the current status, sir.",
"error": "The household staff is momentarily unavailable, I'm afraid.",
},
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>",
"start": "I'm instructing the household staff now, sir.",
"success": "The household has been configured as requested.",
"error": "I'm afraid the staff reports an issue with that request.",
},
},
}
@@ -100,10 +101,13 @@ def _detect_action_type(expert: str, task: str) -> ActionType:
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
@@ -136,7 +140,7 @@ def get_think_message(expert: str, task: str, phase: str) -> str:
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>")
return action_messages.get(phase, f"Consulting {expert}...")
@dataclass
+4 -7
View File
@@ -113,18 +113,15 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
def _create_housekeeper_agent() -> Agent[None, str]:
"""Create the Housekeeper PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip("/")
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url),
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
+25 -11
View File
@@ -19,6 +19,9 @@ from src.agents.librarian.tools import (
get_wiki_page,
hybrid_search,
list_dossiers,
read_url,
read_urls_batch,
search_web,
search_wiki,
semantic_search,
smart_create_wiki_page,
@@ -47,8 +50,17 @@ Your role is to help users find, understand, synthesize, and manage information
## Your Tools
### Research Tools
- **hybrid_search**: Your primary research tool - searches all sources at once
### Web Search & Content Extraction
- **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
- **semantic_search**: Find conceptually similar content
- **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]:
"""Create the Librarian PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
from src.ollama.provider import get_ollama_provider
# Create Ollama model with provider
# Create Ollama model with sanitized provider
# (fixes 'content: null' issue with tool calls)
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider(),
)
agent: Agent[None, str] = Agent(
@@ -130,7 +139,7 @@ def _create_librarian_agent() -> Agent[None, str]:
retries=2,
)
# Register research tools
# Register research tools (internal knowledge)
agent.tool_plain(hybrid_search)
agent.tool_plain(search_wiki)
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(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
agent.tool_plain(get_wiki_page)
@@ -150,7 +164,7 @@ def _create_librarian_agent() -> Agent[None, str]:
logger.info(
"librarian_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
tool_count=11,
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
)
return agent
+8 -1
View File
@@ -281,9 +281,16 @@ class LibraryDeskClient:
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(
results=results,
keywords=data.get("keywords", []),
keywords=keywords,
synonyms=data.get("synonyms", []),
related_dossiers=data.get("related_dossiers", []),
formatted_context=data.get("formatted_context", ""),
+13 -13
View File
@@ -176,19 +176,19 @@ async def orchestrate_with_think_updates(
if delegation_task.expert_name == "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)
result = await execute_delegation(delegation_task)
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
if result.output:
yield f"\n{result.output}"
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(
"orchestration_complete",
@@ -449,12 +449,12 @@ async def orchestrate_multi_expert(
return
# 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:
# Parallel execution - emit one update then run all at once
expert_names = ", ".join(_get_display_name(t.expert_name) for t in tasks)
yield f"<think>🔄 Consulting in parallel: {expert_names}...</think>\n"
yield f"🔄 Consulting in parallel: {expert_names}...\n"
result = await execute_parallel(tasks)
@@ -462,9 +462,9 @@ async def orchestrate_multi_expert(
for expert_name, expert_result in result.results.items():
display_name = _get_display_name(expert_name)
if expert_result.success:
yield f"<think>{display_name} completed.</think>\n"
yield f"{display_name} completed.\n"
else:
yield f"<think>⚠️ {display_name} failed: {expert_result.error}</think>\n"
yield f"⚠️ {display_name} failed: {expert_result.error}\n"
else:
# Sequential execution - emit updates for each task
@@ -472,27 +472,27 @@ async def orchestrate_multi_expert(
for task in tasks:
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)
result.add_result(task_result)
if task_result.success:
yield f"<think>{display_name} completed.</think>\n"
yield f"{display_name} completed.\n"
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:
yield "<think>🛑 Stopping due to failure.</think>\n"
yield "🛑 Stopping due to failure.\n"
break
result.aggregate_outputs()
# Stream: Summary
if result.all_succeeded:
yield "<think>🎉 All experts completed successfully.</think>\n"
yield "🎉 All experts completed successfully.\n"
else:
failed_names = ", ".join(_get_display_name(e) for e in result.failed_experts)
yield f"<think>⚠️ Some experts failed: {failed_names}</think>\n"
yield f"⚠️ Some experts failed: {failed_names}\n"
# Yield combined output
if result.combined_output:
+5 -2
View File
@@ -58,8 +58,9 @@ GUIDELINES:
- Simple greetings/chat → no capabilities needed (conversational response only)
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
- Math/calculations → tatlock_core
- Quick web searches → tatlock_core
- Time/date queries → tatlock_core
- 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 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
@@ -74,8 +75,10 @@ COMPLEXITY: [simple/moderate/complex]
CONTEXT: [any relevant conversation context, or "none"]
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 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: none (conversational response only)"
+10 -10
View File
@@ -143,7 +143,7 @@ class TatlockAgent(AgentInterface):
# Import required classes for Ollama configuration
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
# Remove trailing slash from ollama_host if present
@@ -153,7 +153,7 @@ class TatlockAgent(AgentInterface):
# Create Ollama model with provider
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create PydanticAI agent with Ollama model
@@ -448,7 +448,7 @@ class TatlockAgent(AgentInterface):
... )
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
logger.info(
"tatlock_run_with_scoped_tools",
@@ -464,7 +464,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -542,7 +542,7 @@ class TatlockAgent(AgentInterface):
Text chunks from the streaming response
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
logger.info(
"tatlock_run_with_scoped_tools_stream",
@@ -557,7 +557,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -637,7 +637,7 @@ class TatlockAgent(AgentInterface):
- raw_output: The agent's raw text output
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
from pydantic_ai.settings import ModelSettings
from pydantic_ai.messages import (
ModelRequest,
@@ -661,7 +661,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Create agent with scoped tools
@@ -759,7 +759,7 @@ class TatlockAgent(AgentInterface):
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from src.ollama.provider import get_ollama_provider
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
logger.info(
@@ -802,7 +802,7 @@ class TatlockAgent(AgentInterface):
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
provider=get_ollama_provider()
)
# Synthesis agent uses butler prompt but no tools
+1
View File
@@ -55,6 +55,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
"""Delta in streaming chunk."""
role: str | None = None
content: str | None = None
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
class ChatCompletionChunkChoice(CustomBaseModel):
+6 -35
View File
@@ -172,24 +172,9 @@ async def create_chat_completion_stream(
async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed
if not in_reasoning:
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"),
finish_reason=None,
)
],
)
in_reasoning = True
# Stream reasoning delta
# Stream reasoning via reasoning_content field (DeepSeek R1 format)
# Open WebUI renders this as collapsible thinking block
in_reasoning = True
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -198,29 +183,15 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=event.delta),
delta=ChatCompletionChunkDelta(reasoning_content=event.delta),
finish_reason=None,
)
],
)
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block
if in_reasoning:
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
# Signal end of reasoning block (no content needed)
in_reasoning = False
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content
+130
View File
@@ -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()
+5 -2
View File
@@ -515,15 +515,18 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
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:
# Direct delegation path - collect results then synthesize
orchestration_results = await _direct_delegation_with_results(
user_message, enriched.recommendation, tracker, conversation_id
effective_query, enriched.recommendation, tracker, conversation_id
)
else:
# Phase 1: Orchestrate tool calls
orchestration_results = await tatlock.orchestrate_tool_calls(
user_message=user_message,
user_message=effective_query,
steward_note=enriched.steward_note,
scoped_tools=enriched.scoped_tools,
message_history=conversation_history,
+2
View File
@@ -211,8 +211,10 @@ class StreamingCoordinator:
)
# 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:
+14 -10
View File
@@ -248,13 +248,16 @@ class TestHouseholdThinkMessages:
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."""
def test_messages_are_plain_text(self):
"""Test messages are plain text (no <think> wrappers - those go to reasoning_content)."""
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}"
# Messages should NOT have <think> wrappers - they go to reasoning_content field
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
@@ -310,31 +313,32 @@ class TestGetThinkMessage:
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
# No <think> wrappers - messages go to reasoning_content field
assert "<think>" not in msg
assert "archives" in msg.lower() or "consult" in msg.lower()
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 "<think>" not 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 "<think>" not 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 "<think>" not 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 "<think>" not in msg
assert "unknown_expert" in msg.lower()
+4 -4
View File
@@ -208,8 +208,8 @@ class TestOrchestrateWithThinkUpdates:
):
updates.append(update)
# First update should be think tag about consulting
assert any("<think>" in u and "Consulting" in u for u in updates)
# First update should be about consulting (no <think> wrappers anymore)
assert any("Consulting" in u for u in updates)
@pytest.mark.asyncio
async def test_orchestrate_emits_think_after_delegation(self):
@@ -233,8 +233,8 @@ class TestOrchestrateWithThinkUpdates:
):
updates.append(update)
# Should have think tag about completion
assert any("<think>" in u and "completed" in u for u in updates)
# Should have message about completion (no <think> wrappers anymore)
assert any("completed" in u for u in updates)
@pytest.mark.asyncio
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:
- Wraps Responses API
- Enables reasoning automatically
- Converts reasoning to <think> tags
- Streams reasoning via reasoning_content field (DeepSeek R1 format)
- Streams both reasoning and content
"""
import json
@@ -17,7 +17,7 @@ from src.chat import constants
@pytest.mark.unit
@pytest.mark.asyncio
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 = {
"model": "lorem-tester",
"messages": [
@@ -27,7 +27,7 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
}
chunks_received = []
think_tags_found = False
reasoning_content_found = False
async with async_client.stream(
"POST",
@@ -51,12 +51,12 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
chunk = json.loads(data_str)
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:
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content")
if content and ("<think>" in content or "</think>" in content):
think_tags_found = True
reasoning = delta.get("reasoning_content")
if reasoning:
reasoning_content_found = True
except json.JSONDecodeError:
pass
@@ -64,14 +64,14 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
# Should have received chunks
assert len(chunks_received) > 0
# Should have found <think> tags (reasoning enabled automatically)
assert think_tags_found, "Expected <think> tags in streaming output"
# Should have found reasoning_content (reasoning enabled automatically)
assert reasoning_content_found, "Expected reasoning_content in streaming output"
@pytest.mark.unit
@pytest.mark.asyncio
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 = {
"model": "lorem-tester",
"messages": [
@@ -80,10 +80,7 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
"stream": True
}
all_content = []
found_think_opening = False
found_think_closing = False
found_content_after_think = False
chunk_types = [] # Track order: 'reasoning' or 'content'
async with async_client.stream(
"POST",
@@ -106,28 +103,22 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
chunk = json.loads(data_str)
if "choices" in chunk and len(chunk["choices"]) > 0:
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "")
if content:
all_content.append(content)
reasoning = delta.get("reasoning_content")
content = delta.get("content")
if "<think>" in content:
found_think_opening = True
if "</think>" in content:
found_think_closing = True
# 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
if reasoning:
chunk_types.append("reasoning")
if content:
chunk_types.append("content")
except json.JSONDecodeError:
pass
# Verify ordering
full_text = "".join(all_content)
if found_think_opening and found_think_closing:
# Reasoning should come before main content
think_start = full_text.index("<think>")
think_end = full_text.index("</think>")
assert think_start < think_end, "Opening <think> should come before closing </think>"
# Verify reasoning comes before content
if "reasoning" in chunk_types and "content" in chunk_types:
first_reasoning = chunk_types.index("reasoning")
first_content = chunk_types.index("content")
assert first_reasoning < first_content, "reasoning_content should come before content"
@pytest.mark.unit