Full Chromium serves both headed and headless sessions. The separate shell duplicates the browser payload and is not needed for either mode. Remove the shell from PM and Docker. Select the managed Chromium executable for agent-browser and the full Chromium channel for direct Playwright callers. Route setup through PM and remove retired packages from cached bundle stores without changing the user's tool store. Update signing, architecture checks, launch probes and install guidance. Leave llama packages and Docker archive cleanup unchanged. Verification: - Real agent-browser navigation, clicks, DOM reads and screenshots pass in headed and headless modes with the same Chromium executable. - The direct Playwright doctor probe passes. - Focused Python and desktop packaging tests pass, as do six Docker checks and both real-browser task-scroll tests. - The built linux/amd64 image is 1.393 GB compressed, 223.6 MB smaller. - The broader PM suite and two unrelated setup tests still fail. Those failures reproduce on unchanged HEAD. - Five updated eval scripts parse; their full scenarios were not run.
196 lines
9.8 KiB
Python
196 lines
9.8 KiB
Python
"""Lightpanda engine status and the automatic Chrome fallback for browser commands
|
|
that Lightpanda cannot serve (screenshots, empty snapshots, failed commands).
|
|
|
|
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import 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
|
|
from tools import browser_tool_cdp as _cdp
|
|
from tools import browser_tool_cloud as _cloud
|
|
from tools import browser_tool_install as _install
|
|
from tools import browser_tool_session as _session
|
|
|
|
# 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 elsewhere.
|
|
_FALLBACK_ELIGIBLE = frozenset({"open", "snapshot", "screenshot", "eval", "click",
|
|
"fill", "scroll", "back", "press", "console", "errors"})
|
|
|
|
|
|
def _using_lightpanda_engine() -> bool:
|
|
"""Return True when local browser commands are configured for Lightpanda."""
|
|
return _cloud._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; else the reason names the setting shadowing
|
|
it or the driver running it. Mirrors ``_should_inject_engine`` / ``_resolve_backend_cdp``
|
|
precedence with config-only gates (no network I/O) for ``/browser status`` / ``hermes doctor``.
|
|
"""
|
|
_bt = _origin()
|
|
if not _using_lightpanda_engine():
|
|
return False, ""
|
|
if _cdp._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 before cloud provider: browser_exec resolves real-profile before
|
|
# the backend, so with both set the real-profile toggle claims the session.
|
|
if _cloud._use_real_profile():
|
|
return False, "browser.use_real_profile is on (Lightpanda cannot load a Chromium profile)"
|
|
try:
|
|
provider = _cloud._get_cloud_provider()
|
|
except Exception:
|
|
provider = None
|
|
if provider is not None:
|
|
try:
|
|
name = provider.display_name
|
|
except Exception:
|
|
name = type(provider).__name__
|
|
return False, f"cloud provider {name} is selected (browser.cloud_provider, or auto-detected from credentials)"
|
|
if not _bt._is_browser_use_cli_mode():
|
|
return True, "built-in browser tools: agent-browser --engine lightpanda"
|
|
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)
|
|
return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session"
|
|
|
|
|
|
def _lightpanda_fallback_reason(engine: str, command: str, result: Dict[str, Any]) -> Optional[str]:
|
|
"""User-visible reason a Lightpanda result needs the Chrome fallback (copied into the result), or None."""
|
|
if engine != "lightpanda" or command not in _FALLBACK_ELIGIBLE:
|
|
return None
|
|
if not result.get("success"):
|
|
return f"Lightpanda {command!r} failed ({str(result.get('error') or 'command failed').strip()}); retried with Chrome."
|
|
data = result.get("data", {})
|
|
if command == "snapshot" and len((data.get("snapshot", "") or "").strip()) < 20: # couldn't render
|
|
return "Lightpanda returned an empty/too-short snapshot; retried with Chrome."
|
|
if command == "screenshot" and data.get("path", ""):
|
|
# Lightpanda returns a ~17 KB placeholder PNG (panda logo at 1920x1080); real Chromium is 100 KB+.
|
|
try:
|
|
size = os.path.getsize(data["path"])
|
|
except OSError:
|
|
return "Lightpanda screenshot file was missing/unreadable; retried with Chrome."
|
|
if size < 20480:
|
|
_origin().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."
|
|
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}"
|
|
fields = {"fallback_warning": warning, "browser_engine": "chrome",
|
|
"browser_engine_fallback": {"from": "lightpanda", "to": "chrome", "reason": reason}}
|
|
annotated = {**result, **fields}
|
|
data = annotated.get("data")
|
|
if isinstance(data, dict):
|
|
annotated["data"] = data = dict(data)
|
|
for key, value in fields.items():
|
|
data.setdefault(key, dict(value) if isinstance(value, dict) else value)
|
|
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: fresh temp Chrome session -> same URL -> ``command`` -> tear down.
|
|
"""
|
|
_bt = _origin()
|
|
import uuid
|
|
# 1. Current URL from the Lightpanda session. ``get url`` is not fallback-eligible,
|
|
# so this can't recurse; the explicit override strips Chromium-only env flags.
|
|
url_result = _session._run_browser_command(task_id, "get", ["url"], timeout=10, _engine_override="lightpanda")
|
|
current_url = str(url_result.get("data", {}).get("url", "")).strip() if url_result.get("success") else None
|
|
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. Temporary Chrome session (bypasses _get_session_info's cache).
|
|
tmp_session = f"h_cfb_{uuid.uuid4().hex[:8]}"
|
|
try:
|
|
browser_cmd = _install._find_agent_browser()
|
|
except FileNotFoundError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
if not _install._chromium_installed():
|
|
if _install._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: "
|
|
"hermes pm install chromium")
|
|
return {"success": False, "error": hint}
|
|
|
|
base_args = _session._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"]
|
|
task_socket_dir = _session._prepare_session_socket_dir(tmp_session)
|
|
# Bypasses _run_browser_command, so apply the same Chromium sandbox policy explicitly.
|
|
browser_env = _session._agent_browser_command_env(task_socket_dir)
|
|
_session._apply_chromium_sandbox_args(browser_env)
|
|
|
|
def _run_tmp(cmd: str, cmd_args: List[str]) -> Dict[str, Any]:
|
|
proc = _session._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, encoding="utf-8-sig") 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:
|
|
_session._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."""
|
|
return _run_chrome_fallback_command(task_id, "screenshot", args, timeout)
|