From a1835c8c17f5ae07115499a79bf2809809b3ba64 Mon Sep 17 00:00:00 2001 From: Laith Weinberger Date: Fri, 17 Jul 2026 12:16:07 -0700 Subject: [PATCH] feat(browser): integrate Browser Use CLI 3.0 --- agent/browser_provider.py | 4 +- agent/display.py | 35 +- hermes_cli/cli_commands_mixin.py | 61 ++- hermes_cli/commands.py | 6 +- hermes_cli/config_defaults.py | 6 + hermes_cli/tools_config.py | 57 ++ plugins/browser/browser_use/provider.py | 22 +- .../browser/test_browser_provider_plugins.py | 50 +- tests/tools/test_browser_use_cli.py | 498 ++++++++++++++++++ tools/browser_tool.py | 11 + tools/browser_use_cli.py | 337 ++++++++++++ toolsets.py | 7 +- website/docs/user-guide/features/browser.md | 17 +- 13 files changed, 1085 insertions(+), 26 deletions(-) create mode 100644 tests/tools/test_browser_use_cli.py create mode 100644 tools/browser_use_cli.py diff --git a/agent/browser_provider.py b/agent/browser_provider.py index 3e96fa9c85e66..d7c6dae61fb0d 100644 --- a/agent/browser_provider.py +++ b/agent/browser_provider.py @@ -39,7 +39,7 @@ which provider is in use. from __future__ import annotations import abc -from typing import Any, Dict +from typing import Any, Dict, Optional # --------------------------------------------------------------------------- @@ -126,7 +126,7 @@ class BrowserProvider(abc.ABC): credentials, network errors, etc. โ€” log and move on. Must not raise. """ - def get_setup_schema(self) -> Dict[str, Any]: + def get_setup_schema(self) -> Optional[Dict[str, Any]]: """Return provider metadata for the ``hermes tools`` picker. Used by :mod:`hermes_cli.tools_config` to inject this provider as a diff --git a/agent/display.py b/agent/display.py index d6bea7e54e872..63f101ae3d9fe 100644 --- a/agent/display.py +++ b/agent/display.py @@ -427,6 +427,22 @@ def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, li return len(goals), goals +def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None: + """User-friendly step label from browser_exec code's leading comment.""" + code = str(args.get("code", "") or "").strip() + if not code: + return None + first = code.split("\n", 1)[0].strip() + if not first.startswith("#"): + return None + label = first.lstrip("#").strip() + if not label: + return None + if len(label) > max_chars: + label = label[: max_chars - 1] + "โ€ฆ" + return label + + def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None: """Build a short preview of a tool call's primary argument for display. @@ -447,10 +463,18 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - "vision_analyze": "question", "skill_view": "name", "skills_list": "category", "cronjob": "action", - "execute_code": "code", "delegate_task": "goal", + "execute_code": "code", "browser_exec": "code", "delegate_task": "goal", "clarify": "question", "skill_manage": "name", } + # browser_exec: prefer the leading `# โ€ฆ` comment as a friendly step label + if tool_name == "browser_exec": + label = _browser_exec_step_label(args) + if label is not None: + return _truncate_preview(label, max_len) + preview = _oneline(str(args.get("code", "") or "")) + return _truncate_preview(preview, max_len) if preview else None + # delegate_task: show goal (single) or individual task goals (batch) if tool_name == "delegate_task": tasks = args.get("tasks") @@ -1516,6 +1540,15 @@ def _get_cute_tool_message( code = args.get("code", "") first_line = code.strip().split("\n")[0] if code.strip() else "" return _wrap(f"โ”Š ๐Ÿ exec {_trunc(first_line, 35)} {dur}") + if tool_name == "browser_exec": + label = _browser_exec_step_label(args) + if label is not None: + # Leading `# โ€ฆ` comment (the tool description asks for one): + # surface it as the user-facing step label; the code itself stays + # collapsed behind display.tool_preview_length. + return _wrap(f"โ”Š ๐ŸŒ browser {label} {dur}") + code = " ".join(str(args.get("code", "") or "").split()) + return _wrap(f"โ”Š ๐ŸŒ browser {_trunc(code, 35)} {dur}") if tool_name == "delegate_task": tasks = args.get("tasks") if tasks and isinstance(tasks, list): diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 3ca8e88c67811..884dd175fe00a 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2187,6 +2187,52 @@ class CLICommandsMixin: _DEFAULT_CDP = DEFAULT_BROWSER_CDP_URL current = os.environ.get("BROWSER_CDP_URL", "").strip() + if sub == "use" or sub.startswith("use "): + # /browser use [off] โ€” toggle Browser Use mode (browser.backend), + arg = sub.split(None, 1)[1].strip() if " " in sub else "on" + from hermes_cli.config import load_config, save_config + from tools.registry import invalidate_check_fn_cache + + if arg not in {"on", "off"}: + print() + print("Usage: /browser use [off]") + print(" /browser use โ€” switch to Browser Use mode (browser_exec via CLI 3.0)") + print(" /browser use off โ€” revert to the built-in browser tools") + print() + return + + config = load_config() + browser_cfg = config.setdefault("browser", {}) + if arg == "on": + browser_cfg["backend"] = "browser-use" + save_config(config) + invalidate_check_fn_cache() + self.new_session() + print() + print("๐ŸŒ Browser Use mode enabled โ€” browser_exec via the Browser Use CLI 3.0") + print(" Session reset. New tool configuration is active.") + print() + else: + browser_cfg.pop("backend", None) + save_config(config) + invalidate_check_fn_cache() + self.new_session() + print() + print("๐ŸŒ Browser Use mode disabled โ€” built-in browser tools restored") + try: + from tools.browser_use_cli import is_browser_use_cli_mode + if is_browser_use_cli_mode(): + print( + " โš  Still active via auto-detection: BROWSER_USE_API_KEY is set " + "with no other cloud provider configured." + ) + print(" Pick another provider via `hermes tools`, or unset the key.") + except Exception: + pass + print(" Session reset. New tool configuration is active.") + print() + return + if sub.startswith("connect"): # Optionally accept a custom CDP URL: /browser connect ws://host:port connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."] @@ -2350,6 +2396,18 @@ class CLICommandsMixin: elif sub == "status": print() + try: + from tools.browser_use_cli import is_browser_use_cli_mode + _bu_mode = is_browser_use_cli_mode() + except Exception: + _bu_mode = False + if _bu_mode: + print("๐ŸŒ Browser: Browser Use mode (browser_exec via the Browser Use CLI 3.0)") + print(" Local Chrome via CDP, or Browser Use cloud browsers") + print() + print(" /browser use off โ€” revert to the built-in browser tools") + print() + return if current: print("๐ŸŒ Browser: connected to live Chromium-family browser via CDP") print(f" Endpoint: {current}") @@ -2399,11 +2457,12 @@ class CLICommandsMixin: else: print() - print("Usage: /browser connect|disconnect|status") + print("Usage: /browser connect|disconnect|status|use") print() print(" connect Connect browser tools to your live Chromium-family browser session") print(" disconnect Revert to default browser backend") print(" status Show current browser mode") + print(" use [off] Switch to Browser Use mode (CLI 3.0) / back to built-in tools") print() def _handle_heartbeat_command(self, cmd: str) -> None: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c5de02e8d254a..cb8fb4a0f2d28 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -301,9 +301,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("reload_mcp",)), CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills", "Tools & Skills", aliases=("reload_skills",)), - CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP", "Tools & Skills", - cli_only=True, args_hint="[connect|disconnect|status]", - subcommands=("connect", "disconnect", "status")), + CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP, or switch to Browser Use mode", "Tools & Skills", + cli_only=True, args_hint="[connect|disconnect|status|use]", + subcommands=("connect", "disconnect", "status", "use")), CommandDef("plugins", "List installed plugins and their status", "Tools & Skills", cli_only=True), diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 59f177b0fa2fd..adff0090dbdad 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -403,6 +403,12 @@ DEFAULT_CONFIG = { }, "browser": { + # Browser tool implementation. + # "" โ€” built-in browser tools (browser_navigate, browser_click, โ€ฆ) + # "browser-use" โ€” Browser Use mode: one browser_exec tool driving the + # Browser Use CLI 3.0 (local Chrome over CDP or Browser + # Use cloud browsers) + "backend": "", "inactivity_timeout": 120, "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) "record_sessions": False, # Auto-record browser sessions as WebM videos diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index fa0793f0f671d..cb80de1c9ee63 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -622,6 +622,7 @@ TOOL_CATEGORIES = { # underlying backend but has a distinct setup UX. # - "Camofox" โ€” anti-detection local Firefox; short-circuits the # cloud-provider dispatch path via _is_camofox_mode(). + # - "Browser Use" โ€” the Browser Use CLI 3.0 "providers": [ { "name": "Local Browser", @@ -658,6 +659,14 @@ TOOL_CATEGORIES = { "browser_provider": "camofox", "post_setup": "camofox", }, + { + "name": "Browser Use", + "badge": "free ยท local ยท cloud", + "tag": "New SOTA web harness (CLI 3.0)", + "env_vars": [], + "browser_backend": "browser-use", + "post_setup": "browser_use_cli", + }, ], }, "homeassistant": { @@ -1760,6 +1769,18 @@ def _run_post_setup(post_setup_key: str): _print_warning(f" Chromium install failed: {exc}") _print_info(" Run manually: npx agent-browser install --with-deps") + elif post_setup_key == "browser_use_cli": + if shutil.which("browser-use"): + _print_success(" browser-use CLI found on PATH") + elif shutil.which("uvx"): + _print_info(" browser-use CLI not installed โ€” it will run via `uvx browser-use`") + _print_info(" For a persistent install: uv tool install browser-use") + else: + _print_warning(" browser-use CLI not found and uvx is unavailable") + _print_info(" Install with: uv tool install browser-use (https://docs.astral.sh/uv/)") + _print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging") + _print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)") + elif post_setup_key == "camofox": camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" _npm_bin = find_node_executable("npm") @@ -3623,6 +3644,8 @@ def _is_provider_active( and cfg_get(config, "stt", "provider") == provider["stt_provider"] ) if "browser_provider" in provider: + if cfg_get(config, "browser", "backend"): + return False current = cfg_get(config, "browser", "cloud_provider") return feature.managed_by_nous and provider["browser_provider"] == current if provider.get("web_backend"): @@ -3637,8 +3660,24 @@ def _is_provider_active( current = cfg_get(config, "stt", "provider") or "local" return current == provider["stt_provider"] if "browser_provider" in provider: + if cfg_get(config, "browser", "backend"): + return False current = cfg_get(config, "browser", "cloud_provider") return provider["browser_provider"] == current + if provider.get("browser_backend"): + if cfg_get(config, "browser", "backend") == provider["browser_backend"]: + return True + # Legacy direct-API Browser Use cloud auto-routes to CLI + try: + from tools.browser_use_cli import is_legacy_browser_use_cloud_config + + browser_cfg = config.get("browser") if isinstance(config, dict) else None + return ( + provider["browser_backend"] == "browser-use" + and is_legacy_browser_use_cloud_config(browser_cfg or {}) + ) + except Exception: + return False if provider.get("web_backend"): current = cfg_get(config, "web", "backend") return current == provider["web_backend"] @@ -4084,8 +4123,15 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> browser_cfg = config.setdefault("browser", {}) if bp: browser_cfg["cloud_provider"] = bp + # Leaving Browser Use CLI mode + browser_cfg.pop("backend", None) browser_cfg["use_gateway"] = bool(managed_feature) + if provider.get("browser_backend"): + browser_cfg = config.setdefault("browser", {}) + browser_cfg["backend"] = provider["browser_backend"] + browser_cfg["use_gateway"] = False + # Set web search backend in config if applicable if provider.get("web_backend"): web_cfg = config.setdefault("web", {}) @@ -4229,6 +4275,9 @@ def _configure_provider( elif bp: _print_success(f" Browser cloud provider set to: {bp}") + if provider.get("browser_backend"): + _print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)") + # Set web search backend in config if applicable if provider.get("web_backend"): _print_success(f" Web backend set to: {provider['web_backend']}") @@ -4741,8 +4790,16 @@ def _reconfigure_provider( elif bp: browser_cfg["cloud_provider"] = bp _print_success(f" Browser cloud provider set to: {bp}") + # Leaving Browser Use CLI mode + browser_cfg.pop("backend", None) browser_cfg["use_gateway"] = bool(managed_feature) + if provider.get("browser_backend"): + browser_cfg = config.setdefault("browser", {}) + browser_cfg["backend"] = provider["browser_backend"] + browser_cfg["use_gateway"] = False + _print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)") + # Set web search backend in config if applicable if provider.get("web_backend"): web_cfg = config.setdefault("web", {}) diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py index a10d9a361363f..f4008d440c075 100644 --- a/plugins/browser/browser_use/provider.py +++ b/plugins/browser/browser_use/provider.py @@ -306,19 +306,9 @@ class BrowserUseBrowserProvider(BrowserProvider): "Emergency cleanup failed for Browser Use session %s: %s", session_id, e ) - def get_setup_schema(self) -> Dict[str, Any]: - return { - "name": "Browser Use", - "badge": "paid", - "tag": "Cloud browser with remote execution", - "env_vars": [ - { - "key": "BROWSER_USE_API_KEY", - "prompt": "Browser Use API key", - "url": "https://browser-use.com", - }, - ], - # Cloud-scoped hook: installs the agent-browser CLI only (no - # local Chromium โ€” Browser Use hosts the browser). - "post_setup": "browserbase", - } + def get_setup_schema(self) -> Optional[Dict[str, Any]]: + # Hidden from the hermes tools picker: the "Browser Use" row now + # activates the CLI-based backend (tools/browser_use_cli.py). This + # provider stays registered for the Nous gateway path and un-migrated + # legacy cloud_provider configs. + return None diff --git a/tests/plugins/browser/test_browser_provider_plugins.py b/tests/plugins/browser/test_browser_provider_plugins.py index 5d25ac11ebe8f..f29645fc07739 100644 --- a/tests/plugins/browser/test_browser_provider_plugins.py +++ b/tests/plugins/browser/test_browser_provider_plugins.py @@ -100,6 +100,54 @@ class TestBundledPluginsRegister: assert provider.name == plugin_name assert provider.display_name == expected_display + @pytest.mark.parametrize( + "plugin_name", + ["browserbase", "firecrawl"], + ) + def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: + """``get_setup_schema()`` returns a dict the picker can consume.""" + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + schema = provider.get_setup_schema() + assert isinstance(schema, dict) + assert "name" in schema + assert "env_vars" in schema + # Every cloud-browser plugin carries a post-setup hook so the + # picker can auto-install its CLI dependency on selection. + assert schema.get("post_setup") + + def test_browser_use_hidden_from_picker(self) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + provider = get_provider("browser-use") + assert provider is not None + assert provider.get_setup_schema() is None + + @pytest.mark.parametrize( + "plugin_name", + ["browserbase", "browser-use", "firecrawl"], + ) + def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None: + """The ABC's three lifecycle methods are all overridden.""" + _ensure_plugins_loaded() + from agent.browser_provider import BrowserProvider + from agent.browser_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + # Each method must be a real override, not the ABC's NotImplementedError + # default โ€” we check by comparing the function reference. + assert type(provider).create_session is not BrowserProvider.create_session + assert type(provider).close_session is not BrowserProvider.close_session + assert ( + type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup + ) + + # --------------------------------------------------------------------------- # is_available() behavior @@ -244,6 +292,6 @@ class TestPickerIntegration: rows = _plugin_browser_providers() names = sorted(r.get("browser_provider") for r in rows) - assert names == ["browser-use", "browserbase", "firecrawl"] + assert names == ["browserbase", "firecrawl"] diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py new file mode 100644 index 0000000000000..110ec2c15758a --- /dev/null +++ b/tests/tools/test_browser_use_cli.py @@ -0,0 +1,498 @@ +"""Tests for the Browser Use CLI 3.0 backend (tools/browser_use_cli.py). + +Covers the three seams the integration relies on: + +* Mode detection โ€” ``browser.backend: browser-use`` in config (set via the + ``hermes tools`` picker); off by default. +* Tool-surface swap โ€” when the mode is on, ``check_browser_requirements`` + returns False so every legacy ``browser_*`` tool (including + browser_cdp/browser_dialog, whose check_fns funnel through it) is hidden, + and ``browser_exec`` is advertised instead. +* ``browser_exec`` execution โ€” code is piped on stdin, ``session`` becomes + ``BU_NAME``, bad session names and a missing CLI produce actionable errors. +""" +import json +import os +import stat +import time + +import pytest + +import tools.browser_use_cli as bu_cli + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("BU_NAME", raising=False) + monkeypatch.delenv("BU_AUTOSPAWN", raising=False) + monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False) + yield + + +def _fake_cli(tmp_path, body): + """Write an executable fake browser-use CLI and return its path.""" + script = tmp_path / "browser-use" + script.write_text("#!/bin/sh\n" + body) + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return str(script) + + +class TestModeDetection: + def test_off_by_default(self, monkeypatch): + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) + assert bu_cli.is_browser_use_cli_mode() is False + + def test_config_opt_in(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"backend": "browser-use"}}, + ) + assert bu_cli.is_browser_use_cli_mode() is True + + def test_other_backend_value_is_not_cli_mode(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"backend": "something-else"}}, + ) + assert bu_cli.is_browser_use_cli_mode() is False + + def test_config_read_failure_fails_safe(self, monkeypatch): + def boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr("hermes_cli.config.read_raw_config", boom) + assert bu_cli.is_browser_use_cli_mode() is False + + +class TestToolSurfaceSwap: + def test_legacy_browser_tools_hidden_in_cli_mode(self, monkeypatch): + import tools.browser_tool as browser_tool + + monkeypatch.setattr(browser_tool, "_is_browser_use_cli_mode", lambda: True) + assert browser_tool.check_browser_requirements() is False + assert browser_tool.check_browser_vision_requirements() is False + + def test_browser_exec_registered_with_mode_check(self): + from tools.registry import registry + + entry = registry.get_entry("browser_exec") + assert entry is not None + assert entry.check_fn is bu_cli.is_browser_use_cli_mode + assert entry.toolset == "browser-use" + + def test_browser_exec_in_browser_toolsets(self): + from toolsets import TOOLSETS, _HERMES_CORE_TOOLS + + assert "browser_exec" in _HERMES_CORE_TOOLS + assert "browser_exec" in TOOLSETS["browser"]["tools"] + assert "browser_exec" in TOOLSETS["coding"]["tools"] + + +class TestFindCli: + def test_prefers_installed_binary(self, monkeypatch): + monkeypatch.setattr( + bu_cli.shutil, "which", + lambda name: "/usr/local/bin/browser-use" if name == "browser-use" else "/usr/local/bin/uvx", + ) + assert bu_cli._find_cli() == ["/usr/local/bin/browser-use"] + + def test_falls_back_to_uvx(self, monkeypatch): + monkeypatch.setattr( + bu_cli.shutil, "which", + lambda name: "/usr/local/bin/uvx" if name == "uvx" else None, + ) + assert bu_cli._find_cli() == ["/usr/local/bin/uvx", "browser-use"] + + def test_none_when_neither_available(self, monkeypatch): + monkeypatch.setattr(bu_cli.shutil, "which", lambda name: None) + assert bu_cli._find_cli() is None + + +class TestLegacyCloudMigration: + """Pre-CLI direct-API Browser Use cloud configs (cloud_provider: + "browser-use" + BROWSER_USE_API_KEY) auto-route to the CLI backend; + Nous-gateway users stay on the legacy provider path.""" + + _LEGACY = {"browser": {"cloud_provider": "browser-use"}} + + def test_direct_api_config_migrates(self, monkeypatch): + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: self._LEGACY) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert bu_cli.is_browser_use_cli_mode() is True + + def test_gateway_config_stays_on_legacy_path(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"cloud_provider": "browser-use", "use_gateway": True}}, + ) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert bu_cli.is_browser_use_cli_mode() is False + + def test_no_api_key_stays_on_legacy_path(self, monkeypatch): + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: self._LEGACY) + assert bu_cli.is_browser_use_cli_mode() is False + + def test_explicit_other_backend_wins(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"cloud_provider": "browser-use", "backend": "something-else"}}, + ) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert bu_cli.is_browser_use_cli_mode() is False + + def test_other_cloud_provider_does_not_migrate(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"cloud_provider": "browserbase"}}, + ) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert bu_cli.is_browser_use_cli_mode() is False + + def test_explicit_local_does_not_migrate(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"cloud_provider": "local"}}, + ) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert bu_cli.is_browser_use_cli_mode() is False + + def test_auto_detect_with_key_migrates(self, monkeypatch): + """No cloud_provider configured + BROWSER_USE_API_KEY set: credential + auto-detection prefers Browser Use (even when Browserbase creds are + also present), which now means Browser Use mode.""" + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key") + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb-project") + assert bu_cli.is_browser_use_cli_mode() is True + + def test_auto_detect_without_key_does_not_migrate(self, monkeypatch): + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) + assert bu_cli.is_browser_use_cli_mode() is False + + def test_migrated_config_gets_bu_autospawn(self, tmp_path, monkeypatch): + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: self._LEGACY) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "autospawn:$BU_AUTOSPAWN"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)")) + assert "autospawn:1" in result["output"] + + def test_explicit_backend_does_not_set_bu_autospawn(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"backend": "browser-use"}}, + ) + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "autospawn:[$BU_AUTOSPAWN]"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)")) + assert "autospawn:[]" in result["output"] + + def test_picker_highlights_cli_row_for_migrated_config(self, monkeypatch): + from hermes_cli.tools_config import TOOL_CATEGORIES, _is_provider_active + + cli_row = next( + r for r in TOOL_CATEGORIES["browser"]["providers"] if r.get("browser_backend") + ) + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key") + assert _is_provider_active(cli_row, dict(self._LEGACY)) is True + monkeypatch.delenv("BROWSER_USE_API_KEY") + assert _is_provider_active(cli_row, dict(self._LEGACY)) is False + + +class TestProviderPickerIntegration: + """The `hermes tools` Browser Automation picker row (browser_backend + marker) must enter/leave CLI mode cleanly and highlight correctly.""" + + def _rows(self): + from hermes_cli.tools_config import TOOL_CATEGORIES + + return TOOL_CATEGORIES["browser"]["providers"] + + def test_picker_has_browser_use_cli_row(self): + row = next(r for r in self._rows() if r.get("browser_backend")) + assert row["browser_backend"] == "browser-use" + assert row["name"] == "Browser Use" + + def test_picker_row_names_stay_unique(self): + """The CLI row is named "Browser Use"; the legacy plugin API row must + keep a distinct name โ€” apply_provider_selection matches by name.""" + from hermes_cli.tools_config import TOOL_CATEGORIES, _plugin_browser_providers + + names = [r["name"] for r in TOOL_CATEGORIES["browser"]["providers"]] + names += [r["name"] for r in _plugin_browser_providers()] + assert len(names) == len(set(names)) + + def test_selecting_cli_row_writes_backend_and_keeps_cloud_provider(self): + from hermes_cli.tools_config import _write_provider_config + + row = next(r for r in self._rows() if r.get("browser_backend")) + config = {"browser": {"cloud_provider": "browserbase"}} + assert row["name"] == "Browser Use" + _write_provider_config(row, config, managed_feature=None) + assert config["browser"]["backend"] == "browser-use" + assert config["browser"]["cloud_provider"] == "browserbase" + + def test_selecting_provider_row_leaves_cli_mode(self): + from hermes_cli.tools_config import _write_provider_config + + local_row = next( + r for r in self._rows() if r.get("browser_provider") == "local" + ) + config = {"browser": {"backend": "browser-use"}} + _write_provider_config(local_row, config, managed_feature=None) + assert "backend" not in config["browser"] + assert config["browser"]["cloud_provider"] == "local" + + def test_active_row_highlight_is_mutually_exclusive(self): + from hermes_cli.tools_config import _is_provider_active + + cli_row = next(r for r in self._rows() if r.get("browser_backend")) + local_row = next( + r for r in self._rows() if r.get("browser_provider") == "local" + ) + cli_config = {"browser": {"cloud_provider": "local", "backend": "browser-use"}} + assert _is_provider_active(cli_row, cli_config) is True + assert _is_provider_active(local_row, cli_config) is False + + local_config = {"browser": {"cloud_provider": "local"}} + assert _is_provider_active(cli_row, local_config) is False + assert _is_provider_active(local_row, local_config) is True + + +class TestBrowserUseSlashCommand: + """/browser use [off] toggles browser.backend and resets the session, + mirroring the /tools enable/disable flow.""" + + class _Stub: + def __init__(self): + self.session_resets = 0 + + def new_session(self): + self.session_resets += 1 + + def _run(self, cmd, config, monkeypatch): + import hermes_cli.config as hc + from hermes_cli.cli_commands_mixin import CLICommandsMixin + + saved = {} + monkeypatch.setattr(hc, "load_config", lambda: config) + monkeypatch.setattr(hc, "save_config", lambda c: saved.update(c)) + stub = self._Stub() + CLICommandsMixin._handle_browser_command(stub, cmd) + return stub, saved + + def test_use_enables_backend_and_resets_session(self, monkeypatch): + stub, saved = self._run("/browser use", {}, monkeypatch) + assert saved["browser"]["backend"] == "browser-use" + assert stub.session_resets == 1 + + def test_use_off_removes_backend(self, monkeypatch): + config = {"browser": {"backend": "browser-use"}} + stub, saved = self._run("/browser use off", config, monkeypatch) + assert "backend" not in saved["browser"] + assert stub.session_resets == 1 + + def test_use_bad_arg_prints_usage_without_writing(self, monkeypatch): + stub, saved = self._run("/browser use whatever", {}, monkeypatch) + assert saved == {} + assert stub.session_resets == 0 + + +class TestNativeScreenshots: + """Screenshots printed by capture_screenshot() attach directly to the + model's context when it has native vision โ€” no aux vision-LLM detour.""" + + def _shot(self, tmp_path): + shot = tmp_path / "shot.png" + shot.write_bytes(b"\x89PNG fake") + return str(shot) + + def test_find_screenshot_returns_last_fresh_path(self, tmp_path): + a, b = self._shot(tmp_path), str(tmp_path / "b.png") + (tmp_path / "b.png").write_bytes(b"\x89PNG fake2") + out = f"step one saved {a}\nthen saved {b}\n" + assert bu_cli._find_screenshot(out, since=time.time() - 5) == b + + def test_find_screenshot_rejects_stale_and_missing(self, tmp_path): + stale = self._shot(tmp_path) + os.utime(stale, (time.time() - 900, time.time() - 900)) + out = f"{stale}\n/nonexistent/dir/x.png\n" + assert bu_cli._find_screenshot(out, since=time.time()) is None + + def test_vision_model_gets_multimodal_envelope(self, tmp_path, monkeypatch): + shot = self._shot(tmp_path) + cli = _fake_cli(tmp_path, f'cat > /dev/null\necho "{shot}"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + monkeypatch.setattr( + "tools.vision_tools._should_use_native_vision_fast_path", lambda: True + ) + monkeypatch.setattr( + "tools.vision_tools._resize_image_for_vision", + lambda p, **kw: "data:image/png;base64,QUJD", + ) + result = bu_cli.browser_exec("print(capture_screenshot())") + assert isinstance(result, dict) and result["_multimodal"] is True + kinds = [part["type"] for part in result["content"]] + assert kinds == ["text", "image_url"] + assert result["meta"]["screenshot_path"] == shot + assert shot in result["text_summary"] + + def test_text_only_model_gets_plain_result_with_path(self, tmp_path, monkeypatch): + shot = self._shot(tmp_path) + cli = _fake_cli(tmp_path, f'cat > /dev/null\necho "{shot}"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + monkeypatch.setattr( + "tools.vision_tools._should_use_native_vision_fast_path", lambda: False + ) + result = json.loads(bu_cli.browser_exec("print(capture_screenshot())")) + assert result["screenshot_path"] == shot + + def test_no_screenshot_keeps_string_result(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "no images here"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)")) + assert "screenshot_path" not in result + + +class TestStepLabels: + """browser_exec code leads with a `# โ€ฆ` comment (per the tool + description); the TUI surfaces it as the step label and keeps the code + collapsed behind display.tool_preview_length.""" + + _CODE = "# Searching Amazon for paper towels\nnew_tab('https://amazon.com')\nwait_for_load()" + + def test_leading_comment_becomes_step_label(self): + from agent.display import _browser_exec_step_label + + assert _browser_exec_step_label({"code": self._CODE}) == "Searching Amazon for paper towels" + + def test_no_comment_returns_none(self): + from agent.display import _browser_exec_step_label + + assert _browser_exec_step_label({"code": "new_tab('x')"}) is None + assert _browser_exec_step_label({"code": ""}) is None + assert _browser_exec_step_label({"code": "# "}) is None + + def test_label_hard_capped_regardless_of_global_setting(self): + from agent.display import _browser_exec_step_label + + long = "# " + "x" * 200 + label = _browser_exec_step_label({"code": long}) + assert len(label) <= 80 and label.endswith("โ€ฆ") + + def test_preview_prefers_comment_over_code(self): + from agent.display import build_tool_preview + + assert build_tool_preview("browser_exec", {"code": self._CODE}) == ( + "Searching Amazon for paper towels" + ) + assert "new_tab" in build_tool_preview("browser_exec", {"code": "new_tab('x')"}) + + def test_progress_line_shows_label(self): + from agent.display import get_cute_tool_message + + line = get_cute_tool_message("browser_exec", {"code": self._CODE}, 1.2) + assert "Searching Amazon for paper towels" in line + assert "new_tab" not in line + + def test_header_instructs_leading_comment(self): + assert "one-line comment" in bu_cli._HEADER_BASE + assert "step label" in bu_cli._HEADER_BASE + + +class TestHeaderVariants: + def test_vision_header_forbids_vision_tool_detour(self, monkeypatch): + monkeypatch.setattr( + "tools.vision_tools._should_use_native_vision_fast_path", lambda: True + ) + header = bu_cli._description_header() + assert header.startswith(bu_cli._HEADER_BASE) + assert "attached to your context automatically" in header + + def test_text_only_header_teaches_text_workflow(self, monkeypatch): + monkeypatch.setattr( + "tools.vision_tools._should_use_native_vision_fast_path", lambda: False + ) + header = bu_cli._description_header() + assert "cannot view images" in header + assert "page_info()" in header + + +class TestSkillTextDescription: + @pytest.fixture(autouse=True) + def _reset_skill_cache(self, monkeypatch): + monkeypatch.setattr(bu_cli, "_skill_text_cache", None) + monkeypatch.setattr(bu_cli, "_skill_text_fetched", False) + yield + + def test_description_is_verbatim_cli_skill_text(self, tmp_path, monkeypatch): + cli = _fake_cli( + tmp_path, + 'if [ "$1" = "skill" ]; then echo "# Browser Use\nverbatim skill body"; fi\n', + ) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + overrides = bu_cli._dynamic_schema_overrides() + assert overrides["description"].startswith(bu_cli._DESCRIPTION_HEADER) + assert overrides["description"].endswith("# Browser Use\nverbatim skill body") + + def test_skill_text_cached_after_first_fetch(self, tmp_path, monkeypatch): + calls = [] + cli = _fake_cli(tmp_path, 'echo "skill text"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: (calls.append(1), [cli])[1]) + assert "skill text" in bu_cli._cli_skill_text() + assert "skill text" in bu_cli._cli_skill_text() + assert len(calls) == 1 + + def test_no_override_when_cli_missing(self, monkeypatch): + monkeypatch.setattr(bu_cli, "_find_cli", lambda: None) + assert bu_cli._dynamic_schema_overrides() == {} + + +class TestBrowserExec: + def test_missing_cli_returns_install_hint(self, monkeypatch): + monkeypatch.setattr(bu_cli, "_find_cli", lambda: None) + result = json.loads(bu_cli.browser_exec("print(page_info())")) + assert "uv tool install browser-use" in result["error"] + + def test_empty_code_rejected(self): + result = json.loads(bu_cli.browser_exec(" ")) + assert "error" in result + + def test_code_piped_on_stdin(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, 'code=$(cat)\necho "got:$code"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec('print("hi")')) + assert result["success"] is True + assert result["exit_code"] == 0 + assert 'got:print("hi")' in result["output"] + assert "session" not in result + + def test_session_sets_bu_name(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME"\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)", session="r7k2")) + assert "bu:r7k2" in result["output"] + assert result["session"] == "r7k2" + + def test_invalid_session_name_rejected(self, monkeypatch, tmp_path): + cli = _fake_cli(tmp_path, "cat > /dev/null\n") + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)", session="bad name!")) + assert "error" in result + assert "session" in result["error"].lower() + + def test_nonzero_exit_reports_failure_and_stderr(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "boom" >&2\nexit 3\n') + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + result = json.loads(bu_cli.browser_exec("print(1)")) + assert result["success"] is False + assert result["exit_code"] == 3 + assert "boom" in result["stderr"] + + def test_timeout_returns_actionable_error(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, "cat > /dev/null\nsleep 30\n") + monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) + monkeypatch.setattr(bu_cli, "_MIN_TIMEOUT_S", 1) + result = json.loads(bu_cli.browser_exec("print(1)", timeout_s=1)) + assert "timed out" in result["error"] diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 337c00477bc29..05d4059c30a3e 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -182,6 +182,11 @@ try: from tools.browser_camofox import is_camofox_mode as _is_camofox_mode except ImportError: _is_camofox_mode = lambda: False # noqa: E731 +# Browser Use CLI (optional) +try: + from tools.browser_use_cli import is_browser_use_cli_mode as _is_browser_use_cli_mode +except ImportError: + _is_browser_use_cli_mode = lambda: False # noqa: E731 logger = logging.getLogger(__name__) @@ -4885,6 +4890,12 @@ def check_browser_requirements() -> bool: Returns: True if all requirements are met, False otherwise """ + # Browser Use CLI backend โ€” browser_exec replaces the whole browser_* + # surface (including browser_cdp/browser_dialog, whose check_fns funnel + # through here), so hide these tools from the model. + if _is_browser_use_cli_mode(): + return False + # Camofox backend โ€” only needs the server URL, no agent-browser CLI if _is_camofox_mode(): return True diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py new file mode 100644 index 0000000000000..640cfcad27324 --- /dev/null +++ b/tools/browser_use_cli.py @@ -0,0 +1,337 @@ +"""Use the Browser Use CLI 3.0 (https://browser-use.com) for browser automation + +When browser.backend is "browser-use", the model gets ``browser_exec`` tool +instead of default browser tools +""" + +import json +import logging +import os +import re +import shutil +import subprocess +import time +from typing import Any, Dict, List, Optional + +from utils import is_truthy_value + +logger = logging.getLogger(__name__) + +_BACKEND_KEY = "browser-use" + +# Cloud daemon names become the BU_NAME env var +_SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + +_DEFAULT_TIMEOUT_S = 120 +_MIN_TIMEOUT_S = 5 +_MAX_TIMEOUT_S = 600 +_STDERR_CAP_CHARS = 4000 + +# Screenshot paths printed by capture_screenshot() in the exec output +_IMAGE_PATH_RE = re.compile(r"(/[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE) + + +def _read_browser_cfg() -> dict: + """Return the ``browser:`` config section, or {} on any failure.""" + try: + from hermes_cli.config import cfg_get, read_raw_config + + cfg = cfg_get(read_raw_config(), "browser", default={}) + return cfg if isinstance(cfg, dict) else {} + except Exception as e: + logger.debug("Could not read browser config section: %s", e) + return {} + + +def get_browser_backend() -> str: + """Return the configured browser backend key ("" = legacy stack).""" + return str(_read_browser_cfg().get("backend") or "").strip().lower() + + +def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool: + """True for pre-CLI direct-API Browser Use cloud configs""" + if not isinstance(browser_cfg, dict): + return False + if browser_cfg.get("backend"): + return False # an explicit backend choice wins + provider = str(browser_cfg.get("cloud_provider") or "").strip().lower() + if provider not in {"browser-use", ""}: + return False # explicit local/Browserbase/โ€ฆ choices win + if is_truthy_value(browser_cfg.get("use_gateway"), default=False): + return False + return bool(os.getenv("BROWSER_USE_API_KEY")) + + +def is_browser_use_cli_mode() -> bool: + """True when the Browser Use CLI replaces the built-in browser stack""" + backend = get_browser_backend() + if backend: + return backend == _BACKEND_KEY + return is_legacy_browser_use_cloud_config(_read_browser_cfg()) + + +def _find_cli() -> Optional[List[str]]: + """Locate the browser-use CLI, or None when it can't be run. + + Prefers an installed browser-use binary; falls back to running it + through uvx + """ + direct = shutil.which("browser-use") + if direct: + return [direct] + uvx = shutil.which("uvx") + if uvx: + return [uvx, "browser-use"] + return None + + +def _find_screenshot(stdout: str, since: float) -> Optional[str]: + """Return the last screenshot path printed during this exec, or None. + + Only accepts files that exist and were written after the exec started + """ + for path in reversed(_IMAGE_PATH_RE.findall(stdout or "")): + try: + if os.path.isfile(path) and os.path.getmtime(path) >= since - 1: + return path + except OSError: + continue + return None + + +def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]: + """Build a multimodal tool result attaching path for vision models""" + try: + from pathlib import Path + + from tools.vision_tools import ( + _resize_image_for_vision, + _should_use_native_vision_fast_path, + ) + + if not _should_use_native_vision_fast_path(): + return None + data_url = _resize_image_for_vision(Path(path)) + text = json.dumps(result, ensure_ascii=False) + return { + "_multimodal": True, + "content": [ + { + "type": "text", + "text": ( + text + + "\n\nThe screenshot from this call is attached โ€” " + "inspect it with your native vision." + ), + }, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + "text_summary": text, + "meta": {"screenshot_path": path, "native_vision": True}, + } + except Exception as e: + logger.debug("Native screenshot attach failed (falling back to text): %s", e) + return None + + +def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT_S): + """Run Python code through the browser-use CLI, and return its output""" + from tools.registry import tool_error, tool_result + + if not code or not code.strip(): + return tool_error("No code provided. Pass Python that uses the pre-imported helpers, e.g. new_tab(\"https://example.com\") then print(page_info()).") + + cmd = _find_cli() + if not cmd: + return tool_error( + "browser-use CLI not found on PATH, and uvx is unavailable for a " + "zero-install run. Install it with `uv tool install browser-use` " + "(or `pipx install browser-use`), then run `browser-use --doctor` " + "to verify the setup." + ) + + env = os.environ.copy() + if session: + if not _SESSION_RE.match(session): + return tool_error( + f"Invalid session name {session!r}: use 1-64 letters, digits, " + "dashes, or underscores (e.g. 'r7k2')." + ) + env["BU_NAME"] = session + + # BU_AUTOSPAWN makes the CLI start a Browser Use cloud browser when no + # local Chrome/CDP endpoint is reachable (their API key authenticates it) + if "BU_AUTOSPAWN" not in env and is_legacy_browser_use_cloud_config(_read_browser_cfg()): + env["BU_AUTOSPAWN"] = "1" + + try: + timeout = max(_MIN_TIMEOUT_S, min(int(timeout_s), _MAX_TIMEOUT_S)) + except (TypeError, ValueError): + timeout = _DEFAULT_TIMEOUT_S + + started = time.time() + try: + proc = subprocess.run( + cmd, + input=code, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except subprocess.TimeoutExpired: + return tool_error( + f"browser-use exec timed out after {timeout}s. The daemon may " + "still be working; retry with a larger timeout_s, or split the " + "code into smaller steps." + ) + except OSError as e: + return tool_error(f"Failed to launch browser-use CLI: {e}") + + result = { + "success": proc.returncode == 0, + "exit_code": proc.returncode, + "output": proc.stdout, + } + if session: + result["session"] = session + stderr = (proc.stderr or "").strip() + if stderr: + if len(stderr) > _STDERR_CAP_CHARS: + stderr = stderr[:_STDERR_CAP_CHARS] + "\nโ€ฆ (stderr truncated)" + result["stderr"] = stderr + + screenshot = _find_screenshot(proc.stdout, started) + if screenshot: + result["screenshot_path"] = screenshot + native = _native_screenshot_result(result, screenshot) + if native is not None: + return native + return tool_result(result) + + +# The tool description is the CLI's skill, fetched from browser-use skill +_HEADER_BASE = ( + "Drive a real web browser via the Browser Use CLI. The `code` argument " + "is piped verbatim to the `browser-use` CLI on stdin and executed with " + "its pre-imported helpers; stdout comes back in the result. Start `code` " + "with a one-line comment describing the step for the user in plain, " + "non-technical language, max 60 chars (e.g. `# Searching Amazon for " + "paper towels`) โ€” the UI displays it as the step label. Batch whole " + "sub-procedures (navigate, wait, extract, act) into ONE call โ€” do not " + "spend a call per action. js() takes a JS expression: js('document.title') " + "or js('(() => {...})()') โ€” a bare '() => {...}' returns the function " + "itself, uncalled. The CLI's own documentation follows and is complete " + "(no need to read separate browser-use skill files) โ€” where it shows " + "shell heredocs (browser-use <<'PY' โ€ฆ PY), pass the Python as `code` " + "instead; where it shows BU_NAME=, pass session= instead." +) + +_HEADER_VISION = ( + " Screenshots are attached to your context automatically: when the exec " + "output contains a capture_screenshot() path, the image arrives with " + "this tool's result and you inspect it directly with your own vision โ€” " + "never send browser screenshots to a separate vision tool." +) + +_HEADER_TEXT_ONLY = ( + " Your model cannot view images, so work text-first: page_info() for " + "state, js() for reading/extracting DOM text, fill_input(selector, " + "text) for inputs, and js(\"document.querySelector('โ€ฆ').click()\") for " + "clicks โ€” skip the screenshot-driven workflow described below." +) + +_DESCRIPTION_HEADER = _HEADER_BASE + + +def _description_header() -> str: + """Header tailored to whether the active model can see images natively""" + try: + from tools.vision_tools import _should_use_native_vision_fast_path + + if _should_use_native_vision_fast_path(): + return _HEADER_BASE + _HEADER_VISION + except Exception: + pass + return _HEADER_BASE + _HEADER_TEXT_ONLY + +_skill_text_cache: Optional[str] = None +_skill_text_fetched = False + + +def _cli_skill_text() -> str: + """Return the installed CLI's skill""" + global _skill_text_cache, _skill_text_fetched + if _skill_text_fetched: + return _skill_text_cache or "" + _skill_text_fetched = True + cmd = _find_cli() + if not cmd: + return "" + try: + proc = subprocess.run( + [*cmd, "skill"], capture_output=True, text=True, timeout=30 + ) + if proc.returncode == 0 and proc.stdout.strip(): + _skill_text_cache = proc.stdout.strip() + except Exception as e: + logger.debug("Could not fetch browser-use skill text: %s", e) + return _skill_text_cache or "" + + +def _dynamic_schema_overrides() -> dict: + skill = _cli_skill_text() + if not skill: + return {} + return {"description": _description_header() + "\n\n---\n\n" + skill} + + +BROWSER_EXEC_SCHEMA = { + "name": "browser_exec", + # Static fallback, used only when the CLI (and uvx) is unavailable + "description": ( + _HEADER_BASE + + "\n\n(The browser-use CLI is not installed yet, so its full skill " + "documentation could not be loaded. Install it with " + "`uv tool install browser-use`.)" + ), + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute using the pre-imported browser helpers. Use print(...) for any data you need back.", + }, + "session": { + "type": "string", + "description": "Named cloud browser session (sets BU_NAME). Omit for the local default daemon. Use the same name you passed to start_remote_daemon().", + }, + "timeout_s": { + "type": "integer", + "description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S}).", + "default": _DEFAULT_TIMEOUT_S, + }, + }, + "required": ["code"], + }, +} + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- +from tools.registry import registry + +registry.register( + name="browser_exec", + toolset="browser-use", + schema=BROWSER_EXEC_SCHEMA, + handler=lambda args, **kw: browser_exec( + code=args.get("code", ""), + session=args.get("session", "") or "", + timeout_s=args.get("timeout_s", _DEFAULT_TIMEOUT_S), + ), + check_fn=is_browser_use_cli_mode, + dynamic_schema_overrides=_dynamic_schema_overrides, + emoji="๐ŸŒ", +) diff --git a/toolsets.py b/toolsets.py index f7f7939257b50..b1001bf116eb4 100644 --- a/toolsets.py +++ b/toolsets.py @@ -55,6 +55,8 @@ _HERMES_CORE_TOOLS = [ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + # replaces other tools when browser.backend is "browser-use" + "browser_exec", # Text-to-speech "text_to_speech", # Planning & memory @@ -205,7 +207,7 @@ TOOLSETS = { "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", - "browser_dialog", "web_search" + "browser_dialog", "browser_exec", "web_search" ], "includes": [] }, @@ -410,6 +412,7 @@ TOOLSETS = { "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + "browser_exec", "todo", "memory", "session_search", "clarify", "execute_code", "delegate_task", @@ -442,6 +445,7 @@ TOOLSETS = { "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + "browser_exec", "todo", "memory", "session_search", "execute_code", "delegate_task", @@ -471,6 +475,7 @@ TOOLSETS = { "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + "browser_exec", # Planning & memory "todo", "memory", # Session history search diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 4c9a61a63561a..f5d29713873da 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -11,6 +11,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op - **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling - **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider +- **Browser Use mode** via the [Browser Use CLI 3.0](https://github.com/browser-use/browser-use) โ€” a new browser harness that is SOTA for web tasks; automates your local Chrome or Browser Use cloud browsers - **Firecrawl cloud mode** via [Firecrawl](https://firecrawl.dev) for cloud browsers with built-in scraping - **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing) - **Local Chromium-family CDP** โ€” connect browser tools to your own Chrome, Brave, Chromium, or Edge instance using `/browser connect` @@ -58,7 +59,21 @@ To use Browser Use as your cloud browser provider, add: BROWSER_USE_API_KEY=*** ``` -Get your API key at [browser-use.com](https://browser-use.com). Browser Use provides a cloud browser via its REST API. If both Browserbase and Browser Use credentials are set, Browserbase takes priority. +Get your API key at [browser-use.com](https://browser-use.com). + +### Browser Use mode + +Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/browser-use) โ€” a new browser harness that is state-of-the-art at web tasks โ€” instead of the default browser tools. The agent writes and executes Python in the browser to click, type, drag, scrape, and interact with webpages. It works in your local browser or with Browser Use cloud browsers. + +Enable it with `hermes tools` โ†’ **Browser Automation โ†’ Browser Use** (free ยท local ยท cloud), or directly: + +```yaml +# Add to ~/.hermes/config.yaml +browser: + backend: "browser-use" +``` + +Cloud browsers need `browser-use auth login` or `BROWSER_USE_API_KEY`. ### Firecrawl cloud mode