-
Notifications
You must be signed in to change notification settings - Fork 46k
feat(copilot): Add browse_web tool for JS-rendered page browsing #12214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a131737
3fbc695
9ae1bda
a2ca056
1c26799
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why monkey-patch instead of importing from Stagehand registers OS signal handlers on Matches the exact same pattern used in |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strengthen URL validation beyond a raw prefix check. A 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 |
||
|
|
||
| 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 | ||
|
|
||
|
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 | ||
Uh oh!
There was an error while loading. Please reload this page.