File size: 7,148 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 | """browser_vision helpers: Lightpanda pre-route, native provider vision, auxiliary-LLM screenshot analysis.
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
"""
import os
import shutil
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from hermes_cli.config import cfg_get
from tools.browser_tool_origin import origin as _bt
from tools import browser_tool_cloud as _cloud
from tools import browser_tool_lightpanda_fallback as _lp
def _vision_mode_label() -> str:
_cp = _cloud._get_cloud_provider()
return "local" if _cp is None else f"cloud ({_cp.display_name})"
def _lightpanda_vision_preroute(
effective_task_id: str, annotate: bool, screenshot_path: Path,
) -> Tuple[bool, Optional[str], Path]:
"""Capture the vision screenshot via the Chrome fallback when Lightpanda is the engine
(it has no graphical renderer). Returns ``(prerouted, fallback_warning, path)``;
on fallback failure ``prerouted`` is False and the caller takes the normal
screenshot path (forcing Chrome) so the standard fallback metadata still applies."""
engine = _cloud._get_browser_engine()
if engine != "lightpanda" or not _cloud._should_inject_engine(engine):
return False, None, screenshot_path
_bt.logger.debug("browser_vision: pre-routing screenshot to Chrome (engine=lightpanda)")
screenshot_args = ["--annotate"] if annotate else []
fb_result = _lp._chrome_fallback_screenshot(effective_task_id, screenshot_args, _bt._get_command_timeout())
fb_result = _lp._annotate_lightpanda_fallback(fb_result, _bt._LP_VISION_FALLBACK_REASON)
if not fb_result.get("success"):
_bt.logger.warning("Lightpanda Chrome fallback vision screenshot failed: %s", fb_result.get("error"))
return False, None, screenshot_path
fb_path = fb_result.get("data", {}).get("path", "")
if fb_path and os.path.exists(fb_path):
import uuid as uuid_mod
from hermes_constants import get_hermes_dir
screenshots_dir = get_hermes_dir("cache/screenshots", "browser_screenshots")
screenshots_dir.mkdir(parents=True, exist_ok=True)
persistent_path = screenshots_dir / f"browser_screenshot_{uuid_mod.uuid4().hex}.png"
shutil.copy2(fb_path, persistent_path)
screenshot_path = persistent_path
return True, fb_result.get("fallback_warning"), screenshot_path
def _native_vision_result(
screenshot_path: Path, question: str, annotate: bool,
result: Dict[str, Any], lp_fallback_warning: Optional[str],
) -> Dict[str, Any]:
"""Multimodal tool-result envelope: the main model inspects the pixels itself.
The embed is baked into history and re-sent every later turn, so apply the same
proactive resize as vision_analyze's native path (skipped when already under
both caps; without Pillow it fails open to the raw bytes).
"""
from tools.vision_tools import (
_EMBED_MAX_DIMENSION,
_EMBED_TARGET_BYTES,
_build_native_vision_tool_result,
_resize_image_for_vision,
)
data_url = _resize_image_for_vision(screenshot_path, mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True)
native_result = _build_native_vision_tool_result(image_url=str(screenshot_path), question=question,
image_data_url=data_url,
image_size_bytes=screenshot_path.stat().st_size)
meta = native_result.setdefault("meta", {})
meta["screenshot_path"] = str(screenshot_path)
if lp_fallback_warning:
meta["fallback_warning"] = lp_fallback_warning
if annotate and result.get("data", {}).get("annotations"):
meta["annotations"] = result["data"]["annotations"]
native_result["text_summary"] = f"{native_result.get('text_summary', '')} Screenshot path: {screenshot_path}".strip()
return native_result
def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> str:
"""One-shot aux vision-LLM analysis (not baked into history), secret-redacted.
Full resolution first; on a size-related provider rejection the image is
downscaled once and retried. ``auxiliary.vision.timeout/temperature`` — local
vision models can take well over 30s, so the default timeout is generous.
"""
import base64
vision_prompt = (
f"You are analyzing a screenshot of a web browser.\n\n"
f"User's question: {question}\n\n"
f"Provide a detailed and helpful answer based on what you see in the screenshot. "
f"If there are interactive elements, describe them. If there are verification challenges "
f"or CAPTCHAs, describe what type they are and what action might be needed. "
f"Focus on answering the user's specific question."
)
_screenshot_bytes = screenshot_path.read_bytes()
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
data_url = f"data:image/png;base64,{_screenshot_b64}"
vision_model = _bt._get_vision_model()
_bt.logger.debug("browser_vision: analysing screenshot (%d bytes)", len(_screenshot_bytes))
vision_timeout = 120.0
vision_temperature = 0.1
try:
from hermes_cli.config import load_config
_vision_cfg = cfg_get(load_config(), "auxiliary", "vision", default={})
if _vision_cfg.get("timeout") is not None:
vision_timeout = float(_vision_cfg["timeout"])
if _vision_cfg.get("temperature") is not None:
vision_temperature = float(_vision_cfg["temperature"])
except Exception:
pass
from agent.auxiliary_client import call_llm # lazy: heavy client, only needed on the vision path
call_kwargs = {
"task": "vision", "temperature": vision_temperature, "timeout": vision_timeout,
"messages": [{"role": "user", "content": [
{"type": "text", "text": vision_prompt},
{"type": "image_url", "image_url": {"url": data_url}},
]}],
}
if vision_model:
call_kwargs["model"] = vision_model
try:
response = call_llm(**call_kwargs)
except Exception as _api_err:
from tools.vision_tools import _is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES
if not (_is_image_size_error(_api_err) and len(data_url) > _RESIZE_TARGET_BYTES):
raise
_bt.logger.info("Vision API rejected screenshot (%.1f MB); auto-resizing to ~%.0f MB and retrying...",
len(data_url) / (1024 * 1024), _RESIZE_TARGET_BYTES / (1024 * 1024))
data_url = _resize_image_for_vision(screenshot_path, mime_type="image/png")
call_kwargs["messages"][0]["content"][1]["image_url"]["url"] = data_url
response = call_llm(**call_kwargs)
from agent.redact import redact_sensitive_text # the LLM may have read secrets off the screenshot
return redact_sensitive_text((response.choices[0].message.content or "").strip())
|