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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user