浏览器 CDP 监控器 — 设计
状态: 已发布 (PR 14540) 最后更新: 2026-04-23 作者: @teknium1
问题
原生 JS 对话框(alert/confirm/prompt/beforeunload)和 iframe 是我们浏览器工具链中最大的两个空白:
- 对话框阻塞 JS 线程。 页面上的任何操作都会停滞,直到对话框被处理。在此工作之前,Agent 无法得知对话框已打开——后续的工具调用会挂起或抛出不透明的错误。
- Iframe 不可见。 Agent 可以在 DOM 快照中看到 iframe 节点,但无法在其中点击、输入或执行 eval——尤其是生活在独立 Chromium 进程中的跨源 (OOPIF) iframe。
PR #12550 提议了一个无状态的 browser_dialog 包装器。这并没有解决检测问题——它只是一个更干净的 CDP 调用,适用于 Agent 已经通过症状知道对话框已打开的情况。因被取代而关闭。
后端能力矩阵(于 2026-04-23 实时验证)
使用一次性探测脚本针对一个数据 URL 页面进行测试,该页面在主框架和同源 srcdoc iframe 中触发 alert,以及一个跨源 https://example.com iframe:
| 后端 | 对话框检测 | 对话框响应 | 帧树 | 通过 browser_cdp(frame_id=...) 进行 OOPIF Runtime.evaluate |
|---|---|---|---|---|
本地 Chrome (--remote-debugging-port) / /browser connect | ✓ | ✓ 完整工作流 | ✓ | ✓ |
| Browserbase | ✓ (通过桥接) | ✓ 完整工作流 (通过桥接) | ✓ | ✓ (document.title = "Example Domain" 在真实跨源 iframe 上已验证) |
| Camofox | ✗ 无 CDP (仅 REST) | ✗ | 部分通过 DOM 快照 | ✗ |
Browserbase 响应的工作原理。 Browserbase 的 CDP 代理内部使用 Playwright,并在约 10ms 内自动关闭原生对话框,因此 Page.handleJavaScriptDialog 无法跟上。为了解决这个问题,监控器通过 Page.addScriptToEvaluateOnNewDocument 注入一个桥接脚本,该脚本用同步 XHR 覆盖 window.alert/confirm/prompt,指向一个魔术主机 (hermes-dialog-bridge.invalid)。Fetch.enable 在这些 XHR 接触网络之前拦截它们——对话框变为监控器捕获的 Fetch.requestPaused 事件,而 respond_to_dialog 通过 Fetch.fulfillRequest 完成请求,载荷为注入脚本解码的 JSON 主体。
最终结果:从页面的角度来看,prompt() 仍然返回 Agent 提供的字符串。从 Agent 的角度来看,无论如何都是相同的 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.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调用。幂等。 - 停止: 会话 teardown 或
/browser disconnect。取消 asyncio 任务,关闭 WebSocket,丢弃状态。 - 重新绑定: 如果 CDP URL 更改(用户重新连接到新的 Chrome),停止旧监控器并重新启动——切勿在不同端点间重用状态。
对话框策略
可通过 config.yaml 中的 browser.dialog_policy 进行配置:
must_respond(默认)— 捕获,在browser_snapshot中显示,等待显式的browser_dialog(action=...)调用。如果在 300 秒安全超时后没有响应,则自动关闭并记录日志。防止有缺陷的 Agent 永远停滞。auto_dismiss— 记录并立即关闭;Agent 随后通过browser_snapshot内的browser_state看到它。auto_accept— 记录并接受(对于beforeunload很有用,用户希望干净地导航离开)。
策略是按任务设置的;v1 中没有每个对话框的覆盖设置。
Agent 表面 (PR 1)
一个新工具
browser_dialog(action, prompt_text=None, dialog_id=None)
action="accept"/"dismiss"→ 响应指定的或唯一的待处理对话框(必需)prompt_text=...→ 提供给prompt()对话框的文本dialog_id=...→ 当有多个对话框排队时用于消除歧义(罕见情况)
该工具仅用于响应。Agent 在调用之前从 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 线程的对话框。Agent 必须调用browser_dialog(action=...)进行响应。在 Browserbase 上为空,因为他们的 CDP 代理会在约 10 毫秒内自动关闭对话框。 -
recent_dialogs:最多包含 20 个最近关闭对话框的环形缓冲区,带有closed_by标签 —"agent"(我们已响应)、"auto_policy"(本地 auto_dismiss/auto_accept)、"watchdog"(触及 must_respond 超时)或"remote"(浏览器/后端为我们关闭了它,例如 Browserbase)。这是 Browserbase 上的 Agent 仍能了解发生情况的方式。 -
frame_tree:包括跨源 (OOPIF) 子项的帧结构。上限为 30 个条目 + OOPIF 深度 2,以限制广告密集页面上的快照大小。当触及限制时,truncated: true会显现;需要完整树的 Agent 可以使用带有Page.getFrameTree的browser_cdp。
这些都没有新的工具 schema 表面 — Agent 读取其已经请求的快照。
可用性门控
两个表面都基于 _browser_cdp_check 进行门控(supervisor 仅在 CDP 端点可达时才能运行)。在 Camofox / 无后端会话中,对话框工具被隐藏,且快照省略新字段 — 不会导致 schema 膨胀。
跨源 iframe 交互
扩展对话框检测工作,browser_cdp(frame_id=...) 通过 supervisor 已连接的 WebSocket 路由 CDP 调用(特别是 Runtime.evaluate),使用 OOPIF 的子 sessionId。Agent 从 browser_snapshot.frame_tree.children[] 中挑选 is_oopif=true 的 frame_ids,并将其传递给 browser_cdp。对于同源 iframe(没有专用的 CDP 会话),Agent 改用来自顶层 Runtime.evaluate 的 contentWindow/contentDocument — 当 frame_id 属于非 OOPIF 时,supervisor 会显示指向该回退方案的错误。
在 Browserbase 上,这是 iframe 交互的唯一可靠路径 — 无状态 CDP 连接(每次 browser_cdp 调用时打开)会遇到签名 URL 过期问题,而 supervisor 的长寿命连接保持有效的会话。
Camofox(后续)
计划在 jo-inc/camofox-browser 上解决的问题,添加:
- 每个会话的 Playwright
page.on('dialog', handler) GET /tabs/:tabId/dialogs轮询端点POST /tabs/:tabId/dialogs/:id用于接受/关闭- 帧树内省端点
涉及的文件(PR 1)
新增
tools/browser_supervisor.py—CDPSupervisor,SupervisorRegistry,PendingDialog,FrameInfotools/browser_dialog_tool.py—browser_dialog工具处理程序tests/tools/test_browser_supervisor.py— 模拟 CDP WebSocket 服务器 + 生命周期/状态测试website/docs/developer-guide/browser-supervisor.md— 本文件
修改
toolsets.py— 在browser,hermes-acp,hermes-api-server, core toolsets 中注册browser_dialog(受 CDP 可达性门控)tools/browser_tool.pybrowser_navigate启动钩子:如果 CDP URL 可解析,则执行SupervisorRegistry.get_or_start(task_id, cdp_url)browser_snapshot(约第 1536 行):将 supervisor 状态合并到返回 payload 中/browser connect处理程序:使用新端点重启 supervisor_cleanup_browser_session中的会话清理钩子
hermes_cli/config.py— 向DEFAULT_CONFIG添加browser.dialog_policy和browser.dialog_timeout_s- 文档:
website/docs/user-guide/features/browser.md,website/docs/reference/tools-reference.md,website/docs/reference/toolsets-reference.md
非目标
- Camofox 的检测/交互(上游缺口;单独跟踪)
- 将对话框/帧事件实时流式传输给用户(需要网关钩子)
- 跨会话持久化对话框历史(仅限内存)
- 每个 iframe 的对话框策略(Agent 可以通过
dialog_id表达这一点) - 替换
browser_cdp— 它仍然作为长尾情况(cookie、视口、网络节流)的应急出口
测试
单元测试使用一个 asyncio 模拟 CDP 服务器,该服务器足以执行协议来演练所有状态转换:附加、启用、导航、触发对话框、关闭对话框、帧附加/分离、子目标附加、会话清理。真实后端 E2E(Browserbase + 本地 Chrome)是手动的;来自 2026-04-23 调查的探测脚本保留在仓库中的 scripts/browser_supervisor_e2e.py 下,以便任何人都可以在新后端版本上重新验证。