# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
742 lines
36 KiB
Python
742 lines
36 KiB
Python
"""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 contextlib
|
|
import importlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from utils import is_truthy_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_BACKEND_KEY = "browser-use"
|
|
BACKEND_DISABLED = "off"
|
|
|
|
# Cloud daemon names become the BU_NAME env var
|
|
_SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
|
|
# Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE to this named session
|
|
# (per-name provider / named BU cloud / Lightpanda). Popped before the subprocess launches — never exported.
|
|
_PRIVATE_BROWSER_SENTINEL = "_HERMES_BU_PRIVATE_BROWSER"
|
|
|
|
# Prepended to the model's code for named sessions on SHARED browsers (local Chrome / CDP override): the
|
|
# harness daemon attaches to the first existing page at startup, so two fresh named daemons can land on the
|
|
# SAME tab. Steering each onto a tab it created prevents clobbering. Runs once per daemon (marker keyed by
|
|
# BU_NAME + daemon pid).
|
|
_OWN_TAB_PREAMBLE = """\
|
|
# hermes: pin this named session to its own tab (once per daemon process)
|
|
def _hermes_ensure_own_tab():
|
|
import os as _os, tempfile as _tf
|
|
_name = _os.environ.get("BU_NAME", "default")
|
|
try:
|
|
# Key the marker by the daemon's pid so a daemon restart (which
|
|
# re-attaches to the first shared page) re-pins automatically,
|
|
# while agent-driven tab switches mid-session are left alone.
|
|
from browser_harness import _ipc as _bipc
|
|
_dpid = _bipc.pid_path(_name).read_text().strip() or "0"
|
|
except Exception:
|
|
_dpid = "0"
|
|
_uid = _os.getuid() if hasattr(_os, "getuid") else 0
|
|
_marker = _os.path.join(
|
|
_tf.gettempdir(), "hermes-bu-owntab-%s-%s-%s" % (_uid, _name, _dpid)
|
|
)
|
|
if _os.path.exists(_marker):
|
|
return
|
|
try:
|
|
# Force a fresh target: new_tab() would REUSE a blank current tab,
|
|
# which is exactly the tab a sibling daemon may also hold.
|
|
_tid = cdp("Target.createTarget", url="about:blank").get("targetId")
|
|
if _tid:
|
|
switch_tab(_tid)
|
|
except Exception:
|
|
pass # best-effort: worst case is pre-fix behavior
|
|
try:
|
|
open(_marker, "w").close()
|
|
except OSError:
|
|
pass
|
|
_hermes_ensure_own_tab()
|
|
del _hermes_ensure_own_tab
|
|
"""
|
|
|
|
_DEFAULT_TIMEOUT_S = 300
|
|
_MIN_TIMEOUT_S = 5
|
|
_MAX_TIMEOUT_S = 1800
|
|
_STDERR_CAP_CHARS = 4000
|
|
|
|
_TASK_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") # filesystem-safe task ids
|
|
# Screenshot paths printed by capture_screenshot(): POSIX or Windows drive-letter absolute.
|
|
_IMAGE_PATH_RE = re.compile(r"((?:[A-Za-z]:[\\/]|/)[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE)
|
|
# http(s) URL literals in exec code checked against browser_navigate's policy
|
|
_URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE)
|
|
_FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin")
|
|
|
|
|
|
def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any:
|
|
"""``fn()``, or ``default`` on any exception (debug-logged when ``log_prefix`` is set)."""
|
|
try:
|
|
return fn()
|
|
except Exception as e:
|
|
if log_prefix:
|
|
logger.debug("%s: %s", log_prefix, e)
|
|
return default
|
|
|
|
|
|
def _lazy_call(module: str, name: str, default: Any, log_prefix: str) -> Any:
|
|
"""Call ``module.name()`` resolved at call time (tests stub the module / patch the
|
|
attribute); on any failure log ``log_prefix`` and return ``default``."""
|
|
return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix)
|
|
|
|
|
|
def _camofox_active(context: str = "") -> bool:
|
|
return _lazy_call("tools.browser_camofox", "is_camofox_mode", False, f"Camofox activity check failed{context}")
|
|
|
|
|
|
def _real_profile_consented() -> bool:
|
|
return _lazy_call("tools.browser_tool_cloud", "_use_real_profile", False, "real-profile consent lookup failed")
|
|
|
|
|
|
def _set_cdp_env(env: dict, cdp: str) -> None:
|
|
"""Export a CDP endpoint under the BU_CDP_* contract (http(s) → URL, else WS)."""
|
|
env["BU_CDP_URL" if cdp.startswith(("http://", "https://")) else "BU_CDP_WS"] = cdp
|
|
|
|
|
|
def _has_cdp_env(env: dict) -> bool:
|
|
return bool(env.get("BU_CDP_WS") or env.get("BU_CDP_URL"))
|
|
|
|
|
|
def _export_session_cdp(env: dict, get_session_info: Callable[[str], Any], cache_key: str,
|
|
fail_msg: Callable[[Exception], str], no_cdp_msg: str) -> Optional[str]:
|
|
"""Export the CDP endpoint from ``get_session_info(cache_key)``; error string on failure / no CDP."""
|
|
try:
|
|
cdp = str((get_session_info(cache_key) or {}).get("cdp_url") or "")
|
|
except Exception as e:
|
|
return fail_msg(e)
|
|
if not cdp:
|
|
return no_cdp_msg
|
|
_set_cdp_env(env, cdp)
|
|
return None
|
|
|
|
|
|
def _blocked_url_in_code(code: str) -> Optional[str]:
|
|
"""Return an error if a URL literal fails the built-in navigation checks."""
|
|
from tools.browser_tool import evaluate_url_safety
|
|
return next((err.get("error", "Blocked: unsafe URL") for err in map(evaluate_url_safety, _URL_RE.findall(code or "")) if err), None)
|
|
|
|
|
|
def _base_subprocess_env() -> dict:
|
|
from tools.browser_tool import _build_browser_env
|
|
env = _build_browser_env()
|
|
# The CLI runs under its own Python (uv tool / uvx); an inherited PYTHONPATH/PYTHONHOME
|
|
# (Hermes's venv) wins over its site-packages → wrong-ABI C-extensions and a crash.
|
|
# PYTHONPATH/PYTHONHOME inherited from the agent process point at Hermes's venv site-packages, and a
|
|
# child interpreter honors them ahead of its own site-packages — so the CLI imports compiled
|
|
# C-extensions (e.g. pydantic_core) built for the wrong interpreter and crashes on ABI mismatch (#83427,
|
|
# #84841, #86006, #86104). Strip both — the CLI manages its own environment and never needs Hermes's
|
|
# import path.
|
|
env.pop("PYTHONPATH", None)
|
|
env.pop("PYTHONHOME", None)
|
|
env["PATH"] = _floor_subprocess_path(env.get("PATH", ""))
|
|
env.setdefault("ANONYMIZED_TELEMETRY", "false")
|
|
return env
|
|
|
|
|
|
def _floor_subprocess_path(path: str) -> str:
|
|
"""Guarantee core system dirs on the CLI subprocess PATH: profile workers (kanban bots, cron) can inherit
|
|
a PATH of only version-manager dirs, and the uv binary's POSIX sh trampoline resolves ``dirname``/``realpath``
|
|
via PATH (exit 127 without /usr/bin). Reuses browser_tool's ``_merge_browser_path`` floor, else appends
|
|
FHS bin dirs. Windows .cmd shims don't trampoline: no-op there."""
|
|
if os.name == "nt":
|
|
return path
|
|
with contextlib.suppress(Exception):
|
|
from tools.browser_tool_install import _merge_browser_path
|
|
return _merge_browser_path(path or "")
|
|
parts = [p for p in (path or "").split(os.pathsep) if p]
|
|
return os.pathsep.join(parts + [d for d in _FHS_BIN_DIRS if d not in set(parts) and os.path.isdir(d)])
|
|
|
|
|
|
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 _use_gateway(browser_cfg: dict) -> bool:
|
|
return is_truthy_value(browser_cfg.get("use_gateway"), default=False)
|
|
|
|
|
|
def get_browser_backend() -> str:
|
|
"""Configured browser backend key ("" = unset → default). YAML 1.1 parses an
|
|
unquoted ``off`` as False — that must mean BACKEND_DISABLED, not "unset"."""
|
|
raw = _read_browser_cfg().get("backend")
|
|
return (BACKEND_DISABLED if raw is False else "") if isinstance(raw, bool) else str(raw or "").strip().lower()
|
|
|
|
|
|
def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
|
|
"""True for pre-CLI direct-API Browser Use cloud configs. An explicit backend or
|
|
a non-Browser-Use cloud_provider wins; Camofox is selected via env var, not
|
|
cloud_provider, so a Camofox user with a stray BROWSER_USE_API_KEY keeps it."""
|
|
if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"):
|
|
return False
|
|
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
|
|
if provider not in {"browser-use", ""} or _use_gateway(browser_cfg) or _camofox_active(" during migration"):
|
|
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. Browser Use mode is the DEFAULT:
|
|
unset ``browser.backend`` ("") enables it whenever the CLI is runnable (installed binary or uvx);
|
|
``browser.backend: off`` keeps the built-in browser_* tools. Camofox always falls back to the built-in
|
|
tools (Firefox, custom HTTP API, no CDP surface for the harness)."""
|
|
if _camofox_active():
|
|
return False
|
|
backend = get_browser_backend()
|
|
return backend == _BACKEND_KEY if backend else (is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None)
|
|
|
|
|
|
def default_downgrade_notice() -> Optional[str]:
|
|
"""One-line notice when ``browser.backend`` is unset but the CLI is not runnable, so
|
|
the session fell back to the built-in tools. Rate-limited to once per 24h via a stamp file."""
|
|
try:
|
|
if get_browser_backend() or _camofox_active() or _find_cli() is not None:
|
|
return None # explicit choice / Camofox / CLI present — nothing downgraded
|
|
stamp = Path(get_hermes_home()) / "cache" / ".browser_use_default_notice"
|
|
with contextlib.suppress(OSError):
|
|
if 0 <= time.time() - stamp.stat().st_mtime < 24 * 3600:
|
|
return None
|
|
with contextlib.suppress(OSError):
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.touch()
|
|
return ("Browser Use CLI not found — using the built-in browser tools. Run `hermes tools` "
|
|
"(Browser Automation → Browser Use) to install it, or `browser.backend: off` in config.yaml to silence this.")
|
|
except Exception as e: # pragma: no cover — a notice must never break startup
|
|
logger.debug("browser-use downgrade notice failed: %s", e)
|
|
return None
|
|
|
|
|
|
def _managed_bin_dir() -> str:
|
|
"""$HERMES_HOME/bin — where install.sh puts uv/uvx and install_cli() links browser-use."""
|
|
return str(Path(get_hermes_home()) / "bin")
|
|
|
|
|
|
def _pinned_uvx() -> Optional[str]:
|
|
"""The pinned uvx from pm's store, or None when pm can't provide it.
|
|
|
|
uvx ships inside uv's own store entry, beside the uv binary — the pin
|
|
that governs uv governs it. Resolved from pm's uv fact rather than by
|
|
probing directories: pm names the binary, so nobody goes fishing with
|
|
``shutil.which`` on a dir (a PATH probe could resolve a system uvx of
|
|
unknown version). Pure lookup (``realize=False``) — this is a probe,
|
|
not the converging ``install_cli()`` path.
|
|
"""
|
|
try:
|
|
import pm
|
|
|
|
uv_bin, _env = pm.uv(realize=False)
|
|
if not uv_bin:
|
|
return None
|
|
uvx = str(Path(uv_bin).with_name("uvx.exe" if os.name == "nt" else "uvx"))
|
|
if os.path.isfile(uvx) and os.access(uvx, os.X_OK):
|
|
return uvx
|
|
return None
|
|
except Exception as e: # pragma: no cover — defensive
|
|
logger.debug("Could not resolve pinned uvx: %s", e)
|
|
return None
|
|
|
|
|
|
def _find_cli() -> Optional[List[str]]:
|
|
"""Locate the browser-use CLI, or None when it can't be run.
|
|
|
|
MANAGED-FIRST resolution: Hermes' own ``$HERMES_HOME/bin`` copy — the
|
|
one every browser backend selection installs and updates via
|
|
``install_cli()`` — always wins, so all sessions drive one canonical,
|
|
Hermes-controlled binary. PATH and the user-level tool dir
|
|
(~/.local/bin / %APPDATA%\\uv\\bin, where a manual ``uv tool install``
|
|
links binaries) are fallbacks for setups that never ran our install,
|
|
and cover Desktop/TUI workers that spawn with a minimal PATH. The uvx
|
|
zero-install path is the final fallback — the pinned uvx from pm's
|
|
store first, then the Hermes-owned bin dir and the user-level tool
|
|
dir. A bare PATH uvx probe is deliberately absent: pm names the
|
|
pinned binary, and a PATH uvx is an unknown version.
|
|
"""
|
|
if os.name == "nt":
|
|
appdata = os.environ.get("APPDATA")
|
|
user_bin = str(Path(appdata) / "uv" / "bin") if appdata else None
|
|
else:
|
|
user_bin = str(Path(os.path.expanduser("~")) / ".local" / "bin")
|
|
probe_paths = (_managed_bin_dir(), None, user_bin)
|
|
for probe_path in probe_paths:
|
|
if probe_path is None or probe_path:
|
|
direct = shutil.which("browser-use", path=probe_path)
|
|
if direct:
|
|
return [direct]
|
|
uvx = _pinned_uvx()
|
|
if uvx is not None:
|
|
return [uvx, "browser-use"]
|
|
for probe_path in (_managed_bin_dir(), user_bin):
|
|
if probe_path:
|
|
uvx = shutil.which("uvx", path=probe_path)
|
|
if uvx:
|
|
return [uvx, "browser-use"]
|
|
return None
|
|
|
|
|
|
def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
|
"""Install the browser-use CLI persistently via ``uv tool install``.
|
|
|
|
Resolution order for uv: Hermes' managed uv (realized on demand via
|
|
``pm.uv``) → uv on PATH. The binary is linked
|
|
into ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()``
|
|
resolves it for every profile without touching the user's PATH.
|
|
|
|
Returns ``(ok, message)`` — never raises.
|
|
"""
|
|
# MANAGED-FIRST: only the managed copy short-circuits the install. A
|
|
# browser-use found on PATH is a user-level side install — it must NOT
|
|
# prevent provisioning the canonical Hermes-managed copy, or resolution
|
|
# stays pinned to a binary we don't control (version drift, no updates
|
|
# through hermes tools).
|
|
bin_dir = _managed_bin_dir()
|
|
managed = shutil.which("browser-use", path=bin_dir)
|
|
if managed:
|
|
return True, f"browser-use CLI already installed ({managed})"
|
|
|
|
uv_bin: Optional[str] = None
|
|
env = dict(os.environ)
|
|
try:
|
|
import pm
|
|
|
|
uv_bin, pm_env = pm.uv()
|
|
if uv_bin:
|
|
env = pm_env
|
|
except Exception as e:
|
|
logger.debug("Managed uv unavailable: %s", e)
|
|
if not uv_bin:
|
|
uv_bin = shutil.which("uv")
|
|
if not uv_bin:
|
|
return False, (
|
|
"uv is not available and could not be bootstrapped. Install uv "
|
|
"(https://docs.astral.sh/uv/) and run `uv tool install browser-use`."
|
|
)
|
|
|
|
env["UV_NO_CONFIG"] = "1"
|
|
if bin_dir:
|
|
try:
|
|
Path(bin_dir).mkdir(parents=True, exist_ok=True)
|
|
env["UV_TOOL_BIN_DIR"] = bin_dir
|
|
except OSError as e:
|
|
logger.debug("Could not prepare %s: %s", bin_dir, e)
|
|
|
|
try:
|
|
result = subprocess.run([uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", env=env, timeout=timeout_s, stdin=subprocess.DEVNULL)
|
|
except subprocess.TimeoutExpired:
|
|
return False, f"`uv tool install browser-use` timed out after {timeout_s}s"
|
|
except Exception as e:
|
|
return False, f"Failed to run `uv tool install browser-use`: {e}"
|
|
|
|
if result.returncode != 0:
|
|
tail = "\n".join((result.stderr or result.stdout or "").strip().splitlines()[-3:])
|
|
return False, f"`uv tool install browser-use` failed:\n{tail}"
|
|
found = _find_cli()
|
|
if not found or len(found) != 1:
|
|
return False, ("install reported success but the browser-use binary is still not resolvable — "
|
|
"run `uv tool install browser-use` manually")
|
|
return True, f"browser-use CLI installed ({found[0]})"
|
|
|
|
|
|
def _workspace_dir(task_id: Optional[str]) -> Optional[str]:
|
|
"""Stable per-task scratch dir that persists across browser_exec calls"""
|
|
if os.environ.get("BH_AGENT_WORKSPACE"):
|
|
return os.environ["BH_AGENT_WORKSPACE"]
|
|
try:
|
|
safe = _TASK_ID_SAFE_RE.sub("_", str(task_id or "default"))[:80] or "default"
|
|
path = Path(get_hermes_home()) / "cache" / "browser-use" / "workspace" / safe
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return str(path)
|
|
except Exception as e:
|
|
logger.debug("browser_exec workspace unavailable: %s", e)
|
|
return None
|
|
|
|
|
|
def _find_screenshot(stdout: str, since: float) -> Optional[str]:
|
|
"""Last screenshot path printed during this exec that exists and was written after
|
|
the exec started, or None."""
|
|
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 tools.vision_tools import (_EMBED_MAX_DIMENSION, _EMBED_TARGET_BYTES,
|
|
_resize_image_for_vision, _should_use_native_vision_fast_path)
|
|
if not _should_use_native_vision_fast_path():
|
|
return None
|
|
# History-reuse cap: this data URL bakes into the tool result and is re-sent every later turn —
|
|
# same policy as the vision_analyze / browser_vision native embeds.
|
|
data_url = _resize_image_for_vision(Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
|
|
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True)
|
|
text = json.dumps(result, ensure_ascii=False)
|
|
attached = text + "\n\nThe screenshot from this call is attached — inspect it with your native vision."
|
|
return {"_multimodal": True, "text_summary": text, "meta": {"screenshot_path": path, "native_vision": True},
|
|
"content": [{"type": "text", "text": attached}, {"type": "image_url", "image_url": {"url": data_url}}]}
|
|
except Exception as e:
|
|
logger.debug("Native screenshot attach failed (falling back to text): %s", e)
|
|
return None
|
|
|
|
|
|
def _backend_cache_key(task_id: Optional[str], session_name: str = "") -> str:
|
|
"""Session-cache key for a backend browser: named sessions get their own."""
|
|
return f"bu-named-{session_name}" if session_name else (task_id or "browser-exec-default")
|
|
|
|
|
|
def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
|
"""Point the harness at a Hermes-spawned ``lightpanda serve`` (``browser.engine: lightpanda`` and
|
|
nothing of higher precedence claimed the session). Each cache key gets its own process via the
|
|
legacy ``_get_session_info()`` (cache, reaper, atexit): private browser, own-tab preamble skipped."""
|
|
try:
|
|
from tools.browser_tool_session import _get_session_info
|
|
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
|
if not _using_lightpanda_engine():
|
|
return None
|
|
except Exception as e: # stubbed browser_tool in tests / engine lookup failure
|
|
logger.debug("browser engine lookup failed: %s", e)
|
|
return None
|
|
err = _export_session_cdp(
|
|
env, _get_session_info, _backend_cache_key(task_id, session_name),
|
|
lambda e: (f"Lightpanda could not be started: {e} Set browser.engine to auto "
|
|
"to use local Chrome, or switch backends via `hermes tools` → Browser Automation."),
|
|
"Lightpanda session returned no CDP endpoint. Set browser.engine to auto to use local Chrome.",
|
|
)
|
|
if err is None:
|
|
env[_PRIVATE_BROWSER_SENTINEL] = "1"
|
|
return err
|
|
|
|
|
|
def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
|
"""Point the harness at the configured backend's CDP endpoint; error string on failure.
|
|
|
|
Precedence: (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env (operator override); (2) ``BROWSER_CDP_URL``
|
|
env / ``browser.cdp_url`` (``/browser connect``); (3) a cloud provider via the legacy ``_get_session_info()``
|
|
so browser_exec shares the SAME session machinery (per-task cache, expiry, reaper, atexit);
|
|
(4) ``browser.engine: lightpanda``; (5) nothing → None: the harness attaches to local Chrome (or BU
|
|
cloud via BU_AUTOSPAWN for legacy configs). ``session_name`` (BU_NAME) keys the provider cache so
|
|
each name gets its OWN cloud browser — what makes named sessions concurrent-safe.
|
|
"""
|
|
if _has_cdp_env(env):
|
|
return None
|
|
try:
|
|
from tools.browser_tool_cloud import _get_cloud_provider
|
|
from tools.browser_tool_session import _get_session_info
|
|
from tools.browser_tool_cdp import _get_cdp_override
|
|
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
|
logger.debug("browser_tool backend resolution unavailable: %s", e)
|
|
return None
|
|
override = _quiet(_get_cdp_override, "")
|
|
if override:
|
|
_set_cdp_env(env, override)
|
|
return None
|
|
provider = _quiet(_get_cloud_provider, None, "Cloud provider lookup failed")
|
|
if provider is None:
|
|
return _resolve_lightpanda_cdp(env, task_id, session_name)
|
|
|
|
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN / auth login) — the
|
|
# legacy provider would create a second, redundant session. The Nous-gateway variant (use_gateway: true)
|
|
# DOES resolve through the provider: the gateway provisions the browser server-side and returns its CDP URL.
|
|
provider_key = str(getattr(provider, "name", "") or "").strip().lower()
|
|
if provider_key == _BACKEND_KEY and not _use_gateway(_read_browser_cfg()):
|
|
env[_PRIVATE_BROWSER_SENTINEL] = "1" # named BU cloud browsers are exclusive to their daemon
|
|
return None
|
|
|
|
provider_name = type(provider).__name__
|
|
err = _export_session_cdp(
|
|
env, _get_session_info, _backend_cache_key(task_id, session_name),
|
|
lambda e: (f"Cloud browser provider {provider_name} failed to provide a session: {e}. "
|
|
"Fix the provider configuration or switch backends via `hermes tools` → Browser Automation."),
|
|
f"Cloud browser provider {provider_name} returned no CDP endpoint, so Browser Use mode "
|
|
"cannot drive it. Switch to the built-in browser tools for this provider.",
|
|
)
|
|
# A provider browser keyed bu-named-<name> is exclusive to this session — the
|
|
# own-tab preamble would just leak a blank tab into it.
|
|
if err is None and session_name:
|
|
env[_PRIVATE_BROWSER_SENTINEL] = "1"
|
|
return err
|
|
|
|
|
|
def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
|
"""Point the harness at the user's real-profile copy-browser (a SNAPSHOT of their default Chromium
|
|
profile, hermes_cli.browser_connect) when consented. Two ways in: the effective backend is already local
|
|
(no provider, CDP override, or legacy BU cloud config) → silent upgrade; or ``force_local`` (consent-gated
|
|
``local`` arg) → the user's browser even under a cloud backend. Operator overrides (BU_CDP_* env,
|
|
/browser connect, ``browser.cdp_url``) own the session either way. Fail closed: a launch error is
|
|
returned so a consented user is never silently downgraded."""
|
|
if not _real_profile_consented() or _has_cdp_env(env):
|
|
return None
|
|
try:
|
|
from tools.browser_tool_cdp import _get_cdp_override_raw
|
|
from tools.browser_tool_cloud import _get_cloud_provider
|
|
from tools.browser_tool_real_profile import _real_profile_cdp
|
|
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
|
logger.debug("real-profile backend resolution unavailable: %s", e)
|
|
return None
|
|
if _quiet(_get_cdp_override_raw, ""):
|
|
return None
|
|
# Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider lookup failure, or
|
|
# legacy BU cloud config) stays on its backend unless the model passes local=true.
|
|
if not force_local and (_quiet(_get_cloud_provider, object()) is not None
|
|
or is_legacy_browser_use_cloud_config(_read_browser_cfg())):
|
|
return None
|
|
cdp, err = _real_profile_cdp()
|
|
if cdp and not err:
|
|
_set_cdp_env(env, cdp)
|
|
return err or None
|
|
|
|
|
|
def _route_backend(env: dict, session: str, task_id: Optional[str], local: bool) -> Optional[str]:
|
|
"""Resolve where the harness connects; returns an error string or None. Real-profile consent runs
|
|
BEFORE provider resolution so a hit short-circuits the cloud path via the BU_CDP_* env contract. Named
|
|
sessions compose with the backend: BU_NAME namespaces the harness daemon (IPC socket, log, pid) and on
|
|
provider backends additionally keys its own cloud browser."""
|
|
rp_err = _resolve_real_profile_cdp(env, force_local=local)
|
|
if rp_err:
|
|
return rp_err
|
|
# local=True is only served by the real-profile route; consent off must not pretend.
|
|
if local and not _has_cdp_env(env) and not _real_profile_consented():
|
|
return ("local=true was requested but browser.use_real_profile is off. Enable it in config.yaml "
|
|
"(browser.use_real_profile: true) or the desktop Settings → Browser section, then retry.")
|
|
return _resolve_backend_cdp(env, task_id, session_name=session)
|
|
|
|
|
|
def _windows_popen_kwargs() -> dict:
|
|
"""Hide the console the .cmd shim would flash on Windows (as browser_tool does)."""
|
|
def _flags() -> dict:
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
si = subprocess.STARTUPINFO()
|
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
return {"creationflags": windows_hide_flags(), "startupinfo": si}
|
|
return _quiet(_flags, {}, "Windows hide-flags unavailable") if os.name == "nt" else {}
|
|
|
|
|
|
def _clamp_timeout(timeout_s: Any) -> int:
|
|
try:
|
|
return max(_MIN_TIMEOUT_S, min(int(timeout_s), _MAX_TIMEOUT_S))
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_TIMEOUT_S
|
|
|
|
|
|
def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT_S,
|
|
task_id: Optional[str] = None, local: bool = False):
|
|
"""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()).")
|
|
|
|
blocked = _blocked_url_in_code(code)
|
|
if blocked:
|
|
return tool_error(blocked)
|
|
|
|
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 = _base_subprocess_env()
|
|
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
|
|
route_err = _route_backend(env, session, task_id, bool(local))
|
|
if route_err:
|
|
return tool_error(route_err)
|
|
|
|
# SHARED browser (local Chrome / CDP override): pin each named session to its own tab (see
|
|
# _OWN_TAB_PREAMBLE). Private per-name browsers skip this — nothing to collide with.
|
|
private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None) # always pop: never exported to the CLI
|
|
if session and not private_browser:
|
|
code = _OWN_TAB_PREAMBLE + code
|
|
|
|
workspace = _workspace_dir(task_id)
|
|
if workspace:
|
|
env["BH_AGENT_WORKSPACE"] = workspace
|
|
|
|
# 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"
|
|
|
|
timeout = _clamp_timeout(timeout_s)
|
|
started = time.time()
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd, input=code, capture_output=True, text=True, timeout=timeout, env=env,
|
|
**_windows_popen_kwargs(),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may still be working; retry "
|
|
f"with a larger timeout_s (max {_MAX_TIMEOUT_S}), or split the work into several calls that "
|
|
"append to workspace files — anything already written to the workspace is preserved.")
|
|
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 workspace:
|
|
result["workspace"] = workspace
|
|
if session:
|
|
result["session"] = session
|
|
stderr = (proc.stderr or "").strip()
|
|
if len(stderr) > _STDERR_CAP_CHARS:
|
|
stderr = stderr[:_STDERR_CAP_CHARS] + "\n… (stderr truncated)"
|
|
if stderr:
|
|
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)
|
|
|
|
|
|
_HEADER_BASE = (
|
|
"Drive a real web browser via the Browser Use CLI: `code` runs as full Python (stdlib available) "
|
|
"with pre-imported browser helpers; stdout comes back in the result. Start `code` with a one-line "
|
|
"comment describing the step for the user in plain language, max 60 chars "
|
|
"(e.g. `# Searching Amazon for paper towels`) — the UI shows it as the step label.\n\n"
|
|
"STATE: the browser session and workspace persist across calls; Python variables do NOT (fresh "
|
|
"interpreter each call). The workspace dir is $BH_AGENT_WORKSPACE (also `workspace` in every result); "
|
|
"functions defined in agent_helpers.py there are auto-imported into every call. For multi-item tasks "
|
|
"('all N products / every entry'), append each batch to a JSON/CSV file in the workspace, then read it "
|
|
"back and aggregate in code — dedupe/count/sort with Python, not in your head — and verify the "
|
|
"collected count against what was asked before answering.\n\n"
|
|
"Batch each sub-procedure (navigate, wait, extract, act) into one call — do not spend a call per "
|
|
"action — but for long extractions prefer several medium calls that append to workspace files over "
|
|
"one giant call, so progress survives timeouts."
|
|
)
|
|
|
|
_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."
|
|
)
|
|
|
|
# Appended when the local engine is Lightpanda: no graphical renderer, and one CDP
|
|
# connection holds one page — a second Target.createTarget fails with
|
|
# TargetAlreadyLoaded (drop the new_tab() sentence once lightpanda-io/browser#1962 lands).
|
|
_HEADER_LIGHTPANDA = (
|
|
" The local engine is Lightpanda (no graphical renderer, one page per session): capture_screenshot() "
|
|
"is unavailable, so work text-first; navigate with new_tab(url) exactly once, then goto_url(url) for "
|
|
"every later navigation — a second new_tab() fails with TargetAlreadyLoaded."
|
|
)
|
|
|
|
# Pinned quick-reference for the CLI's pre-imported helpers, replacing the live
|
|
# ``browser-use skill`` fetch (uncontrolled third-party text in every schema: version
|
|
# drift, supply-chain exposure, byte-unstable prompt). A/B benchmarked ~equal.
|
|
_HELPERS_DIGEST = (
|
|
"\n\nHELPERS (pre-imported): new_tab(url) opens/navigates (use for the FIRST navigation), goto_url(url) "
|
|
"navigates the current tab, wait_for_load() after navigation, page_info() summarizes the current page "
|
|
"state, js(expr) evaluates a JS expression and returns its value (js('document.title'); wrap function "
|
|
"bodies as js('(() => {...})()') — a bare '() => {...}' returns the function itself, uncalled), "
|
|
"fill_input(selector, text) types into inputs, click_at_xy(x, y) clicks viewport coordinates, "
|
|
"capture_screenshot() saves and prints a screenshot path, cdp('Domain.method', **kwargs) is raw CDP — "
|
|
"cdp('Accessibility.getFullAXTree')['nodes'] lists every element's role/name/backendDOMNodeId (filter "
|
|
"in Python before printing; it is thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) "
|
|
"gives click coordinates. ensure_real_tab() recovers from a stale/internal tab. Login walls: stop and "
|
|
"ask the user; never guess credentials."
|
|
)
|
|
|
|
|
|
def _description_header() -> str:
|
|
"""Header tailored to whether the active model can see images natively"""
|
|
if _lazy_call("tools.browser_tool_lightpanda_fallback", "lightpanda_engine_status", (False, ""),
|
|
"lightpanda engine status unavailable")[0]: # no screenshots, whatever the model sees
|
|
return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA
|
|
vision = _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, "")
|
|
return _HEADER_BASE + (_HEADER_VISION if vision else _HEADER_TEXT_ONLY)
|
|
|
|
|
|
def _dynamic_schema_overrides() -> dict:
|
|
overrides: dict = {"description": _description_header() + _HELPERS_DIGEST}
|
|
# ``local`` exists ONLY when the user consented to real-profile browsing — everyone
|
|
# else's schema carries zero extra surface. The caller memoizes on config.yaml mtime,
|
|
# so toggling consent applies next session, not mid-chat.
|
|
if _real_profile_consented():
|
|
props = dict(BROWSER_EXEC_SCHEMA["parameters"]["properties"])
|
|
props["local"] = {
|
|
"type": "boolean", "default": False,
|
|
"description": ("Drive the user's own local browser (a Hermes-managed copy of their real "
|
|
"default-Chromium profile, logins/cookies included) instead of the configured "
|
|
"cloud browser backend. Use when the user asks to act as themselves — their "
|
|
"accounts, their sessions. No-op when the backend is already local. Default false."),
|
|
}
|
|
overrides["parameters"] = {**BROWSER_EXEC_SCHEMA["parameters"], "properties": props}
|
|
return overrides
|
|
|
|
|
|
BROWSER_EXEC_SCHEMA = {
|
|
"name": "browser_exec",
|
|
# Static fallback description, used only when the CLI (and uvx) is unavailable
|
|
"description": (_HEADER_BASE + _HELPERS_DIGEST
|
|
+ "\n\n(The browser-use CLI is not installed yet. 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 isolated browser session — its own daemon and (on cloud backends) own browser, so concurrent tasks don't share tabs. Reuse the same name on every related call; omit for the shared default session."},
|
|
"timeout_s": {"type": "integer", "default": _DEFAULT_TIMEOUT_S,
|
|
"description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S})."},
|
|
},
|
|
"required": ["code"],
|
|
},
|
|
}
|
|
|
|
|
|
# browser_exec is additionally gated at tool-definition time — sessions whose toolsets
|
|
# lack ``terminal`` never see it (model_tools._compute_tool_definitions). check_fn only
|
|
# answers "is Browser Use mode configured"; surface policy lives with the session.
|
|
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), task_id=kw.get("task_id"),
|
|
local=bool(args.get("local", False)),
|
|
),
|
|
check_fn=is_browser_use_cli_mode,
|
|
dynamic_schema_overrides=_dynamic_schema_overrides,
|
|
emoji="🌐",
|
|
)
|