feat: 增强 core/agents 工具和 API

- 新增 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>
This commit is contained in:
2026-03-15 19:49:40 +08:00
parent 31f0feafb5
commit 1afa88e812
8 changed files with 231 additions and 17 deletions

View File

@@ -25,3 +25,10 @@ WORKSPACE=./workspace
# Agent settings
MAX_ITERATIONS=10
TEMPERATURE=0.7
# Sandbox Configuration (optional)
# Enable sandbox mode for secure code execution (bwrap/gvisor)
# SANDBOX_TYPE=bwrap # Options: bwrap, gvisor, none
# SANDBOX_TIMEOUT=60 # Default timeout in seconds
# GVISCOR_RUNSC_PATH=runsc # Path to gVisor runsc binary
# BWRAP_PATH=bwrap # Path to bwrap binary

View File

@@ -79,6 +79,18 @@ class AgentLoop:
"""
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}")
@@ -142,6 +154,19 @@ class AgentLoop:
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
@@ -191,6 +216,18 @@ class AgentLoop:
"""
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}")
@@ -244,6 +281,19 @@ class AgentLoop:
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
@@ -461,3 +511,19 @@ class AgentLoop:
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)

View File

@@ -537,8 +537,25 @@ class AgentMemory:
except:
pass
# Check if content contains tool_calls or tool_result markers
# Format as Markdown (产品经理指定格式)
entry = f"## 消息 {msg_count}\n角色: {role}\n时间: {display_timestamp}\n内容: {content}\n\n"
entry_lines = [
f"## 消息 {msg_count}",
f"角色: {role}",
f"时间: {display_timestamp}",
]
# Handle tool_calls and tool_result content
if content.startswith("[tool_calls]"):
entry_lines.append(f"工具调用: {content[len('[tool_calls]'):]}")
entry_lines.append(f"内容: ")
elif content.startswith("[tool_result]"):
entry_lines.append(f"工具结果: {content[len('[tool_result]'):]}")
entry_lines.append(f"内容: ")
else:
entry_lines.append(f"内容: {content}")
entry = "\n".join(entry_lines) + "\n\n"
with open(session_file, "a", encoding="utf-8") as f:
if header:
@@ -610,6 +627,27 @@ class AgentMemory:
current_message["timestamp"] = line.split(":", 1)[1].strip()
continue
# Parse "工具调用: xxx" - for tool_calls
if line.startswith("工具调用:") and current_message is not None:
tool_calls_json = line.split(":", 1)[1].strip()
try:
current_message["tool_calls"] = json.loads(tool_calls_json)
except json.JSONDecodeError:
pass
continue
# Parse "工具结果: xxx" - for tool_result
if line.startswith("工具结果:") and current_message is not None:
tool_result_json = line.split(":", 1)[1].strip()
try:
tool_result = json.loads(tool_result_json)
current_message["tool_call_id"] = tool_result.get("tool_call_id", "")
current_message["name"] = tool_result.get("name", "")
current_message["content"] = tool_result.get("content", "")
except json.JSONDecodeError:
pass
continue
# Parse "内容: xxx"
if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip()
@@ -617,7 +655,7 @@ class AgentMemory:
# Content line
if current_message:
if current_message["content"]:
if current_message.get("content"):
current_message["content"] += "\n" + line
else:
current_message["content"] = line

View File

@@ -20,7 +20,7 @@ class ChatRequest(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
"""
agent_id: int
agent_id: str # 支持 UUID 字符串
message: str
user_id: int = 0
session_id: str | None = None
@@ -37,7 +37,7 @@ class ChatResponse(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
"""
agent_id: int
agent_id: str # 支持 UUID 字符串
response: str
tool_calls: list = []
tokens_used: int = 0
@@ -209,7 +209,10 @@ async def chat_stream(request: ChatRequest):
Yields:
Streaming response chunks in SSE format
"""
logger.info(f"[chat_stream] Received request: agent_id={request.agent_id}, message={request.message[:50]}...")
if _agent is None:
logger.error("[chat_stream] Agent not initialized!")
raise HTTPException(status_code=500, detail="Agent not initialized")
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
@@ -217,6 +220,8 @@ async def chat_stream(request: ChatRequest):
async def generate() -> AsyncGenerator[str, None]:
"""Generate streaming response."""
try:
logger.info(f"[chat_stream] Starting stream for session: {session_id}")
# Prepare kwargs for agent.chat()
kwargs = {
"message": request.message,
@@ -225,28 +230,38 @@ async def chat_stream(request: ChatRequest):
if request.model_id:
kwargs["model_id"] = request.model_id
logger.info(f"[chat_stream] Using model_id: {request.model_id}")
if request.model_name:
kwargs["model_name"] = request.model_name
logger.info(f"[chat_stream] Using model_name: {request.model_name}")
if request.model_provider:
kwargs["model_provider"] = request.model_provider
logger.info(f"[chat_stream] Using model_provider: {request.model_provider}")
if request.api_key:
kwargs["api_key"] = request.api_key
logger.info(f"[chat_stream] Using api_key: {request.api_key[:10]}...")
if request.base_url:
kwargs["base_url"] = request.base_url
logger.info(f"[chat_stream] Using base_url: {request.base_url}")
if request.use_xbot:
kwargs["use_xbot"] = request.use_xbot
logger.info(f"[chat_stream] Using use_xbot: {request.use_xbot}")
# Process with streaming
chunk_count = 0
async for chunk in _agent.chat_stream(**kwargs):
# SSE format: "data: <json>\n\n"
yield f"data: {json.dumps(chunk)}\n\n"
chunk_count += 1
logger.info(f"[chat_stream] Yielding chunk {chunk_count}: {chunk}")
# SSE format: "data: <json>\n\n" - ensure_ascii=False to output UTF-8 characters directly
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
logger.info(f"[chat_stream] Stream complete, yielded {chunk_count} chunks")
# Send final message
yield f"data: {json.dumps({'done': True, 'session_id': session_id})}\n\n"
yield f"data: {json.dumps({'done': True, 'session_id': session_id}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.exception(f"Error in streaming chat: {e}")
yield f"data: {json.dumps({'error': str(e)})}\n\n"
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
from fastapi.responses import StreamingResponse

View File

@@ -17,3 +17,7 @@ chromadb>=0.4.0
# Utilities
python-dotenv>=1.0.0
# Sandbox isolation (optional)
# Install gVisor for enhanced sandbox: https://gvisor.dev/
# Or use bwrapfs which is available on most Linux systems

View File

@@ -21,19 +21,55 @@ from agents.tools.builtin import (
from agents.tools.manager import ToolManager
def create_default_registry() -> ToolRegistry:
def create_default_registry(use_sandbox: bool = False) -> ToolRegistry:
"""Create a tool registry with default tools.
Args:
use_sandbox: Whether to use sandbox for shell execution
Returns:
Tool registry with built-in tools
"""
registry = ToolRegistry()
# Register built-in tools
for tool in get_builtin_tools():
for tool in get_builtin_tools(use_sandbox=use_sandbox):
registry.register(tool)
return registry
# Import sandbox tools from nanobot (optional)
try:
from nanobot.agent.tools.sandbox_execution import (
SandboxType,
SandboxCodeExecutionTool,
SandboxBashTool,
get_sandbox_tools,
)
from nanobot.agent.tools.bwrap_sandbox import (
BwrapSandbox,
get_bwrap_sandbox,
execute_in_bwrap,
)
from nanobot.agent.tools.gvisor_sandbox import (
GvisorSandbox,
get_gvisor_sandbox,
execute_in_gvisor,
)
SANDBOX_AVAILABLE = True
except ImportError as e:
SandboxType = None
SandboxCodeExecutionTool = None
SandboxBashTool = None
get_sandbox_tools = None
BwrapSandbox = None
get_bwrap_sandbox = None
execute_in_bwrap = None
GvisorSandbox = None
get_gvisor_sandbox = None
execute_in_gvisor = None
SANDBOX_AVAILABLE = False
__all__ = [
"Tool",
"ToolRegistry",
@@ -48,4 +84,16 @@ __all__ = [
"CalculatorTool",
"GetTimeTool",
"BashTool",
# Sandbox tools
"SANDBOX_AVAILABLE",
"SandboxType",
"SandboxCodeExecutionTool",
"SandboxBashTool",
"get_sandbox_tools",
"BwrapSandbox",
"GvisorSandbox",
"get_bwrap_sandbox",
"get_gvisor_sandbox",
"execute_in_bwrap",
"execute_in_gvisor",
]

View File

@@ -2,12 +2,24 @@
import asyncio
import json
import os
import re
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
# Import sandbox (optional - graceful fallback if not available)
try:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox, get_bwrap_sandbox
from nanobot.agent.tools.sandbox_execution import SandboxType
SANDBOX_AVAILABLE = True
except ImportError:
BwrapSandbox = None
get_bwrap_sandbox = None
SandboxType = None
SANDBOX_AVAILABLE = False
class ReadFileTool(Tool):
"""Read file contents."""
@@ -361,8 +373,18 @@ class GetTimeTool(Tool):
class BashTool(Tool):
"""Execute bash commands."""
def __init__(self, workspace: Path | None = None):
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize bash tool.
Args:
workspace: Workspace path
use_sandbox: Whether to use sandbox for execution (recommended for untrusted code)
"""
self._workspace = workspace
self._use_sandbox = use_sandbox
self._sandbox = None
if use_sandbox and SANDBOX_AVAILABLE:
self._sandbox = get_bwrap_sandbox()
@property
def name(self) -> str:
@@ -370,11 +392,13 @@ class BashTool(Tool):
@property
def description(self) -> str:
if self._use_sandbox:
return "Execute a bash command in an isolated sandbox and return its output."
return "Execute a bash command and return its output."
@property
def parameters(self) -> dict[str, Any]:
return {
params = {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to execute"},
@@ -386,8 +410,17 @@ class BashTool(Tool):
},
"required": ["command"],
}
return params
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
# Use sandbox if enabled
if self._use_sandbox and self._sandbox:
try:
return await self._sandbox.execute_command(command, timeout)
except Exception as e:
return f"Error executing in sandbox: {str(e)}\nFalling back to direct execution."
# Direct execution (no sandbox)
try:
process = await asyncio.create_subprocess_shell(
command,
@@ -410,11 +443,12 @@ class BashTool(Tool):
return f"Error executing command: {str(e)}"
def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
def get_builtin_tools(workspace: Path | None = None, use_sandbox: bool = False) -> list[Tool]:
"""Get list of all built-in tools.
Args:
workspace: Optional workspace path for file operations
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
Returns:
List of Tool instances
@@ -427,5 +461,5 @@ def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
WebSearchTool(),
CalculatorTool(),
GetTimeTool(),
BashTool(workspace),
BashTool(workspace, use_sandbox=use_sandbox),
]

View File

@@ -14,22 +14,24 @@ logger = logging.getLogger(__name__)
class ToolManager:
"""Manages tools for the agent."""
def __init__(self, workspace: Path | None = None):
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize tool manager.
Args:
workspace: Optional workspace path
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
"""
self.workspace = workspace
self.use_sandbox = use_sandbox
self.registry = ToolRegistry()
self._load_builtin_tools()
def _load_builtin_tools(self) -> None:
"""Load all built-in tools."""
tools = get_builtin_tools(self.workspace)
tools = get_builtin_tools(self.workspace, use_sandbox=self.use_sandbox)
for tool in tools:
self.registry.register(tool)
logger.info(f"Loaded {len(tools)} built-in tools")
logger.info(f"Loaded {len(tools)} built-in tools (sandbox: {self.use_sandbox})")
def register_tool(self, tool: Any) -> None:
"""Register a custom tool.