Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb54887c03 | ||
|
|
d5e5fc1ad8 |
@@ -7,6 +7,30 @@ 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
|
## [1.8.0] - 2025-12-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "1.8.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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -122,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(
|
||||||
|
|||||||
@@ -281,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", ""),
|
||||||
|
|||||||
+10
-10
@@ -143,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
|
||||||
@@ -153,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
|
||||||
@@ -448,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",
|
||||||
@@ -464,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
|
||||||
@@ -542,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",
|
||||||
@@ -557,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
|
||||||
@@ -637,7 +637,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
- raw_output: The agent's raw text output
|
- raw_output: The agent's raw text output
|
||||||
"""
|
"""
|
||||||
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
|
||||||
from pydantic_ai.settings import ModelSettings
|
from pydantic_ai.settings import ModelSettings
|
||||||
from pydantic_ai.messages import (
|
from pydantic_ai.messages import (
|
||||||
ModelRequest,
|
ModelRequest,
|
||||||
@@ -661,7 +661,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
|
||||||
@@ -759,7 +759,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
str: Butler-toned response synthesized from all results
|
str: Butler-toned response synthesized from all results
|
||||||
"""
|
"""
|
||||||
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
|
||||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -802,7 +802,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()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Synthesis agent uses butler prompt but no tools
|
# Synthesis agent uses butler prompt but no tools
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -211,8 +211,10 @@ class StreamingCoordinator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Stream think slugs that were collected during delegation
|
# 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", []):
|
for think_msg in orchestration_results.get("think_messages", []):
|
||||||
yield ReasoningSummaryDelta(delta=think_msg)
|
yield ReasoningSummaryDelta(delta=think_msg)
|
||||||
|
yield ReasoningSummaryDone()
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user