diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index cc4072f..7480f87 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -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 diff --git a/app/config.py b/app/config.py index 3a360be..a881e2a 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/daytona/README.md b/app/daytona/README.md index 93d1eb7..9a707b9 100644 --- a/app/daytona/README.md +++ b/app/daytona/README.md @@ -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 \ No newline at end of file diff --git a/app/daytona/requirements.txt b/app/daytona/requirements.txt deleted file mode 100644 index b2a1486..0000000 --- a/app/daytona/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -structlog -daytona -litellm diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index 5137294..a801a7f 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -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}") diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index f0612ae..6a69b06 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -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: diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index bbb2290..01cffa3 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -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), diff --git a/config/config.example-daytona.toml b/config/config.example-daytona.toml index de065eb..10975df 100644 --- a/config/config.example-daytona.toml +++ b/config/config.example-daytona.toml @@ -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] diff --git a/tests/daytona/test_daytona.py b/tests/daytona/test_daytona.py index 654d67c..ddacad4 100644 --- a/tests/daytona/test_daytona.py +++ b/tests/daytona/test_daytona.py @@ -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 diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py index 0969e37..3b210f7 100644 --- a/tests/daytona/test_sb_browser_use.py +++ b/tests/daytona/test_sb_browser_use.py @@ -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()) # 运行异步主函数