作为 agent 的工具
BUA 如何暴露给主 agent——内建 subagent 把 `browser` 工具(action 判别联合)作为私有工具箱。覆盖委派封装、工具解剖、一次调用的端到端流、以及模型如何把页面读成 Markdown、再以短 uid 句柄定位元素。
一句话
BUA 被封装成一个内建 subagent(subagent_type: "browser"),私有工具箱只有一个 browser 工具——Zod
discriminatedUnion 的 13 种 action(navigate、click、type…)。主 agent 不直接看到 browser,通过标准 task
工具委派。在子 agent 的独立上下文里,浏览器循环(extract → 推理 → click → extract →
…)对用户的已登录 Chrome 走 CDP 执行。
一次委派进去(从主 agent),一次 summary 出来(从子 agent)。N 步内循环不接触主对话。
三种工具配置形态
代码库里有三种形态。按操作家族的特性挑选。
| 形态 | config : tool name | Input schema | 例子 |
|---|---|---|---|
| 单工具平铺 | 1 : 1 | 1 份扁平对象 | reflect、complete、write_todos |
| 工具组 (group) | 1 : N | N 份独立 schema | filesystem、memory |
| 单工具 + actions | 1 : 1 | 1 份判别联合 | browser、Anthropic computer-use |
BUA 为什么选第三种:
| 理由 | 细节 |
|---|---|
| Prompt 密度 | 13 个独立工具 = 13 份 schema + 13 段 description;一份联合 = 1 份 schema + 1 段 description(~1500 字符) |
| 输出同质 | 多数动作返回 { ok: true } 或单一 blob (dataUrl、text)。错误模型和前置条件都一样 |
| 链式调用是常态 | Agent 常做 navigate → extract → click → type → extract——视为同一工具的变体读起来更一致 |
| 生态对齐 | Anthropic computer-use 和 Browser Use 都是这形态;模型 tool-use 能力直接迁移 |
反向压力(filesystem 为什么是工具组):grep 返回匹配数组、read_file 返回文件内容、ls 返回条目——输出异质
→ 拆成独立工具让模型更清楚选择。
选择启发:看 trace——如果模型在 单工具+actions
里混淆变体,拆成组;如果在工具组兄弟之间困惑,合并成 actions。是 prompt 工程问题,不是 API 设计问题。
通过内建 subagent 委派
browser 工具正常注册在工具注册表,但通过 packages/backend/src/agent/subagents/index.ts 里的 INTERNAL_ONLY_TOOLS
从主 agent 的 loadout 里剥离。主 agent 通过标准 task 工具访问 BUA——那里预注册了一个内建 subagent 类型
"browser"(见 packages/backend/src/agent/subagents/browser-subagent.ts)。
为什么要封装成子 agent:BUA 的最小有用单元是一个循环,不是一次调用。如果主 agent 直接跑循环,10-20 次 browser action 每次都要重送主对话(成本 ≈ M × N)。封装成子 agent 后,只有子 agent 自己的 prompt + 内循环上下文随 N 增长——主线程只付一次 tool_call + 一次 summary 返回。
隔离不变量(在 task.tool.ts 强制):
- 全新
RuntimeContext——只携带{ taskId, userId, sandbox, workspace, timezone };不继承messages、todos、reminders、writer browserBridge经toolServices: { browserBridge, kanbanService }到达子 agent(在构造时从父deps捕获),不在那个全新的 runtime context 上taskId保留(所以task_milestone事件能路由到同一个 popup session)- 子 agent 与父级 1:1 共享 workspace + sandbox——无 chroot、无
.tasks/{toolCallId}/子目录隔离(对纯抓取类 BUA 任务本就无关;目前浏览器子 agent 的 loadout 不写文件)
Tier 门控仍然是熟悉的 allowedTools 列表——某个 tier 的 allowedTools 含 "browser"
就注册该 subagent,否则不注册。列表门的是subagent,不是主 agent 的直接访问权。
回滚:设环境变量 ENABLE_BUILTIN_BROWSER_SUBAGENT=false 可禁用内建 subagent,恢复主 agent 直接使用 browser
工具的旧行为(紧急兜底)。
解剖
源码在 packages/backend/src/tools/tools/browser.tool.ts + 共享 schema 三块。
Schema——@zapvol/common
// packages/common/src/schemas/browser-bridge.ts
export const browserActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("navigate"), url, tabId: tabIdSchema.optional() }).describe("..."),
// 定位元素的 action 只接收 `uid` 或 `selector` 之一(一个 .refine 强制异或)。
// 首选 `uid`(e0、e1…,来自上次 extract 的 `elements`);`selector` 是兜底。
z.object({ type: z.literal("click"), selector: sel.optional(), uid: uid.optional() }).describe("..."),
z.object({ type: z.literal("type"), selector: sel.optional(), uid: uid.optional(), text }).describe("..."),
z.object({ type: z.literal("extract"), selector: sel.optional() }).describe("..."), // → { text, markdown, elements }
// …
// `open_tab` 自动建 session;默认把新 tab 放进最小化的 BUA 窗口。
// `focus: true` 可以改为打开在用户聚焦的窗口。
z.object({ type: z.literal("open_tab"), url, focus: z.boolean().optional() }).describe("..."),
// 共 13 个变体:navigate、click、type、press_key、scroll、screenshot、extract、
// wait_for、hover、evaluate、get_tabs、open_tab、close_tab
]);
Zod 的 discriminatedUnion 编译成 JSON schema 的 oneOf + type literal。模型通过写 literal 选分支,AI
SDK 校验剩余字段。
注册——backend
// packages/backend/src/tools/tools/browser.tool.ts
export const browserServerConfig: ServerToolConfig = {
name: TOOL_NAME_BROWSER,
instructions: async (deps: ToolBuildDeps) => (deps.browserBridge ? BROWSER_TOOL_INSTRUCTIONS : ""),
createTools: async (deps: ToolBuildDeps) => {
if (!deps.browserBridge) return {};
const bridge = deps.browserBridge;
return {
browser: tool({
description: "Drive the user's logged-in Chrome tab — pick one action from: navigate, click, type, ...",
inputSchema: zodSchema(browserActionSchema),
execute: async (input) => {
const result = await bridge.request(input as BrowserAction);
return result.ok
? { ok: true, action: input.type, result: result.result }
: { ok: false, action: input.type, error: result.error };
},
}),
};
},
compact: ({ input, output }) => {
/* screenshot 字节截断、extract 文本截断 */
},
toClientOutput: (output) => {
/* UI 渲染保留 dataUrl 和文本 */
},
};
关键点:execute 对 action 无感——把整个 input 直接扔给 bridge.request()。是扩展按 input.type
分派,不是后端。后端只是薄管道。
Tier 控制
browser 不在 ALL_TOOL_KEYS 里——admin 要按 tier 手动勾选。勾上时,每次 task 启动都全量加载 createTools(deps) 和
instructions(deps);没勾的 tier,工具对模型完全不可见。
端到端:一次 click 调用
参与方:Model(LLM)、execute(后端工具的 execute 函数)、bridge(BrowserBridge 按用户实例)、 Pool(BrowserBridgePool 单例)、Ext(扩展的 action-dispatcher)、CDP(chrome.debugger)。
模型看到一次工具调用;后端看到一次 request/response;扩展发一次 CDP 命令。判别联合把本来应该是 13 个 AI SDK 工具的东西塌缩成一个。
取消:AI SDK 给每个 execute(input, { abortSignal }) 传一个 AbortSignal。browser 工具把它透传给
bridge.request(action, signal),pool 为每个 pending request 挂 abort 监听——信号触发时立刻把 pending promise
resolve 成 internal_error "aborted by caller",清掉 timer。父 agent 中途取消时不需要空等 30 秒的 pool 超时。
模型和 DOM 如何对话
上面的图说清了一次 click
怎么流动,但没说为什么模型定位的元素能对上真实 DOM。一个容易误解的点:“后端一定要发出符合前端 DOM 的指令吧?”
其实不是——模型和扩展之间的任何一层都不碰 DOM。后端是一根字符串水管。模型读的是页面的一份摘要,再决定操作哪个元素——首选一个短
uid 句柄,兜底才用 CSS selector。
extract 返回什么
extract 不返回原始 HTML,而是三个字段:
markdown——页面转成的 Markdown,剥掉 nav / footer / overlay。便宜到可以整块读;这是模型用来理解页面的东西。elements——accessibility tree 里的可交互节点,每个带一个短uid(e0、e1…)。这是模型给下一个 action 用的句柄词汇表。text——原始innerText兜底。
定位元素的 action(click、type、hover、wait_for)只接收 uid 或 selector 之一(一个 Zod
.refine 强制异或)。模型被指引优先用 uid——它更短、省 token、抗 class 名变动——只有在没有合适 uid 时(比如属性状态谓词)才用 CSS
selector。
谁能访问什么
| 层 | 能碰真实 DOM 吗? | 经过它的内容 |
|---|---|---|
| Chrome tab | 能(它就是 DOM) | 接收 CDP 命令,派发真实输入事件 |
| 扩展 | 间接——经 chrome.debugger | 读 a11y tree + DOM → 返回 { text, markdown, elements };把 uid → backendNodeId → box-model 中心解析出来做输入 |
| 后端 | 不能 | 纯透传——把 action 序列化发 WS,把 response 反序列化回来。永不解析标记,永不解析 uid 或 selector |
| LLM (经 AI SDK) | 不能 | 在 context 窗口里看 Markdown 摘要 + elements 列表;对想要的元素发出一个 uid(或一个 CSS selector) |
后端仍然是一根字符串水管,没有共享的 DOM 数据模型。唯一的状态是一张 uid → backendNodeId
映射,它活在扩展里:每次 extract 重建,导航时清空(Page.frameNavigated / 一个 navigate
action)。所以确实有一层轻量绑定——但它在扩展边缘,不在后端。
一次具体往返
- 模型发出
browser({ type: "extract" })(不传 selector → 整页) - 扩展遍历 a11y tree + DOM,返回
{ markdown, elements, text }。elements列表长这样:e4 button "View" (行: Jane Doe) e5 button "View" (行: John Smith) e6 input "Search candidates"markdown则把列表渲染成可读文本(- Jane Doe … [View])。 - 后端把载荷原样传回 AI SDK,进入模型 context 窗口。
- 模型决定打开 Jane 的面板,发出
browser({ type: "click", uid: "e4" })——不用自己写 selector。 - 后端把 action 原样传。扩展
debuggerController.click从第 2 步建立的映射里把e4 → backendNodeId解析出来,取 box model,在中心发Input.dispatchMouseEvent。
第 2 步和第 4 步之间的绑定就是那张 uid 映射。如果模型需要的是 a11y 列表说不出的状态谓词(比如“面板的 aria-hidden
翻成 false 之后”),它就退回用 CSS selector。
为什么用 uid 句柄 + Markdown(并保留 selector 兜底)
早先的设计喂原始 HTML,让模型自己写 CSS selector。现在的设计借鉴了两个邻居:
- 元素索引 / 句柄方案(Browser Use
的做法):给每个可交互元素编号,让模型按句柄引用。抗 class 名漂移、省 token。BUA 的
uid(e0、e1…)正是这个。 - Accessibility tree 方案(Anthropic computer-use 在 web 上的做法):读 ARIA
tree 而非原始标记。更语义、更简短。BUA 的
elements列表就是 a11y 可交互集,markdown是语义化的阅读版。
CSS selector 作为兜底保留:每个现代 LLM 对 selector 都有很强先验,它可调试(合法的 document.querySelector
输入,能在 DevTools 里复现),且有些目标最好用属性状态谓词([aria-hidden='false'])表达——那是任何静态 uid 都命名不了的。
代价:一个 uid 只在下一次导航 / 重新 extract 之前有效。element_stale / element_not_found →
重新 extract 的模式(见下一节多步骤示例)就是我们的恢复机制。
这个设计的含义
- 后端对任何站点通用——它零站点相关知识。所有站点形状活在
extract载荷里和 LLM 发出的uid/ selector 里 - uid 会过期:一个
uid是对上一次 extract 的映射解析的;导航之后(或 SPA 重渲染改了 DOM)映射被清空,过期的uid返回element_stale——重新 extract 拿新句柄 - 动作前必须 extract——没有它,模型既没有
elements列表可取uid,也没有 Markdown 给 selector 做根据;无根据地动手很脆
多步骤示例——抓取候选人详情
单个动作是原子;真实场景是链。下面是一个贴近实战的 trace,用户 prompt 是 “把这个招聘后台前 3 个候选人的联系方式抓出来”。
主 agent 视角——一次工具调用,一份结果:
主 agent → task({
subagent_type: "browser",
description: "抓 3 候选人联系方式",
prompt: "打开招聘后台(首个 action 自动建 session)。抽取前 3 行候选人…"
})
← { summary: "抓到 3 个候选人: [{name:'Jane',email:…}, …]", status: "completed", artifacts: [] }
子 agent 视角——17 次 browser
action 在独立上下文里跑。子 agent 自己按 uid 迭代元素、等 aria-hidden 过渡、写最终 summary。主 agent 的上下文只增长了一次
task 调用 + 一次 summary 返回。
下面的 trace 就是子 agent 的内循环。每行是子 agent 发出的一次 browser 工具调用;agent loop 阻塞等结果再决定下一步。
Agent 的循环是:观察页面 → 选元素 uid
→ 执行 → 等结果稳定 → 再观察 → 再执行。每个真实的 click 或 type 前面都要有一次 extract 或
wait_for——这样模型发出的 uid(或 selector)才是从它刚读过的响应里得来的,而不是凭空猜。
起点:用户发了 “把这个招聘后台前 3 个候选人的联系方式抓出来”,agent 处于候选人列表页。下面每一行都是一次 browser
工具调用(= 一次 WS 往返);agent 循环在每一次调用阻塞等结果后才决定下一步。
| # | Action | 作用 | 返回 |
|---|---|---|---|
| 1 | extract()——不传 selector | 勘察页面。模型读 Markdown 并拿到元素 uid(e4/e5/e6 = 三个 “View” 按钮) | { markdown: "Top candidates:\n1. Jane Doe [View]\n2. John Smith [View]…", elements: [e4, e5, e6, …] } |
| 2 | wait_for(".candidate-row", 5000) | 确保列表渲染完再动手 | { ok: true } |
| ——候选人 1 · Jane Doe—— | |||
| 3 | click(uid: "e4") | 打开 Jane 的详情面板(uid 来自 step 1) | { ok: true }——CDP 发出 event.isTrusted 的真实点击 |
| 4 | wait_for(".candidate-detail[aria-hidden='false']", 5000) | 详情面板是滑入动画,等它可见 | { ok: true } |
| 5 | extract(".candidate-detail") | 读 Jane 的联系方式;也带出面板关闭按钮的 uid(e9) | { markdown: "Name: Jane Doe\nEmail: jane@…\nPhone: …", elements: [e9 (Close), …] } |
| 6 | click(".candidate-detail .close-btn") | 关掉面板让列表恢复可操作 | { ok: true } |
| 7 | wait_for(".candidate-detail[aria-hidden='true']", 3000) | 面板已关 | { ok: true } |
| ——候选人 2 · John Smith——(跳过 step 2;列表还在) | |||
| 8 | click(uid: "e5") | 打开 John 的详情面板 | { ok: true } |
| 9 | wait_for(".candidate-detail[aria-hidden='false']", 5000) | 面板可见 | { ok: true } |
| 10 | extract(".candidate-detail") | 读 John 的联系方式(关闭按钮 uid e12) | { markdown: "Name: John Smith\nEmail: john@…\nPhone: …", elements: [e12 (Close), …] } |
| 11 | click(".candidate-detail .close-btn") | 关面板 | { ok: true } |
| 12 | wait_for(".candidate-detail[aria-hidden='true']", 3000) | 面板已关 | { ok: true } |
| ——候选人 3 · Alice Chen—— | |||
| 13 | click(uid: "e6") | 打开 Alice 的详情面板 | { ok: true } |
| 14 | wait_for(".candidate-detail[aria-hidden='false']", 5000) | 面板可见 | { ok: true } |
| 15 | extract(".candidate-detail") | 读 Alice 的联系方式(关闭按钮 uid e15) | { markdown: "Name: Alice Chen\nEmail: alice@…\nPhone: …", elements: [e15 (Close), …] } |
| 16 | click(".candidate-detail .close-btn") | 关面板 | { ok: true } |
| 17 | wait_for(".candidate-detail[aria-hidden='true']", 3000) | 面板已关 | { ok: true } |
| ——BUA 结束,交回给用户—— | |||
| F | 模型输出最终文本(不再发工具调用) | 汇总三位候选人的信息 | "Jane Doe (jane@…) · John Smith (john@…) · Alice Chen (alice@…)" |
合计:17 次 browser 工具调用 + 1 次最终文本输出。第 2、3 次迭代跳过 step 2 的 wait_for
——列表在第一次迭代关闭详情面板后仍然活着,这个小优化是模型从上一步的成功记忆里自己推断出的。
Trace 暴露出的模式
- extract 先于动作——永远不要对模型没见过的元素动手。没有第 1 步,第 3 步就没有
uid可用;有了第 1 步,e4直接来自 extract 的elements列表(Markdown 也给任何 selector 兜底做根据) - wait_for 是每次异步切换的前后门——第 4 和第 7 步等
aria-hidden翻转(selector 状态谓词,正是 uid 不适用的场景)。在 SPA 里,这是“快网下能跑”和“稳定能跑”的区别 - 循环在 agent 循环里——BUA 没有
for-each动作。模型每轮迭代都发一组新的click/wait/extract;迭代次数是 agent plan 里隐式的 - 单 session 里多动作——所有调用打到同一个
(domain, tabId)session。UX 优先模型下 session 不会按时间过期;只在 tab 关 / 用户 idle / Stop / 加入 blocklist 时结束。若用户中途按 Stop all,3’ 会返回session_not_found,agent 停下来、带着部分结果报告
出错时的样子
把第 4 步换成不顺利的路径——详情面板超过 5 秒才加载:
| # | Action | 返回 |
|---|---|---|
| 4 | wait_for(".candidate-detail[aria-hidden='false']", 5000) | { error: { code: "timeout", message: "waiting for .candidate-detail[aria-hidden=false]" } } |
| 4b | extract()——不传 selector | { markdown: "…加载中… 正在加载候选人详情", elements: [] } |
| 4c | wait_for(".candidate-detail[aria-hidden='false']", 20000) | { ok: true } |
模型把 timeout 理解为重新 extract 调查的信号,不是重试同一个 wait——这正是 BROWSER_TOOL_INSTRUCTIONS 里对它的指引。对比
element_stale / element_not_found:模型会重新 extract 拿到新的 uid 句柄。
这就是为什么工具 prompt 按错误码逐个说明处理方式,而不是一条“有错就重试”的通用规则:每个错误码意味着下一步不同。
新增一个 action
只改三个文件。不需要新建 ServerToolConfig、不需要注册新工具、不需要动 subagent:
- Schema——在
@zapvol/common/schemas/browser-bridge.ts的browserActionSchema里加变体 - 扩展 dispatcher——在
src/action-dispatcher.ts的executeActionswitch 加case;如果动作需要新的 CDP 命令,在src/debugger-controller.ts加一个方法 - Prompt——在
BROWSER_TOOL_INSTRUCTIONS(角色中性的工具参考)的动作表里加一行;如果新 action 改变了子 agent 的预期行为(比如新的终止错误语义),同时改browser-subagent.ts里的BROWSER_SUBAGENT_INSTRUCTIONS
对比:新增一个 filesystem 工具要动
TOOL_KEY_TO_NAMES、TOOL_CONFIG_METAS、clientToolConfigs、init-tools.ts、工具文件本身。工具组形态每个操作要走的接线更多。
注:子 agent 的 toolKeys(
["browser", "complete"])不会因为加 action 而扩展——action 是browser这个单一工具内的变体,不是新的工具注册。只有当你想给浏览器 subagent 加不同能力(比如给它 filesystem 工具做 artifact 交付)时才改 toolKeys。
相关
- 运行时拓扑——扩展和后端各在哪
- 协议——消息信封和 action schema 细节
- Session 模型——扩展 dispatcher 里强制的授权规则