Caveman Browse 实现解析:用 a11y 压缩器与 CCR 恢复句柄把 Chrome 可访问性树变成 Agent 可操作的紧凑视图
caveman-browse 是 Caveman 仓库中面向 Agent 的本地浏览器交互组件:它附着到真实 Chrome,读取 Accessibility.getFullAXTree 原始载荷,经引擎的 forced-only a11y 压缩器生成带 uid 句柄的紧凑树,并通过 CCR(压缩-恢复存储)保留字节级原始数据以便精确恢复。读完本文,你将理解它的四个 MCP 工具(browser_snapshot/browser_act/browser_eval/browser_recover)的完整参数语义、fail-closed 契约的源码实现、有界的 actionability 检测机制,以及如何通过集成测试与基准验证其 token 效率。
一、模块定位与目录结构
browse/CLAUDE.md 开篇即声明了该目录的治理边界:Browse 产品的源头在独立仓库 JuliusBrussee/caveman-browse,本目录是消费方副本(consumer copy),仅在锁定集成、迁移/移除或显式跨仓库同步时才编辑;而可访问性树压缩器因为 Engine 归本仓库所有,保留在 engine/compressors/axtree.go。
按 browse/CLAUDE.md 的 Layout 一节,核心只有三个部分:
| 文件 | 职责 |
|---|---|
| browse/session.go | MCP 工具处理器、engine/CCR 集成、UID 目标缓存 |
| browse/cdp.go | 基于 chromedp 的 Mode-A 专用 Chrome 驱动,以及有界 actionability 配方 |
| browse/cmd/caveman-browse/ | stdio MCP 二进制(含直接 CLI 模式与 detached Chrome 管理) |
两个关键的架构约束写在文档中,并有源码佐证:
- 依赖隔离:本包允许引入 CDP/网络/浏览器依赖,而
public/mcp不允许。这解释了为什么 browse 单独成包而不是并入通用 MCP 服务。 - 计量口径:Browse 产出的所有节省数一律标记为
inferred(推断值),从不发出verified。这与仓库整体的"诚实计量"设计一致——token 数由本地计数器算出,不是账单数据。
二、四个 MCP 工具的参数契约
browse/session.go 的 BrowserTools 定义了全部工具面,参数约束与文档所述一一对应:
| 工具 | 参数 | 约束(源码确认) |
|---|---|---|
browser_snapshot |
url |
仅允许 http(s)、about:blank、有界的 data:text/html |
wait |
毫秒数,0..30000(常量 maxSnapshotWaitMS = 30_000) |
|
query |
焦点词,上限 maxQueryBytes = 4 KiB |
|
browser_act |
action |
枚举 click|type|select|scroll|wait,未知动作返回 cave_unknown_action |
uid |
最近一次快照的 UID;查无目标返回 cave_unknown_uid |
|
text / option |
各上限 1 MiB(maxActionTextBytes) |
|
browser_eval |
expression |
在当前页面执行 JS,上限 1 MiB |
browser_recover |
recovery_handle |
快照返回的恢复句柄,长度 ≤ 512 |
query |
可选,用于在恢复时缩小范围 |
URL 白名单由 session.go 的 validateSnapshotArgs 实现:http/https 必须有 host,about 仅放行 about:blank,data 仅放行 data:text/html, 或 data:text/html; 前缀;任何 file:、javascript: 或特权 Chrome scheme 都会以 cave_browser_url_denied 拒绝。这落实了文档中"Navigation denies file:、javascript: and privileged Chrome schemes"的约束。
错误码统一采用 cave_snake_code 命名(cave_invalid_arguments、cave_browser_unavailable、cave_browser_action_failed、cave_unknown_handle 等),未知句柄/动作一律 fail closed——session_test.go 中对每个错误码都有断言测试(如 cave_unknown_uid、cave_unknown_handle 的检查)。
三、快照管线:从原始 AX 树到 uid 紧凑视图
snapshotTool 的完整调用链(session.go#L136-L197):
- 参数校验 → 非法参数返回
cave_invalid_arguments; - 驱动取数:
driver.Snapshot由 cdp.go 实现,顺序执行DOM.enable→ 可选chromedp.Navigate(url)→ 可选Sleep(wait)→accessibility.GetFullAXTree(),把节点数组 JSON 序列化后返回; - 引擎压缩:
eng.Compress(raw, Options{Mode: ModeCompress, Type: TypeA11y, Query: query})。注意a11y是 forced-only 类型——axtree.go#L24-L28 的注释明确写道"Detect never routes here; callers must forceOptions.Type = \"a11y\"",即自动内容嗅探永远不会路由到它,必须由调用方强制指定; - 失败闭合(fail closed):这是 CLAUDE.md 中最重要的契约,文档原文强调"uid 映射是
browser_snapshot的契约,不是压缩率的副作用"。
失败闭合的源码实现
当引擎返回 RecoveryHandle == ""(树没有变小、或没有 CCR 存储可用),snapshotTool 的行为是(session.go#L165-L177):
if res.RecoveryHandle == "" {
// ...The uid map is this tool's contract, not a side effect of a
// compression ratio, so we must NOT (a) dump the raw AX tree into `uids`
// ... nor (b) wipe the prior page's uid cache. Fail closed...
return mcp.ToolError("cave_browser_snapshot_uncompressed",
"snapshot did not compress to a uid view; raw tree withheld and prior uids retained")
}
对应文档的两条铁律:
- 绝不把原始 AX 树倒进
uids——几百 KB 的原始 JSON"比不用 Browse 更差"; - 绝不清空目标缓存——保留上一页的 uid,让
act行为保持可预测。
此外还有一道计量闸门(session.go#L191-L194):若 tokens_after >= tokens_before(agent 可见结果不比原始 AX 小),同样以 cave_browser_snapshot_uncompressed 拒绝并保留旧 uid。文档注明该契约的历史回归对应 issue #140——"破坏这一点会重新打开 #140"。
精确 token 计量:定点迭代
快照 payload 的 tokens_after 与 ratio 由 finalizeSnapshotPayload 计算:它最多迭代 8 轮,把 payload 自身 JSON 序列化后的 token 数回填到 tokens_after,直到 tokens_after 和 ratio 两个字段收敛不动。这保证文档要求的两个等式成立:
tokens_after== agent 可见结果的精确成本(含句柄、计量字段本身);view_tokens== 序列化器输出的成本(即紧凑树本身,不含信封)。
payload 结构(session.go#L125-L134)为:uids、recovery_handle、tokens_before、view_tokens、tokens_after、ratio、basis。
四、a11y 压缩器:UID 生成、焦点裁剪与行级保留
压缩器位于 engine/compressors/axtree.go,处理 CDP 返回的帧内节点数组。几个关键机制:
4.1 UID 生成规则
axUIDBase 生成 UID:"u" + 36 进制 backendDOMNodeId。注释解释了为什么不含 frame id——Phase-1 驱动只支持单个 CDP target,把 base64 frame id 嵌进每个可见 uid 会多花几十个 token 而动作驱动根本不用它;frame id 保留在恢复元数据中,为未来的 OOPIF 拼接留口。
并非所有节点都有 UID。shouldExposeAXUID 采用三层判断:
- 容器类角色(
webarea等):不给; - 可操作角色白名单(
button、checkbox、combobox、link、option、radio、textbox、tab等 15 种):给; - 其余角色:先看
focusable/clickable属性;再对照一个明确的"不给"清单(heading、statictext、table等);未知自定义角色默认给 UID——注释指出,漏掉句柄会"把自定义控件悄悄变成只读文本"。
这与文档/README 中"UID tokens reserved for actionable or unknown custom roles"完全一致。
4.2 焦点查询:12 条上限 + 祖先 + 整行保留
focusAXRecords 实现文档所述"query keeps at most 12 highest-scoring task matches plus ancestors":
- 对每个记录把
role name value 状态拼成小写串,按查询词命中数打分; - 从最高分向下降级填充至 12 条上限(
matches < 12,源码 L409);若已有节点覆盖全部查询词,则最低分锁定为满分,避免"ORD-*"这类公共碎片噪声; - 每条匹配回溯保留其祖先链,并把深度重排为紧凑缩进;
- 行级保留(L430-L455):匹配落在表格行/列表项单元格内时,把整行兄弟单元格一并保留——注释说得很直白:"没有客户/金额单元格的订单 ID 读起来就是'数据不可用'"。
查询无命中时返回根节点加一条 note "no accessible match",而不是空输出。
4.3 输出格式:紧凑缩进文本而非 JSON-lines
renderAXRecords 渲染为每行 [uid] role "name" = "value" {state}、两空格缩进的文本。角色还被压缩映射:rootwebarea/webarea → page、statictext → text、labeltext → label。pruneDuplicateAXText 还会剔除与语义父节点重复的 StaticText 副本(InlineTextBox 在 droppableGeneric 中直接整体丢弃,它是 AX 快照中最大的重复来源)。
恢复元数据只暴露实际可见的 UID:visibleUIDTargets 过滤 uidMap,只留下最终输出里出现过的条目——对应文档"recovery metadata exposes only UIDs actually shown"。
五、有界 Actionability:CDP 确认不等于应用层收敛
文档两条 Gotcha——"actionability 层刻意有界"与"CDP action acknowledgement is not application settlement"——在 cdp.go 中落地为具体代码。
5.1 settled:false 语义
所有非 wait 动作成功后返回 dispatchedAction:
// CDP acknowledged dispatch, but asynchronous application state may still be
// changing. Claiming settled=true here made agents trust an unverified result.
// A focused browser_snapshot is the cheap, evidence-bearing verification step.
return ActionResult{OK: true, Settled: false, Note: "dispatched; resnapshot to verify"}
即 browser_act 返回 settled:false,必须再做一次焦点快照作为状态证据。集成测试 cdp_integration_test.go 的 TestCDPFullTokenEfficientReadActVerifyRecoverLoop 完整验证了这个 read→act→verify→recover 循环(对 agent_checkout.html 执行 type、select、点击折叠线以下按钮,然后恢复字节级原始载荷)。
5.2 有界的可操作判定
waitActionable 在 5 秒截止内轮询,判定一个目标"可点击"需同时满足:
- 盒模型稳定:
dom.GetBoxModel两次采样,x/y/w/h 全部漂移 < 0.5px(先scrollIntoView({behavior:"instant"})居中); - 可见:
getComputedStyle检查display/visibility/pointer-events/opacity且getBoundingClientRect宽高 > 0(visible); - 启用:非
disabled属性、非aria-disabled=true、不处于[inert]子树(enabled)——TestCDPActionabilityRejectsDisabledButton专门验证禁用按钮必须被拒绝; - 命中测试:
dom.GetNodeForLocation取到的后端节点 ID 必须与目标一致,或用document.elementFromPoint兜底确认(receivesEvents)。
文档明确声明其适用边界:"same-origin dashboards and predictable design-system controls, not arbitrary-open-web parity",BENCHMARK.md 的 Claim boundary 一节同样注明 OOPIF、对话框、下载、任意站点操作ability 属于 Phase-1 之后的延期项。另外 callOnNodeRaw 的 decodeBoolObject 对 nil RemoteObject 显式 fail closed,注释指出这修复过"经 MCP panic 洞杀死整个进程"的缺陷(同样对应 issue #140)。
六、直接 CLI 模式:detached Chrome 与原子状态文件
除 stdio MCP 服务外,cmd/caveman-browse/main.go 提供直接 CLI,文档要求的"独立进程共享一个 target"由 detached Chrome 实现:
caveman-browse snapshot http://127.0.0.1:3000
caveman-browse snapshot http://127.0.0.1:3000 "save settings" # 带焦点查询
caveman-browse act <uid> click
caveman-browse act <uid> type "text"
caveman-browse recover <handle> [query]
caveman-browse eval <expression>
caveman-browse close # 必须真正终止 detached Chrome
工作机制(main.go):
- 首用启动:
directEndpoint(L202-L233)先在127.0.0.1默认端口 9333 上探测/json/version是否健康;不健康则以--headless=new --remote-debugging-port=<port> --user-data-dir=<profile> about:blank启动一个脱离当前进程的 Chrome(跨平台分离实现见 detach_unix.go 与 detach_windows.go),并释放 PID、把日志写入browse-chrome.log; - 跨命令粘滞:
snapshot成功后把endpoint、target_id、全量 uid 目标表、owned标志写入<CAVEMAN_HOME>/browse-session.json;act/eval/close通过loadDirectState重新附着同一 target——这正是"separatesnapshot/act/evalprocesses can share a target"的实现; - 原子写入:saveDirectState 走"临时文件 →
Chmod(0o600)→ 写 →Sync→Rename"流程,落实文档"state writes stay atomic and mode 0600";目录以0o700创建; - close 语义:
closeDirectBrowser对自有 Chrome 调用 CDPbrowser.Close()(对应 CDPDriver.Shutdown,区别于只释放上下文的普通Close),并清理状态文件;对外部CAVEMAN_BROWSE_CDP端点只删本地状态、不动对端。
环境变量
| 变量 | 作用 |
|---|---|
CAVEMAN_BROWSE_CDP |
复用外部 CDP 端点(跳过自启 Chrome) |
CAVEMAN_BROWSE_PORT |
直接模式的调试端口,默认 9333,越界即退出 |
CAVEMAN_BROWSE_CHROME |
Chrome/Chromium 可执行路径;缺省按 macOS/Linux/Windows 候选路径探测(defaultChromePath) |
CAVEMAN_BROWSE_HEADFUL |
1 时以有头模式运行 |
CAVEMAN_BROWSE_USER_DATA_DIR |
浏览器 profile 目录;缺省为 <CAVEMAN_HOME>/browse-profile |
CAVEMAN_BROWSE_EPHEMERAL |
1 时 CCR 用内存存储而非 SQLite |
CAVEMAN_CCR_DB |
CCR 数据库路径;缺省 <CAVEMAN_HOME>/ccr.db |
CAVEMAN_HOME |
状态根目录,缺省 ~/.caveman;全新 HOME 必须能直接工作(有专门集成测试覆盖) |
CCR 存储默认落 SQLite(engine/ccr/store_sqlite.go),恢复即 eng.RetrieveQuery(handle, query) 返回字节级原始 AX 载荷(recoverTool)。
七、Site Isolation 与 iframe 叶子边界
CLAUDE.md 最后一条 Gotcha 值得单独展开,因为它是"不要好心办坏事"的典型:
一个
<iframe>是叶子,不是坏树。Accessibility.getFullAXTree一次只返回一个 frame,iframe 节点的childId指向另一个 frame 响应里才存在的子文档。a11y压缩器把无法解析的childId当作叶子,仍然策展 frame 可见节点。正因为如此,CDP 驱动保持 Chrome 默认的 Site Isolation(site-per-process)——不要用禁用它来"修"跨源 iframe。
两处源码相互印证:
- axtree.go 的 validAXTree 末尾注释:"childId 在本载荷中解析不到不是坏树……把这种 childId 当作叶子边界(walk 跳过未知 id)而不是拒绝整棵树——多返回一个可用的 uid 映射是安全的,拒绝压缩整个页面则不然";遍历中
byID[id]查不到的 child 直接被walk跳过(L180-L183); - cdp.go#L58-L63 注释记录了历史:曾经禁用 Site Isolation 来掩盖跨源 iframe 缺失子文档的问题,压缩器修复后"不再用一个安全边界去换它",因此 allocator 只追加
UserDataDir,不改隔离标志。
八、测试矩阵与基准数据
8.1 测试分层
文档给出的三层测试命令(go test ./browse/... 无外部依赖即可跑;集成测试需真实浏览器):
go test ./browse/... # 无依赖单测:工具校验、错误码、payload 计量
make test-browse # 解析已安装的 Playwright Chromium 或系统 Chrome,
# 跑 integration build tag 的 CDP 契约(包 + 直接 CLI)
make test-browser # 额外包含 extension 测试
make test-e2e # 二者都包含
集成测试(cdp_integration_test.go,//go:build integration)与 BENCHMARK.md "Integration gates" 一节列出的验收面一致:type/select/视口外自动滚动点击/动作后焦点验证/禁用控件拒绝/过期 UID 拒绝/字节级恢复/全新 HOME 启动/跨进程 CLI 再附着/显式 Chrome 关闭。CLI 侧的跨进程附着与关闭语义在 main_integration_test.go 中覆盖。
8.2 基准:诚实的赢与诚实的输
BENCHMARK.md(2026-08-10,Chrome 151.0.7922.108,锁定 Playwright 1.56.1,Caveman 离线 o200k_base 计数器,五轮取中位数)给出可复现数据:
200 行运营大表(语料 testdata/order_dashboard.html):
| 表示 | Tokens | 对比原始 AX | 对比 Playwright |
|---|---|---|---|
原始 getFullAXTree JSON |
398,494 | n/a | n/a |
Playwright ariaSnapshot() |
15,704 | 少 96.06% | n/a |
| Caveman 完整 agent 可见结果 | 13,368 | 少 96.65% | 少 14.88% |
Caveman 焦点结果(query ORD-0173) |
121 | 少 99.97% | 小 129.8 倍 |
小型结账表单(agent_checkout.html)——文档特意保留的"输"的样本:
| 表示 | Tokens | 对比原始 AX | 对比 Playwright |
|---|---|---|---|
| 原始 AX | 4,186 | n/a | n/a |
| Playwright ARIA 文本 | 67 | 少 98.40% | n/a |
| Caveman 完整结果 | 157 | 少 96.25% | 大 2.34 倍 |
Caveman 焦点结果(query Email Plan Save order) |
111 | 少 97.35% | 大 1.66 倍 |
基准文档明确解释:小页面上 Caveman 的恢复句柄、精确计数器、inferred 诚实性标记和 UID 的开销高于裸 Playwright ARIA 文本,不宣称无条件的 snapshot-only 胜利;不对称性本身(Caveman 侧带 MCP 信封/恢复/计量而 Playwright 侧只有文本)反而有利于 Playwright 基线。所有数字均为单次快照的 inferred token 计数,不是供应商账单。
复现命令(BENCHMARK.md 原文):
CAVEMAN_BROWSE_CHROME="/path/to/Chrome" \
go test -tags=integration -run 'TestCDPQueryScales|TestCDPFullTokenEfficient' -count=5 -v ./browse
Playwright 基线由 browse/scripts/playwright-aria-baseline.mjs 计数,且 token 预算在测试中保持可执行(文档要求"keep token budgets executable in tests")。
九、小结:Browse 的设计约束清单
把 CLAUDE.md 的 Gotchas 与源码证据汇总,这套实现可以用七条约束概括:
- uid 映射是契约:压缩不达标(无恢复句柄或
tokens_after >= tokens_before)时 fail closed 为cave_browser_snapshot_uncompressed,保留上一页 uid,绝不倾倒原始树(session.go#L165-L194); - 有界操作ability:同源自仪表盘 + 可预测设计系统控件;判定链为盒模型稳定 → 可见 → 启用 → 命中测试(cdp.go#L256-L292);
- settled 语义:CDP 分发确认 ≠ 应用收敛,非 wait 动作恒
settled:false,需焦点快照取证(cdp.go#L224-L229); - 导航白名单:仅
http(s)/about:blank/有界data:text/html(session.go#L263-L279); - 计量精确性:
tokens_after等于 agent 可见结果精确成本,view_tokens等于序列化器输出成本,靠定点迭代收敛(session.go#L227-L244); - 进程模型:直接 CLI 的 Chrome 是 detached 的,多命令共享 target,
close必须真正终止它;状态写入原子且 0600(main.go#L401-L437); - 不拆安全边界:保持 Chrome 默认 Site Isolation,iframe 的跨帧
childId在 a11y 压缩器中按叶子处理(axtree.go#L136-L142)。
配合 browse/README.md(构建方式 go build ./browse/cmd/caveman-browse、stdio 运行方式、BSL 1.1 许可说明)与 LICENSING.md,本文覆盖的即当前仓库中 browse/ 目录的全部技术事实:一个把"读浏览器"变成低 token、可恢复、可验证操作的本地 MCP 组件,其每一个设计决定都能在 session.go、cdp.go、cmd/caveman-browse/main.go 和 engine/compressors/axtree.go 中找到对应代码。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00