diff --git a/src/agents/tatlock.py b/src/agents/tatlock.py index 85ab6c8..b18f657 100644 --- a/src/agents/tatlock.py +++ b/src/agents/tatlock.py @@ -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(