From d5e5fc1ad84d54c98e5c5e3e90d51e2bd167beeb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 16 Dec 2025 00:19:03 +0100 Subject: [PATCH] fix: Ollama message sanitization and streaming think slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix `invalid message content type: ` 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 --- CHANGELOG.md | 18 +++++ pyproject.toml | 2 +- src/agents/biographer/agent.py | 11 +-- src/agents/housekeeper/agent.py | 11 +-- src/agents/librarian/agent.py | 11 +-- src/agents/tatlock.py | 20 ++--- src/ollama/provider.py | 130 ++++++++++++++++++++++++++++++++ src/responses/streaming.py | 2 + 8 files changed, 173 insertions(+), 32 deletions(-) create mode 100644 src/ollama/provider.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ba3aa..1a191c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.1] - 2025-12-16 + +### Fixed + +#### Ollama Message Sanitization +- **Fixed `invalid message content type: ` 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 diff --git a/pyproject.toml b/pyproject.toml index 9528fc2..5641521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tatlock" -version = "1.8.0" +version = "1.8.1" description = "OpenAI-compatible API with Ollama backend" requires-python = ">=3.12" dependencies = [] diff --git a/src/agents/biographer/agent.py b/src/agents/biographer/agent.py index b12deda..e5003ed 100644 --- a/src/agents/biographer/agent.py +++ b/src/agents/biographer/agent.py @@ -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( diff --git a/src/agents/housekeeper/agent.py b/src/agents/housekeeper/agent.py index 1b988e8..7d3bc5f 100644 --- a/src/agents/housekeeper/agent.py +++ b/src/agents/housekeeper/agent.py @@ -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( diff --git a/src/agents/librarian/agent.py b/src/agents/librarian/agent.py index 8293e23..374d59e 100644 --- a/src/agents/librarian/agent.py +++ b/src/agents/librarian/agent.py @@ -122,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( diff --git a/src/agents/tatlock.py b/src/agents/tatlock.py index 362917c..4bcdbe5 100644 --- a/src/agents/tatlock.py +++ b/src/agents/tatlock.py @@ -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 diff --git a/src/ollama/provider.py b/src/ollama/provider.py new file mode 100644 index 0000000..7b04e48 --- /dev/null +++ b/src/ollama/provider.py @@ -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: ' 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: ' 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() diff --git a/src/responses/streaming.py b/src/responses/streaming.py index 0987d75..6b65a89 100644 --- a/src/responses/streaming.py +++ b/src/responses/streaming.py @@ -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: