Merge pull request #373 from kortix-ai/fix/sandbox-browser

Fix/sandbox browser
This commit is contained in:
Marko Kraemer 2025-05-19 04:24:46 +02:00 committed by GitHub
commit 6f68f3ba62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 187 additions and 139 deletions

View File

@ -154,7 +154,6 @@ async def run_agent(
else:
logger.warning("Browser state found but no screenshot data.")
await client.table('messages').delete().eq('message_id', latest_browser_state_msg.data[0]["message_id"]).execute()
except Exception as e:
logger.error(f"Error parsing browser state: {e}")

View File

@ -5,6 +5,7 @@ from agentpress.tool import ToolResult, openapi_schema, xml_schema
from agentpress.thread_manager import ThreadManager
from sandbox.tool_base import SandboxToolsBase
from utils.logger import logger
from utils.s3_upload_utils import upload_base64_image
class SandboxBrowserTool(SandboxToolsBase):
@ -30,7 +31,7 @@ class SandboxBrowserTool(SandboxToolsBase):
await self._ensure_sandbox()
# Build the curl command
url = f"http://localhost:8002/api/automation/{endpoint}"
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()])
@ -59,7 +60,17 @@ class SandboxBrowserTool(SandboxToolsBase):
logger.info("Browser automation request completed successfully")
# Add full result to thread messages for state tracking
if "screenshot_base64" in result:
try:
image_url = await upload_base64_image(result["screenshot_base64"])
result["image_url"] = image_url
# Remove base64 data from result to keep it clean
del result["screenshot_base64"]
logger.debug(f"Uploaded screenshot to {image_url}")
except Exception as e:
logger.error(f"Failed to upload screenshot: {e}")
result["image_upload_error"] = str(e)
added_message = await self.thread_manager.add_message(
thread_id=self.thread_id,
type="browser_state",
@ -67,17 +78,13 @@ class SandboxBrowserTool(SandboxToolsBase):
is_llm_message=False
)
# Return tool-specific success response
success_response = {
"success": True,
"message": result.get("message", "Browser action completed successfully")
}
# Add message ID if available
if added_message and 'message_id' in added_message:
success_response['message_id'] = added_message['message_id']
# Add relevant browser-specific info
if result.get("url"):
success_response["url"] = result["url"]
if result.get("title"):
@ -86,9 +93,10 @@ class SandboxBrowserTool(SandboxToolsBase):
success_response["elements_found"] = result["element_count"]
if result.get("pixels_below"):
success_response["scrollable_content"] = result["pixels_below"] > 0
# Add OCR text when available
if result.get("ocr_text"):
success_response["ocr_text"] = result["ocr_text"]
if result.get("image_url"):
success_response["image_url"] = result["image_url"]
return self.success_response(success_response)
@ -104,6 +112,7 @@ class SandboxBrowserTool(SandboxToolsBase):
logger.debug(traceback.format_exc())
return self.fail_response(f"Error executing browser action: {e}")
@openapi_schema({
"type": "function",
"function": {

View File

@ -978,7 +978,7 @@ class ResponseProcessor:
if value is not None:
params[mapping.param_name] = value
parsing_details["attributes"][mapping.param_name] = value # Store raw attribute
logger.info(f"Found attribute {mapping.param_name}: {value}")
# logger.info(f"Found attribute {mapping.param_name}: {value}")
elif mapping.node_type == "element":
# Extract element content
@ -986,7 +986,7 @@ class ResponseProcessor:
if content is not None:
params[mapping.param_name] = content.strip()
parsing_details["elements"][mapping.param_name] = content.strip() # Store raw element content
logger.info(f"Found element {mapping.param_name}: {content.strip()}")
# logger.info(f"Found element {mapping.param_name}: {content.strip()}")
elif mapping.node_type == "text":
# Extract text content
@ -994,7 +994,7 @@ class ResponseProcessor:
if content is not None:
params[mapping.param_name] = content.strip()
parsing_details["text_content"] = content.strip() # Store raw text content
logger.info(f"Found text content for {mapping.param_name}: {content.strip()}")
# logger.info(f"Found text content for {mapping.param_name}: {content.strip()}")
elif mapping.node_type == "content":
# Extract root content
@ -1002,7 +1002,7 @@ class ResponseProcessor:
if content is not None:
params[mapping.param_name] = content.strip()
parsing_details["root_content"] = content.strip() # Store raw root content
logger.info(f"Found root content for {mapping.param_name}")
# logger.info(f"Found root content for {mapping.param_name}")
except Exception as e:
logger.error(f"Error processing mapping {mapping}: {e}")

View File

@ -1,7 +1,3 @@
# This is a Docker Compose file for the backend service. For self-hosting, look at the root docker-compose.yml file.
version: "3.8"
services:
api:
build:

View File

@ -19,7 +19,8 @@ You can modify the sandbox environment for development or to add new capabilitie
2. Build a custom image:
```
cd backend/sandbox/docker
docker-compose build
docker compose build
docker push kortix/suna:0.1.2
```
3. Test your changes locally using docker-compose
@ -30,3 +31,15 @@ To use your custom sandbox image:
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
## Publishing New Versions
When publishing a new version of the sandbox:
1. Update the version number in `docker-compose.yml` (e.g., from `0.1.2` to `0.1.3`)
2. Build the new image: `docker compose build`
3. Push the new version: `docker push kortix/suna:0.1.3`
4. Update all references to the image version in:
- `backend/utils/config.py`
- Daytona images
- Any other services using this image

View File

@ -68,6 +68,9 @@ RUN apt-get update && apt-get install -y \
iputils-ping \
dnsutils \
sudo \
# OCR Tools
tesseract-ocr \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js and npm
@ -113,11 +116,13 @@ ENV PYTHONUNBUFFERED=1
ENV CHROME_PATH=/ms-playwright/chromium-*/chrome-linux/chrome
ENV ANONYMIZED_TELEMETRY=false
ENV DISPLAY=:99
ENV RESOLUTION=1920x1080x24
ENV RESOLUTION=1024x768x24
ENV VNC_PASSWORD=vncpassword
ENV CHROME_PERSISTENT_SESSION=true
ENV RESOLUTION_WIDTH=1920
ENV RESOLUTION_HEIGHT=1080
ENV RESOLUTION_WIDTH=1024
ENV RESOLUTION_HEIGHT=768
# Add Chrome flags to prevent multiple tabs/windows
ENV CHROME_FLAGS="--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu"
# Set up supervisor configuration
RUN mkdir -p /var/log/supervisor

View File

@ -15,8 +15,6 @@ import traceback
import pytesseract
from PIL import Image
import io
from utils.logger import logger
from services.supabase import DBConnection
#######################################################
# Action model definitions
@ -261,16 +259,15 @@ class BrowserActionResult(BaseModel):
url: Optional[str] = None
title: Optional[str] = None
elements: Optional[str] = None # Formatted string of clickable elements
screenshot_base64: Optional[str] = None # For backward compatibility
screenshot_url: Optional[str] = None
screenshot_base64: Optional[str] = None
pixels_above: int = 0
pixels_below: int = 0
content: Optional[str] = None
ocr_text: Optional[str] = None
ocr_text: Optional[str] = None # Added field for OCR text
# Additional metadata
element_count: int = 0
interactive_elements: Optional[List[Dict[str, Any]]] = None
element_count: int = 0 # Number of interactive elements found
interactive_elements: Optional[List[Dict[str, Any]]] = None # Simplified list of interactive elements
viewport_width: Optional[int] = None
viewport_height: Optional[int] = None
@ -291,7 +288,6 @@ class BrowserAutomation:
self.include_attributes = ["id", "href", "src", "alt", "aria-label", "placeholder", "name", "role", "title", "value"]
self.screenshot_dir = os.path.join(os.getcwd(), "screenshots")
os.makedirs(self.screenshot_dir, exist_ok=True)
self.db = DBConnection() # Initialize DB connection
# Register routes
self.router.on_startup.append(self.startup)
@ -360,12 +356,12 @@ class BrowserAutomation:
self.current_page_index = 0
except Exception as page_error:
print(f"Error finding existing page, creating new one. ( {page_error})")
page = await self.browser.new_page()
page = await self.browser.new_page(viewport={'width': 1024, 'height': 768})
print("New page created successfully")
self.pages.append(page)
self.current_page_index = 0
# Navigate to about:blank to ensure page is ready
# await page.goto("google.com", timeout=30000)
# Navigate directly to google.com instead of about:blank
await page.goto("https://www.google.com", wait_until="domcontentloaded", timeout=30000)
print("Navigated to google.com")
print("Browser initialization completed successfully")
@ -603,95 +599,50 @@ class BrowserAutomation:
is_top_element=True
)
dummy_map = {1: dummy_root}
current_url = "unknown"
try:
if 'page' in locals():
current_url = page.url
except:
pass
return DOMState(
element_tree=dummy_root,
selector_map=dummy_map,
url=page.url if 'page' in locals() else "about:blank",
url=current_url,
title="Error page",
pixels_above=0,
pixels_below=0
)
async def take_screenshot(self) -> str:
"""Take a screenshot and return as base64 encoded string or S3 URL"""
"""Take a screenshot and return as base64 encoded string"""
try:
page = await self.get_current_page()
screenshot_bytes = await page.screenshot(type='jpeg', quality=60, full_page=False)
client = await self.db.client
if client:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
random_id = random.randint(1000, 9999)
filename = f"screenshot_{timestamp}_{random_id}.jpg"
logger.info(f"Attempting to upload screenshot: {filename}")
result = await self.upload_to_storage(client, screenshot_bytes, filename)
if isinstance(result, dict) and result.get("is_s3") and result.get("url"):
if await self.verify_file_exists(client, filename):
logger.info(f"Screenshot upload verified: {filename}")
else:
logger.error(f"Screenshot upload failed verification: {filename}")
return base64.b64encode(screenshot_bytes).decode('utf-8')
return result
else:
logger.warning("No Supabase client available, falling back to base64")
return base64.b64encode(screenshot_bytes).decode('utf-8')
except Exception as e:
logger.error(f"Error taking screenshot: {str(e)}")
traceback.print_exc()
return ""
async def upload_to_storage(self, client, file_bytes: bytes, filename: str) -> str:
"""Upload file to Supabase Storage and return the URL"""
try:
bucket_name = 'screenshots'
buckets = client.storage.list_buckets()
if not any(bucket.name == bucket_name for bucket in buckets):
logger.info(f"Creating bucket: {bucket_name}")
try:
client.storage.create_bucket(bucket_name)
logger.info("Bucket created successfully")
except Exception as e:
logger.error(f"Failed to create bucket: {str(e)}")
raise
logger.info(f"Uploading file: {filename}")
# Wait for network to be idle and DOM to be stable
try:
result = client.storage.from_(bucket_name).upload(
path=filename,
file=file_bytes,
file_options={"content-type": "image/jpeg"}
)
logger.info("File upload successful")
await page.wait_for_load_state("networkidle", timeout=60000) # Increased timeout to 60s
except Exception as e:
logger.error(f"Failed to upload file: {str(e)}")
raise
print(f"Warning: Network idle timeout, proceeding anyway: {e}")
file_url = client.storage.from_(bucket_name).get_public_url(filename)
logger.info(f"Generated URL: {file_url}")
# Wait for any animations to complete
# await page.wait_for_timeout(1000) # Wait 1 second for animations
return {"url": file_url, "is_s3": True}
# Take screenshot with increased timeout and better options
screenshot_bytes = await page.screenshot(
type='jpeg',
quality=60,
full_page=False,
timeout=60000, # Increased timeout to 60s
scale='device' # Use device scale factor
)
return base64.b64encode(screenshot_bytes).decode('utf-8')
except Exception as e:
logger.error(f"Error in upload_to_storage: {str(e)}")
print(f"Error taking screenshot: {e}")
traceback.print_exc()
return base64.b64encode(file_bytes).decode('utf-8')
async def verify_file_exists(self, client, filename: str) -> bool:
"""Verify that a file exists in the storage bucket"""
logger.info(f"=== Verifying file exists: {filename} ===")
try:
bucket_name = 'screenshots'
files = client.storage.from_(bucket_name).list()
exists = any(f['name'] == filename for f in files)
logger.info(f"File verification result: {'exists' if exists else 'not found'}")
return exists
except Exception as e:
logger.error(f"Error verifying file: {str(e)}")
return False
# Return an empty string rather than failing
return ""
async def save_screenshot_to_file(self) -> str:
"""Take a screenshot and save to file, returning the path"""
@ -734,32 +685,20 @@ class BrowserAutomation:
"""Helper method to get updated browser state after any action
Returns a tuple of (dom_state, screenshot, elements, metadata)
"""
logger.info(f"=== Starting get_updated_browser_state for action: {action_name} ===")
try:
# Wait a moment for any potential async processes to settle
logger.info("Waiting for async processes to settle")
await asyncio.sleep(0.5)
# Get updated state
logger.info("Getting current DOM state")
dom_state = await self.get_current_dom_state()
logger.info(f"DOM state retrieved - URL: {dom_state.url}, Title: {dom_state.title}")
logger.info("Taking screenshot")
screenshot = await self.take_screenshot()
logger.info(f"Screenshot result type: {'dict' if isinstance(screenshot, dict) else 'base64 string'}")
if isinstance(screenshot, dict) and screenshot.get("url"):
logger.info(f"Screenshot URL: {screenshot['url']}")
# Format elements for output
logger.info("Formatting clickable elements")
elements = dom_state.element_tree.clickable_elements_to_string(
include_attributes=self.include_attributes
)
logger.info(f"Found {len(dom_state.selector_map)} clickable elements")
# Collect additional metadata
logger.info("Collecting metadata")
page = await self.get_current_page()
metadata = {}
@ -785,9 +724,8 @@ class BrowserAutomation:
metadata['interactive_elements'] = interactive_elements
# Get viewport dimensions
# Get viewport dimensions - Fix syntax error in JavaScript
try:
logger.info("Getting viewport dimensions")
viewport = await page.evaluate("""
() => {
return {
@ -798,43 +736,33 @@ class BrowserAutomation:
""")
metadata['viewport_width'] = viewport.get('width', 0)
metadata['viewport_height'] = viewport.get('height', 0)
logger.info(f"Viewport dimensions: {metadata['viewport_width']}x{metadata['viewport_height']}")
except Exception as e:
logger.error(f"Error getting viewport dimensions: {e}")
print(f"Error getting viewport dimensions: {e}")
metadata['viewport_width'] = 0
metadata['viewport_height'] = 0
# Extract OCR text from screenshot if available
ocr_text = ""
if screenshot:
logger.info("Extracting OCR text from screenshot")
ocr_text = await self.extract_ocr_text_from_screenshot(screenshot)
metadata['ocr_text'] = ocr_text
logger.info(f"OCR text length: {len(ocr_text)} characters")
logger.info(f"=== Completed get_updated_browser_state for {action_name} ===")
print(f"Got updated state after {action_name}: {len(dom_state.selector_map)} elements")
return dom_state, screenshot, elements, metadata
except Exception as e:
logger.error(f"Error in get_updated_browser_state for {action_name}: {e}")
print(f"Error getting updated state after {action_name}: {e}")
traceback.print_exc()
# Return empty values in case of error
return None, "", "", {}
def build_action_result(self, success: bool, message: str, dom_state, screenshot: str,
elements: str, metadata: dict, error: str = "", content: str = None,
fallback_url: str = None) -> BrowserActionResult:
elements: str, metadata: dict, error: str = "", content: str = None,
fallback_url: str = None) -> BrowserActionResult:
"""Helper method to build a consistent BrowserActionResult"""
# Ensure elements is never None to avoid display issues
if elements is None:
elements = ""
screenshot_base64 = None
screenshot_url = None
if isinstance(screenshot, dict) and screenshot.get("is_s3"):
screenshot_url = screenshot.get("url")
else:
screenshot_base64 = screenshot
return BrowserActionResult(
success=success,
message=message,
@ -842,8 +770,7 @@ class BrowserAutomation:
url=dom_state.url if dom_state else fallback_url or "",
title=dom_state.title if dom_state else "",
elements=elements,
screenshot_base64=screenshot_base64,
screenshot_url=screenshot_url,
screenshot_base64=screenshot,
pixels_above=dom_state.pixels_above if dom_state else 0,
pixels_below=dom_state.pixels_below if dom_state else 0,
content=content,
@ -2157,4 +2084,4 @@ if __name__ == '__main__':
asyncio.run(test_browser_api_2())
else:
print("Starting API server")
uvicorn.run("browser_api:api_app", host="0.0.0.0", port=8002)
uvicorn.run("browser_api:api_app", host="0.0.0.0", port=8003)

View File

@ -6,7 +6,7 @@ services:
dockerfile: ${DOCKERFILE:-Dockerfile}
args:
TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64}
image: kortix/suna:0.1.2
image: kortix/suna:0.1.2.8
ports:
- "6080:6080" # noVNC web interface
- "5901:5901" # VNC port
@ -27,6 +27,7 @@ services:
- VNC_PASSWORD=${VNC_PASSWORD:-vncpassword}
- CHROME_DEBUGGING_PORT=9222
- CHROME_DEBUGGING_HOST=localhost
- CHROME_FLAGS=${CHROME_FLAGS:-"--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu"}
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
restart: unless-stopped

View File

@ -6,6 +6,9 @@ from typing import Optional
from supabase import create_async_client, AsyncClient
from utils.logger import logger
from utils.config import config
import base64
import uuid
from datetime import datetime
class DBConnection:
"""Singleton database connection manager using Supabase."""
@ -66,4 +69,45 @@ class DBConnection:
raise RuntimeError("Database not initialized")
return self._client
async def upload_base64_image(self, base64_data: str, bucket_name: str = "browser-screenshots") -> str:
"""Upload a base64 encoded image to Supabase storage and return the URL.
Args:
base64_data (str): Base64 encoded image data (with or without data URL prefix)
bucket_name (str): Name of the storage bucket to upload to
Returns:
str: Public URL of the uploaded image
"""
try:
# Remove data URL prefix if present
if base64_data.startswith('data:'):
base64_data = base64_data.split(',')[1]
# Decode base64 data
image_data = base64.b64decode(base64_data)
# Generate unique filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_id = str(uuid.uuid4())[:8]
filename = f"image_{timestamp}_{unique_id}.png"
# Upload to Supabase storage
client = await self.client
storage_response = await client.storage.from_(bucket_name).upload(
filename,
image_data,
{"content-type": "image/png"}
)
# Get public URL
public_url = await client.storage.from_(bucket_name).get_public_url(filename)
logger.debug(f"Successfully uploaded image to {public_url}")
return public_url
except Exception as e:
logger.error(f"Error uploading base64 image: {e}")
raise RuntimeError(f"Failed to upload image: {str(e)}")

View File

@ -155,11 +155,11 @@ class Configuration:
STRIPE_DEFAULT_TRIAL_DAYS: int = 14
# Stripe Product IDs
STRIPE_PRODUCT_ID_PROD: str = 'prod_SCl7AQ2C8kK1CD' # Production product ID
STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY' # Staging product ID
STRIPE_PRODUCT_ID_PROD: str = 'prod_SCl7AQ2C8kK1CD'
STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY'
# Sandbox configuration
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.2"
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.2.8"
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
@property

View File

@ -0,0 +1,51 @@
"""
Utility functions for handling image operations.
"""
import base64
import uuid
from datetime import datetime
from utils.logger import logger
from services.supabase import DBConnection
async def upload_base64_image(base64_data: str, bucket_name: str = "browser-screenshots") -> str:
"""Upload a base64 encoded image to Supabase storage and return the URL.
Args:
base64_data (str): Base64 encoded image data (with or without data URL prefix)
bucket_name (str): Name of the storage bucket to upload to
Returns:
str: Public URL of the uploaded image
"""
try:
# Remove data URL prefix if present
if base64_data.startswith('data:'):
base64_data = base64_data.split(',')[1]
# Decode base64 data
image_data = base64.b64decode(base64_data)
# Generate unique filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_id = str(uuid.uuid4())[:8]
filename = f"image_{timestamp}_{unique_id}.png"
# Upload to Supabase storage
db = DBConnection()
client = await db.client
storage_response = await client.storage.from_(bucket_name).upload(
filename,
image_data,
{"content-type": "image/png"}
)
# Get public URL
public_url = await client.storage.from_(bucket_name).get_public_url(filename)
logger.debug(f"Successfully uploaded image to {public_url}")
return public_url
except Exception as e:
logger.error(f"Error uploading base64 image: {e}")
raise RuntimeError(f"Failed to upload image: {str(e)}")

View File

@ -13,6 +13,9 @@ services:
rabbitmq:
image: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
restart: unless-stopped