浏览器 CDP 监控器——设计

状态: 已发布(PR 14540) 最后更新: 2026-04-23 作者: @teknium1

问题

原生 JS 对话框(alert/confirm/prompt/beforeunload)和 iframe 是我们浏览器工具中最大的两个缺口:

  1. 对话框阻塞 JS 线程。 页面上的任何操作都会一直停滞,直到对话框被处理。在这项工作之前,智能体无法知道有对话框打开——后续的工具调用会挂起或抛出模糊的错误。
  2. iframe 不可见。 智能体可以在 DOM 快照中看到 iframe 节点,但无法在其中点击、输入或执行 eval——特别是跨源(OOPIF)的 iframe,它们存在于独立的 Chromium 进程中。

PR #12550 提出了一个无状态的 browser_dialog 包装器。但这并不能解决检测问题——它只是当智能体已经(通过症状)知道有对话框打开时,提供一个更清晰的 CDP 调用。已关闭,被本方案取代。

后端能力矩阵(2026-04-23 实时验证)

使用一次性探测脚本,针对一个触发 alert 的数据 URL 页面(在主框架中和在同源 srcdoc iframe 中),以及一个跨源 https://example.com iframe:

| 后端 | 对话框检测 | 对话框响应 | 框架树 | 通过 browser_cdp(frame_id=...) 的 OOPIF Runtime.evaluate | | |---|---|---|---|---| | 本地 Chrome(--remote-debugging-port)/ /browser connect | ✓ | ✓ 完整工作流 | ✓ | ✓ | | Browserbase | ✓(通过桥接) | ✓ 完整工作流(通过桥接) | ✓ | ✓(在真实跨源 iframe 上验证了 document.title = "Example Domain") | | 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 通过带有 JSON 体的 Fetch.fulfillRequest 来满足请求,注入的脚本会解码该 JSON 体。

最终结果:从页面的角度看,prompt() 仍然返回智能体提供的字符串。从智能体的角度看,无论哪种方式都是相同的 browser_dialog(action=...) API。已在真实 Browserbase 会话上进行了端到端测试——4/4(alert/prompt/confirm-accept/confirm-dismiss)通过,包括值往返回页面 JS。

Camofox 在此 PR 中保持不支持;计划在 jo-inc/camofox-browser 提交后续上游问题,请求添加对话框轮询端点。

架构

CDPSupervisor

每个 Hermes task_id 一个 asyncio.Task,在后台上线线程中运行。持有到后端 CDP 端点的持久 WebSocket 连接。维护:

  • 对话框队列List[PendingDialog],包含 {id, type, message, default_prompt, session_id, opened_at}
  • 框架树Dict[frame_id, FrameInfo],包含父级关系、URL、源、是否为跨源子会话
  • 会话映射Dict[session_id, SessionInfo],以便交互工具可以路由到正确的附加会话以进行 OOPIF 操作
  • 最近的控制台错误 — 最后 50 条环形缓冲区(用于 PR 2 诊断)

附加时订阅:

  • Page.enablejavascriptDialogOpeningframeAttachedframeNavigatedframeDetached
  • Runtime.enableexecutionContextCreatedconsoleAPICalledexceptionThrown
  • Target.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 秒的安全超时后无响应时,自动关闭并记录日志。防止有 bug 的智能体永远停滞。
  • auto_dismiss — 记录并立即关闭;智能体事后通过 browser_snapshot 内的 browser_state 看到它。
  • auto_accept — 记录并接受(对 beforeunload 很有用,此时用户希望干净地导航离开)。

策略是每任务的;v1 中没有每对话框的覆盖。

智能体接口(PR 1)

一个新工具

browser_dialog(action, prompt_text=None, dialog_id=None)
  • action="accept" / "dismiss" → 响应指定或唯一的待处理对话框(必需)
  • prompt_text=... → 提供给 prompt() 对话框的文本
  • dialog_id=... → 当多个对话框排队时消除歧义(罕见)

该工具仅用于响应。智能体在调用之前从 browser_snapshot 输出中读取待处理的对话框。

browser_snapshot 扩展

当监控器附加时,向现有快照输出添加三个可选字段:

{
  "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 代理会在约 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 在达到限制时显示;需要完整树的智能体可以使用带 Page.getFrameTreebrowser_cdp

这些都不需要新的工具模式接口——智能体读取它已经请求的快照。

可用性门控

两个接口都依赖于 _browser_cdp_check(监控器只能在 CDP 端点可达时运行)。在 Camofox / 无后端会话上,对话框工具被隐藏,快照省略新字段——没有模式膨胀。

跨源 iframe 交互

扩展对话框检测工作,browser_cdp(frame_id=...) 通过监控器已连接的 WebSocket,使用 OOPIF 的子 sessionId 路由 CDP 调用(特别是 Runtime.evaluate)。智能体从 browser_snapshot.frame_tree.children[] 中提取 frame_id,其中 is_oopif=true,并将其传递给 browser_cdp。对于同源 iframe(没有专用的 CDP 会话),智能体改用顶层的 Runtime.evaluate 中的 contentWindow/contentDocument——当 frame_id 属于非 OOPIF 时,监控器会显示指向该回退方案的错误。

在 Browserbase 上,这是 iframe 交互唯一可靠的路径——无状态的 CDP 连接(每次 browser_cdp 调用时打开)会遇到签名 URL 过期,而监控器的长期连接保持有效的会话。

Camofox(后续工作)

计划向 jo-inc/camofox-browser 提交 Issue 添加:

  • 每个会话的 Playwright page.on('dialog', handler)
  • GET /tabs/:tabId/dialogs 轮询端点
  • POST /tabs/:tabId/dialogs/:id 接受/关闭
  • 框架树内省端点

涉及的文件(PR 1)

新建

  • tools/browser_supervisor.pyCDPSupervisorSupervisorRegistryPendingDialogFrameInfo
  • tools/browser_dialog_tool.pybrowser_dialog 工具处理程序
  • tests/tools/test_browser_supervisor.py — 模拟 CDP WebSocket 服务器 + 生命周期/状态测试
  • website/docs/developer-guide/browser-supervisor.md — 本文件

修改

  • toolsets.py — 在 browserhermes-acphermes-api-server、核心工具集中注册 browser_dialog(基于 CDP 可达性门控)
  • tools/browser_tool.py
    • browser_navigate 启动钩子:如果 CDP URL 可解析,则 SupervisorRegistry.get_or_start(task_id, cdp_url)
    • browser_snapshot(约第 1536 行):将监控器状态合并到返回载荷中
    • /browser connect 处理程序:使用新端点重启监控器
    • _cleanup_browser_session 中的会话拆除钩子
  • hermes_cli/config.py — 将 browser.dialog_policybrowser.dialog_timeout_s 添加到 DEFAULT_CONFIG
  • 文档:website/docs/user-guide/features/browser.mdwebsite/docs/reference/tools-reference.mdwebsite/docs/reference/toolsets-reference.md

非目标

  • Camofox 的检测/交互(上游差距;单独跟踪)
  • 将对话框/框架事件流式实时传输给用户(需要网关钩子)
  • 跨会话持久化对话框历史(仅内存中)
  • 每 iframe 对话框策略(智能体可以通过 dialog_id 表达)
  • 替换 browser_cdp——它仍然是针对长尾情况(cookies、视口、网络限速)的逃生口

测试

单元测试使用一个 asyncio 模拟 CDP 服务器,该服务器能够说足够的协议来执行所有状态转换:附加、启用、导航、对话框触发、对话框关闭、框架附加/分离、子目标附加、会话拆除。真实后端 E2E(Browserbase + 本地 Chromium 系列浏览器)是手动的——通过连接到实时的 Chromium 系列浏览器 /browser connect 并运行上述对话框/框架测试用例来测试。