Caveman Browse 实现解析:用 a11y 压缩器与 CCR 恢复句柄把 Chrome 可访问性树变成 Agent 可操作的紧凑视图
【免费下载链接】caveman🪨 why use many token when few token do trick — Claude Code skill that cuts 65% of tokens by talking like caveman项目地址: https://gitcode.com/GitHub_Trending/caveman1/caveman
caveman-browse 是 Caveman 仓库中面向 Agent 的本地浏览器交互组件:它附着到真实 Chrome,读取Accessibility.getFullAXTree原始载荷,经引擎的 forced-onlya11y压缩器生成带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 deniesfile:、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 |
|---|---|---|---|
原始getFullAXTreeJSON | 398,494 | n/a | n/a |
PlaywrightariaSnapshot() | 15,704 | 少 96.06% | n/a |
| Caveman 完整 agent 可见结果 | 13,368 | 少 96.65% | 少 14.88% |
Caveman 焦点结果(queryORD-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 焦点结果(queryEmail Plan Save order) | 111 | 少 97.35% | 大 1.66 倍 |
基准文档明确解释:小页面上 Caveman 的恢复句柄、精确计数器、inferred诚实性标记和 UID 的开销高于裸 Playwright ARIA 文本,不宣称无条件的 snapshot-only 胜利;不对称性本身(Caveman 侧带 MCP 信封/恢复/计量而 Playwright 侧只有文本)反而有利于 Playwright 基线。所有数字均为单次快照的inferredtoken 计数,不是供应商账单。
复现命令(BENCHMARK.md 原文):
CAVEMAN_BROWSE_CHROME="/path/to/Chrome" \ go test -tags=integration -run 'TestCDPQueryScales|TestCDPFullTokenEfficient' -count=5 -v ./browsePlaywright 基线由 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 中找到对应代码。
【免费下载链接】caveman🪨 why use many token when few token do trick — Claude Code skill that cuts 65% of tokens by talking like caveman项目地址: https://gitcode.com/GitHub_Trending/caveman1/caveman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考