File size: 10,116 Bytes
2969d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""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: "
                    "npx agent-browser install --with-deps (or: npx playwright install --with-deps 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") 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)