diff --git a/core/agents/.env.example b/core/agents/.env.example index c05e615..9b70416 100644 --- a/core/agents/.env.example +++ b/core/agents/.env.example @@ -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 diff --git a/core/agents/agent/loop.py b/core/agents/agent/loop.py index c2dd6fd..54a0d28 100644 --- a/core/agents/agent/loop.py +++ b/core/agents/agent/loop.py @@ -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) diff --git a/core/agents/agent/memory.py b/core/agents/agent/memory.py index 357468e..07b44b9 100644 --- a/core/agents/agent/memory.py +++ b/core/agents/agent/memory.py @@ -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 diff --git a/core/agents/api/routes.py b/core/agents/api/routes.py index 8bf61cd..1b18bec 100644 --- a/core/agents/api/routes.py +++ b/core/agents/api/routes.py @@ -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: \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: \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 diff --git a/core/agents/requirements.txt b/core/agents/requirements.txt index 554a3d3..74ede05 100644 --- a/core/agents/requirements.txt +++ b/core/agents/requirements.txt @@ -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 diff --git a/core/agents/tools/__init__.py b/core/agents/tools/__init__.py index 3573a61..c7f9866 100644 --- a/core/agents/tools/__init__.py +++ b/core/agents/tools/__init__.py @@ -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", ] diff --git a/core/agents/tools/builtin.py b/core/agents/tools/builtin.py index ca65095..1d33061 100644 --- a/core/agents/tools/builtin.py +++ b/core/agents/tools/builtin.py @@ -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), ] diff --git a/core/agents/tools/manager.py b/core/agents/tools/manager.py index 1736d7d..0cc3b21 100644 --- a/core/agents/tools/manager.py +++ b/core/agents/tools/manager.py @@ -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.