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 # Agent settings
MAX_ITERATIONS=10 MAX_ITERATIONS=10
TEMPERATURE=0.7 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 [] 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 # Check if dynamic provider parameters are provided
if api_key or model_provider: if api_key or model_provider:
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}") 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 Agent response content
""" """
history = history or [] 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 provider = provider or self.provider
model = model or self.model model = model or self.model
@@ -191,6 +216,18 @@ class AgentLoop:
""" """
history = history or [] 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 # Check if dynamic provider parameters are provided
if api_key or model_provider: 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}") 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 Response content chunks
""" """
history = history or [] 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 provider = provider or self.provider
model = model or self.model model = model or self.model
@@ -461,3 +511,19 @@ class AgentLoop:
self.memory.add_to_history("user", str(content)[:1000], session_key) self.memory.add_to_history("user", str(content)[:1000], session_key)
elif role == "assistant" and content: elif role == "assistant" and content:
self.memory.add_to_history("assistant", str(content)[:1000], session_key) 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: except:
pass pass
# Check if content contains tool_calls or tool_result markers
# Format as Markdown (产品经理指定格式) # 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: with open(session_file, "a", encoding="utf-8") as f:
if header: if header:
@@ -610,6 +627,27 @@ class AgentMemory:
current_message["timestamp"] = line.split(":", 1)[1].strip() current_message["timestamp"] = line.split(":", 1)[1].strip()
continue 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" # Parse "内容: xxx"
if line.startswith("内容:") and current_message is not None: if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip() current_message["content"] = line.split(":", 1)[1].strip()
@@ -617,7 +655,7 @@ class AgentMemory:
# Content line # Content line
if current_message: if current_message:
if current_message["content"]: if current_message.get("content"):
current_message["content"] += "\n" + line current_message["content"] += "\n" + line
else: else:
current_message["content"] = line current_message["content"] = line

View File

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

View File

@@ -17,3 +17,7 @@ chromadb>=0.4.0
# Utilities # Utilities
python-dotenv>=1.0.0 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 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. """Create a tool registry with default tools.
Args:
use_sandbox: Whether to use sandbox for shell execution
Returns: Returns:
Tool registry with built-in tools Tool registry with built-in tools
""" """
registry = ToolRegistry() registry = ToolRegistry()
# Register built-in tools # Register built-in tools
for tool in get_builtin_tools(): for tool in get_builtin_tools(use_sandbox=use_sandbox):
registry.register(tool) registry.register(tool)
return registry 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__ = [ __all__ = [
"Tool", "Tool",
"ToolRegistry", "ToolRegistry",
@@ -48,4 +84,16 @@ __all__ = [
"CalculatorTool", "CalculatorTool",
"GetTimeTool", "GetTimeTool",
"BashTool", "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 asyncio
import json import json
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool 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): class ReadFileTool(Tool):
"""Read file contents.""" """Read file contents."""
@@ -361,8 +373,18 @@ class GetTimeTool(Tool):
class BashTool(Tool): class BashTool(Tool):
"""Execute bash commands.""" """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._workspace = workspace
self._use_sandbox = use_sandbox
self._sandbox = None
if use_sandbox and SANDBOX_AVAILABLE:
self._sandbox = get_bwrap_sandbox()
@property @property
def name(self) -> str: def name(self) -> str:
@@ -370,11 +392,13 @@ class BashTool(Tool):
@property @property
def description(self) -> str: 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." return "Execute a bash command and return its output."
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
return { params = {
"type": "object", "type": "object",
"properties": { "properties": {
"command": {"type": "string", "description": "Command to execute"}, "command": {"type": "string", "description": "Command to execute"},
@@ -386,8 +410,17 @@ class BashTool(Tool):
}, },
"required": ["command"], "required": ["command"],
} }
return params
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str: 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: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
@@ -410,11 +443,12 @@ class BashTool(Tool):
return f"Error executing command: {str(e)}" 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. """Get list of all built-in tools.
Args: Args:
workspace: Optional workspace path for file operations workspace: Optional workspace path for file operations
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
Returns: Returns:
List of Tool instances List of Tool instances
@@ -427,5 +461,5 @@ def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
WebSearchTool(), WebSearchTool(),
CalculatorTool(), CalculatorTool(),
GetTimeTool(), GetTimeTool(),
BashTool(workspace), BashTool(workspace, use_sandbox=use_sandbox),
] ]

View File

@@ -14,22 +14,24 @@ logger = logging.getLogger(__name__)
class ToolManager: class ToolManager:
"""Manages tools for the agent.""" """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. """Initialize tool manager.
Args: Args:
workspace: Optional workspace path workspace: Optional workspace path
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
""" """
self.workspace = workspace self.workspace = workspace
self.use_sandbox = use_sandbox
self.registry = ToolRegistry() self.registry = ToolRegistry()
self._load_builtin_tools() self._load_builtin_tools()
def _load_builtin_tools(self) -> None: def _load_builtin_tools(self) -> None:
"""Load all built-in tools.""" """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: for tool in tools:
self.registry.register(tool) 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: def register_tool(self, tool: Any) -> None:
"""Register a custom tool. """Register a custom tool.