Browser CDP Supervisor

CDP supervisor закрывает два застарелых пробела в браузерном инструментарии Hermes:

  1. Нативные JS-диалоги (alert/confirm/prompt/beforeunload) блокируют поток JS на странице. Без мониторинга агент не знает, что диалог открыт — последующие вызовы инструментов зависают или бросают непрозрачные ошибки.
  2. Кросс-доменные iframe (OOPIFs) невидимы для Runtime.evaluate верхнего уровня. Узлы iframe в снимке DOM агенту видны, но кликнуть, напечатать или выполнить eval внутри них он не может без CDP-сессии, прикреплённой к дочернему таргету.

Обе задачи supervisor решает так: держит постоянный WebSocket к CDP-эндпоинту бэкенда на каждую браузерную задачу, отражает ожидающие диалоги и структуру фреймов в browser_snapshot, а для явных ответов на диалоги даёт инструмент browser_dialog.

Поддержка бэкендов

БэкендОбнаружение диалоговОтвет на диалогДерево фреймовOOPIF Runtime.evaluate через browser_cdp(frame_id=...)
Локальный Chrome (--remote-debugging-port) / /browser connect✓ полный воркфлоу
Browserbase✓ (через мост)✓ полный воркфлоу (через мост)
Camofox✗ нет CDP (только REST)частично через снимок DOM

Особенность Browserbase. CDP-прокси Browserbase использует Playwright внутри и автоматически закрывает нативные диалоги примерно за 10 мс, поэтому Page.handleJavaScriptDialog не успевает среагировать. Supervisor инжектирует мостовой скрипт через Page.addScriptToEvaluateOnNewDocument, который перекрывает window.alert/confirm/prompt синхронным XHR к магическому хосту (hermes-dialog-bridge.invalid). Fetch.enable перехватывает эти XHR до того, как они уходят в сеть — диалог превращается в событие Fetch.requestPaused, которое supervisor захватывает, а respond_to_dialog выполняет через Fetch.fulfillRequest с JSON-телом, которое декодирует инжектированный скрипт.

Для страницы prompt() по-прежнему возвращает строку, заданную агентом. Со стороны агента в обоих случаях это один и тот же API browser_dialog(action=...).

Camofox не поддерживается — нет CDP-поверхности, только REST.

Архитектура

CDPSupervisor

Один asyncio.Task в фоновом daemon-потоке на каждый task_id в Hermes. Держит постоянный WebSocket к CDP-эндпоинту бэкенда. Хранит:

  • Очередь диалоговList[PendingDialog] с полями {id, type, message, default_prompt, session_id, opened_at}
  • Дерево фреймовDict[frame_id, FrameInfo] с отношениями родитель-потомок, URL, origin и признаком кросс-доменной дочерней сессии
  • Карту сессийDict[session_id, SessionInfo], чтобы инструменты взаимодействия маршрутизировали вызовы в правильную прикреплённую сессию для OOPIF-операций
  • Последние ошибки консоли — кольцевой буфер из последних 50 записей для диагностики

Подписки при подключении:

  • Page.enablejavascriptDialogOpening, frameAttached, frameNavigated, frameDetached
  • Runtime.enableexecutionContextCreated, consoleAPICalled, exceptionThrown
  • Target.setAutoAttach {autoAttach: true, flatten: true} — обнаруживает дочерние OOPIF-таргеты; на каждом из них supervisor включает Page+Runtime

Доступ к состоянию потокобезопасен через snapshot-блокировку; синхронные обработчики инструментов читают замороженный снимок без await.

Жизненный цикл

  • Запуск: SupervisorRegistry.get_or_start(task_id, cdp_url) — вызывается из browser_navigate, при создании сессии Browserbase, через /browser connect. Идемпотентен.
  • Остановка: при завершении сессии или через /browser disconnect. Отменяет asyncio-задачу, закрывает WebSocket, сбрасывает состояние.
  • Переподключение: при смене CDP URL (пользователь подключается к новому Chrome) старый supervisor останавливается и запускается свежий — состояние между эндпоинтами не переносится никогда.

Политика диалогов

Настраивается в config.yaml в разделе browser.dialog_policy:

  • must_respond (по умолчанию) — захватить, отразить в browser_snapshot, ждать явного вызова browser_dialog(action=...). После 300 секунд без ответа срабатывает защитный таймаут: диалог автоматически закрывается с записью в лог. Это страховка от зависания агента с багом.
  • auto_dismiss — зафиксировать и сразу закрыть; диалог агент видит постфактум через browser_state внутри browser_snapshot.
  • auto_accept — зафиксировать и принять (удобно для beforeunload, когда воркфлоу нужно корректно уйти со страницы).

Политика задаётся на уровне задачи; переопределений для отдельных диалогов нет.

Поверхность для агента

Инструмент browser_dialog

browser_dialog(action, prompt_text=None, dialog_id=None)
  • action="accept" / "dismiss" → отвечает на указанный или единственный ожидающий диалог (обязательный параметр)
  • prompt_text=... → текст для диалога prompt()
  • dialog_id=... → уточняет выбор, если в очереди несколько диалогов (редкий случай)

Инструмент работает только на ответ. Ожидающие диалоги агент читает из вывода browser_snapshot перед вызовом.

Расширение browser_snapshot

При подключённом supervisor к существующему выводу снимка добавляются три опциональных поля:

{
  "pending_dialogs": [
    {"id": "d-1", "type": "alert", "message": "Hello", "opened_at": 1650000000.0}
  ],
  "recent_dialogs": [
    {"id": "d-1", "type": "alert", "message": "...", "opened_at": 1650000000.0,
     "closed_at": 1650000000.1, "closed_by": "remote"}
  ],
  "frame_tree": {
    "top": {"frame_id": "FRAME_A", "url": "https://example.com/", "origin": "https://example.com"},
    "children": [
      {"frame_id": "FRAME_B", "url": "about:srcdoc", "is_oopif": false},
      {"frame_id": "FRAME_C", "url": "https://ads.example.net/", "is_oopif": true, "session_id": "SID_C"}
    ],
    "truncated": false
  }
}
  • pending_dialogs — диалоги, которые прямо сейчас блокируют поток JS страницы. Для ответа агент обязан вызвать browser_dialog(action=...). На Browserbase всегда пусто, поскольку CDP-прокси закрывает диалоги за ~10 мс.

  • recent_dialogs — кольцевой буфер до 20 недавно закрытых диалогов с тегом closed_by: "agent" (мы ответили), "auto_policy" (локальный auto_dismiss/auto_accept), "watchdog" (сработал таймаут must_respond) или "remote" (браузер/бэкенд закрыл сам, например Browserbase). Так агенты на Browserbase всё же узнают, что произошло.

  • frame_tree — структура фреймов, включая кросс-доменных (OOPIF) потомков. Ограничена 30 записями и глубиной OOPIF 2, чтобы сдержать размер снимка на страницах с обилием рекламы. При достижении лимитов выставляется truncated: true; если агенту нужно полное дерево, он может использовать browser_cdp с Page.getFrameTree.

Отдельной схемы инструментов ни одно из этих полей не вводит — агент читает снимок, который уже запрашивает.

Условия доступности

Обе поверхности зависят от _browser_cdp_check (supervisor запускается только при наличии доступного CDP-эндпоинта). На Camofox и в сессиях без бэкенда инструмент диалога скрывается, а снимок не включает новые поля — схема не раздувается.

Взаимодействие с кросс-доменными iframe

browser_cdp(frame_id=...) направляет CDP-вызовы (прежде всего Runtime.evaluate) через уже подключённый WebSocket supervisor, используя дочерний sessionId OOPIF. Frame_id агенты берут из browser_snapshot.frame_tree.children[], где is_oopif=true, и передают их в browser_cdp. Для однодоменных iframe (без отдельной CDP-сессии) агент использует contentWindow/contentDocument из Runtime.evaluate верхнего уровня — если же frame_id принадлежит не-OOPIF, supervisor выводит ошибку с подсказкой про этот вариант.

На Browserbase это единственный надёжный путь для взаимодействия с iframe: соединения CDP без состояния (открываемые на каждый вызов browser_cdp) упираются в истечение срока подписанного URL, тогда как долгоживущее соединение supervisor удерживает валидную сессию.

Расположение файлов

  • tools/browser_supervisor.pyCDPSupervisor, SupervisorRegistry, PendingDialog, FrameInfo
  • tools/browser_dialog_tool.py — обработчик инструмента browser_dialog
  • tools/browser_tool.py — хук запуска в browser_navigate, слияние в browser_snapshot, переподключение через /browser connect, завершение в _cleanup_browser_session
  • toolsets.py — регистрирует browser_dialog в наборах инструментов browser, hermes-acp, hermes-api-server и core (с проверкой доступности CDP)
  • hermes_cli/config.py — значения по умолчанию для browser.dialog_policy и browser.dialog_timeout_s

За рамками задачи

  • Обнаружение и взаимодействие для Camofox (пробел на стороне upstream; отслеживается отдельно)
  • Потоковая передача событий диалогов и фреймов пользователю в реальном времени (потребовала бы хуков шлюза)
  • Сохранение истории диалогов между сессиями (только в памяти)
  • Политики диалогов на уровне отдельного iframe (агент может выразить это через dialog_id)
  • Замена browser_cdp — он остаётся аварийным выходом для длинного хвоста случаев (куки, viewport, троттлинг сети)

Тестирование

Юнит-тесты (tests/tools/test_browser_supervisor.py) используют asyncio mock-сервер CDP, реализующий ровно столько протокола, сколько нужно для проверки всех переходов состояний: подключение, enable, навигация, открытие диалога, закрытие диалога, прикрепление/отсоединение фрейма, подключение дочернего таргета, завершение сессии. E2E с реальным бэкендом (Browserbase + локальный браузер на базе Chromium) — ручное тестирование: подключитесь через /browser connect к живому браузеру на базе Chromium и прогоните тест-кейсы для диалогов и фреймов, описанные выше.

ESC