Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions autogpt_platform/backend/backend/copilot/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .agent_output import AgentOutputTool
from .base import BaseTool
from .bash_exec import BashExecTool
from .browse_web import BrowseWebTool
Comment thread
majdyz marked this conversation as resolved.
from .create_agent import CreateAgentTool
from .customize_agent import CustomizeAgentTool
from .edit_agent import EditAgentTool
Expand Down Expand Up @@ -50,6 +51,8 @@
"get_doc_page": GetDocPageTool(),
# Web fetch for safe URL retrieval
"web_fetch": WebFetchTool(),
# Browser-based browsing for JS-rendered pages (Stagehand + Browserbase)
"browse_web": BrowseWebTool(),
# Sandboxed code execution (bubblewrap)
"bash_exec": BashExecTool(),
# Persistent workspace tools (cloud storage, survives across sessions)
Expand Down
227 changes: 227 additions & 0 deletions autogpt_platform/backend/backend/copilot/tools/browse_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Web browsing tool — navigate real browser sessions to extract page content.

Uses Stagehand + Browserbase for cloud-based browser execution. Handles
JS-rendered pages, SPAs, and dynamic content that web_fetch cannot reach.

Requires environment variables:
STAGEHAND_API_KEY — Browserbase API key
STAGEHAND_PROJECT_ID — Browserbase project ID
ANTHROPIC_API_KEY — LLM key used by Stagehand for extraction
"""

import logging
import os

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why monkey-patch instead of importing from blocks/stagehand/blocks.py?

Stagehand registers OS signal handlers on __init__, which raises ValueError in non-main threads (the CoPilot executor thread pool). The same patch is applied in blocks.py but importing it from there would create a copilot/tools → blocks dependency, which is an unusual direction for this codebase. Duplicating the 5-line patch here keeps the tools module self-contained.

Matches the exact same pattern used in blocks/stagehand/blocks.py:38-48.

import threading
from typing import Any

from backend.copilot.model import ChatSession

from .base import BaseTool
from .models import BrowseWebResponse, ErrorResponse, ToolResponseBase

logger = logging.getLogger(__name__)

# Stagehand uses the LLM internally for natural-language extraction/actions.
_STAGEHAND_MODEL = "anthropic/claude-sonnet-4-5-20250929"
# Hard cap on extracted content returned to the LLM context.
_MAX_CONTENT_CHARS = 50_000
# Explicit timeouts for Stagehand browser operations (milliseconds).
_GOTO_TIMEOUT_MS = 30_000 # page navigation
_EXTRACT_TIMEOUT_MS = 60_000 # LLM extraction

# ---------------------------------------------------------------------------
# Thread-safety patch for Stagehand signal handlers (applied lazily, once).
#
# Stagehand calls signal.signal() during __init__, which raises ValueError
# when called from a non-main thread (e.g. the CoPilot executor thread pool).
# We patch _register_signal_handlers to be a no-op outside the main thread.
# The patch is applied exactly once per process via double-checked locking.
# ---------------------------------------------------------------------------
_stagehand_patched = False
_patch_lock = threading.Lock()


def _patch_stagehand_once() -> None:
"""Monkey-patch Stagehand signal handler registration to be thread-safe.

Must be called after ``import stagehand.main`` has succeeded.
Safe to call from multiple threads — applies the patch at most once.
"""
global _stagehand_patched
if _stagehand_patched:
return
with _patch_lock:
if _stagehand_patched:
return
import stagehand.main # noqa: PLC0415

_original = stagehand.main.Stagehand._register_signal_handlers

def _safe_register(self: Any) -> None:
if threading.current_thread() is threading.main_thread():
_original(self)

stagehand.main.Stagehand._register_signal_handlers = _safe_register
_stagehand_patched = True


class BrowseWebTool(BaseTool):
"""Navigate a URL with a real browser and extract its content.

Use this instead of ``web_fetch`` when the page requires JavaScript
to render (SPAs, dashboards, paywalled content with JS checks, etc.).
"""

@property
def name(self) -> str:
return "browse_web"

@property
def description(self) -> str:
return (
"Navigate to a URL using a real browser and extract content. "
"Handles JavaScript-rendered pages and dynamic content that "
"web_fetch cannot reach. "
"Specify exactly what to extract via the `instruction` parameter."
)

@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The HTTP/HTTPS URL to navigate to.",
},
"instruction": {
"type": "string",
"description": (
"What to extract from the page. Be specific — e.g. "
"'Extract all pricing plans with features and prices', "
"'Get the main article text and author', "
"'List all navigation links'. "
"Defaults to extracting the main page content."
),
"default": "Extract the main content of this page.",
},
},
"required": ["url"],
}

@property
def requires_auth(self) -> bool:
return True

async def _execute(
self,
user_id: str | None, # noqa: ARG002
session: ChatSession,
**kwargs: Any,
) -> ToolResponseBase:
"""Navigate to a URL with a real browser and return extracted content."""
url: str = (kwargs.get("url") or "").strip()
instruction: str = (
kwargs.get("instruction") or "Extract the main content of this page."
)
session_id = session.session_id if session else None

if not url:
return ErrorResponse(
message="Please provide a URL to browse.",
error="missing_url",
session_id=session_id,
)

if not url.startswith(("http://", "https://")):
return ErrorResponse(
message="Only HTTP/HTTPS URLs are supported.",
error="invalid_url",
session_id=session_id,
)
Comment on lines +136 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Strengthen URL validation beyond a raw prefix check.

A startswith check can reject valid mixed-case schemes and accept malformed HTTP URLs without a host. Parse and validate scheme + netloc explicitly.

Suggested fix
 import logging
 import os
 import threading
 from typing import Any
+from urllib.parse import urlparse
@@
-        if not url.startswith(("http://", "https://")):
+        parsed = urlparse(url)
+        if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
             return ErrorResponse(
                 message="Only HTTP/HTTPS URLs are supported.",
                 error="invalid_url",
                 session_id=session_id,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@autogpt_platform/backend/backend/copilot/tools/browse_web.py` around lines
136 - 141, The prefix-only URL check is too weak; update the validation in the
browse_web handler (the code that checks the variable `url` and currently
returns `ErrorResponse`) to parse the URL with urllib.parse.urlparse, verify
that parsed.scheme.lower() is either "http" or "https", and ensure parsed.netloc
is non-empty (reject if empty or missing); replace the startswith branch with
this parsed-scheme + netloc check and return the same ErrorResponse
(error="invalid_url") when validation fails so malformed or mixed-case schemes
are rejected and true HTTP/HTTPS URLs are accepted.


api_key = os.environ.get("STAGEHAND_API_KEY")
project_id = os.environ.get("STAGEHAND_PROJECT_ID")
model_api_key = os.environ.get("ANTHROPIC_API_KEY")

if not api_key or not project_id:
return ErrorResponse(
message=(
"Web browsing is not configured on this platform. "
"STAGEHAND_API_KEY and STAGEHAND_PROJECT_ID are required."
),
error="not_configured",
session_id=session_id,
)

if not model_api_key:
return ErrorResponse(
message=(
"Web browsing is not configured: ANTHROPIC_API_KEY is required "
"for Stagehand's extraction model."
),
error="not_configured",
session_id=session_id,
)

# Lazy import — Stagehand is an optional heavy dependency.
# Importing here scopes any ImportError to this tool only, so other
# tools continue to register and work normally if Stagehand is absent.
try:
from stagehand import Stagehand # noqa: PLC0415
except ImportError:
return ErrorResponse(
message="Web browsing is not available: Stagehand is not installed.",
error="not_configured",
session_id=session_id,
)

# Apply the signal handler patch now that we know stagehand is present.
_patch_stagehand_once()

client: Any | None = None
try:
client = Stagehand(
api_key=api_key,
project_id=project_id,
model_name=_STAGEHAND_MODEL,
model_api_key=model_api_key,
)
await client.init()

page = client.page
assert page is not None, "Stagehand page is not initialized"
await page.goto(url, timeoutMs=_GOTO_TIMEOUT_MS)
result = await page.extract(instruction, timeoutMs=_EXTRACT_TIMEOUT_MS)

# Extract the text content from the Pydantic result model.
raw = result.model_dump().get("extraction", "")
content = str(raw) if raw else ""

truncated = len(content) > _MAX_CONTENT_CHARS
if truncated:
suffix = "\n\n[Content truncated]"
keep = max(0, _MAX_CONTENT_CHARS - len(suffix))
content = content[:keep] + suffix

Comment thread
majdyz marked this conversation as resolved.
return BrowseWebResponse(
message=f"Browsed {url}",
url=url,
content=content,
truncated=truncated,
session_id=session_id,
)

except Exception:
logger.exception("[browse_web] Failed for %s", url)
return ErrorResponse(
message="Failed to browse URL.",
error="browse_failed",
session_id=session_id,
)
finally:
if client is not None:
try:
await client.close()
except Exception:
pass
Loading