- 新增 loop.py Agent 运行循环 - 优化 memory.py 记忆模块 - 扩展 api/routes.py 接口 - 更新 tools 模块:builtin.py, manager.py, __init__.py - 新增 .env.example 配置示例 - 更新 requirements.txt 依赖 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
530 lines
19 KiB
Python
530 lines
19 KiB
Python
"""Agent run loop - complete implementation."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Awaitable, AsyncGenerator
|
|
|
|
from agents.agent.context import ContextBuilder
|
|
from agents.agent.memory import AgentMemory
|
|
from agents.llm import LLMProvider, LLMResponse, ProviderFactory
|
|
from agents.tools import ToolRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AgentLoop:
|
|
"""Agent loop with message processing, LLM calls, tool execution, and streaming."""
|
|
|
|
_TOOL_RESULT_MAX_CHARS = 10000
|
|
|
|
def __init__(
|
|
self,
|
|
provider: LLMProvider,
|
|
model: str,
|
|
workspace: Path | None = None,
|
|
max_iterations: int = 10,
|
|
tools: ToolRegistry | None = None,
|
|
):
|
|
"""Initialize the agent loop.
|
|
|
|
Args:
|
|
provider: LLM provider (OpenAI, Anthropic, etc.)
|
|
model: Model name to use
|
|
workspace: Workspace directory for memory and configs
|
|
max_iterations: Maximum tool call iterations
|
|
tools: Tool registry (creates default if None)
|
|
"""
|
|
self.provider = provider
|
|
self.model = model
|
|
self.workspace = workspace or Path.cwd()
|
|
self.max_iterations = max_iterations
|
|
self.tools = tools
|
|
|
|
self.context = ContextBuilder(self.workspace)
|
|
self.memory = AgentMemory(self.workspace)
|
|
|
|
async def chat(
|
|
self,
|
|
message: str,
|
|
history: list[dict[str, Any]] | None = None,
|
|
session_key: str = "default",
|
|
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
|
model_id: str | None = None,
|
|
model_name: str | None = None,
|
|
model_provider: str | None = None,
|
|
api_key: str | None = None,
|
|
base_url: str | None = None,
|
|
use_xbot: bool = False,
|
|
) -> str:
|
|
"""Process a chat message and return the response.
|
|
|
|
Args:
|
|
message: User message
|
|
history: Conversation history
|
|
session_key: Session identifier
|
|
on_progress: Optional callback for progress updates
|
|
model_id: Model ID (optional)
|
|
model_name: Model name (optional)
|
|
model_provider: Model provider (optional)
|
|
api_key: API key (optional)
|
|
base_url: Custom API base URL (optional)
|
|
use_xbot: Use xbot mode (optional)
|
|
|
|
Returns:
|
|
Agent response content
|
|
"""
|
|
history = history or []
|
|
|
|
# Load history from session if session_key is provided
|
|
if session_key and session_key != "default":
|
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
|
if loaded_history:
|
|
logger.info(f"Loaded {len(loaded_history)} messages from session history")
|
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
|
if not history:
|
|
history = loaded_history
|
|
else:
|
|
# Append loaded history before current messages
|
|
history = loaded_history + history
|
|
|
|
# Check if dynamic provider parameters are provided
|
|
if api_key or model_provider:
|
|
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
|
# Create temporary provider with dynamic parameters
|
|
temp_provider = ProviderFactory.create(
|
|
provider=model_provider or "openai",
|
|
api_key=api_key,
|
|
api_base=base_url,
|
|
)
|
|
# Use temporary provider and model
|
|
temp_model = model_name or temp_provider.get_default_model()
|
|
logger.info(f"Created temp provider with model: {temp_model}")
|
|
return await self._chat_with_provider(
|
|
message=message,
|
|
history=history,
|
|
session_key=session_key,
|
|
on_progress=on_progress,
|
|
provider=temp_provider,
|
|
model=temp_model,
|
|
)
|
|
|
|
# Build messages for LLM
|
|
messages = self.context.build_messages(
|
|
history=history,
|
|
current_message=message,
|
|
)
|
|
|
|
# Log which provider is being used
|
|
logger.info(f"Using static provider: {type(self.provider).__name__}, model={self.model}")
|
|
|
|
# Run the agent loop
|
|
final_content, tools_used, all_messages = await self._run_loop(
|
|
messages, on_progress
|
|
)
|
|
|
|
# Save to history
|
|
self._save_history(session_key, all_messages, len(history))
|
|
|
|
return final_content or "No response generated."
|
|
|
|
async def _chat_with_provider(
|
|
self,
|
|
message: str,
|
|
history: list[dict[str, Any]] | None = None,
|
|
session_key: str = "default",
|
|
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
|
provider: LLMProvider | None = None,
|
|
model: str | None = None,
|
|
) -> str:
|
|
"""Chat with a specific provider (used for dynamic provider support).
|
|
|
|
Args:
|
|
message: User message
|
|
history: Conversation history
|
|
session_key: Session identifier
|
|
on_progress: Optional callback for progress updates
|
|
provider: LLM provider to use
|
|
model: Model name to use
|
|
|
|
Returns:
|
|
Agent response content
|
|
"""
|
|
history = history or []
|
|
|
|
# Load history from session if session_key is provided
|
|
if session_key and session_key != "default":
|
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
|
if loaded_history:
|
|
logger.info(f"Loaded {len(loaded_history)} messages from session history")
|
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
|
if not history:
|
|
history = loaded_history
|
|
else:
|
|
# Append loaded history before current messages
|
|
history = loaded_history + history
|
|
|
|
provider = provider or self.provider
|
|
model = model or self.model
|
|
|
|
# Build messages for LLM
|
|
messages = self.context.build_messages(
|
|
history=history,
|
|
current_message=message,
|
|
)
|
|
|
|
# Run the agent loop with custom provider
|
|
final_content, tools_used, all_messages = await self._run_loop(
|
|
messages, on_progress, provider=provider, model=model
|
|
)
|
|
|
|
# Save to history
|
|
self._save_history(session_key, all_messages, len(history))
|
|
|
|
return final_content or "No response generated."
|
|
|
|
async def chat_stream(
|
|
self,
|
|
message: str,
|
|
history: list[dict[str, Any]] | None = None,
|
|
session_key: str = "default",
|
|
model_id: str | None = None,
|
|
model_name: str | None = None,
|
|
model_provider: str | None = None,
|
|
api_key: str | None = None,
|
|
base_url: str | None = None,
|
|
use_xbot: bool = False,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Process a chat message with streaming response.
|
|
|
|
Args:
|
|
message: User message
|
|
history: Conversation history
|
|
session_key: Session identifier
|
|
model_id: Model ID (optional)
|
|
model_name: Model name (optional)
|
|
model_provider: Model provider (optional)
|
|
api_key: API key (optional)
|
|
base_url: Custom API base URL (optional)
|
|
use_xbot: Use xbot mode (optional)
|
|
|
|
Yields:
|
|
Response content chunks
|
|
"""
|
|
history = history or []
|
|
|
|
# Load history from session if session_key is provided
|
|
if session_key and session_key != "default":
|
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
|
if loaded_history:
|
|
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
|
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
|
if not history:
|
|
history = loaded_history
|
|
else:
|
|
# Append loaded history before current messages
|
|
history = loaded_history + history
|
|
|
|
# Check if dynamic provider parameters are provided
|
|
if api_key or model_provider:
|
|
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
|
# Create temporary provider with dynamic parameters
|
|
temp_provider = ProviderFactory.create(
|
|
provider=model_provider or "openai",
|
|
api_key=api_key,
|
|
api_base=base_url,
|
|
)
|
|
# Use temporary provider and model
|
|
temp_model = model_name or temp_provider.get_default_model()
|
|
logger.info(f"[stream] Created temp provider with model: {temp_model}")
|
|
async for chunk in self._chat_stream_with_provider(
|
|
message=message,
|
|
history=history,
|
|
session_key=session_key,
|
|
provider=temp_provider,
|
|
model=temp_model,
|
|
):
|
|
yield chunk
|
|
return
|
|
|
|
# Build messages for LLM
|
|
messages = self.context.build_messages(
|
|
history=history,
|
|
current_message=message,
|
|
)
|
|
|
|
# Stream the response
|
|
async for chunk in self._run_loop_stream(messages):
|
|
yield chunk
|
|
|
|
async def _chat_stream_with_provider(
|
|
self,
|
|
message: str,
|
|
history: list[dict[str, Any]] | None = None,
|
|
session_key: str = "default",
|
|
provider: LLMProvider | None = None,
|
|
model: str | None = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Stream chat with a specific provider (used for dynamic provider support).
|
|
|
|
Args:
|
|
message: User message
|
|
history: Conversation history
|
|
session_key: Session identifier
|
|
provider: LLM provider to use
|
|
model: Model name to use
|
|
|
|
Yields:
|
|
Response content chunks
|
|
"""
|
|
history = history or []
|
|
|
|
# Load history from session if session_key is provided
|
|
if session_key and session_key != "default":
|
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
|
if loaded_history:
|
|
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
|
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
|
if not history:
|
|
history = loaded_history
|
|
else:
|
|
# Append loaded history before current messages
|
|
history = loaded_history + history
|
|
|
|
provider = provider or self.provider
|
|
model = model or self.model
|
|
|
|
# Build messages for LLM
|
|
messages = self.context.build_messages(
|
|
history=history,
|
|
current_message=message,
|
|
)
|
|
|
|
# Stream the response with custom provider
|
|
async for chunk in self._run_loop_stream(messages, provider=provider, model=model):
|
|
yield chunk
|
|
|
|
async def _run_loop(
|
|
self,
|
|
initial_messages: list[dict],
|
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
|
provider: LLMProvider | None = None,
|
|
model: str | None = None,
|
|
) -> tuple[str | None, list[str], list[dict]]:
|
|
"""Run the agent iteration loop.
|
|
|
|
Args:
|
|
initial_messages: Initial message list
|
|
on_progress: Progress callback
|
|
provider: Optional LLM provider to use (defaults to self.provider)
|
|
model: Optional model name to use (defaults to self.model)
|
|
|
|
Returns:
|
|
Tuple of (final_content, tools_used, all_messages)
|
|
"""
|
|
messages = initial_messages
|
|
iteration = 0
|
|
final_content = None
|
|
tools_used: list[str] = []
|
|
provider = provider or self.provider
|
|
model = model or self.model
|
|
|
|
tool_defs = self.tools.get_definitions() if self.tools else []
|
|
|
|
while iteration < self.max_iterations:
|
|
iteration += 1
|
|
|
|
# Call LLM
|
|
response = await provider.chat_with_retry(
|
|
messages=messages,
|
|
tools=tool_defs if tool_defs else None,
|
|
model=model,
|
|
)
|
|
|
|
if response.has_tool_calls:
|
|
# Progress callback for tool calls
|
|
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_dict() for tc in response.tool_calls]
|
|
messages = self.context.add_assistant_message(
|
|
messages,
|
|
response.content,
|
|
tool_call_dicts,
|
|
reasoning_content=response.reasoning_content,
|
|
)
|
|
|
|
# Execute tools
|
|
for tool_call in response.tool_calls:
|
|
tools_used.append(tool_call.name)
|
|
args = tool_call.arguments
|
|
logger.info(f"Tool call: {tool_call.name}({args})")
|
|
|
|
# Execute tool
|
|
result = await self._execute_tool(tool_call.name, args)
|
|
|
|
# Truncate large results
|
|
if len(result) > self._TOOL_RESULT_MAX_CHARS:
|
|
result = result[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
|
|
|
|
# Add tool result
|
|
messages = self.context.add_tool_result(
|
|
messages, tool_call.id, tool_call.name, result
|
|
)
|
|
else:
|
|
# No tool calls - return the response
|
|
clean = self._strip_think(response.content)
|
|
|
|
# Handle errors
|
|
if response.finish_reason == "error":
|
|
logger.error(f"LLM error: {clean}")
|
|
final_content = clean or "Sorry, I encountered an error calling the AI model."
|
|
break
|
|
|
|
messages = self.context.add_assistant_message(
|
|
messages, clean, reasoning_content=response.reasoning_content
|
|
)
|
|
final_content = clean
|
|
break
|
|
|
|
if final_content is None and iteration >= self.max_iterations:
|
|
logger.warning(f"Max iterations ({self.max_iterations}) reached")
|
|
final_content = (
|
|
f"I reached the maximum number of iterations ({self.max_iterations}) "
|
|
"without completing the task."
|
|
)
|
|
|
|
return final_content, tools_used, messages
|
|
|
|
async def _run_loop_stream(
|
|
self,
|
|
initial_messages: list[dict],
|
|
provider: LLMProvider | None = None,
|
|
model: str | None = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Run the agent loop with streaming response.
|
|
|
|
Args:
|
|
initial_messages: Initial message list
|
|
provider: Optional LLM provider to use (defaults to self.provider)
|
|
model: Optional model name to use (defaults to self.model)
|
|
|
|
Yields:
|
|
Response content chunks
|
|
"""
|
|
provider = provider or self.provider
|
|
model = model or self.model
|
|
tool_defs = self.tools.get_definitions() if self.tools else []
|
|
|
|
# First call to check for tool calls
|
|
response = await provider.chat_with_retry(
|
|
messages=initial_messages,
|
|
tools=tool_defs if tool_defs else None,
|
|
model=model,
|
|
)
|
|
|
|
if response.has_tool_calls:
|
|
# Execute tools first
|
|
for tool_call in response.tool_calls:
|
|
logger.info(f"Tool call: {tool_call.name}")
|
|
result = await self._execute_tool(tool_call.name, tool_call.arguments)
|
|
|
|
# Add to messages
|
|
initial_messages = self.context.add_tool_result(
|
|
initial_messages, tool_call.id, tool_call.name, result
|
|
)
|
|
|
|
# Recursive call after tool execution
|
|
async for chunk in self._run_loop_stream(initial_messages, provider=provider, model=model):
|
|
yield chunk
|
|
else:
|
|
# Stream the content
|
|
content = self._strip_think(response.content)
|
|
if content:
|
|
yield content
|
|
|
|
async def _execute_tool(self, tool_name: str, args: dict) -> str:
|
|
"""Execute a tool.
|
|
|
|
Args:
|
|
tool_name: Name of the tool to execute
|
|
args: Tool arguments
|
|
|
|
Returns:
|
|
Tool execution result
|
|
"""
|
|
if self.tools:
|
|
return await self.tools.execute(tool_name, args)
|
|
return json.dumps({"error": "No tools registered"})
|
|
|
|
@staticmethod
|
|
def _strip_think(text: str | None) -> str | None:
|
|
"""Strip think blocks that some models embed in content."""
|
|
if not text:
|
|
return None
|
|
import re
|
|
# Match content between [/INST] or [/CONTINUE] tags commonly used in thinking
|
|
patterns = [
|
|
r"<think>[\s\S]*?</think>",
|
|
r"<\/?think>",
|
|
]
|
|
for pattern in patterns:
|
|
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)
|
|
|
|
def _save_history(
|
|
self,
|
|
session_key: str,
|
|
messages: list[dict],
|
|
skip: int = 0,
|
|
) -> None:
|
|
"""Save messages to history.
|
|
|
|
Args:
|
|
session_key: Session identifier
|
|
messages: Messages to save
|
|
skip: Number of messages to skip
|
|
"""
|
|
for m in messages[skip:]:
|
|
role = m.get("role")
|
|
content = m.get("content")
|
|
|
|
if role == "user" and content:
|
|
self.memory.add_to_history("user", str(content)[:1000], session_key)
|
|
elif role == "assistant" and content:
|
|
self.memory.add_to_history("assistant", str(content)[:1000], session_key)
|
|
# Save tool_calls for assistant messages (needed for multi-turn tool calls)
|
|
elif role == "assistant" and m.get("tool_calls"):
|
|
# Save the assistant message with tool_calls
|
|
tool_calls_str = json.dumps(m.get("tool_calls", []))
|
|
self.memory.add_to_history("assistant", f"[tool_calls]{tool_calls_str}", session_key)
|
|
# Save tool results (needed for multi-turn conversations)
|
|
elif role == "tool":
|
|
tool_call_id = m.get("tool_call_id", "")
|
|
tool_name = m.get("name", "")
|
|
tool_content = m.get("content", "")
|
|
tool_result_str = json.dumps({
|
|
"tool_call_id": tool_call_id,
|
|
"name": tool_name,
|
|
"content": tool_content
|
|
})
|
|
self.memory.add_to_history("tool", f"[tool_result]{tool_result_str}", session_key)
|