作为 agent 的工具

BUA 如何暴露给主 agent——内建 subagent 把 `browser` 工具(action 判别联合)作为私有工具箱。覆盖委派封装、工具解剖、一次调用的端到端流、以及模型如何把页面读成 Markdown、再以短 uid 句柄定位元素。

一句话

BUA 被封装成一个内建 subagentsubagent_type: "browser"),私有工具箱只有一个 browser 工具——Zod discriminatedUnion 的 13 种 action(navigateclicktype…)。主 agent 不直接看到 browser,通过标准 task 工具委派。在子 agent 的独立上下文里,浏览器循环(extract → 推理 → click → extract → …)对用户的已登录 Chrome 走 CDP 执行。

一次委派进去(从主 agent),一次 summary 出来(从子 agent)。N 步内循环不接触主对话。

三种工具配置形态

代码库里有三种形态。按操作家族的特性挑选。

三种工具配置形态 (Three tool-config shapes) 一个 config key → 一个或多个 tool name → 一份或多份 input schema 单工具平铺 1 : 1 —— 默认形态 reflect · complete · write_todos reflect config key 1 reflect tool name (AI SDK) 1 份扁平 schema prompt 成本: 低 工具组 1 : N —— 输出异质 filesystem · memory filesystem config key N = 6 ls read_file write_file edit_file grep glob 6 个 tool name (AI SDK) 6 份独立 schema prompt 成本: N 份 schema 单工具 + actions 1 : 1 —— 判别联合 input browser · computer-use browser config key 1 browser tool name (AI SDK) oneOf 13 种 action 变体: navigate · click · type extract · screenshot · … prompt 成本: 1 份 schema 按输出 shape 异质性选 —— 相似用"单工具 + actions",异质用"工具组"

形态config : tool nameInput schema例子
单工具平铺1 : 11 份扁平对象reflectcompletewrite_todos
工具组 (group)1 : NN 份独立 schemafilesystemmemory
单工具 + actions1 : 11 份判别联合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 一次 task 调用进、一次 summary 出 —— N 次 browser action 留在子 agent 的上下文里 主 agent (Main agent) 已累积上下文 · 约 M tokens [用户提问] [思考] · [其它 tool 调用] · … task({ subagent_type: "browser", description, prompt }) 收到 summary 继续 上下文增加 1 tool_call + 1 tool_result —— 内部 N 次 action 主 agent 看不到 浏览器子 agent (Browser subagent) 全新 RuntimeContext · 不继承父对话 toolKeys: [browser, complete] 继承 browserBridge · taskId 沿用父 内循环 ×N (Inner loop) open_tab → extract → click → wait_for → extract → … 全走 CDP · 不触达主 agent 上下文 complete({ summary, paths: [] }) 委派 (delegate) { summary }

为什么要封装成子 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 };不继承 messagestodosreminderswriter
  • browserBridgetoolServices: { 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)。

sequenceDiagram participant M as Model participant E as execute participant B as bridge participant P as Pool participant X as Ext participant C as CDP M->>E: tool_call (click) E->>B: 转发 input B->>P: pool.request P->>X: ws request Note over P,X: WebSocket 边界 X->>X: checkScope X->>C: dispatchMouseEvent C-->>X: ok X-->>P: ws response P-->>B: resolve B-->>E: ok + result E-->>M: tool_result

模型看到一次工具调用;后端看到一次 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 里的可交互节点,每个带一个短 uide0e1…)。这是模型给下一个 action 用的句柄词汇表。
  • text——原始 innerText 兜底。

定位元素的 action(clicktypehoverwait_for)只接收 uidselector 之一(一个 Zod .refine 强制异或)。模型被指引优先用 uid——它更短、省 token、抗 class 名变动——只有在没有合适 uid 时(比如属性状态谓词)才用 CSS selector

谁能访问什么

能碰真实 DOM 吗?经过它的内容
Chrome tab能(它就是 DOM)接收 CDP 命令,派发真实输入事件
扩展间接——经 chrome.debugger读 a11y tree + DOM → 返回 { text, markdown, elements };把 uidbackendNodeId → box-model 中心解析出来做输入
后端不能纯透传——把 action 序列化发 WS,把 response 反序列化回来。永不解析标记,永不解析 uid 或 selector
LLM (经 AI SDK)不能在 context 窗口里看 Markdown 摘要 + elements 列表;对想要的元素发出一个 uid(或一个 CSS selector)

后端仍然是一根字符串水管,没有共享的 DOM 数据模型。唯一的状态是一张 uid → backendNodeId 映射,它活在扩展里:每次 extract 重建,导航时清空(Page.frameNavigated / 一个 navigate action)。所以确实有一层轻量绑定——但它在扩展边缘,不在后端。

一次具体往返

  1. 模型发出 browser({ type: "extract" })(不传 selector → 整页)
  2. 扩展遍历 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])。
  3. 后端把载荷原样传回 AI SDK,进入模型 context 窗口。
  4. 模型决定打开 Jane 的面板,发出 browser({ type: "click", uid: "e4" })——不用自己写 selector。
  5. 后端把 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 的 uide0e1…)正是这个。
  • 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 前面都要有一次 extractwait_for——这样模型发出的 uid(或 selector)才是从它刚读过的响应里得来的,而不是凭空猜。

多步骤 BUA —— 抓取候选人详情 观察 · 执行 · 同步 —— 按候选人循环 观察 (extract) 执行 (click/type) 同步 (wait_for) 最终 (模型文本) extract() 勘察整页 · 一次 按候选人循环 (Per-candidate loop) × N wait_for(".candidate-row", 5000) 列表渲染完 click(uid: "eN") — uid 来自 survey extract 打开详情 wait_for(".candidate-detail[aria-hidden='false']") 面板可见 extract(".candidate-detail") 读联系方式 click(".candidate-detail .close-btn") 关面板 wait_for(".candidate-detail[aria-hidden='true']") 面板已关 每个候选人重复 模型输出最终文本 总结 · 一次 3 个候选人 ≈ 17 次工具调用 —— 每次一个 WebSocket 往返

起点:用户发了 “把这个招聘后台前 3 个候选人的联系方式抓出来”,agent 处于候选人列表页。下面每一行都是一次 browser 工具调用(= 一次 WS 往返);agent 循环在每一次调用阻塞等结果后才决定下一步。

#Action作用返回
1extract()——不传 selector勘察页面。模型读 Markdown 并拿到元素 uid(e4/e5/e6 = 三个 “View” 按钮){ markdown: "Top candidates:\n1. Jane Doe [View]\n2. John Smith [View]…", elements: [e4, e5, e6, …] }
2wait_for(".candidate-row", 5000)确保列表渲染完再动手{ ok: true }
——候选人 1 · Jane Doe——
3click(uid: "e4")打开 Jane 的详情面板(uid 来自 step 1){ ok: true }——CDP 发出 event.isTrusted 的真实点击
4wait_for(".candidate-detail[aria-hidden='false']", 5000)详情面板是滑入动画,等它可见{ ok: true }
5extract(".candidate-detail")读 Jane 的联系方式;也带出面板关闭按钮的 uid(e9{ markdown: "Name: Jane Doe\nEmail: jane@…\nPhone: …", elements: [e9 (Close), …] }
6click(".candidate-detail .close-btn")关掉面板让列表恢复可操作{ ok: true }
7wait_for(".candidate-detail[aria-hidden='true']", 3000)面板已关{ ok: true }
——候选人 2 · John Smith——(跳过 step 2;列表还在)
8click(uid: "e5")打开 John 的详情面板{ ok: true }
9wait_for(".candidate-detail[aria-hidden='false']", 5000)面板可见{ ok: true }
10extract(".candidate-detail")读 John 的联系方式(关闭按钮 uid e12{ markdown: "Name: John Smith\nEmail: john@…\nPhone: …", elements: [e12 (Close), …] }
11click(".candidate-detail .close-btn")关面板{ ok: true }
12wait_for(".candidate-detail[aria-hidden='true']", 3000)面板已关{ ok: true }
——候选人 3 · Alice Chen——
13click(uid: "e6")打开 Alice 的详情面板{ ok: true }
14wait_for(".candidate-detail[aria-hidden='false']", 5000)面板可见{ ok: true }
15extract(".candidate-detail")读 Alice 的联系方式(关闭按钮 uid e15{ markdown: "Name: Alice Chen\nEmail: alice@…\nPhone: …", elements: [e15 (Close), …] }
16click(".candidate-detail .close-btn")关面板{ ok: true }
17wait_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返回
4wait_for(".candidate-detail[aria-hidden='false']", 5000){ error: { code: "timeout", message: "waiting for .candidate-detail[aria-hidden=false]" } }
4bextract()——不传 selector{ markdown: "…加载中… 正在加载候选人详情", elements: [] }
4cwait_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:

  1. Schema——在 @zapvol/common/schemas/browser-bridge.tsbrowserActionSchema 里加变体
  2. 扩展 dispatcher——在 src/action-dispatcher.tsexecuteAction switch 加 case;如果动作需要新的 CDP 命令,在 src/debugger-controller.ts 加一个方法
  3. Prompt——在 BROWSER_TOOL_INSTRUCTIONS(角色中性的工具参考)的动作表里加一行;如果新 action 改变了子 agent 的预期行为(比如新的终止错误语义),同时改 browser-subagent.ts 里的 BROWSER_SUBAGENT_INSTRUCTIONS

对比:新增一个 filesystem 工具要动 TOOL_KEY_TO_NAMESTOOL_CONFIG_METASclientToolConfigsinit-tools.ts、工具文件本身。工具组形态每个操作要走的接线更多。

注:子 agent 的 toolKeys(["browser", "complete"])不会因为加 action 而扩展——action 是 browser 这个单一工具内的变体,不是新的工具注册。只有当你想给浏览器 subagent 加不同能力(比如给它 filesystem 工具做 artifact 交付)时才改 toolKeys。

相关

这页有帮助吗?