refactor(browser): compact sibling modules — supervisor event dispatch inline, dialog responder unified (_respond/_respond_quiet/_cdp_quiet), dead ConsoleEvent ring buffer removed, browser_use_cli _quiet helper

This commit is contained in:
Teknium
2026-09-02 16:02:42 -07:00
parent 88b74d6ef0
commit c4ce73edc2
8 changed files with 333 additions and 690 deletions
-1
View File
@@ -296,7 +296,6 @@ class TestBrowserSupervisorRedaction:
closed_by="agent",
),),
frame_tree={"top": {"frame_id": "f1", "url": "about:blank", "origin": "null", "is_oopif": False}},
console_errors=(),
active=True,
cdp_url="ws://example.invalid/devtools/browser/mock",
task_id="test",
+50 -122
View File
@@ -32,7 +32,6 @@ _CDP_PRIVATE_PAGE_ALLOWED_METHODS = {
"Page.stopLoading",
}
# method → result paths that are ALWAYS opaque base64 (protocol-declared binary).
# redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match
# arbitrary spans inside such payloads and corrupt the decoded bytes; the payload
@@ -55,49 +54,41 @@ _CDP_FLAGGED_BINARY_PATHS: Dict[str, tuple] = {
}
def _redact_cdp_output(
value: Any,
*,
always_paths: tuple = (),
flagged_paths: tuple = (),
) -> Any:
def _redact_cdp_output(value: Any, *, always_paths: tuple = (), flagged_paths: tuple = ()) -> Any:
"""Redact browser-originated CDP result text; opaque bytes stay byte-identical.
Exemptions come ONLY from the calling method's spec as exact result paths.
Path suffixes propagate only into the matching subtree, so ``base64Encoded``
is honored solely as a sibling on the trusted carrier object — never as
ambient trust a ``Runtime.evaluate`` by-value object could spoof (#94142).
ambient trust a ``Runtime.evaluate`` by-value object could spoof.
"""
from agent.redact import redact_sensitive_text
if isinstance(value, str):
return redact_sensitive_text(value, force=True)
if isinstance(value, list):
return [_redact_cdp_output(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_cdp_output(item) for item in value)
if isinstance(value, dict):
base64_flagged = value.get("base64Encoded") is True
if isinstance(value, (list, tuple)):
return type(value)(_redact_cdp_output(item) for item in value)
if not isinstance(value, dict):
return value
base64_flagged = value.get("base64Encoded") is True
def leaf(paths: tuple, key: str) -> bool:
return any(len(p) == 1 and p[0] == key for p in paths)
def leaf(paths: tuple, key: str) -> bool:
return any(len(p) == 1 and p[0] == key for p in paths)
def descend(paths: tuple, key: str) -> tuple:
return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key)
def descend(paths: tuple, key: str) -> tuple:
return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key)
redacted: Dict[str, Any] = {}
for key, item in value.items():
opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged)
if isinstance(item, str) and opaque:
redacted[key] = item
else:
redacted[key] = _redact_cdp_output(
item, always_paths=descend(always_paths, key), flagged_paths=descend(flagged_paths, key),
)
return redacted
redacted: Dict[str, Any] = {}
for key, item in value.items():
opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged)
if isinstance(item, str) and opaque:
redacted[key] = item
else:
redacted[key] = _redact_cdp_output(
item,
always_paths=descend(always_paths, key),
flagged_paths=descend(flagged_paths, key),
)
return redacted
return value
# ``websockets`` is a direct dependency; wrap so a stale env yields a clean error.
try:
@@ -117,13 +108,11 @@ def _run_async(coro):
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, coro)
return future.result()
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
@@ -163,30 +152,25 @@ _METHOD_PARAM_GUARDS = {
}
def _browser_cdp_private_guard(
*,
task_id: str,
method: str,
params: Dict[str, Any],
) -> Optional[str]:
def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, Any]) -> Optional[str]:
"""Apply the browser SSRF/private-page guard to raw CDP calls.
Raw CDP shares the cloud/private-network boundary of ``browser_snapshot`` /
``browser_console`` / ``browser_eval`` and must not become their bypass.
Guard probes are best-effort; a probe failure never breaks local/custom CDP
workflows.
"""
try:
from tools import browser_tool as bt # type: ignore[import-not-found]
if not bt._eval_ssrf_guard_active(task_id): # type: ignore[attr-defined]
return None
guard = _METHOD_PARAM_GUARDS.get(method)
if guard is not None:
probe, template = guard
literal = probe(bt, params or {})
if literal:
return _blocked(template.format(literal), method)
if method not in _CDP_PRIVATE_PAGE_ALLOWED_METHODS:
blocked_url = bt._current_page_private_url(task_id) # type: ignore[attr-defined]
if blocked_url:
@@ -197,17 +181,12 @@ def _browser_cdp_private_guard(
method,
)
except Exception as exc: # noqa: BLE001
# Guard probes are best-effort; never break local/custom CDP workflows.
logger.debug("browser_cdp: private-page guard probe failed: %s", exc)
return None
async def _cdp_call(
ws_url: str,
method: str,
params: Dict[str, Any],
target_id: Optional[str],
timeout: float,
ws_url: str, method: str, params: Dict[str, Any], target_id: Optional[str], timeout: float,
) -> Dict[str, Any]:
"""Make a single CDP call, optionally attaching to a target first.
@@ -232,22 +211,18 @@ async def _cdp_call(
next_id += 1
await ws.send(json.dumps({"id": call_id, **req}))
deadline = asyncio.get_running_loop().time() + timeout
while True:
while True: # ignore events / out-of-order responses
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise TimeoutError(f"Timed out {what}")
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=remaining))
if msg.get("id") == call_id:
return msg
# Ignore events / out-of-order responses
session_id: Optional[str] = None
if target_id:
msg = await _send(
{
"method": "Target.attachToTarget",
"params": {"targetId": target_id, "flatten": True},
},
{"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}},
f"attaching to target {target_id}",
)
if "error" in msg:
@@ -266,11 +241,7 @@ async def _cdp_call(
def _browser_cdp_via_supervisor(
task_id: str,
frame_id: str,
method: str,
params: Optional[Dict[str, Any]],
timeout: float,
task_id: str, frame_id: str, method: str, params: Optional[Dict[str, Any]], timeout: float,
) -> str:
"""Route a CDP call through the live supervisor session for an OOPIF frame."""
try:
@@ -291,11 +262,9 @@ def _browser_cdp_via_supervisor(
f"frame_tree with frame_ids you can pass here."
)
snap = supervisor.snapshot()
tree = snap.frame_tree
tree = supervisor.snapshot().frame_tree
frame_info: Optional[Dict[str, Any]] = next(
(f for f in [tree.get("top"), *(tree.get("children") or [])]
if f and f.get("frame_id") == frame_id),
(f for f in [tree.get("top"), *(tree.get("children") or [])] if f and f.get("frame_id") == frame_id),
None,
)
if frame_info is None:
@@ -304,7 +273,6 @@ def _browser_cdp_via_supervisor(
raw = supervisor._frames.get(frame_id) # type: ignore[attr-defined]
if raw is not None:
frame_info = raw.to_dict()
if frame_info is None:
return tool_error(
f"frame_id {frame_id!r} not found in supervisor state. "
@@ -325,10 +293,7 @@ def _browser_cdp_via_supervisor(
loop = supervisor._loop # type: ignore[attr-defined]
if loop is None or not loop.is_running():
return tool_error(
"CDP supervisor loop is not running. Try reconnecting with "
"/browser connect."
)
return tool_error("CDP supervisor loop is not running. Try reconnecting with /browser connect.")
try:
from agent.async_utils import safe_schedule_threadsafe
@@ -337,25 +302,20 @@ def _browser_cdp_via_supervisor(
loop,
)
if fut is None:
return tool_error(
"CDP call via supervisor failed: loop unavailable",
cdp_docs=CDP_DOCS_URL,
)
return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL)
result_msg = fut.result(timeout=timeout + 2)
except Exception as exc:
return tool_error(
f"CDP call via supervisor failed: {type(exc).__name__}: {exc}",
cdp_docs=CDP_DOCS_URL,
f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL,
)
payload: Dict[str, Any] = {
return json.dumps({
"success": True,
"method": method,
"frame_id": frame_id,
"session_id": child_sid,
"result": result_msg.get("result", {}),
}
return json.dumps(payload, ensure_ascii=False)
}, ensure_ascii=False)
def browser_cdp(
@@ -372,40 +332,26 @@ def browser_cdp(
(OOPIF from ``browser_snapshot.frame_tree``) routes through the supervisor's
live WebSocket instead — the only reliable way to evaluate inside an iframe
on backends where fresh per-call connections hit signed-URL expiry
(Browserbase). Returns JSON ``{"success": True, "method", "result"}`` or
``{"error": ...}``.
(Browserbase). Both paths share the same private-page/SSRF guard. Returns
JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``.
"""
effective_task_id = task_id or "default"
if frame_id:
# Same private-page/SSRF boundary as the stateless path below.
blocked = _browser_cdp_private_guard(
task_id=effective_task_id,
method=method,
params=params or {},
)
blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=params or {})
if blocked:
return blocked
return _browser_cdp_via_supervisor(
task_id=effective_task_id,
frame_id=frame_id,
method=method,
params=params,
timeout=timeout,
task_id=effective_task_id, frame_id=frame_id, method=method, params=params, timeout=timeout,
)
if not method or not isinstance(method, str):
return tool_error(
"'method' is required (e.g. 'Target.getTargets')",
cdp_docs=CDP_DOCS_URL,
)
return tool_error("'method' is required (e.g. 'Target.getTargets')", cdp_docs=CDP_DOCS_URL)
if not _WS_AVAILABLE:
return tool_error(
"The 'websockets' Python package is required but not installed. "
"Install it with: pip install websockets"
)
endpoint = _resolve_cdp_endpoint()
if not endpoint:
return tool_error(
@@ -415,7 +361,6 @@ def browser_cdp(
"and does not expose CDP.",
cdp_docs=CDP_DOCS_URL,
)
if not endpoint.startswith(("ws://", "wss://")):
return tool_error(
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
@@ -423,18 +368,11 @@ def browser_cdp(
"resolver should have rewritten this. Check that a Chromium-family "
"browser is actually listening on the debug port."
)
call_params: Dict[str, Any] = params or {}
if not isinstance(call_params, dict):
return tool_error(
f"'params' must be an object/dict, got {type(call_params).__name__}"
)
return tool_error(f"'params' must be an object/dict, got {type(call_params).__name__}")
blocked = _browser_cdp_private_guard(
task_id=effective_task_id,
method=method,
params=call_params,
)
blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=call_params)
if blocked:
return blocked
@@ -445,14 +383,9 @@ def browser_cdp(
safe_timeout = max(1.0, min(safe_timeout, 300.0))
try:
result = _run_async(
_cdp_call(endpoint, method, call_params, target_id, safe_timeout)
)
result = _run_async(_cdp_call(endpoint, method, call_params, target_id, safe_timeout))
except asyncio.TimeoutError as exc:
return tool_error(
f"CDP call timed out after {safe_timeout}s: {exc}",
method=method,
)
return tool_error(f"CDP call timed out after {safe_timeout}s: {exc}", method=method)
except (TimeoutError, RuntimeError) as exc:
return tool_error(str(exc), method=method)
except WebSocketException as exc:
@@ -463,10 +396,7 @@ def browser_cdp(
)
except Exception as exc: # pragma: no cover — unexpected
logger.exception("browser_cdp unexpected error")
return tool_error(
f"Unexpected error: {type(exc).__name__}: {exc}",
method=method,
)
return tool_error(f"Unexpected error: {type(exc).__name__}: {exc}", method=method)
payload: Dict[str, Any] = {
"success": True,
@@ -586,6 +516,8 @@ def _browser_cdp_check() -> bool:
Camofox (REST-only), default local agent-browser (hidden CDP port) and cloud
providers whose per-session ``cdp_url`` isn't surfaced are gated out. Thin
wrapper so ``registry.register`` stays a top-level statement (AST scan).
Raw (no-I/O) gate: check_fns run at every startup; resolving the endpoint
over HTTP here would block launch on a stale endpoint.
"""
try:
from tools.browser_tool import ( # type: ignore[import-not-found]
@@ -595,11 +527,7 @@ def _browser_cdp_check() -> bool:
except ImportError as exc: # pragma: no cover — defensive
logger.debug("browser_cdp check: browser_tool import failed: %s", exc)
return False
if not check_browser_requirements():
return False
# Raw (no-I/O) gate: check_fns run at every startup; resolving the
# endpoint over HTTP here would block launch on a stale endpoint.
return bool(_get_cdp_override_raw())
return bool(check_browser_requirements() and _get_cdp_override_raw())
registry.register(
+11 -24
View File
@@ -80,31 +80,18 @@ def browser_dialog(
"""Respond to a pending dialog on the active task's CDP supervisor."""
supervisor = SUPERVISOR_REGISTRY.get(task_id or "default")
if supervisor is None:
return json.dumps(
{
"success": False,
"error": (
"No CDP supervisor is attached to this task. Either the "
"browser backend doesn't expose CDP (Camofox, default "
"Playwright) or no browser session has been started yet. "
"Call browser_navigate or /browser connect first."
),
}
)
result = supervisor.respond_to_dialog(
action=action,
prompt_text=prompt_text,
dialog_id=dialog_id,
)
return json.dumps({
"success": False,
"error": (
"No CDP supervisor is attached to this task. Either the "
"browser backend doesn't expose CDP (Camofox, default "
"Playwright) or no browser session has been started yet. "
"Call browser_navigate or /browser connect first."
),
})
result = supervisor.respond_to_dialog(action=action, prompt_text=prompt_text, dialog_id=dialog_id)
if result.get("ok"):
return json.dumps(
{
"success": True,
"action": action,
"dialog": result.get("dialog", {}),
}
)
return json.dumps({"success": True, "action": action, "dialog": result.get("dialog", {})})
return json.dumps({"success": False, "error": result.get("error", "unknown error")})
+13 -47
View File
@@ -51,10 +51,7 @@ def extension_controller_available(action: str) -> bool:
consults the process-local broker directly and fails closed on any gap.
"""
try:
from gateway.browser_control_broker import (
browser_control_enabled,
get_browser_control_broker,
)
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
if not browser_control_enabled():
return False
@@ -63,17 +60,11 @@ def extension_controller_available(action: str) -> bool:
return False
broker = get_browser_control_broker()
scope = broker.scope_for_session(
session_id=session_id,
principal_id=principal_id,
transport_family=transport_family,
session_id=session_id, principal_id=principal_id, transport_family=transport_family,
)
return scope is not None and broker.select(scope, action) is not None
except Exception:
logger.debug(
"browser extension availability check failed for %s",
action,
exc_info=True,
)
logger.debug("browser extension availability check failed for %s", action, exc_info=True)
return False
@@ -97,17 +88,11 @@ def route_browser_tool(
called exactly once when the feature is off or no server-bound identity
exists; once a controller is selected its result/exception is final.
"""
if not enabled:
return fallback()
if not str(principal_id or "").strip() or not str(transport_family or "").strip():
if not enabled or not str(principal_id or "").strip() or not str(transport_family or "").strip():
return fallback()
identity = dict(
session_id=session_id,
task_id=task_id,
principal_id=principal_id,
transport_family=transport_family,
session_id=session_id, task_id=task_id, principal_id=principal_id, transport_family=transport_family,
)
scope = broker.scope_for_session(**identity)
if scope is None:
@@ -117,25 +102,16 @@ def route_browser_tool(
lane_bound = getattr(broker, "lane_registered", None)
if callable(lane_bound) and not lane_bound(**identity):
return fallback()
raise _controller_unavailable(
f"bound browser controller unavailable for {action}"
)
raise _controller_unavailable(f"bound browser controller unavailable for {action}")
controller = broker.select(scope, action)
if controller is None:
raise _controller_unavailable(
f"bound browser controller cannot execute {action}"
)
if broker.select(scope, action) is None:
raise _controller_unavailable(f"bound browser controller cannot execute {action}")
# Controller is authoritative: never retry the legacy backend. Registry
# handlers must return a string; keep string results byte-identical and
# serialize decoded JSON values at this boundary.
result = broker.dispatch(
scope, action=action, arguments=args, tool_call_id=tool_call_id
)
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False)
result = broker.dispatch(scope, action=action, arguments=args, tool_call_id=tool_call_id)
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
def current_tool_call_id() -> str:
@@ -164,23 +140,13 @@ def routed_browser_handler(
Feature off (or gateway unimportable) ⇒ the legacy handler runs unchanged.
"""
try:
from gateway.browser_control_broker import (
browser_control_enabled,
get_browser_control_broker,
)
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
except Exception as exc: # pragma: no cover - defensive, gateway always present
logger.debug(
"browser extension router unavailable (%s); using legacy backend",
exc,
)
logger.debug("browser extension router unavailable (%s); using legacy backend", exc)
return fallback()
if not browser_control_enabled():
return fallback()
if tool_call_id is None:
tool_call_id = current_tool_call_id()
try:
env_session, env_principal, env_transport = _bound_identity()
except Exception:
@@ -196,5 +162,5 @@ def routed_browser_handler(
task_id=task_id,
principal_id=principal_id or env_principal,
transport_family=transport_family or env_transport,
tool_call_id=tool_call_id,
tool_call_id=current_tool_call_id() if tool_call_id is None else tool_call_id,
)
+55 -132
View File
@@ -57,12 +57,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Ring buffer of recent console-level events.
CONSOLE_HISTORY_MAX = 50
def _redact_cdp_error_text(exc: object) -> str:
"""Redact CDP endpoint credentials from an exception's string form.
"""Redact CDP endpoint credentials from an exception's (or URL's) string form.
``websockets`` bakes the raw target URL (``?token=`` / ``user:pass@``) into
its exception messages. Every egress point that turns such an exception into
@@ -91,17 +88,8 @@ def _schedule(coro, loop, *, timeout: float):
return fut.result(timeout=timeout)
# ── Data model ────────────────────────────────────────────────────────────────
@dataclass
class ConsoleEvent:
"""Ring buffer entry for console + exception traffic."""
ts: float
level: str # "log" | "error" | "warning" | "exception"
text: str
url: Optional[str] = None
def _err(exc: BaseException) -> Dict[str, Any]:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
@dataclass(frozen=True)
@@ -111,7 +99,6 @@ class SupervisorSnapshot:
pending_dialogs: Tuple[PendingDialog, ...]
recent_dialogs: Tuple[DialogRecord, ...]
frame_tree: Dict[str, Any]
console_errors: Tuple[ConsoleEvent, ...]
active: bool # False if supervisor is detached/stopped
cdp_url: str
task_id: str
@@ -163,7 +150,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
self._pending_dialogs: Dict[str, PendingDialog] = {}
self._recent_dialogs: List[DialogRecord] = []
self._frames: Dict[str, FrameInfo] = {}
self._console_events: List[ConsoleEvent] = []
self._active = False
# Supervisor loop machinery — populated in start().
@@ -197,21 +183,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
self._start_error = None
self._stop_requested = False
self._thread = threading.Thread(
target=self._thread_main,
name=f"cdp-supervisor-{self.task_id}",
daemon=True,
target=self._thread_main, name=f"cdp-supervisor-{self.task_id}", daemon=True,
)
self._thread.start()
if not self._ready_event.wait(timeout=timeout):
self.stop()
try:
from agent.redact import redact_cdp_url
_safe_url = redact_cdp_url(self.cdp_url)
except Exception:
_safe_url = "<cdp_url redacted>"
raise TimeoutError(
f"CDP supervisor did not attach within {timeout}s "
f"(cdp_url={_safe_url[:80]}...)"
f"(cdp_url={_redact_cdp_error_text(self.cdp_url)[:80]}...)"
)
if self._start_error is not None:
err = self._start_error
@@ -247,7 +226,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
pending_dialogs=tuple(self._pending_dialogs.values()),
recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]),
frame_tree=self._build_frame_tree_locked(),
console_errors=tuple(self._console_events[-CONSOLE_HISTORY_MAX:]),
active=self._active,
cdp_url=self.cdp_url,
task_id=self.task_id,
@@ -309,7 +287,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
except _LoopUnavailable as e:
return {"ok": False, "error": str(e)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return _err(e)
return {"ok": True, "dialog": dialog.to_dict()}
def evaluate_runtime(
@@ -362,13 +340,12 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
# CDP's recursion guard with the protocol-level error ``Object
# reference chain is too long``. Retry once with returnByValue=False
# so Chrome returns the description string instead of failing.
if return_by_value and "reference chain is too long" in str(exc).lower():
try:
response = _run_eval(False)
except Exception as exc2:
return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"}
else:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
if not (return_by_value and "reference chain is too long" in str(exc).lower()):
return _err(exc)
try:
response = _run_eval(False)
except Exception as exc2:
return _err(exc2)
# Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}}
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
@@ -382,7 +359,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
result_obj = result_payload.get("result", {})
result_type = result_obj.get("type", "undefined")
if "value" in result_obj:
value = result_obj["value"]
elif result_type == "undefined":
@@ -391,7 +367,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
# Non-serializable (functions, DOM nodes…) — give the model the
# browser's description so it gets *something*.
value = result_obj.get("description") or result_obj.get("unserializableValue")
return {"ok": True, "result": value, "result_type": result_type}
# ── Supervisor loop internals ────────────────────────────────────────────
@@ -404,13 +379,10 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
asyncio.set_event_loop(loop)
loop.run_until_complete(self._run())
except BaseException as e: # noqa: BLE001 — propagate via _start_error
if not self._ready_event.is_set():
self._start_error = e
self._ready_event.set()
else:
if not self._fail_start(e):
logger.warning("CDP supervisor %s crashed: %s", self.task_id, e)
finally:
# Flush remaining tasks before closing the loop to avoid
# Cancel + flush remaining tasks before closing the loop to avoid
# "Task was destroyed but it is pending" warnings.
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
@@ -418,19 +390,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
try:
loop.close()
except Exception:
pass
with self._state_lock:
self._active = False
def _fail_start(self, e: BaseException) -> bool:
"""Propagate ``e`` to ``start()`` if we never got ready; True if it was consumed."""
if self._ready_event.is_set():
return False
self._start_error = e
self._ready_event.set()
return True
async def _close_ws(self) -> None:
"""Detach and close the current WebSocket, swallowing close errors."""
ws = self._ws
self._ws = None
ws, self._ws = self._ws, None
if ws is not None:
try:
await ws.close()
@@ -442,7 +418,8 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
Browserbase tears down the CDP socket every time a short-lived client
(e.g. agent-browser's per-command CDP client) disconnects, so on drop we
reset per-session ids, re-attach, and keep going.
reset per-session ids, re-attach, and keep going. A failure before the
first successful attach is fatal for ``start()``.
"""
attempt = 0
last_success_at = 0.0
@@ -451,15 +428,11 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
while not self._stop_requested:
try:
self._ws = await asyncio.wait_for(
websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024),
timeout=10.0,
websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024), timeout=10.0,
)
except Exception as e:
attempt += 1
if not self._ready_event.is_set():
# Never connected once — fatal for start().
self._start_error = e
self._ready_event.set()
if self._fail_start(e):
return
logger.warning(
"CDP supervisor %s: connect failed (attempt %s): %s",
@@ -481,20 +454,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
self._active = True
last_success_at = time.time()
backoff = 0.5 # reset after a successful attach
if not self._ready_event.is_set():
self._ready_event.set()
self._ready_event.set()
await reader_task
except BaseException as e:
if not self._ready_event.is_set():
# Never got to ready — propagate to start().
self._start_error = e
self._ready_event.set()
if self._fail_start(e):
raise
logger.warning(
"CDP supervisor %s: session dropped after %.1fs: %s",
self.task_id,
time.time() - last_success_at,
_redact_cdp_error_text(e),
self.task_id, time.time() - last_success_at, _redact_cdp_error_text(e),
)
finally:
with self._state_lock:
@@ -505,22 +472,19 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
await reader_task
except (asyncio.CancelledError, Exception):
pass
for handle in list(self._dialog_watchdogs.values()):
for handle in self._dialog_watchdogs.values():
handle.cancel()
self._dialog_watchdogs.clear()
await self._close_ws()
if self._stop_requested:
return
logger.debug(
"CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff,
)
logger.debug("CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 10.0)
async def _attach_initial_page(self) -> None:
"""Find a page target, attach flattened session, enable domains, install dialog bridge."""
"""Find (or create) a page target, attach flattened, enable domains, install dialog bridge."""
resp = await self._cdp("Target.getTargets")
targets = resp.get("result", {}).get("targetInfos", [])
page_target = next((t for t in targets if t.get("type") == "page"), None)
@@ -529,11 +493,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
target_id = created["result"]["targetId"]
else:
target_id = page_target["targetId"]
attach = await self._cdp(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
attach = await self._cdp("Target.attachToTarget", {"targetId": target_id, "flatten": True})
self._page_session_id = attach["result"]["sessionId"]
await self._enable_page_domains(self._page_session_id, timeout=10.0)
await self._install_dialog_bridge(self._page_session_id)
@@ -565,7 +525,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
self._pending_calls.pop(call_id, None)
async def _read_loop(self) -> None:
"""Continuously dispatch incoming CDP frames."""
"""Continuously dispatch incoming CDP frames (responses → futures, events → handlers)."""
assert self._ws is not None
try:
async for raw in self._ws:
@@ -580,58 +540,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
fut = self._pending_calls.pop(msg["id"], None)
if fut is not None and not fut.done():
if "error" in msg:
fut.set_exception(
RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}")
)
fut.set_exception(RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}"))
else:
fut.set_result(msg)
elif "method" in msg:
await self._on_event(msg["method"], msg.get("params", {}), msg.get("sessionId"))
handler = self._EVENT_HANDLERS.get(msg["method"])
if handler is not None:
result = handler(self, msg.get("params", {}), msg.get("sessionId"))
if result is not None:
await result
except Exception as e:
logger.debug("CDP read loop exited: %s", e)
# ── Event dispatch ──────────────────────────────────────────────────────
async def _on_event(
self, method: str, params: Dict[str, Any], session_id: Optional[str]
) -> None:
handler = self._EVENT_HANDLERS.get(method)
if handler is None:
return
result = handler(self, params, session_id)
if result is not None:
await result
# ── Console / exception ring buffer ─────────────────────────────────────
def _on_console(self, params: Dict[str, Any], *, level_from: str) -> None:
if level_from == "exception":
details = params.get("exceptionDetails") or {}
text = str(details.get("text") or "")
url = details.get("url")
event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url)
else:
raw_level = str(params.get("type") or "log")
level = "error" if raw_level in {"error", "assert"} else (
"warning" if raw_level == "warning" else "log"
)
args = params.get("args") or []
parts: List[str] = []
for a in args[:4]:
if isinstance(a, dict):
parts.append(str(a.get("value") or a.get("description") or ""))
event = ConsoleEvent(ts=time.time(), level=level, text=" ".join(parts))
with self._state_lock:
self._console_events.append(event)
self._console_events = _trim_ring(self._console_events, CONSOLE_HISTORY_MAX)
# CDP event → handler(self, params, session_id). Async handlers return an
# awaitable that ``_on_event`` awaits; sync handlers return None.
# awaitable that ``_read_loop`` awaits; sync handlers return None.
_EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
**DialogSupervisionMixin.EVENT_HANDLERS,
**FrameTrackingMixin.EVENT_HANDLERS,
"Runtime.consoleAPICalled": lambda self, p, _sid: self._on_console(p, level_from="api"),
"Runtime.exceptionThrown": lambda self, p, _sid: self._on_console(p, level_from="exception"),
}
@@ -664,27 +589,26 @@ class _SupervisorRegistry:
) -> CDPSupervisor:
"""Idempotently ensure a supervisor is running for ``(task_id, cdp_url)``.
An existing supervisor bound to a different ``cdp_url`` (or unhealthy)
is stopped and replaced.
An existing supervisor bound to a different ``cdp_url`` (or unhealthy:
dead thread / stopped loop) is stopped and replaced.
"""
with self._lock:
existing = self._by_task.get(task_id)
if existing is not None:
if existing.cdp_url == cdp_url:
thread_ok = existing._thread is not None and existing._thread.is_alive()
loop_ok = existing._loop is not None and existing._loop.is_running()
if thread_ok and loop_ok:
return existing
# URL changed or unhealthy — tear down, fall through to re-create.
thread, loop = existing._thread, existing._loop
if (
existing.cdp_url == cdp_url
and thread is not None and thread.is_alive()
and loop is not None and loop.is_running()
):
return existing
self._by_task.pop(task_id, None)
if existing is not None:
existing.stop()
supervisor = CDPSupervisor(
task_id=task_id,
cdp_url=cdp_url,
dialog_policy=dialog_policy,
dialog_timeout_s=dialog_timeout_s,
task_id=task_id, cdp_url=cdp_url,
dialog_policy=dialog_policy, dialog_timeout_s=dialog_timeout_s,
)
supervisor.start(timeout=start_timeout)
with self._lock:
@@ -706,9 +630,9 @@ class _SupervisorRegistry:
def stop_all(self) -> None:
"""Stop every running supervisor. For shutdown / test teardown."""
with self._lock:
items = list(self._by_task.items())
items = list(self._by_task.values())
self._by_task.clear()
for _, supervisor in items:
for supervisor in items:
supervisor.stop()
@@ -717,7 +641,6 @@ SUPERVISOR_REGISTRY = _SupervisorRegistry()
__all__ = [
"CDPSupervisor",
"ConsoleEvent",
"DEFAULT_DIALOG_POLICY",
"DEFAULT_DIALOG_TIMEOUT_S",
"DIALOG_POLICY_AUTO_ACCEPT",
+88 -182
View File
@@ -22,7 +22,7 @@ import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, Coroutine, Dict, Optional
from typing import Any, Callable, Dict, Optional
from urllib.parse import parse_qs, urlparse
# Logger-name parity with the origin module (records must look unchanged).
@@ -46,11 +46,9 @@ def _trim_ring(events: list, keep: int) -> list:
DIALOG_POLICY_MUST_RESPOND = "must_respond"
DIALOG_POLICY_AUTO_DISMISS = "auto_dismiss"
DIALOG_POLICY_AUTO_ACCEPT = "auto_accept"
_VALID_POLICIES = frozenset(
{DIALOG_POLICY_MUST_RESPOND, DIALOG_POLICY_AUTO_DISMISS, DIALOG_POLICY_AUTO_ACCEPT}
)
DEFAULT_DIALOG_POLICY = DIALOG_POLICY_MUST_RESPOND
DEFAULT_DIALOG_TIMEOUT_S = 300.0
@@ -64,7 +62,12 @@ RECENT_DIALOGS_MAX = 20
DIALOG_BRIDGE_HOST = "hermes-dialog-bridge.invalid"
DIALOG_BRIDGE_URL_PATTERN = f"http://{DIALOG_BRIDGE_HOST}/*"
# Injected into every frame via Page.addScriptToEvaluateOnNewDocument.
# Injected into every frame via Page.addScriptToEvaluateOnNewDocument. Uses a
# sync GET with query params so the Fetch interceptor never parses a body; if
# the bridge is unreachable it returns null so the page still sees *some*
# behavior (the backend auto-dismisses). onbeforeunload is left native — it
# can't be prompted synchronously without racing navigation; the native-dialog
# fallback path still surfaces it in recent_dialogs.
_DIALOG_BRIDGE_SCRIPT = r"""
(() => {
if (window.__hermesDialogBridgeInstalled) return;
@@ -73,8 +76,6 @@ _DIALOG_BRIDGE_SCRIPT = r"""
function ask(kind, message, defaultPrompt) {
try {
const xhr = new XMLHttpRequest();
// Use GET with query params so we don't need to worry about request
// body encoding in the Fetch interceptor.
const params = new URLSearchParams({
kind: String(kind || ""),
message: String(message == null ? "" : message),
@@ -83,9 +84,8 @@ _DIALOG_BRIDGE_SCRIPT = r"""
xhr.open("GET", ENDPOINT + "?" + params.toString(), false); // sync
xhr.send(null);
if (xhr.status !== 200) return null;
const body = xhr.responseText || "";
let parsed;
try { parsed = JSON.parse(body); } catch (e) { return null; }
try { parsed = JSON.parse(xhr.responseText || ""); } catch (e) { return null; }
if (kind === "alert") return undefined;
if (kind === "confirm") return Boolean(parsed && parsed.accept);
if (kind === "prompt") {
@@ -94,14 +94,9 @@ _DIALOG_BRIDGE_SCRIPT = r"""
}
return null;
} catch (e) {
// If the bridge is unreachable, fall back to the native call so the
// page still sees *some* behavior (the backend will auto-dismiss).
return null;
}
}
const realAlert = window.alert;
const realConfirm = window.confirm;
const realPrompt = window.prompt;
window.alert = function(message) { ask("alert", message, ""); };
window.confirm = function(message) {
const r = ask("confirm", message, "");
@@ -111,10 +106,6 @@ _DIALOG_BRIDGE_SCRIPT = r"""
const r = ask("prompt", message, def == null ? "" : def);
return r === null ? null : String(r);
};
// onbeforeunload — we can't really synchronously prompt the user from this
// event without racing navigation. Leave native behavior for now; the
// supervisor's native-dialog fallback path still surfaces them in
// recent_dialogs.
})();
"""
@@ -178,6 +169,14 @@ class DialogRecord:
class DialogSupervisionMixin:
"""Dialog event handling for ``CDPSupervisor`` (all methods run on its loop)."""
async def _cdp_quiet(self, method: str, params: Dict[str, Any], *, session_id: Optional[str],
timeout: float, what: str) -> None:
"""Best-effort CDP call: failures are logged at debug and swallowed."""
try:
await self._cdp(method, params, session_id=session_id, timeout=timeout)
except Exception as e:
logger.debug("%s failed (%s): %s", method, what, e)
async def _install_dialog_bridge(self, session_id: str) -> None:
"""Install the dialog-bridge init script + Fetch interceptor on a session.
@@ -185,110 +184,65 @@ class DialogSupervisionMixin:
scoped to the bridge URL catches the XHRs, which surface as pending
dialogs and are fulfilled when the agent responds. Idempotent at the CDP
level (Chromium de-dupes identical add-script calls; Fetch.enable
replaces prior patterns).
replaces prior patterns). The final Runtime.evaluate injects into the
already-loaded document so existing pages pick up the override on reconnect.
"""
try:
await self._cdp(
"Page.addScriptToEvaluateOnNewDocument",
{"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True},
session_id=session_id,
timeout=5.0,
)
except Exception as e:
logger.debug(
"dialog bridge: addScriptToEvaluateOnNewDocument failed on sid=%s: %s",
(session_id or "")[:16], e,
)
try:
await self._cdp(
"Fetch.enable",
{
"patterns": [
{
"urlPattern": DIALOG_BRIDGE_URL_PATTERN,
"requestStage": "Request",
}
],
"handleAuthRequests": False,
},
session_id=session_id,
timeout=5.0,
)
except Exception as e:
logger.debug(
"dialog bridge: Fetch.enable failed on sid=%s: %s",
(session_id or "")[:16], e,
)
# Best-effort inject into the already-loaded document so existing pages
# pick up the override on reconnect.
try:
await self._cdp(
"Runtime.evaluate",
{"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True},
session_id=session_id,
timeout=3.0,
)
except Exception:
pass
sid = (session_id or "")[:16]
await self._cdp_quiet(
"Page.addScriptToEvaluateOnNewDocument",
{"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True},
session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}",
)
await self._cdp_quiet(
"Fetch.enable",
{"patterns": [{"urlPattern": DIALOG_BRIDGE_URL_PATTERN, "requestStage": "Request"}],
"handleAuthRequests": False},
session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}",
)
await self._cdp_quiet(
"Runtime.evaluate",
{"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True},
session_id=session_id, timeout=3.0, what=f"dialog bridge inject sid={sid}",
)
# ── Capture ──────────────────────────────────────────────────────────────
async def _on_dialog_opening(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
dialog = self._new_dialog(
async def _on_dialog_opening(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
self._admit_dialog(self._new_dialog(
type=str(params.get("type") or ""),
message=str(params.get("message") or ""),
default_prompt=str(params.get("defaultPrompt") or ""),
session_id=session_id,
frame_id=params.get("frameId"),
)
self._admit_dialog(dialog, self._auto_handle_dialog)
))
async def _on_fetch_paused(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
async def _on_fetch_paused(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
"""Bridge XHR captured mid-flight — materialize as a pending dialog.
The page's JS thread is blocked on the XHR until we Fetch.fulfillRequest
(from ``respond_to_dialog`` or the watchdog).
(from ``respond_to_dialog`` or the watchdog). Requests for other hosts
are forwarded unchanged so the page sees its own request.
"""
url = str(params.get("request", {}).get("url") or "")
request_id = params.get("requestId")
if not request_id:
return
if DIALOG_BRIDGE_HOST not in url:
# Not ours — forward unchanged so the page sees its own request.
try:
await self._cdp(
"Fetch.continueRequest", {"requestId": request_id},
session_id=session_id, timeout=3.0,
)
except Exception:
pass
await self._cdp_quiet("Fetch.continueRequest", {"requestId": request_id},
session_id=session_id, timeout=3.0, what="passthrough")
return
q = parse_qs(urlparse(url).query)
dialog = self._new_dialog(
self._admit_dialog(self._new_dialog(
type=q.get("kind", [""])[0] or "alert",
message=q.get("message", [""])[0],
default_prompt=q.get("default_prompt", [""])[0],
session_id=session_id,
frame_id=params.get("frameId"),
bridge_request_id=str(request_id),
)
self._admit_dialog(dialog, self._fulfill_bridge_request)
))
def _new_dialog(
self,
*,
type: str,
message: str,
default_prompt: str,
session_id: Optional[str],
frame_id: Optional[str],
bridge_request_id: Optional[str] = None,
) -> PendingDialog:
def _new_dialog(self, *, type: str, message: str, default_prompt: str, session_id: Optional[str],
frame_id: Optional[str], bridge_request_id: Optional[str] = None) -> PendingDialog:
self._dialog_seq += 1
return PendingDialog(
id=f"d-{self._dialog_seq}",
@@ -301,12 +255,8 @@ class DialogSupervisionMixin:
bridge_request_id=bridge_request_id,
)
def _admit_dialog(
self,
dialog: PendingDialog,
responder: Callable[..., Coroutine[Any, Any, None]],
) -> None:
"""Apply the dialog policy: auto-respond via ``responder`` or queue + arm watchdog.
def _admit_dialog(self, dialog: PendingDialog) -> None:
"""Apply the dialog policy: auto-respond, or queue + arm the watchdog.
Auto policies archive FIRST (tagged ``auto_policy``) so the ``closed``
event that follows our own response isn't re-archived as ``remote``.
@@ -316,61 +266,33 @@ class DialogSupervisionMixin:
DIALOG_POLICY_AUTO_ACCEPT: (True, dialog.default_prompt),
}.get(self.dialog_policy)
if auto is not None:
accept, prompt_text = auto
with self._state_lock:
self._archive_dialog_locked(dialog, "auto_policy")
asyncio.create_task(responder(dialog, accept=accept, prompt_text=prompt_text))
asyncio.create_task(self._respond_quiet(dialog, accept=auto[0], prompt_text=auto[1]))
return
# must_respond → add to pending and arm watchdog.
with self._state_lock:
self._pending_dialogs[dialog.id] = dialog
loop = asyncio.get_running_loop()
handle = loop.call_later(
self._dialog_watchdogs[dialog.id] = asyncio.get_running_loop().call_later(
self.dialog_timeout_s,
lambda: asyncio.create_task(self._dialog_timeout_expired(dialog.id)),
)
self._dialog_watchdogs[dialog.id] = handle
# ── Responding ───────────────────────────────────────────────────────────
async def _respond(
self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]
) -> None:
"""Bridge-fulfill for XHR-captured dialogs, else native CDP. Raises on native CDP failure."""
async def _respond(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None:
"""Bridge-fulfill for XHR-captured dialogs, else native CDP.
Native path sends ``promptText`` only for prompt dialogs when
``prompt_text`` is given, and raises on CDP failure; the bridge path
(Fetch.fulfillRequest so the page unblocks) swallows failures.
"""
if dialog.bridge_request_id:
await self._fulfill_bridge_request(dialog, accept=accept, prompt_text=prompt_text or "")
else:
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
async def _native_handle_dialog(
self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]
) -> None:
"""Page.handleJavaScriptDialog; ``promptText`` sent only for prompt dialogs
when ``prompt_text`` is given. Raises on CDP failure."""
params: Dict[str, Any] = {"accept": accept}
if prompt_text is not None and dialog.type == "prompt":
params["promptText"] = prompt_text
await self._cdp(
"Page.handleJavaScriptDialog",
params,
session_id=dialog.cdp_session_id or None,
timeout=5.0,
)
async def _fulfill_bridge_request(
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
) -> None:
"""Resolve a bridge XHR via Fetch.fulfillRequest so the page unblocks."""
if not dialog.bridge_request_id:
return
payload = {
"accept": bool(accept),
"prompt_text": prompt_text if dialog.type == "prompt" else "",
"dialog_id": dialog.id,
}
body = json.dumps(payload).encode()
try:
await self._cdp(
body = json.dumps({
"accept": bool(accept),
"prompt_text": (prompt_text or "") if dialog.type == "prompt" else "",
"dialog_id": dialog.id,
}).encode()
await self._cdp_quiet(
"Fetch.fulfillRequest",
{
"requestId": dialog.bridge_request_id,
@@ -381,24 +303,23 @@ class DialogSupervisionMixin:
],
"body": base64.b64encode(body).decode(),
},
session_id=dialog.cdp_session_id or None,
timeout=5.0,
session_id=dialog.cdp_session_id or None, timeout=5.0, what=f"bridge fulfill {dialog.id}",
)
except Exception as e:
logger.debug("bridge fulfill failed for %s: %s", dialog.id, e)
return
params: Dict[str, Any] = {"accept": accept}
if prompt_text is not None and dialog.type == "prompt":
params["promptText"] = prompt_text
await self._cdp("Page.handleJavaScriptDialog", params,
session_id=dialog.cdp_session_id or None, timeout=5.0)
async def _auto_handle_dialog(
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
) -> None:
"""Auto-policy response for a native dialog (already archived by the caller)."""
async def _respond_quiet(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None:
"""Auto-policy / watchdog response (already archived by the caller); failures logged only."""
try:
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
await self._respond(dialog, accept=accept, prompt_text=prompt_text)
except Exception as e:
logger.debug("auto-handle CDP call failed for %s: %s", dialog.id, e)
logger.debug("auto response failed for %s: %s", dialog.id, e)
async def _handle_dialog_cdp(
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
) -> None:
async def _handle_dialog_cdp(self, dialog: PendingDialog, *, accept: bool, prompt_text: str) -> None:
"""Agent response path.
The dialog is retired regardless of outcome — a CDP error usually means
@@ -416,19 +337,13 @@ class DialogSupervisionMixin:
return
logger.warning(
"CDP supervisor %s: dialog %s (%s) auto-dismissed after %ss timeout",
self.task_id,
dialog_id,
dialog.type,
self.dialog_timeout_s,
self.task_id, dialog_id, dialog.type, self.dialog_timeout_s,
)
try:
# Archive with watchdog tag BEFORE unblocking the page.
with self._state_lock:
if self._pending_dialogs.pop(dialog_id, None) is not None:
self._archive_dialog_locked(dialog, "watchdog")
await self._respond(dialog, accept=False, prompt_text=None)
except Exception as e:
logger.debug("auto-dismiss failed for %s: %s", dialog_id, e)
# Archive with watchdog tag BEFORE unblocking the page.
with self._state_lock:
if self._pending_dialogs.pop(dialog_id, None) is not None:
self._archive_dialog_locked(dialog, "watchdog")
await self._respond_quiet(dialog, accept=False, prompt_text=None)
# ── Bookkeeping ──────────────────────────────────────────────────────────
@@ -444,29 +359,20 @@ class DialogSupervisionMixin:
def _archive_dialog_locked(self, dialog: PendingDialog, closed_by: str) -> None:
"""Move a pending dialog to the recent_dialogs ring buffer. Must hold state_lock."""
record = DialogRecord(
id=dialog.id,
type=dialog.type,
message=dialog.message,
opened_at=dialog.opened_at,
closed_at=time.time(),
closed_by=closed_by,
frame_id=dialog.frame_id,
)
self._recent_dialogs.append(record)
self._recent_dialogs.append(DialogRecord(
id=dialog.id, type=dialog.type, message=dialog.message, opened_at=dialog.opened_at,
closed_at=time.time(), closed_by=closed_by, frame_id=dialog.frame_id,
))
self._recent_dialogs = _trim_ring(self._recent_dialogs, RECENT_DIALOGS_MAX)
async def _on_dialog_closed(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
async def _on_dialog_closed(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
# ``Page.javascriptDialogClosed`` carries only ``result``/``userInput``, not
# the message. Match by session id and clear the oldest native dialog on
# it — the JS thread blocks while a dialog is up, so at most one is in
# flight per session. Bridge dialogs resolve via Fetch.fulfillRequest.
with self._state_lock:
candidate_ids = [
d.id
for d in self._pending_dialogs.values()
d.id for d in self._pending_dialogs.values()
if d.cdp_session_id == session_id and d.bridge_request_id is None
]
if candidate_ids:
+32 -70
View File
@@ -41,12 +41,7 @@ class FrameInfo:
name: str = ""
def to_dict(self) -> Dict[str, Any]:
d = {
"frame_id": self.frame_id,
"url": self.url,
"origin": self.origin,
"is_oopif": self.is_oopif,
}
d = {"frame_id": self.frame_id, "url": self.url, "origin": self.origin, "is_oopif": self.is_oopif}
if self.cdp_session_id:
d["session_id"] = self.cdp_session_id
if self.parent_frame_id:
@@ -63,49 +58,36 @@ class FrameTrackingMixin:
"""Page.enable + Runtime.enable + nested auto-attach on one session."""
await self._cdp("Page.enable", session_id=session_id, timeout=timeout)
await self._cdp("Runtime.enable", session_id=session_id, timeout=timeout)
await self._cdp(
"Target.setAutoAttach", _AUTO_ATTACH_PARAMS,
session_id=session_id, timeout=timeout,
)
await self._cdp("Target.setAutoAttach", _AUTO_ATTACH_PARAMS, session_id=session_id, timeout=timeout)
def _on_frame_attached(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
def _on_frame_attached(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
frame_id = params.get("frameId")
if not frame_id:
return
with self._state_lock:
self._frames[frame_id] = FrameInfo(
frame_id=frame_id,
url="",
origin="",
parent_frame_id=params.get("parentFrameId"),
is_oopif=False,
cdp_session_id=session_id,
frame_id=frame_id, url="", origin="", parent_frame_id=params.get("parentFrameId"),
is_oopif=False, cdp_session_id=session_id,
)
def _on_frame_navigated(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
def _on_frame_navigated(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
frame = params.get("frame") or {}
frame_id = frame.get("id")
if not frame_id:
return
with self._state_lock:
existing = self._frames.get(frame_id)
old = self._frames.get(frame_id)
self._frames[frame_id] = FrameInfo(
frame_id=frame_id,
url=str(frame.get("url") or ""),
origin=str(frame.get("securityOrigin") or frame.get("origin") or ""),
parent_frame_id=frame.get("parentId") or (existing.parent_frame_id if existing else None),
is_oopif=bool(existing.is_oopif if existing else False),
cdp_session_id=existing.cdp_session_id if existing else session_id,
name=str(frame.get("name") or (existing.name if existing else "")),
parent_frame_id=frame.get("parentId") or (old.parent_frame_id if old else None),
is_oopif=bool(old.is_oopif if old else False),
cdp_session_id=old.cdp_session_id if old else session_id,
name=str(frame.get("name") or (old.name if old else "")),
)
def _on_frame_detached(
self, params: Dict[str, Any], session_id: Optional[str]
) -> None:
def _on_frame_detached(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
"""Drop a frame only when it's truly gone.
``reason="swap"`` means the frame is migrating processes (e.g. promoted
@@ -115,14 +97,11 @@ class FrameTrackingMixin:
alive, so keep it until Target.detached + a later frameDetached clear it.
"""
frame_id = params.get("frameId")
if not frame_id:
return
reason = str(params.get("reason") or "remove").lower()
if reason == "swap":
if not frame_id or str(params.get("reason") or "remove").lower() == "swap":
return
with self._state_lock:
existing = self._frames.get(frame_id)
if existing and existing.is_oopif and existing.cdp_session_id:
old = self._frames.get(frame_id)
if old and old.is_oopif and old.cdp_session_id:
return
self._frames.pop(frame_id, None)
@@ -132,22 +111,17 @@ class FrameTrackingMixin:
target_type = info.get("type")
if not sid or target_type not in {"iframe", "worker"}:
return
# Record the frame with its OOPIF session id for interaction routing.
if target_type == "iframe":
# Record the frame with its OOPIF session id for interaction routing;
# origin is filled by frameNavigated on the child session.
target_id = info.get("targetId")
with self._state_lock:
existing = self._frames.get(target_id)
old = self._frames.get(target_id)
self._frames[target_id] = FrameInfo(
frame_id=target_id,
url=str(info.get("url") or ""),
origin="", # filled by frameNavigated on the child session
parent_frame_id=(existing.parent_frame_id if existing else None),
is_oopif=True,
cdp_session_id=sid,
name=str(info.get("title") or (existing.name if existing else "")),
frame_id=target_id, url=str(info.get("url") or ""), origin="",
parent_frame_id=(old.parent_frame_id if old else None), is_oopif=True,
cdp_session_id=sid, name=str(info.get("title") or (old.name if old else "")),
)
# Enable child domains off-loop: awaiting the replies here would deadlock
# because only the reader can resolve those Futures.
asyncio.create_task(self._enable_child_domains(sid))
@@ -178,25 +152,21 @@ class FrameTrackingMixin:
self._frames[fid] = replace(frame, cdp_session_id=None)
def _build_frame_tree_locked(self) -> Dict[str, Any]:
"""Build the capped frame_tree payload. Must be called under state lock."""
frames = self._frames
empty = {"top": None, "children": [], "truncated": False}
if not frames:
return empty
"""Build the capped frame_tree payload. Must be called under state lock.
# Top frame: one with no parent, preferring oopif=False.
Top frame = one with no parent, preferring oopif=False. BFS from it,
capped by FRAME_TREE_MAX_ENTRIES and FRAME_TREE_MAX_OOPIF_DEPTH for
OOPIF branches.
"""
frames = self._frames
tops = [f for f in frames.values() if not f.parent_frame_id]
top = next((f for f in tops if not f.is_oopif), tops[0] if tops else None)
if top is None:
return empty
return {"top": None, "children": [], "truncated": False}
# BFS from top, capped by FRAME_TREE_MAX_ENTRIES and
# FRAME_TREE_MAX_OOPIF_DEPTH for OOPIF branches.
children: List[Dict[str, Any]] = []
truncated = False
queue: List[Tuple[FrameInfo, int]] = [
(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id
]
queue: List[Tuple[FrameInfo, int]] = [(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id]
visited: set[str] = {top.frame_id}
while queue and len(children) < FRAME_TREE_MAX_ENTRIES:
frame, depth = queue.pop(0)
@@ -207,17 +177,9 @@ class FrameTrackingMixin:
truncated = True
continue
children.append(frame.to_dict())
for f in frames.values():
if f.parent_frame_id == frame.frame_id and f.frame_id not in visited:
queue.append((f, depth + 1))
if queue:
truncated = True
return {
"top": top.to_dict(),
"children": children,
"truncated": truncated,
}
queue.extend((f, depth + 1) for f in frames.values()
if f.parent_frame_id == frame.frame_id and f.frame_id not in visited)
return {"top": top.to_dict(), "children": children, "truncated": truncated or bool(queue)}
# CDP event → handler(self, params, session_id); merged into CDPSupervisor._EVENT_HANDLERS.
EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
+84 -112
View File
@@ -4,6 +4,7 @@ When browser.backend is "browser-use", the model gets ``browser_exec`` tool
instead of default browser tools
"""
import contextlib
import importlib
import json
import logging
@@ -91,14 +92,20 @@ _URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE)
_FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin")
def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any:
"""``fn()``, or ``default`` on any exception (debug-logged when ``log_prefix`` is set)."""
try:
return fn()
except Exception as e:
if log_prefix:
logger.debug("%s: %s", log_prefix, e)
return default
def _lazy_call(module: str, name: str, default: Any, log_prefix: str) -> Any:
"""Call ``module.name()`` resolved at call time (so tests can stub the module or
patch the attribute); on any failure log ``log_prefix`` and return ``default``."""
try:
return getattr(importlib.import_module(module), name)()
except Exception as e:
logger.debug("%s: %s", log_prefix, e)
return default
return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix)
def _camofox_active(context: str = "") -> bool:
@@ -106,6 +113,16 @@ def _camofox_active(context: str = "") -> bool:
return _lazy_call("tools.browser_camofox", "is_camofox_mode", False, f"Camofox activity check failed{context}")
def _real_profile_consented() -> bool:
"""Whether the user opted in to real-profile local browsing (config read)."""
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
def _lightpanda_engine_in_use() -> bool:
return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
"lightpanda engine status unavailable")[0]
def _set_cdp_env(env: dict, cdp: str) -> None:
"""Export a CDP endpoint under the BU_CDP_* contract (http(s) → URL, else WS)."""
env["BU_CDP_URL" if cdp.startswith(("http://", "https://")) else "BU_CDP_WS"] = cdp
@@ -142,9 +159,9 @@ def _blocked_url_in_code(code: str) -> Optional[str]:
def _base_subprocess_env() -> dict:
from tools.browser_tool import _build_browser_env
env = _build_browser_env()
# The CLI runs under its own Python (uv tool / uvx); inherited
# PYTHONPATH/PYTHONHOME (Hermes's venv) win over its site-packages → wrong-ABI
# C-extensions (pydantic_core) and a crash. Strip both.
# The CLI runs under its own Python (uv tool / uvx); inherited PYTHONPATH/PYTHONHOME
# (Hermes's venv) win over its site-packages → wrong-ABI C-extensions (pydantic_core)
# and a crash. Strip both.
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
env["PATH"] = _floor_subprocess_path(env.get("PATH", ""))
@@ -153,24 +170,18 @@ def _base_subprocess_env() -> dict:
def _floor_subprocess_path(path: str) -> str:
"""Guarantee core system dirs survive onto the CLI subprocess PATH.
Profile workers (kanban bots, cron) can inherit a PATH of only version-manager
dirs; the uv browser-use binary's POSIX sh trampoline resolves
``dirname``/``realpath`` through PATH and dies (exit 127) without /usr/bin.
Reuses browser_tool's ``_merge_browser_path`` floor, else appends FHS bin
dirs. Windows .cmd shims don't trampoline through PATH, so no-op there.
"""
"""Guarantee core system dirs survive onto the CLI subprocess PATH: profile workers
(kanban bots, cron) can inherit a PATH of only version-manager dirs, and the uv
browser-use binary's POSIX sh trampoline resolves ``dirname``/``realpath`` through
PATH and dies (exit 127) without /usr/bin. Reuses browser_tool's ``_merge_browser_path``
floor, else appends FHS bin dirs. Windows .cmd shims don't trampoline: no-op there."""
if os.name == "nt":
return path
try:
with contextlib.suppress(Exception):
from tools.browser_tool import _merge_browser_path
return _merge_browser_path(path or "")
except Exception:
pass
parts = [p for p in (path or "").split(os.pathsep) if p]
existing = set(parts)
parts.extend(d for d in _FHS_BIN_DIRS if d not in existing and os.path.isdir(d))
parts.extend(d for d in _FHS_BIN_DIRS if d not in set(parts) and os.path.isdir(d))
return os.pathsep.join(parts)
@@ -185,6 +196,10 @@ def _read_browser_cfg() -> dict:
return {}
def _use_gateway(browser_cfg: dict) -> bool:
return is_truthy_value(browser_cfg.get("use_gateway"), default=False)
def get_browser_backend() -> str:
"""Return the configured browser backend key ("" = unset → default).
@@ -198,16 +213,14 @@ def get_browser_backend() -> str:
def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
"""True for pre-CLI direct-API Browser Use cloud configs"""
"""True for pre-CLI direct-API Browser Use cloud configs. An explicit backend or
a non-Browser-Use cloud_provider wins; Camofox is selected via env var, not
cloud_provider, so a Camofox user with a stray BROWSER_USE_API_KEY keeps it."""
if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"):
return False # an explicit backend choice wins
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
if provider not in {"browser-use", ""}:
return False # explicit local/Browserbase/… choices win
if is_truthy_value(browser_cfg.get("use_gateway"), default=False):
return False
# Camofox is selected via env var, not cloud_provider — a Camofox user
# with a stray BROWSER_USE_API_KEY must keep their explicit choice.
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
if provider not in {"browser-use", ""} or _use_gateway(browser_cfg):
return False
if _camofox_active(" during migration"):
return False
return bool(os.getenv("BROWSER_USE_API_KEY"))
@@ -217,21 +230,17 @@ def is_browser_use_cli_mode() -> bool:
"""True when the Browser Use CLI replaces the built-in browser stack.
Browser Use mode is the DEFAULT: unset ``browser.backend`` ("") enables it
whenever the CLI is runnable (installed binary or uvx); ``browser.backend:
off`` (or ``/browser use off``) keeps the built-in browser_* tools. Camofox
always falls back to the built-in tools regardless — Firefox-based with a
custom HTTP API and no CDP surface, so the CDP-only harness cannot drive it.
whenever the CLI is runnable (installed binary or uvx), so browsing never
silently breaks; ``browser.backend: off`` (or ``/browser use off``) keeps the
built-in browser_* tools. Camofox always falls back to the built-in tools —
Firefox-based with a custom HTTP API and no CDP surface for the harness.
"""
if _camofox_active():
return False
backend = get_browser_backend()
if backend:
return backend == _BACKEND_KEY
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
return True
# Default (backend unset): Browser Use mode when the CLI can run at all;
# otherwise keep the built-in tools so browsing never silently breaks.
return _find_cli() is not None
return is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None
_NOTICE_STAMP_NAME = ".browser_use_default_notice"
@@ -246,16 +255,12 @@ def default_downgrade_notice() -> Optional[str]:
if get_browser_backend() or _camofox_active() or _find_cli() is not None:
return None # explicit choice / Camofox / CLI present — nothing downgraded
stamp = Path(get_hermes_home()) / "cache" / _NOTICE_STAMP_NAME
try:
with contextlib.suppress(OSError):
if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S:
return None
except OSError:
pass
try:
with contextlib.suppress(OSError):
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
except OSError:
pass
return ("Browser Use CLI not found — using the built-in browser tools. "
"Run `hermes tools` (Browser Automation → Browser Use) to install it, "
"or `browser.backend: off` in config.yaml to silence this.")
@@ -296,27 +301,25 @@ def _find_cli() -> Optional[List[str]]:
def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
"""Install the browser-use CLI persistently via ``uv tool install``
(managed uv via ``ensure_uv`` → uv on PATH), linking the binary into
``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for
every profile without touching the user's PATH. Returns ``(ok, message)``;
never raises.
"""Install the browser-use CLI persistently via ``uv tool install`` (managed uv
via ``ensure_uv`` → uv on PATH), linking the binary into ``$HERMES_HOME/bin``
(``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without
touching the user's PATH. Returns ``(ok, message)``; never raises.
MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH is a
user-level side install and must NOT prevent provisioning the canonical
Hermes-managed copy (version drift, no updates via hermes tools).
"""
# MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH
# is a user-level side install and must NOT prevent provisioning the
# canonical Hermes-managed copy (version drift, no updates via hermes tools).
bin_dir = _managed_bin_dir()
managed = shutil.which("browser-use", path=bin_dir)
if managed:
return True, f"browser-use CLI already installed ({managed})"
uv_bin: Optional[str] = None
try:
def _managed_uv() -> Optional[str]:
from hermes_cli.managed_uv import ensure_uv
uv_bin = str(ensure_uv() or "") or None
except Exception as e:
logger.debug("Managed uv bootstrap unavailable: %s", e)
uv_bin = uv_bin or shutil.which("uv")
return str(ensure_uv() or "") or None
uv_bin = _quiet(_managed_uv, None, "Managed uv bootstrap unavailable") or shutil.which("uv")
if not uv_bin:
return False, ("uv is not available and could not be bootstrapped. Install uv "
"(https://docs.astral.sh/uv/) and run `uv tool install browser-use`.")
@@ -385,9 +388,9 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic
if not _should_use_native_vision_fast_path():
return None
# History-reuse cap: this data URL bakes into the tool result and is
# re-sent every later turn — same policy as the vision_analyze /
# browser_vision native embeds (256 KB / 1568 px, JPEG quality ladder).
# History-reuse cap: this data URL bakes into the tool result and is re-sent
# every later turn — same policy as the vision_analyze / browser_vision native
# embeds (256 KB / 1568 px, JPEG quality ladder).
data_url = _resize_image_for_vision(
Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True,
@@ -418,13 +421,9 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str
private to this session and the own-tab preamble is skipped."""
try:
from tools.browser_tool import _get_session_info, _using_lightpanda_engine
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
logger.debug("browser_tool lightpanda resolution unavailable: %s", e)
return None
try:
if not _using_lightpanda_engine():
return None
except Exception as e:
except Exception as e: # stubbed browser_tool in tests / engine lookup failure
logger.debug("browser engine lookup failed: %s", e)
return None
err = _export_session_cdp(
@@ -460,29 +459,22 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
logger.debug("browser_tool backend resolution unavailable: %s", e)
return None
try:
override = _get_cdp_override()
except Exception:
override = ""
override = _quiet(_get_cdp_override, "")
if override:
_set_cdp_env(env, override)
return None
try:
provider = _get_cloud_provider()
except Exception as e:
logger.debug("Cloud provider lookup failed: %s", e)
provider = None
provider = _quiet(_get_cloud_provider, None, "Cloud provider lookup failed")
if provider is None:
return _resolve_lightpanda_cdp(env, task_id, session_name)
# Browser Use direct-API configs: the CLI talks to BU cloud natively
# (BU_AUTOSPAWN / auth login) — the legacy provider would create a second,
# redundant session. The Nous-gateway variant (use_gateway: true) DOES resolve
# through the provider: the gateway provisions the browser server-side and
# returns its CDP URL, giving subscribers CLI mode with no raw key.
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN /
# auth login) — the legacy provider would create a second, redundant session. The
# Nous-gateway variant (use_gateway: true) DOES resolve through the provider: the
# gateway provisions the browser server-side and returns its CDP URL, giving
# subscribers CLI mode with no raw key.
provider_key = str(getattr(provider, "name", "") or "").strip().lower()
if provider_key == _BACKEND_KEY and not is_truthy_value(_read_browser_cfg().get("use_gateway"), default=False):
if provider_key == _BACKEND_KEY and not _use_gateway(_read_browser_cfg()):
env[_PRIVATE_BROWSER_SENTINEL] = "1" # named BU cloud browsers are exclusive to their daemon
return None
@@ -501,11 +493,6 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
return err
def _real_profile_consented() -> bool:
"""Whether the user opted in to real-profile local browsing (config read)."""
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
"""Point the harness at the user's real-profile copy-browser when consented.
@@ -527,19 +514,14 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
logger.debug("real-profile backend resolution unavailable: %s", e)
return None
try:
if _get_cdp_override_raw():
return None
except Exception:
pass
if _quiet(_get_cdp_override_raw, ""):
return None
if not force_local:
# Only auto-upgrade genuinely-local attaches; any cloud path (provider or
# legacy BU cloud config) stays on its backend unless the model passes local=true.
try:
if _get_cloud_provider() is not None:
return None
except Exception:
# Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider
# lookup failure, or legacy BU cloud config) stays on its backend unless the model
# passes local=true.
if _quiet(_get_cloud_provider, object()) is not None:
return None
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
return None
@@ -576,14 +558,13 @@ def _windows_popen_kwargs() -> dict:
"""Hide the console the .cmd shim would flash on Windows (as browser_tool does)."""
if os.name != "nt":
return {}
try:
def _flags() -> dict:
from hermes_cli._subprocess_compat import windows_hide_flags
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return {"creationflags": windows_hide_flags(), "startupinfo": si}
except Exception as e:
logger.debug("Windows hide-flags unavailable: %s", e)
return {}
return _quiet(_flags, {}, "Windows hide-flags unavailable")
def _clamp_timeout(timeout_s: Any) -> int:
@@ -746,21 +727,12 @@ _HELPERS_DIGEST = (
# lives with the session, not in the process-wide TTL-cached check_fn.
def _lightpanda_engine_in_use() -> bool:
return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
"lightpanda engine status unavailable")[0]
def _description_header() -> str:
"""Header tailored to whether the active model can see images natively"""
if _lightpanda_engine_in_use(): # no screenshots at all, whatever the model can see
return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA
try:
from tools.vision_tools import _should_use_native_vision_fast_path
if _should_use_native_vision_fast_path():
return _HEADER_BASE + _HEADER_VISION
except Exception:
pass
if _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, ""):
return _HEADER_BASE + _HEADER_VISION
return _HEADER_BASE + _HEADER_TEXT_ONLY