Add streaming support and refactor Chat UI
- Add run_stream method to AgentCore for streaming output - Add base_url parameter to LLM clients for OpenRouter support - Add xbot module for new agent implementation - Refactor Chat.vue into composable + components (ChatHeader, ChatMessage, ChatInput, ChatSidebar, ChatAgentSelector) - Add ChatStream handler for SSE streaming in Go server - Add UseXBot field to chat request Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
190
agent/app/xbot/loop.py
Normal file
190
agent/app/xbot/loop.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""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 <thinking> tags commonly used by models like DeepSeek
|
||||
pattern = r"<thinking>[\s\S]*?</thinking>"
|
||||
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
|
||||
Reference in New Issue
Block a user