feat: add conversation history and tool call logging
Add two major features to enhance Tatlock's capabilities: 1. Conversation History Support: - Convert OpenAI-format messages to PydanticAI ModelRequest/ModelResponse - Pass full conversation context via message_history parameter - Filter empty messages to prevent Ollama errors - Add debug logging for message history construction - Tatlock now remembers previous turns in multi-turn conversations 2. Tool Call Logging: - Implement ToolCallTracker dependency for per-request tracking - Tools log usage via RunContext deps parameter - Web search: "🔍 Searching for: 'query'" - Calculator: "🧮 Calculating: expression" - Date/time: "🕐 Calculating date offset: description" - Tool logs appear in reasoning output as <think> tags in Open WebUI Both features improve user experience by maintaining conversation context and providing transparency into tool usage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+93
-8
@@ -8,6 +8,7 @@ The agent embodies a witty, capable British butler personality.
|
||||
import logging
|
||||
import secrets
|
||||
from typing import AsyncGenerator, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
@@ -24,6 +25,16 @@ from src.core.config import config
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallTracker:
|
||||
"""Tracks tool calls for reporting to reasoning output."""
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def log_call(self, message: str):
|
||||
"""Log a tool call."""
|
||||
self.calls.append(message)
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
"""Generate unique ID for output items."""
|
||||
return secrets.token_hex(16)
|
||||
@@ -135,7 +146,7 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Calculator tool
|
||||
@self._agent.tool
|
||||
def calculate_math(ctx: RunContext[None], expression: str) -> str:
|
||||
def calculate_math(ctx: RunContext[ToolCallTracker], expression: str) -> str:
|
||||
"""
|
||||
Evaluate mathematical expressions safely.
|
||||
|
||||
@@ -147,11 +158,14 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
String result of the calculation
|
||||
"""
|
||||
# Log the calculation to reasoning output
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🧮 Calculating: {expression}")
|
||||
return calculate(expression)
|
||||
|
||||
# Current date/time tool
|
||||
@self._agent.tool
|
||||
def get_current_time(ctx: RunContext[None], format_str: str = "full") -> str:
|
||||
def get_current_time(ctx: RunContext[ToolCallTracker], format_str: str = "full") -> str:
|
||||
"""
|
||||
Get the current date and time.
|
||||
|
||||
@@ -161,11 +175,13 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
Formatted current datetime string
|
||||
"""
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🕐 Getting current time (format: {format_str})")
|
||||
return get_current_datetime(format_str)
|
||||
|
||||
# Time offset calculator
|
||||
@self._agent.tool
|
||||
def calculate_date_offset(ctx: RunContext[None], offset_description: str) -> str:
|
||||
def calculate_date_offset(ctx: RunContext[ToolCallTracker], offset_description: str) -> str:
|
||||
"""
|
||||
Calculate a date/time relative to now.
|
||||
|
||||
@@ -175,11 +191,13 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
Formatted datetime string (YYYY-MM-DD HH:MM:SS)
|
||||
"""
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🕐 Calculating date offset: {offset_description}")
|
||||
return calculate_time_offset(offset_description)
|
||||
|
||||
# Time difference calculator
|
||||
@self._agent.tool
|
||||
def calculate_time_difference(ctx: RunContext[None], date1_str: str, date2_str: str = "now") -> str:
|
||||
def calculate_time_difference(ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now") -> str:
|
||||
"""
|
||||
Calculate the difference between two dates.
|
||||
|
||||
@@ -190,11 +208,13 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
Human-readable description of the time difference
|
||||
"""
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
||||
return time_difference(date1_str, date2_str)
|
||||
|
||||
# Web search tool
|
||||
@self._agent.tool
|
||||
async def web_search(ctx: RunContext[None], query: str, num_results: int = 5) -> str:
|
||||
async def web_search(ctx: RunContext[ToolCallTracker], query: str, num_results: int = 5) -> str:
|
||||
"""
|
||||
Search the web using SearXNG for current information.
|
||||
|
||||
@@ -210,6 +230,9 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
Formatted search results with titles, URLs, and snippets
|
||||
"""
|
||||
# Log the search query to reasoning output
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🔍 Searching for: '{query}'")
|
||||
return await search_web(query, num_results)
|
||||
|
||||
@property
|
||||
@@ -244,8 +267,11 @@ class TatlockAgent(AgentInterface):
|
||||
OutputItem: Response items (reasoning, message)
|
||||
"""
|
||||
try:
|
||||
# Extract user message from messages
|
||||
# For now, use the last user message as the prompt
|
||||
# Convert OpenAI-format messages to PydanticAI format
|
||||
# PydanticAI uses: {"role": "user"/"assistant", "content": "text"}
|
||||
# OpenAI format is the same, so we can use messages directly
|
||||
|
||||
# Extract the latest user message for the prompt
|
||||
user_message = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
@@ -266,6 +292,47 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
return
|
||||
|
||||
# Build message history (all messages except the last user message)
|
||||
# PydanticAI expects history as list of ModelRequest/ModelResponse objects
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
|
||||
message_history = []
|
||||
for i, msg in enumerate(messages[:-1]): # All messages except the last one
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Skip messages with empty content (can cause Ollama errors)
|
||||
if not content or not content.strip():
|
||||
logger.warning(f"Skipping message {i} with empty content: role={role}")
|
||||
continue
|
||||
|
||||
# Debug: Check for problematic content
|
||||
if '"' in content or "'" in content:
|
||||
logger.debug(f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}...")
|
||||
|
||||
# Convert to PydanticAI message format
|
||||
try:
|
||||
if role == "user":
|
||||
message_history.append(
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
elif role == "assistant":
|
||||
message_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating message history item {i}: {e}")
|
||||
logger.error(f"Problematic content: {repr(content)}")
|
||||
raise
|
||||
|
||||
# Debug: Log the message history summary
|
||||
logger.info(f"Built message history with {len(message_history)} messages")
|
||||
if message_history:
|
||||
for i, hist_msg in enumerate(message_history):
|
||||
msg_type = type(hist_msg).__name__
|
||||
content_preview = str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
|
||||
logger.info(f" History[{i}]: {msg_type} - {content_preview}...")
|
||||
|
||||
# Generate reasoning output if requested
|
||||
if reasoning and reasoning.get("effort") != "none":
|
||||
yield OutputItem(
|
||||
@@ -279,6 +346,9 @@ class TatlockAgent(AgentInterface):
|
||||
status="completed"
|
||||
)
|
||||
|
||||
# Create a tool call tracker for this request
|
||||
tracker = ToolCallTracker()
|
||||
|
||||
# Stream the agent response token-by-token
|
||||
msg_id = f"msg_{generate_id()}"
|
||||
final_text = ""
|
||||
@@ -286,9 +356,24 @@ class TatlockAgent(AgentInterface):
|
||||
# Use run() instead of run_stream() to avoid GeneratorExit issues
|
||||
# with async context managers inside generators
|
||||
# The StreamingCoordinator will handle word-by-word streaming
|
||||
result = await self.agent.run(user_message)
|
||||
# Pass message_history to maintain conversation context and tracker for tool logging
|
||||
result = await self.agent.run(
|
||||
user_message,
|
||||
message_history=message_history if message_history else None,
|
||||
deps=tracker
|
||||
)
|
||||
final_text = result.output
|
||||
|
||||
# If tools were called, yield a reasoning item showing what was done
|
||||
if tracker.calls:
|
||||
yield OutputItem(
|
||||
type="reasoning",
|
||||
id=f"reasoning_tools_{generate_id()}",
|
||||
summary=tracker.calls,
|
||||
thinking="",
|
||||
status="completed"
|
||||
)
|
||||
|
||||
# Yield the complete message
|
||||
# The StreamingCoordinator will break this into word-by-word deltas
|
||||
yield OutputItem(
|
||||
|
||||
Reference in New Issue
Block a user