From 629d8b843d8d8507925fd35344f57de776cb1490 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 6 May 2026 16:08:20 +0530 Subject: [PATCH] fix(browser): tighten Lightpanda fallback edge cases --- tests/tools/test_browser_lightpanda.py | 81 ++++++++++++++++++++++++++ tools/browser_tool.py | 60 +++++++++++++++---- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/tests/tools/test_browser_lightpanda.py b/tests/tools/test_browser_lightpanda.py index a618df72a9..dabfc5d1bd 100644 --- a/tests/tools/test_browser_lightpanda.py +++ b/tests/tools/test_browser_lightpanda.py @@ -250,6 +250,36 @@ class TestConfigIntegration: assert entry["advanced"] is True + + +class TestLightpandaRequirements: + """Lightpanda should expose browser tools without local Chromium.""" + + def test_lightpanda_local_mode_does_not_require_chromium(self): + import tools.browser_tool as bt + + with patch("tools.browser_tool._is_camofox_mode", return_value=False), \ + patch("tools.browser_tool._get_cdp_override", return_value=""), \ + patch("tools.browser_tool._find_agent_browser", return_value="/usr/bin/agent-browser"), \ + patch("tools.browser_tool._requires_real_termux_browser_install", return_value=False), \ + patch("tools.browser_tool._get_cloud_provider", return_value=None), \ + patch("tools.browser_tool._get_browser_engine", return_value="lightpanda"), \ + patch("tools.browser_tool._chromium_installed", return_value=False): + assert bt.check_browser_requirements() is True + + def test_chrome_local_mode_still_requires_chromium(self): + import tools.browser_tool as bt + + with patch("tools.browser_tool._is_camofox_mode", return_value=False), \ + patch("tools.browser_tool._get_cdp_override", return_value=""), \ + patch("tools.browser_tool._find_agent_browser", return_value="/usr/bin/agent-browser"), \ + patch("tools.browser_tool._requires_real_termux_browser_install", return_value=False), \ + patch("tools.browser_tool._get_cloud_provider", return_value=None), \ + patch("tools.browser_tool._get_browser_engine", return_value="auto"), \ + patch("tools.browser_tool._chromium_installed", return_value=False): + assert bt.check_browser_requirements() is False + + # --------------------------------------------------------------------------- # cleanup_all_browsers resets engine cache # --------------------------------------------------------------------------- @@ -407,6 +437,57 @@ class TestLightpandaFallbackWarning: assert "images" not in captured_kwargs assert captured_kwargs["task"] == "vision" + + def test_browser_get_images_preserves_fallback_warning(self): + import json + import tools.browser_tool as bt + + result = bt._annotate_lightpanda_fallback( + {"success": True, "data": {"result": "[]"}}, + "Lightpanda 'eval' failed (timeout); retried with Chrome.", + ) + bt._last_active_session_key["warn-images"] = "warn-images" + with patch("tools.browser_tool._run_browser_command", return_value=result): + response = json.loads(bt.browser_get_images(task_id="warn-images")) + + assert response["success"] is True + assert response["browser_engine"] == "chrome" + assert "Lightpanda fallback" in response["fallback_warning"] + bt._last_active_session_key.pop("warn-images", None) + + def test_browser_vision_lightpanda_response_has_structured_fallback(self, tmp_path): + import json + import tools.browser_tool as bt + + chrome_shot = tmp_path / "chrome-structured.png" + chrome_shot.write_bytes(b"\x89PNG" + b"0" * 128) + + class _Msg: + content = "Example Domain screenshot" + + class _Choice: + message = _Msg() + + class _Response: + choices = [_Choice()] + + with patch("tools.browser_tool._get_browser_engine", return_value="lightpanda"), \ + patch("tools.browser_tool._should_inject_engine", return_value=True), \ + patch("tools.browser_tool._chrome_fallback_screenshot", return_value={ + "success": True, "data": {"path": str(chrome_shot)} + }), \ + patch("hermes_constants.get_hermes_dir", return_value=tmp_path), \ + patch("tools.browser_tool.call_llm", return_value=_Response()): + response = json.loads(bt.browser_vision("what is this?", task_id="vision-structured")) + + assert response["success"] is True + assert response["browser_engine"] == "chrome" + assert response["browser_engine_fallback"] == { + "from": "lightpanda", + "to": "chrome", + "reason": "Lightpanda has no graphical renderer for screenshots; used Chrome for vision capture.", + } + # --------------------------------------------------------------------------- # _engine_override parameter # --------------------------------------------------------------------------- diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 9c8e355c87..049565d638 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -555,6 +555,11 @@ def _should_inject_engine(engine: str) -> bool: return _is_local_mode() +def _using_lightpanda_engine() -> bool: + """Return True when local browser commands are configured for Lightpanda.""" + return _get_browser_engine() == "lightpanda" + + def _lightpanda_fallback_reason(engine: str, command: str, result: Dict[str, Any]) -> Optional[str]: """Return the user-visible reason a Lightpanda result needs Chrome fallback. @@ -684,6 +689,21 @@ def _run_chrome_fallback_command( except FileNotFoundError as e: return {"success": False, "error": str(e)} + if not _chromium_installed(): + if _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} + cmd_prefix = ["npx", "agent-browser"] if browser_cmd == "npx agent-browser" else [browser_cmd] base_args = cmd_prefix + ["--engine", "chrome", "--session", tmp_session, "--json"] @@ -2686,23 +2706,26 @@ def browser_get_images(task_id: Optional[str] = None) -> str: else: images = raw_result - return json.dumps({ + response = { "success": True, "images": images, "count": len(images) - }, ensure_ascii=False) + } + return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) except json.JSONDecodeError: - return json.dumps({ + response = { "success": True, "images": [], "count": 0, "warning": "Could not parse image data" - }, ensure_ascii=False) + } + return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) else: - return json.dumps({ + response = { "success": False, "error": result.get("error", "Failed to get images") - }, ensure_ascii=False) + } + return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str: @@ -2768,9 +2791,9 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] screenshot_path = persistent_path else: logger.warning("Lightpanda Chrome fallback vision screenshot failed: %s", fb_result.get("error")) - # Fall through to normal path as last resort. Mark that we already - # tried Chrome so _run_browser_command doesn't recursively fallback. - _lp_prerouted = True + # Fall through to the normal screenshot path so _run_browser_command + # can still produce the standard fallback metadata/error. + _lp_prerouted = False try: screenshots_dir.mkdir(parents=True, exist_ok=True) @@ -2793,6 +2816,11 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] }, "fallback_warning": _lp_fallback_warning, "browser_engine": "chrome", + "browser_engine_fallback": { + "from": "lightpanda", + "to": "chrome", + "reason": "Lightpanda has no graphical renderer for screenshots; used Chrome for vision capture.", + }, } else: # Take screenshot using agent-browser @@ -3248,7 +3276,9 @@ def check_browser_requirements() -> bool: Check if browser tool requirements are met. In **local mode** (no cloud provider configured): the ``agent-browser`` - CLI must be findable *and* a Chromium build must be installed on disk. + CLI must be findable. Chrome/Chromium is required for the default Chrome + engine and for fallback/screenshot paths, but not for Lightpanda-only text + navigation/snapshot workflows. In **cloud mode** (Browserbase, Browser Use, or Firecrawl): the CLI and the provider's required credentials must be present. The cloud @@ -3285,8 +3315,14 @@ def check_browser_requirements() -> bool: if provider is not None: return provider.is_configured() - # Local mode: agent-browser needs a Chromium build on disk. Without it - # the CLI hangs on first use until the command timeout fires. + # Local mode with Lightpanda can provide text/navigation tools without a + # local Chromium install. Chrome fallback, screenshots, and browser_vision + # will still return actionable Chromium install errors if invoked. + if _using_lightpanda_engine(): + return True + + # Local Chrome mode: agent-browser needs a Chromium build on disk. Without + # it the CLI hangs on first use until the command timeout fires. if not _chromium_installed(): return False