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:
2025-12-07 01:13:19 +01:00
co-authored by Claude Sonnet 4.5
parent 426f9885fc
commit 38120696bf
+93 -8
View File
@@ -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(