Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9980e4764c | ||
|
|
4907798e74 | ||
|
|
fb54887c03 | ||
|
|
d5e5fc1ad8 | ||
|
|
74f47097c2 |
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.7.0] - 2025-12-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "1.7.0"
|
version = "1.8.4"
|
||||||
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(
|
||||||
|
|||||||
+26
-22
@@ -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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -100,10 +101,13 @@ def _detect_action_type(expert: str, task: str) -> ActionType:
|
|||||||
task_lower = task.lower()
|
task_lower = task.lower()
|
||||||
|
|
||||||
if expert == "librarian":
|
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 ["search", "find", "look up", "research"]):
|
||||||
if any(w in task_lower for w in ["web", "online", "internet"]):
|
if any(w in task_lower for w in ["web", "online", "internet"]):
|
||||||
return ActionType.RESEARCH
|
return ActionType.RESEARCH
|
||||||
return ActionType.RETRIEVE
|
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"]):
|
if any(w in task_lower for w in ["create", "write", "add", "make", "new"]):
|
||||||
return ActionType.CREATE
|
return ActionType.CREATE
|
||||||
return ActionType.RETRIEVE
|
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)
|
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
|
||||||
|
|||||||
@@ -113,18 +113,15 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
|
|||||||
|
|
||||||
def _create_housekeeper_agent() -> Agent[None, str]:
|
def _create_housekeeper_agent() -> Agent[None, str]:
|
||||||
"""Create the Housekeeper PydanticAI agent."""
|
"""Create the Housekeeper PydanticAI agent."""
|
||||||
# Import required classes for Ollama configuration
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
from src.ollama.provider import get_ollama_provider
|
||||||
clean_host = str(config.OLLAMA_HOST).rstrip("/")
|
|
||||||
base_url = f"{clean_host}/v1"
|
|
||||||
|
|
||||||
# Create Ollama model with provider
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
provider=OllamaProvider(base_url=base_url),
|
provider=get_ollama_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
agent: Agent[None, str] = Agent(
|
agent: Agent[None, str] = Agent(
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from src.agents.librarian.tools import (
|
|||||||
get_wiki_page,
|
get_wiki_page,
|
||||||
hybrid_search,
|
hybrid_search,
|
||||||
list_dossiers,
|
list_dossiers,
|
||||||
|
read_url,
|
||||||
|
read_urls_batch,
|
||||||
|
search_web,
|
||||||
search_wiki,
|
search_wiki,
|
||||||
semantic_search,
|
semantic_search,
|
||||||
smart_create_wiki_page,
|
smart_create_wiki_page,
|
||||||
@@ -47,8 +50,17 @@ Your role is to help users find, understand, synthesize, and manage information
|
|||||||
|
|
||||||
## Your Tools
|
## Your Tools
|
||||||
|
|
||||||
### Research Tools
|
### Web Search & Content Extraction
|
||||||
- **hybrid_search**: Your primary research tool - searches all sources at once
|
- **search_web**: Search the internet for current information (weather, news, facts)
|
||||||
|
- Use for: weather forecasts, current events, recent developments, external facts
|
||||||
|
- Returns extracted content from search results, not just snippets
|
||||||
|
- **read_url**: Read and extract content from a specific URL
|
||||||
|
- Use when: user provides a URL or you need to read a specific webpage
|
||||||
|
- **read_urls_batch**: Read multiple URLs in parallel (up to 20)
|
||||||
|
- Use for: comparing multiple sources, gathering info from several pages
|
||||||
|
|
||||||
|
### Internal Research Tools
|
||||||
|
- **hybrid_search**: Your primary research tool - searches wiki, graph, and web at once
|
||||||
- **search_wiki**: Find specific wiki pages by keyword
|
- **search_wiki**: Find specific wiki pages by keyword
|
||||||
- **semantic_search**: Find conceptually similar content
|
- **semantic_search**: Find conceptually similar content
|
||||||
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
||||||
@@ -110,18 +122,15 @@ _librarian_agent: Optional[Agent[None, str]] = None
|
|||||||
|
|
||||||
def _create_librarian_agent() -> Agent[None, str]:
|
def _create_librarian_agent() -> Agent[None, str]:
|
||||||
"""Create the Librarian PydanticAI agent."""
|
"""Create the Librarian PydanticAI agent."""
|
||||||
# Import required classes for Ollama configuration
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.providers.ollama import OllamaProvider
|
|
||||||
|
|
||||||
# PydanticAI expects Ollama base URL to end with /v1
|
from src.ollama.provider import get_ollama_provider
|
||||||
clean_host = str(config.OLLAMA_HOST).rstrip('/')
|
|
||||||
base_url = f"{clean_host}/v1"
|
|
||||||
|
|
||||||
# Create Ollama model with provider
|
# Create Ollama model with sanitized provider
|
||||||
|
# (fixes 'content: null' issue with tool calls)
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||||
provider=OllamaProvider(base_url=base_url)
|
provider=get_ollama_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
agent: Agent[None, str] = Agent(
|
agent: Agent[None, str] = Agent(
|
||||||
@@ -130,7 +139,7 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
retries=2,
|
retries=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register research tools
|
# Register research tools (internal knowledge)
|
||||||
agent.tool_plain(hybrid_search)
|
agent.tool_plain(hybrid_search)
|
||||||
agent.tool_plain(search_wiki)
|
agent.tool_plain(search_wiki)
|
||||||
agent.tool_plain(semantic_search)
|
agent.tool_plain(semantic_search)
|
||||||
@@ -139,6 +148,11 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
agent.tool_plain(explore_knowledge_graph)
|
agent.tool_plain(explore_knowledge_graph)
|
||||||
agent.tool_plain(find_related_entities)
|
agent.tool_plain(find_related_entities)
|
||||||
|
|
||||||
|
# Register web search & content extraction tools
|
||||||
|
agent.tool_plain(search_web)
|
||||||
|
agent.tool_plain(read_url)
|
||||||
|
agent.tool_plain(read_urls_batch)
|
||||||
|
|
||||||
# Register wiki read tools
|
# Register wiki read tools
|
||||||
agent.tool_plain(get_wiki_page)
|
agent.tool_plain(get_wiki_page)
|
||||||
|
|
||||||
@@ -150,7 +164,7 @@ def _create_librarian_agent() -> Agent[None, str]:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"librarian_agent_created",
|
"librarian_agent_created",
|
||||||
model=config.OLLAMA_DEFAULT_MODEL,
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
tool_count=11,
|
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
|
||||||
)
|
)
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
+13
-13
@@ -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:
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ GUIDELINES:
|
|||||||
- Simple greetings/chat → no capabilities needed (conversational response only)
|
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||||
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||||
- Math/calculations → tatlock_core
|
- Math/calculations → tatlock_core
|
||||||
- Quick web searches → tatlock_core
|
|
||||||
- Time/date queries → tatlock_core
|
- Time/date queries → tatlock_core
|
||||||
|
- Web searches, weather, news, current information → librarian with search_web
|
||||||
|
- Read a URL or article → librarian with read_url
|
||||||
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
||||||
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
||||||
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
||||||
@@ -74,8 +75,10 @@ COMPLEXITY: [simple/moderate/complex]
|
|||||||
CONTEXT: [any relevant conversation context, or "none"]
|
CONTEXT: [any relevant conversation context, or "none"]
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
|
- "DELEGATE: librarian to search_web for tomorrow's weather forecast"
|
||||||
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
||||||
- "DELEGATE: librarian to search for information about Docker networking"
|
- "DELEGATE: librarian to hybrid_search for information about Docker networking"
|
||||||
|
- "DELEGATE: librarian to read_url https://example.com/article"
|
||||||
- "DELEGATE: tatlock_core to calculate the result"
|
- "DELEGATE: tatlock_core to calculate the result"
|
||||||
- "DELEGATE: none (conversational response only)"
|
- "DELEGATE: none (conversational response only)"
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -515,15 +515,18 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
from src.agents.tatlock import TatlockAgent
|
from src.agents.tatlock import TatlockAgent
|
||||||
tatlock = TatlockAgent()
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
# Use enriched query (with location/timezone context) if available
|
||||||
|
effective_query = enriched.recommendation.enriched_query or user_message
|
||||||
|
|
||||||
if delegation_only:
|
if delegation_only:
|
||||||
# Direct delegation path - collect results then synthesize
|
# Direct delegation path - collect results then synthesize
|
||||||
orchestration_results = await _direct_delegation_with_results(
|
orchestration_results = await _direct_delegation_with_results(
|
||||||
user_message, enriched.recommendation, tracker, conversation_id
|
effective_query, enriched.recommendation, tracker, conversation_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Phase 1: Orchestrate tool calls
|
# Phase 1: Orchestrate tool calls
|
||||||
orchestration_results = await tatlock.orchestrate_tool_calls(
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
user_message=user_message,
|
user_message=effective_query,
|
||||||
steward_note=enriched.steward_note,
|
steward_note=enriched.steward_note,
|
||||||
scoped_tools=enriched.scoped_tools,
|
scoped_tools=enriched.scoped_tools,
|
||||||
message_history=conversation_history,
|
message_history=conversation_history,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user