"""Agent loop for tool-calling conversation.""" import asyncio import json import re from typing import Any, Callable, Optional from loguru import logger class AgentLoop: """ Agent loop with tool-calling capability. This is the core of the nanobot agent - it handles: - Multi-turn conversation with the LLM - Tool execution when the model requests it - Progress callbacks for streaming responses """ _TOOL_RESULT_MAX_CHARS = 50000 def __init__( self, provider: Any, model: str, tools: Any, max_iterations: int = 50, ): """ Initialize the agent loop. Args: provider: LLM provider (must implement chat_with_retry) model: Model name tools: Tool registry (must have get_definitions() and execute()) max_iterations: Maximum tool call iterations """ self.provider = provider self.model = model self.tools = tools self.max_iterations = max_iterations @staticmethod def _strip_think(text: Optional[str]) -> Optional[str]: """Strip model thinking blocks from content.""" if not text: return None # Strip tags commonly used by models like DeepSeek pattern = r"[\s\S]*?" text = re.sub(pattern, "", text) return text.strip() or None @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint.""" def _fmt(tc): args = tc.arguments or {} val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")' return ", ".join(_fmt(tc) for tc in tool_calls) async def run_loop( self, initial_messages: list[dict], system_prompt: str = "", on_progress: Optional[Callable[..., Any]] = None, ) -> tuple[Optional[str], list[str], list[dict]]: """ Run the agent iteration loop. Args: initial_messages: Starting message list system_prompt: System prompt to prepend on_progress: Optional callback for progress updates Returns: Tuple of (final_content, tools_used, all_messages) """ # Prepend system prompt if provided if system_prompt: messages = [{"role": "system", "content": system_prompt}] + initial_messages else: messages = initial_messages iteration = 0 final_content = None tools_used: list[str] = [] while iteration < self.max_iterations: iteration += 1 tool_defs = self.tools.get_definitions() if self.tools else [] response = await self.provider.chat_with_retry( messages=messages, tools=tool_defs, model=self.model, ) if response.has_tool_calls: # Send progress update if on_progress: thought = self._strip_think(response.content) if thought: await on_progress(thought) await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) # Add assistant message with tool calls tool_call_dicts = [ tc.to_openai_tool_call() if hasattr(tc, 'to_openai_tool_call') else tc for tc in response.tool_calls ] messages = self._add_assistant_message( messages, response.content, tool_call_dicts, reasoning_content=getattr(response, 'reasoning_content', None), ) # Execute tools for tool_call in response.tool_calls: tools_used.append(tool_call.name) args_str = json.dumps(tool_call.arguments, ensure_ascii=False) logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self._add_tool_result(messages, tool_call.id, tool_call.name, result) else: clean = self._strip_think(response.content) # Handle error responses if response.finish_reason == "error": logger.error("LLM returned error: {}", (clean or "")[:200]) final_content = clean or "Sorry, I encountered an error calling the AI model." break messages = self._add_assistant_message( messages, clean, reasoning_content=getattr(response, 'reasoning_content', None), ) final_content = clean break if final_content is None and iteration >= self.max_iterations: logger.warning("Max iterations ({}) reached", self.max_iterations) final_content = ( f"I reached the maximum number of tool call iterations ({self.max_iterations}) " "without completing the task." ) return final_content, tools_used, messages def _add_assistant_message( self, messages: list[dict], content: Optional[str], tool_calls: Optional[list[dict]] = None, reasoning_content: Optional[str] = None, ) -> list[dict]: """Add an assistant message to the message list.""" msg: dict[str, Any] = {"role": "assistant", "content": content} if tool_calls: msg["tool_calls"] = tool_calls if reasoning_content is not None: msg["reasoning_content"] = reasoning_content messages.append(msg) return messages def _add_tool_result( self, messages: list[dict], tool_call_id: str, tool_name: str, result: Any, ) -> list[dict]: """Add a tool result message to the message list.""" # Truncate large results content = str(result) if len(content) > self._TOOL_RESULT_MAX_CHARS: content = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" messages.append({ "role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": content, }) return messages