add README and delete sandbox_id

This commit is contained in:
GhostC
2025-07-27 18:36:27 +08:00
parent 50f56f5708
commit f99dfac5bb
10 changed files with 403 additions and 254 deletions
+53 -57
View File
@@ -1,11 +1,14 @@
import json
from typing import Dict, List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Dict, List, Optional
import daytona
from pydantic import Field, model_validator
from app.agent.browser import BrowserContextHelper
from app.agent.toolcall import ToolCallAgent
from app.config import config
from app.daytona.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox
from app.daytona.tool_base import SandboxToolsBase
from app.logger import logger
from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT
from app.schema import Message
@@ -14,20 +17,20 @@ from app.tool.ask_human import AskHuman
from app.tool.browser_use_tool import BrowserUseTool
from app.tool.mcp import MCPClients, MCPClientTool
from app.tool.python_execute import PythonExecute
from app.tool.sb_browser_tool import SandboxBrowserTool
from app.tool.sb_files_tool import SandboxFilesTool
from app.tool.sb_shell_tool import SandboxShellTool
from app.tool.sb_vision_tool import SandboxVisionTool
from app.tool.str_replace_editor import StrReplaceEditor
from app.tool.sb_browser_tool import SandboxBrowserTool
from app.daytona.sandbox import create_sandbox,get_or_start_sandbox
import daytona
class SandboxManus(ToolCallAgent):
"""A versatile general-purpose agent with support for both local and MCP tools."""
name: str = "SandboxManus"
description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools"
description: str = (
"A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools"
)
system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root)
next_step_prompt: str = NEXT_STEP_PROMPT
@@ -74,66 +77,41 @@ class SandboxManus(ToolCallAgent):
instance._initialized = True
return instance
async def initialize_sandbox_tools(self, sandbox_id: str = config.daytona.sandbox_id, password:str = config.daytona.VNC_password) -> None:
async def initialize_sandbox_tools(
self,
password: str = config.daytona.VNC_password,
) -> None:
try:
if sandbox_id:
sandbox = await get_or_start_sandbox(sandbox_id)
# Initialize sandbox_link if not exists
if not self.sandbox_link:
self.sandbox_link = {}
# Check if URLs are already cached
if sandbox_id in self.sandbox_link:
vnc_url = self.sandbox_link[sandbox_id]["vnc"]
website_url = self.sandbox_link[sandbox_id]["website"]
logger.info(f"Using cached URLs - VNC: {vnc_url}, Website: {website_url}")
else:
# Get URLs from sandbox and cache them
vnc_link = sandbox.get_preview_link(6080)
website_link = sandbox.get_preview_link(8080)
vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link)
website_url = website_link.url if hasattr(website_link, 'url') else str(website_link)
# Cache the URLs
self.sandbox_link[sandbox_id] = {
"vnc": str(vnc_url),
"website": str(website_url)
}
logger.info(f"Cached new URLs - VNC: {vnc_url}, Website: {website_url}")
logger.info(f"VNC URL: {vnc_url}")
logger.info(f"Website URL: {website_url}")
# 创建新沙箱
if password:
sandbox = create_sandbox(password=password)
self.sandbox = sandbox
else:
# 创建新沙箱
if password:
sandbox = create_sandbox(password=password)
else:
raise ValueError("Sandbox ID or password must be provided")
vnc_link = sandbox.get_preview_link(6080)
website_link = sandbox.get_preview_link(8080)
vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link)
website_url = website_link.url if hasattr(website_link, 'url') else str(website_link)
# Get the actual sandbox_id from the created sandbox
actual_sandbox_id = sandbox.id if hasattr(sandbox, 'id') else 'new_sandbox'
if not self.sandbox_link:
self.sandbox_link = {}
self.sandbox_link[actual_sandbox_id] = {
"vnc": vnc_url,
"website": website_url,
}
logger.info(f"VNC URL: {vnc_url}")
logger.info(f"Website URL: {website_url}")
raise ValueError("password must be provided")
vnc_link = sandbox.get_preview_link(6080)
website_link = sandbox.get_preview_link(8080)
vnc_url = vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link)
website_url = (
website_link.url if hasattr(website_link, "url") else str(website_link)
)
# Get the actual sandbox_id from the created sandbox
actual_sandbox_id = sandbox.id if hasattr(sandbox, "id") else "new_sandbox"
if not self.sandbox_link:
self.sandbox_link = {}
self.sandbox_link[actual_sandbox_id] = {
"vnc": vnc_url,
"website": website_url,
}
logger.info(f"VNC URL: {vnc_url}")
logger.info(f"Website URL: {website_url}")
SandboxToolsBase._urls_printed = True
sb_tools = [
SandboxBrowserTool(sandbox),
SandboxFilesTool(sandbox),
SandboxShellTool(sandbox),
SandboxVisionTool(sandbox)
SandboxVisionTool(sandbox),
]
# for sb_tool in sb_tools:
# self.available_tools.add_tool(sb_tool)
self.available_tools.add_tools(*sb_tools)
except Exception as e:
@@ -204,6 +182,17 @@ class SandboxManus(ToolCallAgent):
self.available_tools = ToolCollection(*base_tools)
self.available_tools.add_tools(*self.mcp_clients.tools)
async def delete_sandbox(self, sandbox_id: str) -> None:
"""Delete a sandbox by ID."""
try:
await delete_sandbox(sandbox_id)
logger.info(f"Sandbox {sandbox_id} deleted successfully")
if sandbox_id in self.sandbox_link:
del self.sandbox_link[sandbox_id]
except Exception as e:
logger.error(f"Error deleting sandbox {sandbox_id}: {e}")
raise e
async def cleanup(self):
"""Clean up Manus agent resources."""
if self.browser_context_helper:
@@ -211,6 +200,7 @@ class SandboxManus(ToolCallAgent):
# Disconnect from all MCP servers only if we were initialized
if self._initialized:
await self.disconnect_mcp_server()
await self.delete_sandbox(self.sandbox.id if self.sandbox else "unknown")
self._initialized = False
async def think(self) -> bool:
@@ -239,3 +229,9 @@ class SandboxManus(ToolCallAgent):
self.next_step_prompt = original_prompt
return result
return result
return result
return result
return result
return result
return result
+17 -6
View File
@@ -107,12 +107,22 @@ class SandboxSettings(BaseModel):
class DaytonaSettings(BaseModel):
daytona_api_key: str
daytona_server_url: Optional[str] = Field("https://app.daytona.io/api", description="")
daytona_target: Optional[str] = Field("us", description="enum ['asia', 'eu', 'us']")
sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="")
sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="")
sandbox_id: Optional[str] = Field(None, description="ID of the daytona sandbox to use, if any")
VNC_password: Optional[str] = Field("123456", description="VNC password for the vnc service in sandbox")
daytona_server_url: Optional[str] = Field(
"https://app.daytona.io/api", description=""
)
daytona_target: Optional[str] = Field("us", description="enum ['eu', 'us']")
sandbox_image_name: Optional[str] = Field("whitezxj/sandbox:0.1.0", description="")
sandbox_entrypoint: Optional[str] = Field(
"/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
description="",
)
# sandbox_id: Optional[str] = Field(
# None, description="ID of the daytona sandbox to use, if any"
# )
VNC_password: Optional[str] = Field(
"123456", description="VNC password for the vnc service in sandbox"
)
class MCPServerConfig(BaseModel):
"""Configuration for a single MCP server"""
@@ -325,6 +335,7 @@ class Config:
@property
def sandbox(self) -> SandboxSettings:
return self._config.sandbox
@property
def daytona(self) -> DaytonaSettings:
return self._config.daytona_config
+44 -25
View File
@@ -1,39 +1,58 @@
# Agent Sandbox
# Agent with Daytona sandbox
This directory contains the agent sandbox implementation - a Docker-based virtual environment that agents use as their own computer to execute tasks, access the web, and manipulate files.
## Overview
The sandbox provides a complete containerized Linux environment with:
- Chrome browser for web interactions
- VNC server for accessing the Web User
- Full file system access
- Full sudo access
## Customizing the Sandbox
## Prerequisites
- conda activate 'Your OpenManus python env'
- pip install daytona==0.21.8 structlog==25.4.0
You can modify the sandbox environment for development or to add new capabilities:
1. Edit files in the `docker/` directory
2. Build a custom image:
## Setup & Running
1. daytona config :
```bash
cd OpenManus
cp config/config.example-daytona.toml config/config.toml
```
cd daytona/docker
docker build
2. get daytona apikey :
goto https://app.daytona.io/dashboard/keys and create your apikey
3. set your apikey in config.toml
```toml
# daytona config
[daytona]
daytona_api_key = ""
#daytona_server_url = "https://app.daytona.io/api"
#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu)
#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless
#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless
#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set
```
3. Test your changes locally using docker-compose
2. Run :
## Using a Custom Image
```bash
cd OpenManus
python sandbox_main.py
```
To use your custom sandbox image:
3. Send tasks to Agent
You can sent tasks to Agent by terminate,agent will use sandbox tools to handle your tasks.
1. Change the `image` parameter in `docker-compose.yml` (that defines the image name `kortix/suna:___`)
2. Update the same image name in `backend/sandbox/sandbox.py` in the `create_sandbox` function
3. If using Daytona for deployment, update the image reference there as well
4. See results
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.
If agent use sb_shell tool, you can see the results by terminate of sandbox in https://app.daytona.io/dashboard/sandboxes.
Agent can use sb_files tool to operate files to sandbox.
## Publishing New Versions
When publishing a new version of the sandbox:
## Example
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"
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).
## Learn More
- [Daytona Documentation](https://www.daytona.io/docs/)
4. Update all references to the image version in:
- Daytona images
- Any other services using this image
-3
View File
@@ -1,3 +0,0 @@
structlog
daytona
litellm
+39 -22
View File
@@ -1,36 +1,44 @@
from daytona import Daytona, DaytonaConfig, CreateSandboxFromImageParams, Sandbox, SessionExecuteRequest, Resources, SandboxState
from daytona import (
CreateSandboxFromImageParams,
Daytona,
DaytonaConfig,
Resources,
Sandbox,
SandboxState,
SessionExecuteRequest,
)
from dotenv import load_dotenv
from app.utils.logger import logger
# from app.utils.config import config
# from utils.config import config
# from app.utils.config import Configuration
from app.config import config
from app.utils.logger import logger
# load_dotenv()
daytona_settings=config.daytona
logger.debug("Initializing Daytona sandbox configuration")
daytona_settings = config.daytona
logger.info("Initializing Daytona sandbox configuration")
daytona_config = DaytonaConfig(
api_key=daytona_settings.daytona_api_key,
server_url=daytona_settings.daytona_server_url,
target=daytona_settings.daytona_target
target=daytona_settings.daytona_target,
)
if daytona_config.api_key:
logger.debug("Daytona API key configured successfully")
logger.info("Daytona API key configured successfully")
else:
logger.warning("No Daytona API key found in environment variables")
if daytona_config.server_url:
logger.debug(f"Daytona server URL set to: {daytona_config.server_url}")
logger.info(f"Daytona server URL set to: {daytona_config.server_url}")
else:
logger.warning("No Daytona server URL found in environment variables")
if daytona_config.target:
logger.debug(f"Daytona target set to: {daytona_config.target}")
logger.info(f"Daytona target set to: {daytona_config.target}")
else:
logger.warning("No Daytona target found in environment variables")
daytona = Daytona(daytona_config)
logger.debug("Daytona client initialized")
logger.info("Daytona client initialized")
async def get_or_start_sandbox(sandbox_id: str):
"""Retrieve a sandbox by ID, check its state, and start it if needed."""
@@ -41,7 +49,10 @@ async def get_or_start_sandbox(sandbox_id: str):
sandbox = daytona.get(sandbox_id)
# Check if sandbox needs to be started
if sandbox.state == SandboxState.ARCHIVED or sandbox.state == SandboxState.STOPPED:
if (
sandbox.state == SandboxState.ARCHIVED
or sandbox.state == SandboxState.STOPPED
):
logger.info(f"Sandbox is in {sandbox.state} state. Starting...")
try:
daytona.start(sandbox)
@@ -63,6 +74,7 @@ async def get_or_start_sandbox(sandbox_id: str):
logger.error(f"Error retrieving or starting sandbox: {str(e)}")
raise e
def start_supervisord_session(sandbox: Sandbox):
"""Start supervisord in a session."""
session_id = "supervisord-session"
@@ -71,25 +83,29 @@ def start_supervisord_session(sandbox: Sandbox):
sandbox.process.create_session(session_id)
# Execute supervisord command
sandbox.process.execute_session_command(session_id, SessionExecuteRequest(
command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
var_async=True
))
sandbox.process.execute_session_command(
session_id,
SessionExecuteRequest(
command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
var_async=True,
),
)
logger.info(f"Supervisord started in session {session_id}")
except Exception as e:
logger.error(f"Error starting supervisord session: {str(e)}")
raise e
def create_sandbox(password: str, project_id: str = None):
"""Create a new sandbox with all required services configured and running."""
logger.debug("Creating new Daytona sandbox environment")
logger.debug("Configuring sandbox with browser-use image and environment variables")
logger.info("Creating new Daytona sandbox environment")
logger.info("Configuring sandbox with browser-use image and environment variables")
labels = None
if project_id:
logger.debug(f"Using sandbox_id as label: {project_id}")
labels = {'id': project_id}
logger.info(f"Using sandbox_id as label: {project_id}")
labels = {"id": project_id}
params = CreateSandboxFromImageParams(
image=daytona_settings.sandbox_image_name,
@@ -106,7 +122,7 @@ def create_sandbox(password: str, project_id: str = None):
"CHROME_USER_DATA": "",
"CHROME_DEBUGGING_PORT": "9222",
"CHROME_DEBUGGING_HOST": "localhost",
"CHROME_CDP": ""
"CHROME_CDP": "",
},
resources=Resources(
cpu=2,
@@ -127,6 +143,7 @@ def create_sandbox(password: str, project_id: str = None):
logger.debug(f"Sandbox environment successfully initialized")
return sandbox
async def delete_sandbox(sandbox_id: str):
"""Delete a sandbox by its ID."""
logger.info(f"Deleting sandbox with ID: {sandbox_id}")
+74 -48
View File
@@ -1,25 +1,46 @@
import asyncio
from dataclasses import field, dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, ClassVar, Dict, Any
from typing import Any, ClassVar, Dict, Optional
from daytona import Daytona, DaytonaConfig, Sandbox, SandboxState
from pydantic import Field
from app.config import config
from app.daytona.sandbox import (
create_sandbox,
get_or_start_sandbox,
start_supervisord_session,
)
# from app.agentpress.thread_manager import ThreadManager
from app.tool.base import BaseTool, ToolResult
from daytona import Sandbox
from app.utils.logger import logger
from app.utils.files_utils import clean_path
from app.utils.logger import logger
# load_dotenv()
daytona_settings = config.daytona
daytona_config = DaytonaConfig(
api_key=daytona_settings.daytona_api_key,
server_url=daytona_settings.daytona_server_url,
target=daytona_settings.daytona_target,
)
daytona = Daytona(daytona_config)
@dataclass
class ThreadMessage:
"""
Represents a message to be added to a thread.
"""
type: str
content: Dict[str, Any]
is_llm_message: bool = False
metadata: Optional[Dict[str, Any]] = None
timestamp: Optional[float] = field(default_factory=lambda: datetime.now().timestamp())
timestamp: Optional[float] = field(
default_factory=lambda: datetime.now().timestamp()
)
def to_dict(self) -> Dict[str, Any]:
"""Convert the message to a dictionary for API calls"""
@@ -28,9 +49,10 @@ class ThreadMessage:
"content": self.content,
"is_llm_message": self.is_llm_message,
"metadata": self.metadata or {},
"timestamp": self.timestamp
"timestamp": self.timestamp,
}
class SandboxToolsBase(BaseTool):
"""Base class for all sandbox tools that provides project-based sandbox access."""
@@ -48,53 +70,55 @@ class SandboxToolsBase(BaseTool):
workspace_path: str = Field(default="/workspace", exclude=True)
_sessions: dict[str, str] = {}
class Config:
arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager
underscore_attrs_are_private = True
async def _ensure_sandbox(self) -> Sandbox:
"""Ensure we have a valid sandbox instance, retrieving it from the project if needed."""
if self._sandbox is None:
# try:
# # Get database client
# client = await self.thread_manager.db.client
#
# # Get project data
# project = await client.table('projects').select('*').eq('project_id', self.project_id).execute()
# if not project.data or len(project.data) == 0:
# raise ValueError(f"Project {self.project_id} not found")
#
# project_data = project.data[0]
# sandbox_info = project_data.get('sandbox', {})
#
# if not sandbox_info.get('id'):
# raise ValueError(f"No sandbox found for project {self.project_id}")
#
# # Store sandbox info
# self._sandbox_id = sandbox_info['id']
# self._sandbox_pass = sandbox_info.get('pass')
#
# # Get or start the sandbox
# self._sandbox = await get_or_start_sandbox(self._sandbox_id)
#
# # # Log URLs if not already printed
# # if not SandboxToolsBase._urls_printed:
# # vnc_link = self._sandbox.get_preview_link(6080)
# # website_link = self._sandbox.get_preview_link(8080)
#
# # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link)
# # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link)
#
# # print("\033[95m***")
# # print(f"VNC URL: {vnc_url}")
# # print(f"Website URL: {website_url}")
# # print("***\033[0m")
# # SandboxToolsBase._urls_printed = True
#
# except Exception as e:
logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True)
raise Exception
# Get or start the sandbox
try:
self._sandbox = create_sandbox(password=config.daytona.VNC_password)
# Log URLs if not already printed
if not SandboxToolsBase._urls_printed:
vnc_link = self._sandbox.get_preview_link(6080)
website_link = self._sandbox.get_preview_link(8080)
vnc_url = (
vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link)
)
website_url = (
website_link.url
if hasattr(website_link, "url")
else str(website_link)
)
print("\033[95m***")
print(f"VNC URL: {vnc_url}")
print(f"Website URL: {website_url}")
print("***\033[0m")
SandboxToolsBase._urls_printed = True
except Exception as e:
logger.error(f"Error retrieving or starting sandbox: {str(e)}")
raise e
else:
if (
self._sandbox.state == SandboxState.ARCHIVED
or self._sandbox.state == SandboxState.STOPPED
):
logger.info(f"Sandbox is in {self._sandbox.state} state. Starting...")
try:
daytona.start(self._sandbox)
# Wait a moment for the sandbox to initialize
# sleep(5)
# Refresh sandbox state after starting
# Start supervisord in a session when restarting
start_supervisord_session(self._sandbox)
except Exception as e:
logger.error(f"Error starting sandbox: {e}")
raise e
return self._sandbox
@property
@@ -108,7 +132,9 @@ class SandboxToolsBase(BaseTool):
def sandbox_id(self) -> str:
"""Get the sandbox ID, ensuring it exists."""
if self._sandbox_id is None:
raise RuntimeError("Sandbox ID not initialized. Call _ensure_sandbox() first.")
raise RuntimeError(
"Sandbox ID not initialized. Call _ensure_sandbox() first."
)
return self._sandbox_id
def clean_path(self, path: str) -> str:
+116 -53
View File
@@ -1,18 +1,20 @@
import traceback
import json
import base64
import io
from PIL import Image
import json
import traceback
from typing import Optional # Add this import for Optional
from PIL import Image
from pydantic import Field
from app.daytona.tool_base import Sandbox, ThreadMessage # Ensure Sandbox is imported correctly
from app.daytona.tool_base import ( # Ensure Sandbox is imported correctly
Sandbox,
SandboxToolsBase,
ThreadMessage,
)
from app.tool.base import ToolResult
from app.daytona.tool_base import SandboxToolsBase
from app.utils.logger import logger
# Context = TypeVar("Context")
_BROWSER_DESCRIPTION = """\
A sandbox-based browser automation tool that allows interaction with web pages through various actions.
@@ -55,7 +57,7 @@ class SandboxBrowserTool(SandboxToolsBase):
"get_dropdown_options",
"select_dropdown_option",
"click_coordinates",
"drag_drop"
"drag_drop",
],
"description": "The browser action to perform",
},
@@ -102,7 +104,7 @@ class SandboxBrowserTool(SandboxToolsBase):
"element_target": {
"type": "string",
"description": "Target element for drag and drop",
}
},
},
"required": ["action"],
"dependencies": {
@@ -124,13 +126,17 @@ class SandboxBrowserTool(SandboxToolsBase):
}
browser_message: Optional[ThreadMessage] = Field(default=None, exclude=True)
def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data):
def __init__(
self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data
):
"""Initialize with optional sandbox and thread_id."""
super().__init__(**data)
if sandbox is not None:
self._sandbox = sandbox # Directly set the base class private attribute
def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]:
def _validate_base64_image(
self, base64_string: str, max_size_mb: int = 10
) -> tuple[bool, str]:
"""
Validate base64 image data.
Args:
@@ -142,13 +148,14 @@ class SandboxBrowserTool(SandboxToolsBase):
try:
if not base64_string or len(base64_string) < 10:
return False, "Base64 string is empty or too short"
if base64_string.startswith('data:'):
if base64_string.startswith("data:"):
try:
base64_string = base64_string.split(',', 1)[1]
base64_string = base64_string.split(",", 1)[1]
except (IndexError, ValueError):
return False, "Invalid data URL format"
import re
if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', base64_string):
if not re.match(r"^[A-Za-z0-9+/]*={0,2}$", base64_string):
return False, "Invalid base64 characters detected"
if len(base64_string) % 4 != 0:
return False, "Invalid base64 string length"
@@ -163,7 +170,7 @@ class SandboxBrowserTool(SandboxToolsBase):
image_stream = io.BytesIO(image_data)
with Image.open(image_stream) as img:
img.verify()
supported_formats = {'JPEG', 'PNG', 'GIF', 'BMP', 'WEBP', 'TIFF'}
supported_formats = {"JPEG", "PNG", "GIF", "BMP", "WEBP", "TIFF"}
if img.format not in supported_formats:
return False, f"Unsupported image format: {img.format}"
image_stream.seek(0)
@@ -171,7 +178,10 @@ class SandboxBrowserTool(SandboxToolsBase):
width, height = img_check.size
max_dimension = 8192
if width > max_dimension or height > max_dimension:
return False, f"Image dimensions exceed limit ({max_dimension}x{max_dimension})"
return (
False,
f"Image dimensions exceed limit ({max_dimension}x{max_dimension})",
)
if width < 1 or height < 1:
return False, f"Invalid image dimensions: {width}x{height}"
except Exception as e:
@@ -180,16 +190,24 @@ class SandboxBrowserTool(SandboxToolsBase):
except Exception as e:
logger.error(f"Unexpected error during base64 image validation: {e}")
return False, f"Validation error: {str(e)}"
async def _execute_browser_action(self, endpoint: str, params: dict = None, method: str = "POST") -> ToolResult:
async def _execute_browser_action(
self, endpoint: str, params: dict = None, method: str = "POST"
) -> ToolResult:
"""Execute a browser automation action through the sandbox API."""
try:
await self._ensure_sandbox()
url = f"http://localhost:8003/api/automation/{endpoint}"
if method == "GET" and params:
query_params = "&".join([f"{k}={v}" for k, v in params.items()])
url = f"{url}?{query_params}"
curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
curl_cmd = (
f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
)
else:
curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
curl_cmd = (
f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
)
if params:
json_data = json.dumps(params)
curl_cmd += f" -d '{json_data}'"
@@ -202,9 +220,13 @@ class SandboxBrowserTool(SandboxToolsBase):
result.setdefault("role", "assistant")
if "screenshot_base64" in result:
screenshot_data = result["screenshot_base64"]
is_valid, validation_message = self._validate_base64_image(screenshot_data)
is_valid, validation_message = self._validate_base64_image(
screenshot_data
)
if not is_valid:
logger.warning(f"Screenshot validation failed: {validation_message}")
logger.warning(
f"Screenshot validation failed: {validation_message}"
)
result["image_validation_error"] = validation_message
del result["screenshot_base64"]
@@ -215,33 +237,43 @@ class SandboxBrowserTool(SandboxToolsBase):
# is_llm_message=False
# )
message = ThreadMessage(
type="browser_state",
content=result,
is_llm_message=False
type="browser_state", content=result, is_llm_message=False
)
self.browser_message = message
success_response = {
"success": result.get("success", False),
"message": result.get("message", "Browser action completed")
"message": result.get("message", "Browser action completed"),
}
# if added_message and 'message_id' in added_message:
# success_response['message_id'] = added_message['message_id']
for field in ["url", "title", "element_count", "pixels_below", "ocr_text", "image_url"]:
# if added_message and 'message_id' in added_message:
# success_response['message_id'] = added_message['message_id']
for field in [
"url",
"title",
"element_count",
"pixels_below",
"ocr_text",
"image_url",
]:
if field in result:
success_response[field] = result[field]
return (result,self.success_response(success_response)) if success_response["success"] else self.fail_response(success_response)
return (
(result, self.success_response(success_response))
if success_response["success"]
else self.fail_response(success_response)
)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse response JSON: {e}")
return self.fail_response(f"Failed to parse response JSON: {e}")
else:
logger.error(f"Browser automation request failed: {response}")
return self.fail_response(f"Browser automation request failed: {response}")
return self.fail_response(
f"Browser automation request failed: {response}"
)
except Exception as e:
logger.error(f"Error executing browser action: {e}")
logger.debug(traceback.format_exc())
return self.fail_response(f"Error executing browser action: {e}")
async def execute(
self,
action: str,
@@ -256,7 +288,7 @@ class SandboxBrowserTool(SandboxToolsBase):
y: Optional[int] = None,
element_source: Optional[str] = None,
element_target: Optional[str] = None,
**kwargs
**kwargs,
) -> ToolResult:
"""
Execute a browser action in the sandbox environment.
@@ -278,7 +310,7 @@ class SandboxBrowserTool(SandboxToolsBase):
"""
# async with self.lock:
try:
# Navigation actions
# Navigation actions
if action == "navigate_to":
if not url:
return self.fail_response("URL is required for navigation")
@@ -289,11 +321,17 @@ class SandboxBrowserTool(SandboxToolsBase):
elif action == "click_element":
if index is None:
return self.fail_response("Index is required for click_element")
return await self._execute_browser_action("click_element", {"index": index})
return await self._execute_browser_action(
"click_element", {"index": index}
)
elif action == "input_text":
if index is None or not text:
return self.fail_response("Index and text are required for input_text")
return await self._execute_browser_action("input_text", {"index": index, "text": text})
return self.fail_response(
"Index and text are required for input_text"
)
return await self._execute_browser_action(
"input_text", {"index": index, "text": text}
)
elif action == "send_keys":
if not keys:
return self.fail_response("Keys are required for send_keys")
@@ -302,11 +340,15 @@ class SandboxBrowserTool(SandboxToolsBase):
elif action == "switch_tab":
if page_id is None:
return self.fail_response("Page ID is required for switch_tab")
return await self._execute_browser_action("switch_tab", {"page_id": page_id})
return await self._execute_browser_action(
"switch_tab", {"page_id": page_id}
)
elif action == "close_tab":
if page_id is None:
return self.fail_response("Page ID is required for close_tab")
return await self._execute_browser_action("close_tab", {"page_id": page_id})
return await self._execute_browser_action(
"close_tab", {"page_id": page_id}
)
# Scrolling actions
elif action == "scroll_down":
params = {"amount": amount} if amount is not None else {}
@@ -317,32 +359,53 @@ class SandboxBrowserTool(SandboxToolsBase):
elif action == "scroll_to_text":
if not text:
return self.fail_response("Text is required for scroll_to_text")
return await self._execute_browser_action("scroll_to_text", {"text": text})
return await self._execute_browser_action(
"scroll_to_text", {"text": text}
)
# Dropdown actions
elif action == "get_dropdown_options":
if index is None:
return self.fail_response("Index is required for get_dropdown_options")
return await self._execute_browser_action("get_dropdown_options", {"index": index})
return self.fail_response(
"Index is required for get_dropdown_options"
)
return await self._execute_browser_action(
"get_dropdown_options", {"index": index}
)
elif action == "select_dropdown_option":
if index is None or not text:
return self.fail_response("Index and text are required for select_dropdown_option")
return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text})
return self.fail_response(
"Index and text are required for select_dropdown_option"
)
return await self._execute_browser_action(
"select_dropdown_option", {"index": index, "text": text}
)
# Coordinate-based actions
elif action == "click_coordinates":
if x is None or y is None:
return self.fail_response("X and Y coordinates are required for click_coordinates")
return await self._execute_browser_action("click_coordinates", {"x": x, "y": y})
return self.fail_response(
"X and Y coordinates are required for click_coordinates"
)
return await self._execute_browser_action(
"click_coordinates", {"x": x, "y": y}
)
elif action == "drag_drop":
if not element_source or not element_target:
return self.fail_response("Source and target elements are required for drag_drop")
return await self._execute_browser_action("drag_drop", {
"element_source": element_source,
"element_target": element_target
})
return self.fail_response(
"Source and target elements are required for drag_drop"
)
return await self._execute_browser_action(
"drag_drop",
{
"element_source": element_source,
"element_target": element_target,
},
)
# Utility actions
elif action == "wait":
seconds_to_wait = seconds if seconds is not None else 3
return await self._execute_browser_action("wait", {"seconds": seconds_to_wait})
return await self._execute_browser_action(
"wait", {"seconds": seconds_to_wait}
)
else:
return self.fail_response(f"Unknown action: {action}")
except Exception as e:
@@ -365,7 +428,7 @@ class SandboxBrowserTool(SandboxToolsBase):
screenshot = state.get("screenshot_base64")
# Build the state info with all required fields
state_info = {
"url": state.get("url",""),
"url": state.get("url", ""),
"title": state.get("title", ""),
"tabs": [tab.model_dump() for tab in state.get("tabs", [])],
"pixels_above": getattr(state, "pixels_above", 0),
+6 -7
View File
@@ -96,14 +96,13 @@ temperature = 0.0 # Controls randomness for vision mode
#network_enabled = true
# Daytona configuration
#[daytona]
#daytona_api_key = ""
[daytona]
daytona_api_key = ""
#daytona_server_url = "https://app.daytona.io/api"
#daytona_target = "us"
#sandbox_image_name = "kortix/suna:0.1.3"
#sandbox_entrypoint =
#sandbox_id =
#VNC_password =
#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu)
#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless
#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless
#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set
# MCP (Model Context Protocol) configuration
[mcp]
+29 -26
View File
@@ -1,40 +1,43 @@
from daytona import Daytona, DaytonaConfig,CreateSandboxFromImageParams,Resources, Image
from daytona import (
CreateSandboxFromImageParams,
Daytona,
DaytonaConfig,
Image,
Resources,
)
# Using environment variables (DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET)
# daytona = Daytona()
# Using explicit configuration
config = DaytonaConfig(
api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a",
api_url="https://app.daytona.io/api",
target="us"
)
config = DaytonaConfig(api_key="", api_url="https://app.daytona.io/api", target="us")
daytona = Daytona(config)
params = CreateSandboxFromImageParams(
image="kortix/suna:0.1.3",
image="",
# image=Image.debian_slim("3.12"),
public=True,
labels=None,
env_vars={
"CHROME_PERSISTENT_SESSION": "true",
"RESOLUTION": "1024x768x24",
"RESOLUTION_WIDTH": "1024",
"RESOLUTION_HEIGHT": "768",
"VNC_PASSWORD": "123456",
"ANONYMIZED_TELEMETRY": "false",
"CHROME_PATH": "",
"CHROME_USER_DATA": "",
"CHROME_DEBUGGING_PORT": "9222",
"CHROME_DEBUGGING_HOST": "localhost",
"CHROME_CDP": ""
},
"CHROME_PERSISTENT_SESSION": "true",
"RESOLUTION": "1024x768x24",
"RESOLUTION_WIDTH": "1024",
"RESOLUTION_HEIGHT": "768",
"VNC_PASSWORD": "123456",
"ANONYMIZED_TELEMETRY": "false",
"CHROME_PATH": "",
"CHROME_USER_DATA": "",
"CHROME_DEBUGGING_PORT": "9222",
"CHROME_DEBUGGING_HOST": "localhost",
"CHROME_CDP": "",
},
resources=Resources(
cpu=1,
memory=1,
disk=1,
),
cpu=1,
memory=1,
disk=1,
),
auto_stop_interval=15,
auto_archive_interval=24 * 60,
)
# Create the sandbox
)
# Create the sandbox
sandbox = daytona.create(params)
print(f"Sandbox created with ID: {sandbox.id}")
@@ -58,7 +61,7 @@ print(f"Sandbox created with ID: {sandbox.id}")
# Define the configuration
# config = DaytonaConfig(api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a")
# config = DaytonaConfig(api_key="")
# # Initialize the Daytona client
+25 -7
View File
@@ -1,14 +1,18 @@
from app.tool.sb_browser_tool import SandboxBrowserTool
from app.daytona.sandbox import create_sandbox,start_supervisord_session
import asyncio
from app.utils.logger import logger
from daytona import DaytonaConfig, Daytona
import json
from daytona import Daytona, DaytonaConfig
from app.daytona.sandbox import create_sandbox, start_supervisord_session
from app.tool.sb_browser_tool import SandboxBrowserTool
from app.utils.logger import logger
async def main():
# 创建沙箱和工具
sandbox = create_sandbox(password="123456")
# config = DaytonaConfig(
# api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a",
# api_key="",
# api_url="https://app.daytona.io/api",
# target="us"
# )
@@ -42,7 +46,9 @@ async def main():
tool = SandboxBrowserTool(sandbox)
# # 执行截图操作
result,tooresult = await tool.execute(action="navigate_to",url="https://www.github.com")
result, tooresult = await tool.execute(
action="navigate_to", url="https://www.github.com"
)
print(result)
# endpoint = "navigate_to"
@@ -58,11 +64,23 @@ async def main():
# response = sandbox.process.exec(curl_cmd, timeout=30)
# print(f"Response: {response}")
# response = sandbox.process.exec("ls -la")
# print(response.result)
# 清理资源(可选)
# await computer_tool.cleanup()
if __name__ == "__main__":
asyncio.run(main()) # 运行异步主函数
# await computer_tool.cleanup()
if __name__ == "__main__":
asyncio.run(main()) # 运行异步主函数
# await computer_tool.cleanup()
if __name__ == "__main__":
asyncio.run(main()) # 运行异步主函数
# await computer_tool.cleanup()
if __name__ == "__main__":
asyncio.run(main()) # 运行异步主函数