Skip to content

Commit 67d6c1c

Browse files
Merge pull request #1206 from XYDT-AI/sandbox
Feat: support 4 sandbox tools by using daytona
2 parents 20c7207 + efccc9f commit 67d6c1c

24 files changed

+2932
-53
lines changed

app/agent/browser.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.prompt.browser import NEXT_STEP_PROMPT, SYSTEM_PROMPT
99
from app.schema import Message, ToolChoice
1010
from app.tool import BrowserUseTool, Terminate, ToolCollection
11+
from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool
1112

1213

1314
# Avoid circular import if BrowserAgent needs BrowserContextHelper
@@ -22,6 +23,10 @@ def __init__(self, agent: "BaseAgent"):
2223

2324
async def get_browser_state(self) -> Optional[dict]:
2425
browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name)
26+
if not browser_tool:
27+
browser_tool = self.agent.available_tools.get_tool(
28+
SandboxBrowserTool().name
29+
)
2530
if not browser_tool or not hasattr(browser_tool, "get_current_state"):
2631
logger.warning("BrowserUseTool not found or doesn't have get_current_state")
2732
return None

app/agent/sandbox_agent.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
from typing import Dict, List, Optional
2+
3+
from pydantic import Field, model_validator
4+
5+
from app.agent.browser import BrowserContextHelper
6+
from app.agent.toolcall import ToolCallAgent
7+
from app.config import config
8+
from app.daytona.sandbox import create_sandbox, delete_sandbox
9+
from app.daytona.tool_base import SandboxToolsBase
10+
from app.logger import logger
11+
from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT
12+
from app.tool import Terminate, ToolCollection
13+
from app.tool.ask_human import AskHuman
14+
from app.tool.mcp import MCPClients, MCPClientTool
15+
from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool
16+
from app.tool.sandbox.sb_files_tool import SandboxFilesTool
17+
from app.tool.sandbox.sb_shell_tool import SandboxShellTool
18+
from app.tool.sandbox.sb_vision_tool import SandboxVisionTool
19+
20+
21+
class SandboxManus(ToolCallAgent):
22+
"""A versatile general-purpose agent with support for both local and MCP tools."""
23+
24+
name: str = "SandboxManus"
25+
description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools"
26+
27+
system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root)
28+
next_step_prompt: str = NEXT_STEP_PROMPT
29+
30+
max_observe: int = 10000
31+
max_steps: int = 20
32+
33+
# MCP clients for remote tool access
34+
mcp_clients: MCPClients = Field(default_factory=MCPClients)
35+
36+
# Add general-purpose tools to the tool collection
37+
available_tools: ToolCollection = Field(
38+
default_factory=lambda: ToolCollection(
39+
# PythonExecute(),
40+
# BrowserUseTool(),
41+
# StrReplaceEditor(),
42+
AskHuman(),
43+
Terminate(),
44+
)
45+
)
46+
47+
special_tool_names: list[str] = Field(default_factory=lambda: [Terminate().name])
48+
browser_context_helper: Optional[BrowserContextHelper] = None
49+
50+
# Track connected MCP servers
51+
connected_servers: Dict[str, str] = Field(
52+
default_factory=dict
53+
) # server_id -> url/command
54+
_initialized: bool = False
55+
sandbox_link: Optional[dict[str, dict[str, str]]] = Field(default_factory=dict)
56+
57+
@model_validator(mode="after")
58+
def initialize_helper(self) -> "SandboxManus":
59+
"""Initialize basic components synchronously."""
60+
self.browser_context_helper = BrowserContextHelper(self)
61+
return self
62+
63+
@classmethod
64+
async def create(cls, **kwargs) -> "SandboxManus":
65+
"""Factory method to create and properly initialize a Manus instance."""
66+
instance = cls(**kwargs)
67+
await instance.initialize_mcp_servers()
68+
await instance.initialize_sandbox_tools()
69+
instance._initialized = True
70+
return instance
71+
72+
async def initialize_sandbox_tools(
73+
self,
74+
password: str = config.daytona.VNC_password,
75+
) -> None:
76+
try:
77+
# 创建新沙箱
78+
if password:
79+
sandbox = create_sandbox(password=password)
80+
self.sandbox = sandbox
81+
else:
82+
raise ValueError("password must be provided")
83+
vnc_link = sandbox.get_preview_link(6080)
84+
website_link = sandbox.get_preview_link(8080)
85+
vnc_url = vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link)
86+
website_url = (
87+
website_link.url if hasattr(website_link, "url") else str(website_link)
88+
)
89+
90+
# Get the actual sandbox_id from the created sandbox
91+
actual_sandbox_id = sandbox.id if hasattr(sandbox, "id") else "new_sandbox"
92+
if not self.sandbox_link:
93+
self.sandbox_link = {}
94+
self.sandbox_link[actual_sandbox_id] = {
95+
"vnc": vnc_url,
96+
"website": website_url,
97+
}
98+
logger.info(f"VNC URL: {vnc_url}")
99+
logger.info(f"Website URL: {website_url}")
100+
SandboxToolsBase._urls_printed = True
101+
sb_tools = [
102+
SandboxBrowserTool(sandbox),
103+
SandboxFilesTool(sandbox),
104+
SandboxShellTool(sandbox),
105+
SandboxVisionTool(sandbox),
106+
]
107+
self.available_tools.add_tools(*sb_tools)
108+
109+
except Exception as e:
110+
logger.error(f"Error initializing sandbox tools: {e}")
111+
raise
112+
113+
async def initialize_mcp_servers(self) -> None:
114+
"""Initialize connections to configured MCP servers."""
115+
for server_id, server_config in config.mcp_config.servers.items():
116+
try:
117+
if server_config.type == "sse":
118+
if server_config.url:
119+
await self.connect_mcp_server(server_config.url, server_id)
120+
logger.info(
121+
f"Connected to MCP server {server_id} at {server_config.url}"
122+
)
123+
elif server_config.type == "stdio":
124+
if server_config.command:
125+
await self.connect_mcp_server(
126+
server_config.command,
127+
server_id,
128+
use_stdio=True,
129+
stdio_args=server_config.args,
130+
)
131+
logger.info(
132+
f"Connected to MCP server {server_id} using command {server_config.command}"
133+
)
134+
except Exception as e:
135+
logger.error(f"Failed to connect to MCP server {server_id}: {e}")
136+
137+
async def connect_mcp_server(
138+
self,
139+
server_url: str,
140+
server_id: str = "",
141+
use_stdio: bool = False,
142+
stdio_args: List[str] = None,
143+
) -> None:
144+
"""Connect to an MCP server and add its tools."""
145+
if use_stdio:
146+
await self.mcp_clients.connect_stdio(
147+
server_url, stdio_args or [], server_id
148+
)
149+
self.connected_servers[server_id or server_url] = server_url
150+
else:
151+
await self.mcp_clients.connect_sse(server_url, server_id)
152+
self.connected_servers[server_id or server_url] = server_url
153+
154+
# Update available tools with only the new tools from this server
155+
new_tools = [
156+
tool for tool in self.mcp_clients.tools if tool.server_id == server_id
157+
]
158+
self.available_tools.add_tools(*new_tools)
159+
160+
async def disconnect_mcp_server(self, server_id: str = "") -> None:
161+
"""Disconnect from an MCP server and remove its tools."""
162+
await self.mcp_clients.disconnect(server_id)
163+
if server_id:
164+
self.connected_servers.pop(server_id, None)
165+
else:
166+
self.connected_servers.clear()
167+
168+
# Rebuild available tools without the disconnected server's tools
169+
base_tools = [
170+
tool
171+
for tool in self.available_tools.tools
172+
if not isinstance(tool, MCPClientTool)
173+
]
174+
self.available_tools = ToolCollection(*base_tools)
175+
self.available_tools.add_tools(*self.mcp_clients.tools)
176+
177+
async def delete_sandbox(self, sandbox_id: str) -> None:
178+
"""Delete a sandbox by ID."""
179+
try:
180+
await delete_sandbox(sandbox_id)
181+
logger.info(f"Sandbox {sandbox_id} deleted successfully")
182+
if sandbox_id in self.sandbox_link:
183+
del self.sandbox_link[sandbox_id]
184+
except Exception as e:
185+
logger.error(f"Error deleting sandbox {sandbox_id}: {e}")
186+
raise e
187+
188+
async def cleanup(self):
189+
"""Clean up Manus agent resources."""
190+
if self.browser_context_helper:
191+
await self.browser_context_helper.cleanup_browser()
192+
# Disconnect from all MCP servers only if we were initialized
193+
if self._initialized:
194+
await self.disconnect_mcp_server()
195+
await self.delete_sandbox(self.sandbox.id if self.sandbox else "unknown")
196+
self._initialized = False
197+
198+
async def think(self) -> bool:
199+
"""Process current state and decide next actions with appropriate context."""
200+
if not self._initialized:
201+
await self.initialize_mcp_servers()
202+
self._initialized = True
203+
204+
original_prompt = self.next_step_prompt
205+
recent_messages = self.memory.messages[-3:] if self.memory.messages else []
206+
browser_in_use = any(
207+
tc.function.name == SandboxBrowserTool().name
208+
for msg in recent_messages
209+
if msg.tool_calls
210+
for tc in msg.tool_calls
211+
)
212+
213+
if browser_in_use:
214+
self.next_step_prompt = (
215+
await self.browser_context_helper.format_next_step_prompt()
216+
)
217+
218+
result = await super().think()
219+
220+
# Restore original prompt
221+
self.next_step_prompt = original_prompt
222+
223+
return result

app/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ class SandboxSettings(BaseModel):
105105
)
106106

107107

108+
class DaytonaSettings(BaseModel):
109+
daytona_api_key: str
110+
daytona_server_url: Optional[str] = Field(
111+
"https://app.daytona.io/api", description=""
112+
)
113+
daytona_target: Optional[str] = Field("us", description="enum ['eu', 'us']")
114+
sandbox_image_name: Optional[str] = Field("whitezxj/sandbox:0.1.0", description="")
115+
sandbox_entrypoint: Optional[str] = Field(
116+
"/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
117+
description="",
118+
)
119+
# sandbox_id: Optional[str] = Field(
120+
# None, description="ID of the daytona sandbox to use, if any"
121+
# )
122+
VNC_password: Optional[str] = Field(
123+
"123456", description="VNC password for the vnc service in sandbox"
124+
)
125+
126+
108127
class MCPServerConfig(BaseModel):
109128
"""Configuration for a single MCP server"""
110129

@@ -167,6 +186,9 @@ class AppConfig(BaseModel):
167186
run_flow_config: Optional[RunflowSettings] = Field(
168187
None, description="Run flow configuration"
169188
)
189+
daytona_config: Optional[DaytonaSettings] = Field(
190+
None, description="Daytona configuration"
191+
)
170192

171193
class Config:
172194
arbitrary_types_allowed = True
@@ -268,6 +290,11 @@ def _load_initial_config(self):
268290
sandbox_settings = SandboxSettings(**sandbox_config)
269291
else:
270292
sandbox_settings = SandboxSettings()
293+
daytona_config = raw_config.get("daytona", {})
294+
if daytona_config:
295+
daytona_settings = DaytonaSettings(**daytona_config)
296+
else:
297+
daytona_settings = DaytonaSettings()
271298

272299
mcp_config = raw_config.get("mcp", {})
273300
mcp_settings = None
@@ -296,6 +323,7 @@ def _load_initial_config(self):
296323
"search_config": search_settings,
297324
"mcp_config": mcp_settings,
298325
"run_flow_config": run_flow_settings,
326+
"daytona_config": daytona_settings,
299327
}
300328

301329
self._config = AppConfig(**config_dict)
@@ -308,6 +336,10 @@ def llm(self) -> Dict[str, LLMSettings]:
308336
def sandbox(self) -> SandboxSettings:
309337
return self._config.sandbox
310338

339+
@property
340+
def daytona(self) -> DaytonaSettings:
341+
return self._config.daytona_config
342+
311343
@property
312344
def browser_config(self) -> Optional[BrowserSettings]:
313345
return self._config.browser_config

app/daytona/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Agent with Daytona sandbox
2+
3+
4+
5+
6+
## Prerequisites
7+
- conda activate 'Your OpenManus python env'
8+
- pip install daytona==0.21.8 structlog==25.4.0
9+
10+
11+
12+
## Setup & Running
13+
14+
1. daytona config :
15+
```bash
16+
cd OpenManus
17+
cp config/config.example-daytona.toml config/config.toml
18+
```
19+
2. get daytona apikey :
20+
goto https://app.daytona.io/dashboard/keys and create your apikey
21+
22+
3. set your apikey in config.toml
23+
```toml
24+
# daytona config
25+
[daytona]
26+
daytona_api_key = ""
27+
#daytona_server_url = "https://app.daytona.io/api"
28+
#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu)
29+
#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless
30+
#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless
31+
#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set
32+
```
33+
2. Run :
34+
35+
```bash
36+
cd OpenManus
37+
python sandbox_main.py
38+
```
39+
40+
3. Send tasks to Agent
41+
You can sent tasks to Agent by terminate,agent will use sandbox tools to handle your tasks.
42+
43+
4. See results
44+
If agent use sb_browser_use tool, you can see the operations by VNC link, The VNC link will print in the termination,e.g.:https://6080-sandbox-123456.h7890.daytona.work.
45+
If agent use sb_shell tool, you can see the results by terminate of sandbox in https://app.daytona.io/dashboard/sandboxes.
46+
Agent can use sb_files tool to operate files to sandbox.
47+
48+
49+
## Example
50+
51+
You can send task e.g.:"帮我在https://hk.trip.com/travel-guide/guidebook/nanjing-9696/?ishideheader=true&isHideNavBar=YES&disableFontScaling=1&catalogId=514634&locale=zh-HK查询相关信息上制定一份南京旅游攻略,并在工作区保存为index.html"
52+
53+
Then you can see the agent's browser action in VNC link(https://6080-sandbox-123456.h7890.proxy.daytona.work) and you can see the html made by agent in Website URL(https://8080-sandbox-123456.h7890.proxy.daytona.work).
54+
55+
## Learn More
56+
57+
- [Daytona Documentation](https://www.daytona.io/docs/)

0 commit comments

Comments
 (0)