Caveman Browse 实现解析:用 a11y 压缩器与 CCR 恢复句柄把 Chrome 可访问性树变成 Agent 可操作的紧凑视图
2026/9/6 15:24:25 网站建设 项目流程

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.goMCP 工具处理器、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_snapshoturl仅允许http(s)about:blank、有界的data:text/html
wait毫秒数,0..30000(常量maxSnapshotWaitMS = 30_000
query焦点词,上限maxQueryBytes = 4 KiB
browser_actaction枚举click\|type\|select\|scroll\|wait,未知动作返回cave_unknown_action
uid最近一次快照的 UID;查无目标返回cave_unknown_uid
text/option各上限 1 MiB(maxActionTextBytes
browser_evalexpression在当前页面执行 JS,上限 1 MiB
browser_recoverrecovery_handle快照返回的恢复句柄,长度 ≤ 512
query可选,用于在恢复时缩小范围

URL 白名单由 session.go 的validateSnapshotArgs实现:http/https必须有 host,about仅放行about:blankdata仅放行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_argumentscave_browser_unavailablecave_browser_action_failedcave_unknown_handle等),未知句柄/动作一律fail closed——session_test.go 中对每个错误码都有断言测试(如cave_unknown_uidcave_unknown_handle的检查)。

三、快照管线:从原始 AX 树到 uid 紧凑视图

snapshotTool的完整调用链(session.go#L136-L197):

  1. 参数校验→ 非法参数返回cave_invalid_arguments
  2. 驱动取数driver.Snapshot由 cdp.go 实现,顺序执行DOM.enable→ 可选chromedp.Navigate(url)→ 可选Sleep(wait)accessibility.GetFullAXTree(),把节点数组 JSON 序列化后返回;
  3. 引擎压缩eng.Compress(raw, Options{Mode: ModeCompress, Type: TypeA11y, Query: query})。注意a11yforced-only类型——axtree.go#L24-L28 的注释明确写道"Detect never routes here; callers must forceOptions.Type = \"a11y\"",即自动内容嗅探永远不会路由到它,必须由调用方强制指定;
  4. 失败闭合(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_afterratio由 finalizeSnapshotPayload 计算:它最多迭代 8 轮,把 payload 自身 JSON 序列化后的 token 数回填到tokens_after,直到tokens_afterratio两个字段收敛不动。这保证文档要求的两个等式成立:

  • tokens_after==agent 可见结果的精确成本(含句柄、计量字段本身);
  • view_tokens==序列化器输出的成本(即紧凑树本身,不含信封)。

payload 结构(session.go#L125-L134)为:uidsrecovery_handletokens_beforeview_tokenstokens_afterratiobasis

四、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等):不给;
  • 可操作角色白名单(buttoncheckboxcomboboxlinkoptionradiotextboxtab等 15 种):给;
  • 其余角色:先看focusable/clickable属性;再对照一个明确的"不给"清单(headingstatictexttable等);未知自定义角色默认给 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 → pagestatictext → textlabeltext → labelpruneDuplicateAXText还会剔除与语义父节点重复的StaticText副本(InlineTextBoxdroppableGeneric中直接整体丢弃,它是 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 秒截止内轮询,判定一个目标"可点击"需同时满足:

  1. 盒模型稳定dom.GetBoxModel两次采样,x/y/w/h 全部漂移 < 0.5px(先scrollIntoView({behavior:"instant"})居中);
  2. 可见getComputedStyle检查display/visibility/pointer-events/opacitygetBoundingClientRect宽高 > 0(visible);
  3. 启用:非disabled属性、非aria-disabled=true、不处于[inert]子树(enabled)——TestCDPActionabilityRejectsDisabledButton专门验证禁用按钮必须被拒绝;
  4. 命中测试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成功后把endpointtarget_id、全量 uid 目标表、owned标志写入<CAVEMAN_HOME>/browse-session.jsonact/eval/close通过loadDirectState重新附着同一 target——这正是"separatesnapshot/act/evalprocesses can share a target"的实现;
  • 原子写入:saveDirectState 走"临时文件 →Chmod(0o600)→ 写 →SyncRename"流程,落实文档"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_CHROMEChrome/Chromium 可执行路径;缺省按 macOS/Linux/Windows 候选路径探测(defaultChromePath)
CAVEMAN_BROWSE_HEADFUL1时以有头模式运行
CAVEMAN_BROWSE_USER_DATA_DIR浏览器 profile 目录;缺省为<CAVEMAN_HOME>/browse-profile
CAVEMAN_BROWSE_EPHEMERAL1时 CCR 用内存存储而非 SQLite
CAVEMAN_CCR_DBCCR 数据库路径;缺省<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
原始getFullAXTreeJSON398,494n/an/a
PlaywrightariaSnapshot()15,704少 96.06%n/a
Caveman 完整 agent 可见结果13,368少 96.65%少 14.88%
Caveman 焦点结果(queryORD-0173121少 99.97%小 129.8 倍

小型结账表单agent_checkout.html)——文档特意保留的"输"的样本:

表示Tokens对比原始 AX对比 Playwright
原始 AX4,186n/an/a
Playwright ARIA 文本67少 98.40%n/a
Caveman 完整结果157少 96.25%大 2.34 倍
Caveman 焦点结果(queryEmail Plan Save order111少 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 ./browse

Playwright 基线由 browse/scripts/playwright-aria-baseline.mjs 计数,且 token 预算在测试中保持可执行(文档要求"keep token budgets executable in tests")。

九、小结:Browse 的设计约束清单

把 CLAUDE.md 的 Gotchas 与源码证据汇总,这套实现可以用七条约束概括:

  1. uid 映射是契约:压缩不达标(无恢复句柄或tokens_after >= tokens_before)时 fail closed 为cave_browser_snapshot_uncompressed,保留上一页 uid,绝不倾倒原始树(session.go#L165-L194);
  2. 有界操作ability:同源自仪表盘 + 可预测设计系统控件;判定链为盒模型稳定 → 可见 → 启用 → 命中测试(cdp.go#L256-L292);
  3. settled 语义:CDP 分发确认 ≠ 应用收敛,非 wait 动作恒settled:false,需焦点快照取证(cdp.go#L224-L229);
  4. 导航白名单:仅http(s)/about:blank/有界data:text/html(session.go#L263-L279);
  5. 计量精确性tokens_after等于 agent 可见结果精确成本,view_tokens等于序列化器输出成本,靠定点迭代收敛(session.go#L227-L244);
  6. 进程模型:直接 CLI 的 Chrome 是 detached 的,多命令共享 target,close必须真正终止它;状态写入原子且 0600(main.go#L401-L437);
  7. 不拆安全边界:保持 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询