Files
hermes-agent/tools/browser_tool_lightpanda_fallback.py

259 lines
11 KiB
Python

"""Lightpanda engine status and the automatic Chrome fallback for browser commands
that Lightpanda cannot serve (screenshots, empty snapshots, failed commands).
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
so ``patch("tools.browser_tool.X")`` keeps working; never import ``tools.browser_tool``
at import time (cycle).
"""
import json
import os
import shutil
import subprocess
from typing import Any, Dict, List, Optional, Tuple
from tools.browser_tool_origin import origin_module as _origin
def _using_lightpanda_engine() -> bool:
"""Return True when local browser commands are configured for Lightpanda."""
_bt = _origin()
return _bt._get_browser_engine() == "lightpanda"
def lightpanda_engine_status() -> Tuple[bool, str]:
"""Whether ``browser.engine: lightpanda`` is actually in effect, and why.
``(False, "")`` when the engine isn't lightpanda; otherwise the reason names
the setting shadowing it or the driver running it. Mirrors the precedence
of ``_should_inject_engine`` / ``browser_use_cli._resolve_backend_cdp``
with config-only gates (no network I/O) so ``/browser status`` and
``hermes doctor`` can call it.
"""
_bt = _origin()
if not _bt._using_lightpanda_engine():
return False, ""
if _bt._get_cdp_override_raw():
return False, "a CDP override is active (/browser connect or browser.cdp_url)"
if _bt._is_camofox_mode():
return False, "Camofox is the selected browser (CAMOFOX_URL)"
# Real-profile is checked before the cloud provider: in browser_exec the
# real-profile resolution runs before backend resolution, so with both
# set it is the real-profile toggle that actually claims the session.
if _bt._use_real_profile():
return False, "browser.use_real_profile is on (Lightpanda cannot load a Chromium profile)"
try:
provider = _bt._get_cloud_provider()
except Exception:
provider = None
if provider is not None:
try:
name = provider.provider_name()
except Exception:
name = type(provider).__name__
return False, (
f"cloud provider {name} is selected (browser.cloud_provider, or "
"auto-detected from credentials)"
)
bu_mode = _bt._is_browser_use_cli_mode()
if bu_mode:
try:
from tools.browser_use_cli import (
_read_browser_cfg, is_legacy_browser_use_cloud_config
)
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
return False, "Browser Use cloud (BROWSER_USE_API_KEY) is selected"
except Exception as e:
_bt.logger.debug("legacy Browser Use cloud check failed: %s", e)
if bu_mode:
return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session"
return True, "built-in browser tools: agent-browser --engine lightpanda"
def _lightpanda_fallback_reason(engine: str, command: str, result: Dict[str, Any]) -> Optional[str]:
"""User-visible reason a Lightpanda result needs the Chrome fallback, or None.
The string is copied into the fallback result so users can see when Hermes
silently switched engines.
"""
_bt = _origin()
if engine != "lightpanda":
return None
# Only retry commands where Chrome can meaningfully produce a different
# result. Session-management commands (close, record) are tied to the
# engine's daemon and can't be retried on a different engine.
_FALLBACK_ELIGIBLE = {"open", "snapshot", "screenshot", "eval", "click",
"fill", "scroll", "back", "press", "console", "errors"}
if command not in _FALLBACK_ELIGIBLE:
return None
# Explicit failure
if not result.get("success"):
error = str(result.get("error") or "command failed").strip()
return f"Lightpanda {command!r} failed ({error}); retried with Chrome."
data = result.get("data", {})
if command == "snapshot":
snap = data.get("snapshot", "")
# Empty or near-empty snapshots indicate Lightpanda couldn't render
if not snap or len(snap.strip()) < 20:
return "Lightpanda returned an empty/too-short snapshot; retried with Chrome."
if command == "screenshot":
# Lightpanda returns a placeholder PNG with its panda logo.
# Since Lightpanda resized it to 1920x1080, the placeholder is
# ~17 KB. Real Chromium screenshots are typically 100 KB+.
path = data.get("path", "")
if path:
try:
size = os.path.getsize(path)
if size < 20480:
_bt.logger.debug("Lightpanda screenshot is suspiciously small (%d bytes), "
"triggering Chrome fallback", size)
return (
f"Lightpanda screenshot was suspiciously small ({size} bytes); "
"retried with Chrome."
)
except OSError:
return "Lightpanda screenshot file was missing/unreadable; retried with Chrome."
return None
def _needs_lightpanda_fallback(engine: str, command: str, result: Dict[str, Any]) -> bool:
"""Check if a Lightpanda result should trigger an automatic Chrome fallback."""
return _lightpanda_fallback_reason(engine, command, result) is not None
def _annotate_lightpanda_fallback(result: Dict[str, Any], reason: str) -> Dict[str, Any]:
"""Add a user-visible Chrome fallback warning to a browser command result."""
warning = (
"⚠ Lightpanda fallback: Chrome was used for this browser action. " f"{reason}"
)
annotated = dict(result)
annotated["fallback_warning"] = warning
annotated["browser_engine"] = "chrome"
annotated["browser_engine_fallback"] = {
"from": "lightpanda", "to": "chrome", "reason": reason
}
data = annotated.get("data")
if isinstance(data, dict):
data = dict(data)
data.setdefault("fallback_warning", warning)
data.setdefault("browser_engine", "chrome")
data.setdefault(
"browser_engine_fallback", {"from": "lightpanda", "to": "chrome", "reason": reason}
)
annotated["data"] = data
return annotated
def _copy_fallback_warning(target: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]:
"""Copy browser fallback metadata from an internal result into a tool response."""
if result.get("fallback_warning"):
target["fallback_warning"] = result["fallback_warning"]
target["browser_engine"] = result.get("browser_engine")
target["browser_engine_fallback"] = result.get("browser_engine_fallback")
return target
def _run_chrome_fallback_command(
task_id: str, command: str, args: List[str], timeout: int
) -> Dict[str, Any]:
"""Run a browser command in a temporary Chrome session at the current URL.
agent-browser locks the engine when a named daemon starts, so ``--engine
chrome`` on the Lightpanda session is ignored: use a fresh temp Chrome
session, navigate it to the current URL, run ``command``, tear it down.
"""
_bt = _origin()
import uuid
# 1. Grab the current URL from the Lightpanda session. ``get url`` is not
# fallback-eligible, so an error cannot recursively trigger this helper.
# Keep the explicit Lightpanda override so Chromium-only environment flags
# are stripped while querying the already-running Lightpanda daemon.
url_result = _bt._run_browser_command(
task_id, "get", ["url"], timeout=10, _engine_override="lightpanda"
)
current_url = None
if url_result.get("success"):
current_url = str(url_result.get("data", {}).get("url", "")).strip()
if not current_url:
_bt.logger.warning("Chrome fallback: could not determine current URL from LP session")
return {"success": False, "error": "Chrome fallback failed: could not determine current URL"}
# 2. Create a temporary Chrome session (bypasses _get_session_info's cache).
tmp_session = f"h_cfb_{uuid.uuid4().hex[:8]}"
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError as e:
return {"success": False, "error": str(e)}
if not _bt._chromium_installed():
if _bt._running_in_docker():
hint = (
"Chrome fallback requires Chromium, but it is missing. "
"You're running in Docker — pull the latest image: "
"docker pull ghcr.io/nousresearch/hermes-agent:latest"
)
else:
hint = (
"Chrome fallback requires Chromium, but it is missing. Install it with: "
"npx agent-browser install --with-deps "
"(or: npx playwright install --with-deps chromium)"
)
return {"success": False, "error": hint}
base_args = _bt._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"]
task_socket_dir = _bt._prepare_session_socket_dir(tmp_session)
# Bypasses _run_browser_command, so apply the same Chromium sandbox policy explicitly.
browser_env = _bt._agent_browser_command_env(task_socket_dir)
_bt._apply_chromium_sandbox_args(browser_env)
def _run_tmp(cmd: str, cmd_args: List[str]) -> Dict[str, Any]:
proc = _bt._popen_agent_browser(base_args + [cmd] + cmd_args, browser_env, task_socket_dir, cmd)
stdout_path = os.path.join(task_socket_dir, f"_stdout_{cmd}")
stderr_path = os.path.join(task_socket_dir, f"_stderr_{cmd}")
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return {"success": False, "error": f"Chrome fallback '{cmd}' timed out"}
try:
with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read().strip()
if stdout:
return json.loads(stdout.split("\n")[-1])
except Exception as exc:
_bt.logger.debug("Chrome fallback tmp cmd '%s' error: %s", cmd, exc)
finally:
_bt._unlink_command_output_files(stdout_path, stderr_path)
return {"success": False, "error": f"Chrome fallback '{cmd}' failed"}
try:
# 3. Navigate Chrome to the same URL, then 4. run the requested command.
nav = _run_tmp("open", [current_url])
if not nav.get("success"):
_bt.logger.warning("Chrome fallback: navigate failed: %s", nav.get("error"))
return {"success": False, "error": f"Chrome fallback navigate failed: {nav.get('error')}"}
return _run_tmp(command, args)
finally:
# 5. Tear down the temporary Chrome session and its socket directory.
try:
_run_tmp("close", [])
except Exception:
pass
shutil.rmtree(task_socket_dir, ignore_errors=True)
def _chrome_fallback_screenshot(
task_id: str, args: List[str], timeout: int
) -> Dict[str, Any]:
"""Take a screenshot using a temporary Chrome session."""
_bt = _origin()
return _bt._run_chrome_fallback_command(task_id, "screenshot", args, timeout)