Files
hermes-agent/tools/browser_supervisor.py

733 lines
29 KiB
Python

"""Persistent CDP supervisor for browser dialog + frame detection.
One ``CDPSupervisor`` runs per Hermes ``task_id`` with a reachable CDP endpoint.
It holds one persistent WebSocket, subscribes to ``Page`` / ``Runtime`` /
``Target`` events on every attached session (top page + auto-attached OOPIF /
worker targets), and exposes pending dialogs + frame tree through a
thread-safe snapshot that tool handlers read synchronously.
Not in the agent's tool schema. Output reaches the agent via
``browser_snapshot`` (merges supervisor state, see ``tools/browser_tool.py``)
and ``browser_dialog`` (calls ``respond_to_dialog()``).
Design spec: ``website/docs/developer-guide/browser-supervisor.md``.
Dialog capture lives in ``tools.browser_supervisor_dialogs``, frame tracking in
``tools.browser_supervisor_frames``; both are mixed into ``CDPSupervisor`` and
their public names are re-exported here.
"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
from tools.browser_supervisor_dialogs import ( # noqa: F401 — re-exported
DEFAULT_DIALOG_POLICY,
DEFAULT_DIALOG_TIMEOUT_S,
DIALOG_BRIDGE_HOST,
DIALOG_BRIDGE_URL_PATTERN,
DIALOG_POLICY_AUTO_ACCEPT,
DIALOG_POLICY_AUTO_DISMISS,
DIALOG_POLICY_MUST_RESPOND,
RECENT_DIALOGS_MAX,
_DIALOG_BRIDGE_SCRIPT,
_VALID_POLICIES,
DialogRecord,
DialogSupervisionMixin,
PendingDialog,
_redact_supervisor_text,
_trim_ring,
)
from tools.browser_supervisor_frames import ( # noqa: F401 — re-exported
FRAME_TREE_MAX_ENTRIES,
FRAME_TREE_MAX_OOPIF_DEPTH,
FrameInfo,
FrameTrackingMixin,
)
# ``websockets`` costs ~22 ms at import and is only needed once a supervisor
# connects; with postponed annotations the type import stays under TYPE_CHECKING.
if TYPE_CHECKING:
from websockets.asyncio.client import ClientConnection
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.
``websockets`` bakes the raw target URL (``?token=`` / ``user:pass@``) into
its exception messages. Every egress point that turns such an exception into
log text or a re-raised message MUST route through here; falls back to a
fixed sentinel if redaction itself raises, erring toward masking.
"""
try:
from agent.redact import redact_cdp_url
return redact_cdp_url(str(exc))
except Exception:
return "<error redacted>"
class _LoopUnavailable(RuntimeError):
"""The supervisor loop refused new work (closed / shutting down)."""
def _schedule(coro, loop, *, timeout: float):
"""Run ``coro`` on the supervisor loop from a sync caller and wait for its result."""
from agent.async_utils import safe_schedule_threadsafe
fut = safe_schedule_threadsafe(coro, loop)
if fut is None:
raise _LoopUnavailable("Browser supervisor loop unavailable")
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
@dataclass(frozen=True)
class SupervisorSnapshot:
"""Read-only (frozen) snapshot of supervisor state for tool handlers."""
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
def to_dict(self) -> Dict[str, Any]:
"""Serialize for inclusion in ``browser_snapshot`` output."""
out: Dict[str, Any] = {
"pending_dialogs": [d.to_dict() for d in self.pending_dialogs],
"frame_tree": self.frame_tree,
}
if self.recent_dialogs:
out["recent_dialogs"] = [d.to_dict() for d in self.recent_dialogs]
return out
# ── Supervisor core ───────────────────────────────────────────────────────────
class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
"""One supervisor per (task_id, cdp_url) pair.
``start()`` spawns a daemon thread running its own asyncio loop, connects,
attaches to the first page target, enables domains and auto-attach.
``snapshot()`` / ``respond_to_dialog()`` / ``evaluate_runtime()`` are sync,
thread-safe bridges onto that loop; ``stop()`` tears it down. All CDP I/O
lives on the supervisor's own loop.
"""
def __init__(
self,
task_id: str,
cdp_url: str,
*,
dialog_policy: str = DEFAULT_DIALOG_POLICY,
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S,
) -> None:
if dialog_policy not in _VALID_POLICIES:
raise ValueError(
f"Invalid dialog_policy {dialog_policy!r}; "
f"must be one of {sorted(_VALID_POLICIES)}"
)
self.task_id = task_id
self.cdp_url = cdp_url
self.dialog_policy = dialog_policy
self.dialog_timeout_s = float(dialog_timeout_s)
# State protected by ``_state_lock`` for cross-thread reads.
self._state_lock = threading.Lock()
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().
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._thread: Optional[threading.Thread] = None
self._ready_event = threading.Event()
self._start_error: Optional[BaseException] = None
self._stop_requested = False
# CDP call tracking (runs on supervisor loop only).
self._next_call_id = 1
self._pending_calls: Dict[int, asyncio.Future] = {}
self._ws: Optional[ClientConnection] = None
self._page_session_id: Optional[str] = None
# Dialog auto-dismiss watchdog handles (per dialog id) + id generator.
self._dialog_watchdogs: Dict[str, asyncio.TimerHandle] = {}
self._dialog_seq = 0
# ── Public sync API ──────────────────────────────────────────────────────
def start(self, timeout: float = 15.0) -> None:
"""Launch the background loop and block until attachment completes.
Raises whatever attach failed with (redacted). On return, dialog events
are already being captured.
"""
if self._thread and self._thread.is_alive():
return
self._ready_event.clear()
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,
)
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]}...)"
)
if self._start_error is not None:
err = self._start_error
self.stop()
# ``err`` is a raw ``websockets`` exception embedding the full cdp_url
# (token / userinfo). Re-raise redacted and suppress the cause
# (``from None``) so nothing leaks via message OR traceback chain.
raise RuntimeError(
f"CDP supervisor failed to start: {_redact_cdp_error_text(err)}"
) from None
def stop(self, timeout: float = 5.0) -> None:
"""Cancel the supervisor task and join the thread."""
self._stop_requested = True
loop = self._loop
if loop is not None and loop.is_running():
# Close the WebSocket from inside the loop so ``async for raw in
# self._ws`` returns cleanly, ``_run`` hits its ``finally``, pending
# tasks cancel in order, THEN the thread exits.
try:
_schedule(self._close_ws(), loop, timeout=2.0)
except Exception:
pass # loop already shutting down / close timed out
if self._thread is not None:
self._thread.join(timeout=timeout)
with self._state_lock:
self._active = False
def snapshot(self) -> SupervisorSnapshot:
"""Return an immutable snapshot of current state."""
with self._state_lock:
return SupervisorSnapshot(
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,
)
def respond_to_dialog(
self,
action: str,
*,
prompt_text: Optional[str] = None,
dialog_id: Optional[str] = None,
timeout: float = 10.0,
) -> Dict[str, Any]:
"""Accept/dismiss a pending dialog (sync bridge onto the supervisor loop).
Returns ``{"ok": True, "dialog": {...}}`` or ``{"ok": False, "error": ...}``
for recoverable errors (no dialog, ambiguous dialog_id, inactive).
"""
if action not in {"accept", "dismiss"}:
return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"}
with self._state_lock:
if not self._active:
return {"ok": False, "error": "supervisor is not active"}
pending = list(self._pending_dialogs.values())
if not pending:
return {"ok": False, "error": "no dialog is currently open"}
if dialog_id:
dialog = self._pending_dialogs.get(dialog_id)
if dialog is None:
return {
"ok": False,
"error": f"dialog_id {dialog_id!r} not found "
f"(known: {sorted(self._pending_dialogs)})",
}
elif len(pending) > 1:
return {
"ok": False,
"error": (
f"{len(pending)} pending dialogs; specify dialog_id. "
f"Candidates: {[d.id for d in pending]}"
),
}
else:
dialog = pending[0]
loop = self._loop
if loop is None:
return {"ok": False, "error": "supervisor loop is not running"}
try:
_schedule(
self._handle_dialog_cdp(
dialog, accept=(action == "accept"), prompt_text=prompt_text or ""
),
loop,
timeout=timeout,
)
except _LoopUnavailable as e:
return {"ok": False, "error": str(e)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "dialog": dialog.to_dict()}
def evaluate_runtime(
self,
expression: str,
*,
return_by_value: bool = True,
await_promise: bool = True,
timeout: float = 10.0,
) -> Dict[str, Any]:
"""Evaluate ``expression`` in the page's Runtime context over the live WS.
Zero subprocess cost vs the agent-browser CLI ``eval``. Returns
``{"ok": True, "result": <value>, "result_type": ...}`` or
``{"ok": False, "error": ...}``. ``return_by_value=True`` JSON-serializes
the result (DevTools-console semantics); non-serializable objects come
back as a description string.
"""
loop = self._loop
if loop is None or not loop.is_running():
return {"ok": False, "error": "supervisor loop is not running"}
with self._state_lock:
if not self._active:
return {"ok": False, "error": "supervisor is not active"}
session_id = self._page_session_id
if not session_id:
return {"ok": False, "error": "supervisor has no attached page session"}
def _run_eval(by_value: bool) -> Dict[str, Any]:
coro = self._cdp(
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": by_value,
"awaitPromise": await_promise,
# userGesture: clipboard / fullscreen APIs need user activation.
"userGesture": True,
},
session_id=session_id,
timeout=timeout,
)
return _schedule(coro, loop, timeout=timeout + 1)
try:
response = _run_eval(return_by_value)
except Exception as exc:
# Deep-serializing live DOM nodes / NodeLists / Window can blow past
# 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}"}
# Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}}
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
exception_details = result_payload.get("exceptionDetails")
if exception_details:
exc_text = exception_details.get("text") or "JavaScript exception"
description = (exception_details.get("exception") or {}).get("description")
if description:
exc_text = f"{exc_text}: {description}"
return {"ok": False, "error": exc_text}
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":
value = None
else:
# 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 ────────────────────────────────────────────
def _thread_main(self) -> None:
"""Entry point for the supervisor's dedicated thread."""
loop = asyncio.new_event_loop()
self._loop = loop
try:
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:
logger.warning("CDP supervisor %s crashed: %s", self.task_id, e)
finally:
# 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()]
for t in pending:
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
async def _close_ws(self) -> None:
"""Detach and close the current WebSocket, swallowing close errors."""
ws = self._ws
self._ws = None
if ws is not None:
try:
await ws.close()
except Exception:
pass
async def _run(self) -> None:
"""Top-level reconnecting supervisor coroutine.
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.
"""
attempt = 0
last_success_at = 0.0
backoff = 0.5
import websockets # deferred: only supervisors that connect pay the import
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,
)
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()
return
logger.warning(
"CDP supervisor %s: connect failed (attempt %s): %s",
self.task_id, attempt, _redact_cdp_error_text(e),
)
await asyncio.sleep(min(backoff, 10.0))
backoff = min(backoff * 2, 10.0)
continue
reader_task = asyncio.create_task(self._read_loop(), name="cdp-reader")
try:
# Reset the per-connection page session id. ``_pending_dialogs``
# and ``_frames`` are deliberately kept — they reconcile as fresh
# events arrive; worst case a stale dialog entry is rejected
# with "no dialog is showing" (logged, not surfaced).
self._page_session_id = None
await self._attach_initial_page()
with self._state_lock:
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()
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()
raise
logger.warning(
"CDP supervisor %s: session dropped after %.1fs: %s",
self.task_id,
time.time() - last_success_at,
_redact_cdp_error_text(e),
)
finally:
with self._state_lock:
self._active = False
if not reader_task.done():
reader_task.cancel()
try:
await reader_task
except (asyncio.CancelledError, Exception):
pass
for handle in list(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,
)
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."""
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)
if page_target is None:
created = await self._cdp("Target.createTarget", {"url": "about:blank"})
target_id = created["result"]["targetId"]
else:
target_id = page_target["targetId"]
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)
async def _cdp(
self,
method: str,
params: Optional[Dict[str, Any]] = None,
*,
session_id: Optional[str] = None,
timeout: float = 10.0,
) -> Dict[str, Any]:
"""Send a CDP command and await its response."""
if self._ws is None:
raise RuntimeError("supervisor WebSocket is not connected")
call_id = self._next_call_id
self._next_call_id += 1
payload: Dict[str, Any] = {"id": call_id, "method": method}
if params:
payload["params"] = params
if session_id:
payload["sessionId"] = session_id
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending_calls[call_id] = fut
await self._ws.send(json.dumps(payload))
try:
return await asyncio.wait_for(fut, timeout=timeout)
finally:
self._pending_calls.pop(call_id, None)
async def _read_loop(self) -> None:
"""Continuously dispatch incoming CDP frames."""
assert self._ws is not None
try:
async for raw in self._ws:
if self._stop_requested:
break
try:
msg = json.loads(raw)
except Exception:
logger.debug("CDP supervisor: non-JSON frame dropped")
continue
if "id" in msg:
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']}")
)
else:
fut.set_result(msg)
elif "method" in msg:
await self._on_event(msg["method"], msg.get("params", {}), msg.get("sessionId"))
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.
_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"),
}
# ── Registry ─────────────────────────────────────────────────────────────────
class _SupervisorRegistry:
"""Process-global (task_id → supervisor) map with idempotent start/stop.
One instance, exposed as ``SUPERVISOR_REGISTRY``; mutations go through ``_lock``.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._by_task: Dict[str, CDPSupervisor] = {}
def get(self, task_id: str) -> Optional[CDPSupervisor]:
"""Return the supervisor for ``task_id`` if running, else ``None``."""
with self._lock:
return self._by_task.get(task_id)
def get_or_start(
self,
task_id: str,
cdp_url: str,
*,
dialog_policy: str = DEFAULT_DIALOG_POLICY,
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S,
start_timeout: float = 15.0,
) -> 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.
"""
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.
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,
)
supervisor.start(timeout=start_timeout)
with self._lock:
# Guard against a concurrent get_or_start from another thread.
already = self._by_task.get(task_id)
if already is not None and already.cdp_url == cdp_url:
supervisor.stop()
return already
self._by_task[task_id] = supervisor
return supervisor
def stop(self, task_id: str) -> None:
"""Stop and discard the supervisor for ``task_id`` if it exists."""
with self._lock:
supervisor = self._by_task.pop(task_id, None)
if supervisor is not None:
supervisor.stop()
def stop_all(self) -> None:
"""Stop every running supervisor. For shutdown / test teardown."""
with self._lock:
items = list(self._by_task.items())
self._by_task.clear()
for _, supervisor in items:
supervisor.stop()
SUPERVISOR_REGISTRY = _SupervisorRegistry()
__all__ = [
"CDPSupervisor",
"ConsoleEvent",
"DEFAULT_DIALOG_POLICY",
"DEFAULT_DIALOG_TIMEOUT_S",
"DIALOG_POLICY_AUTO_ACCEPT",
"DIALOG_POLICY_AUTO_DISMISS",
"DIALOG_POLICY_MUST_RESPOND",
"DialogRecord",
"FrameInfo",
"PendingDialog",
"SUPERVISOR_REGISTRY",
"SupervisorSnapshot",
"_SupervisorRegistry",
]