ブラウザの CDP スーパーバイザ
目次
CDP スーパーバイザは、Hermes のブラウザ操作に長く残っていた 2 つの穴をふさぎます。
- JavaScript のネイティブなダイアログ(
alert/confirm/prompt/beforeunload)は、
ページの JavaScript の処理を止めてしまいます。見張るしくみがないと、エージェントは ダイアログが開いていることに気づけず、そのあとのツール呼び出しが固まるか、 理由の分からないエラーになります。
- 別オリジンの iframe(OOPIF) は、最上位の
Runtime.evaluateからは見えません。
エージェントは DOM のスナップショットで iframe のノードを見ることはできますが、 子ターゲットに CDP のセッションをつながないかぎり、その中でクリックも入力も 評価もできません。
スーパーバイザは、ブラウザのタスクごとにバックエンドの CDP エンドポイントへ WebSocket を つなぎっぱなしにし、待機中のダイアログとフレームの構造を browser_snapshot に載せ、 明示的に応答するための browser_dialog ツールを用意することで、この両方を解決します。
対応している実行基盤
| 実行基盤 | ダイアログの検出 | ダイアログへの応答 | フレームツリー | browser_cdp(frame_id=...) 経由の OOPIF での Runtime.evaluate |
|---|---|---|---|---|
ローカルの Chrome(--remote-debugging-port)/ /browser connect |
✓ | ✓ ひととおり対応 | ✓ | ✓ |
| Browserbase | ✓(ブリッジ経由) | ✓ ひととおり対応(ブリッジ経由) | ✓ | ✓ |
| Camofox | ✗ CDP なし(REST のみ) | ✗ | DOM スナップショットで部分的に | ✗ |
Browserbase のくせ。 Browserbase の CDP プロキシは内部で Playwright を使っていて、 ネイティブなダイアログを 10ms ほどで自動的に閉じてしまうため、Page.handleJavaScriptDialog では間に合いません。そこでスーパーバイザは、Page.addScriptToEvaluateOnNewDocument で ブリッジ用のスクリプトを差し込み、window.alert/confirm/prompt を、専用のホスト名 (hermes-dialog-bridge.invalid)へ同期の XHR を投げるものに置き換えます。Fetch.enable が その XHR をネットワークに出る前に横取りするので、ダイアログはスーパーバイザが捕まえられる Fetch.requestPaused のイベントになり、respond_to_dialog が Fetch.fulfillRequest で JSON の本文を返して、差し込んだスクリプトがそれを読み取ります。
ページから見れば、prompt() はこれまでどおりエージェントが渡した文字列を返します。 エージェントから見れば、どちらの場合も同じ browser_dialog(action=...) の 使い方になります。
Camofox は対象外です。CDP の口がなく、REST のみだからです。
構成
CDPSupervisor
Hermes の task_id ごとに、バックグラウンドのデーモンスレッドで asyncio.Task を 1 つ動かします。 バックエンドの CDP エンドポイントへの WebSocket をつないだまま保ち、次のものを管理します。
- ダイアログの待ち行列 —
{id, type, message, default_prompt, session_id, opened_at}を持つList[PendingDialog] - フレームツリー —
Dict[frame_id, FrameInfo]。親子関係、URL、オリジン、別オリジンの子セッションかどうかを持ちます - セッションの対応表 —
Dict[session_id, SessionInfo]。OOPIF を操作するとき、ツールが正しい接続済みセッションへ振り分けられるようにします - 直近のコンソールエラー — 診断用に直近 50 件をためるリングバッファ
接続時に購読するものは次のとおりです。
Page.enable—javascriptDialogOpening、frameAttached、frameNavigated、frameDetachedRuntime.enable—executionContextCreated、consoleAPICalled、exceptionThrownTarget.setAutoAttach {autoAttach: true, flatten: true}— 子の OOPIF ターゲットを表に出します。スーパーバイザはそれぞれでPageとRuntimeを有効にします
状態への読み書きはスナップショット用のロックで守られていて、同期で動くツールのハンドラは 待たずに凍結されたスナップショットを読めます。
起動から終了まで
- 開始:
SupervisorRegistry.get_or_start(task_id, cdp_url)。browser_navigate、
Browserbase のセッション作成、/browser connect から呼ばれます。 何度呼んでも結果は同じです。
- 停止: セッションの片づけ、または
/browser disconnectのとき。asyncio の
タスクを取り消し、WebSocket を閉じ、状態を捨てます。
- つなぎ直し: CDP の URL が変わったとき(利用者が別の Chrome につなぎ直したときなど)は、
古いスーパーバイザを止めて新しいものを立ち上げます。エンドポイントをまたいで 状態が使い回されることはありません。
ダイアログの扱い方
config.yaml の browser.dialog_policy で設定します。
must_respond(既定)— ダイアログを捕まえてbrowser_snapshotに載せ、
browser_dialog(action=...) が明示的に呼ばれるのを待ちます。安全のため 300 秒待っても 応答がなければ、自動で閉じてログに残します。動きのおかしいエージェントが いつまでも止まったままになるのを防ぎます。
auto_dismiss— 記録したうえですぐ閉じます。エージェントは、あとから
browser_snapshot の中の browser_state で知ることになります。
auto_accept— 記録したうえで承諾します(beforeunloadで、そのまま
きれいに次のページへ移りたいときに便利です)。
扱い方はタスク単位で決まり、ダイアログごとに上書きすることはできません。
エージェントから見える部分
browser_dialog ツール
browser_dialog(action, prompt_text=None, dialog_id=None)action="accept"/"dismiss"→ 指定したダイアログ、または待機中のただ 1 つのダイアログに応答します(必須)prompt_text=...→prompt()のダイアログに渡す文字列dialog_id=...→ ダイアログが複数たまっているときに、どれかを指定します(まれです)
このツールは応答するためだけのものです。エージェントは呼ぶ前に、待機中のダイアログを browser_snapshot の出力から読み取ります。
browser_snapshot の拡張
スーパーバイザがつながっているとき、これまでのスナップショットの出力に 3 つの項目が加わります。
{
"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— 今まさにページの JavaScript の処理を止めているダイアログです。
エージェントは browser_dialog(action=...) を呼んで応答する必要があります。Browserbase では CDP プロキシが 10ms ほどで自動的に閉じてしまうため、ここは空になります。
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(CDP のエンドポイントに届くときだけ スーパーバイザは動けます)で切り分けられます。Camofox や実行基盤のないセッションでは、 ダイアログのツールは表に出ず、スナップショットにも新しい項目は載りません。 スキーマが無駄に膨らむこともありません。
別オリジンの iframe の操作
browser_cdp(frame_id=...) は、CDP の呼び出し(とくに Runtime.evaluate)を、 スーパーバイザがすでにつないでいる WebSocket と OOPIF の子の sessionId を通して 振り分けます。エージェントは browser_snapshot.frame_tree.children[] のうち is_oopif=true のものから frame_id を拾い、browser_cdp に渡します。同一オリジンの iframe(専用の CDP セッションがないもの)では、最上位の Runtime.evaluate から contentWindow / contentDocument を使います。frame_id が OOPIF でないものを 指していた場合、スーパーバイザはその代わりの手を示すエラーを返します。
Browserbase では、iframe を操作する確実な方法はこれだけです。browser_cdp の呼び出しごとに 開く使い捨ての CDP 接続は、署名付き URL の期限切れに引っかかりますが、スーパーバイザの 長くつないだ接続なら有効なセッションを保てます。
ファイルの配置
tools/browser_supervisor.py—CDPSupervisor、SupervisorRegistry、PendingDialog、FrameInfotools/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、およびコアのツールセットに登録(CDP に届くかどうかで切り分け)hermes_cli/config.py—browser.dialog_policyとbrowser.dialog_timeout_sの既定値
やらないこと
- Camofox でのダイアログの検出と操作(上流側の穴。別途追いかけています)
- ダイアログやフレームのイベントを利用者へその場で流すこと(ゲートウェイ側のフックが必要になります)
- ダイアログの履歴をセッションをまたいで残すこと(メモリ上だけです)
- iframe ごとに別のダイアログの扱い方を決めること(エージェントは
dialog_idで表現できます) browser_cdpを置き換えること。細かい用途(Cookie、表示領域、通信速度の制限など)の逃げ道として残します
テスト
単体テスト(tests/tools/test_browser_supervisor.py)では、プロトコルを必要な範囲で話す asyncio のモック CDP サーバーを使い、状態の移り変わりをひととおり動かします。接続、有効化、 ページ移動、ダイアログの発生と却下、フレームの追加と切り離し、子ターゲットの接続、 セッションの片づけです。実際の実行基盤での E2E(Browserbase とローカルの Chromium 系ブラウザ)は手作業です。動いている Chromium 系ブラウザに /browser connect して、 上に書いたダイアログとフレームの場合分けを試してください。