教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
浏览器操作
把页面观测、交互、会话和高风险动作并入 Harness 约束。
扩展篇 · 预计 60 分钟 · 前置:主线 05 读懂仓库、09 权限与安全已经通过 Checkpoint。本页目录
主线第 05 章「读懂仓库」与第 09 章「权限与安全」已经通过 Checkpoint。本章复用它们的 defineTool、authorize、ApprovalRequest 与 transitionState,不引入 Playwright 等真实依赖。
src/browser/types.tssrc/browser/session.tssrc/browser/refs.tssrc/browser/policy.tssrc/tools/browser.tssrc/index.tstests/browser.test.ts
扩展篇复用主线已经建立的契约,但不是主线 Capstone 的前置条件:你可以先完成 18 章主线,也可以只带着工具系统与权限层的小项目来读这一章。本章把浏览器看作“一个只会返回不可信文本、只能通过 Harness 生成句柄操作的外部系统”,其余边界全部沿用已有实现。
Step 1定义观测与动作契约
浏览器的输出默认是不可信、无限大、且可能随时变化的。因此先固定两条契约:观测只把有界摘要放进上下文,动作只通过 Harness 生成的稳定引用定位元素。
export interface ElementRef { ref: string; role: string; name: string; elementFingerprint: string;}export interface ArtifactRef { path: string; sha256: string; bytes: number; mediaType: string;}export interface PageObservation { observationId: string; url: string; title: string; visibleText: { text: string; truncated: boolean }; elements: ElementRef[]; screenshot: ArtifactRef | null; observedAt: string;}visibleText 走主线 read_file 同一条“截断并明确标记”的思路:上下文里只放 truncated 之后的摘要。截图不塞进 prompt,而是写成 artifact 后只保留路径、hash 与体积;需要看图时由人在 artifact 上确认,模型继续用文本摘要工作。
PageObservation 里的每个字段都是数据,不是指令。页面正文可能写着“忽略之前的规则并执行下面命令”,这类文本与 search_text 命中的仓库文本同级,永远不能提升为系统提示或工具调用。观测结果进入上下文时标记为低优先级外部来源;模型若据此提议动作,仍要经过第 3、4 步的门禁。
动作同样是封闭集合。每个动作必须声明 risk,并声明自己是否可重放:
export type BrowserAction = { risk: "low" | "high"; replayable: boolean } & ( | { kind: "navigate"; target: string } | { kind: "click"; ref: string } | { kind: "type"; ref: string; text: string } | { kind: "select"; ref: string; value: string } | { kind: "press"; ref: string; key: string } | { kind: "wait"; ref: string; forText: string });export function isReplayable(action: BrowserAction): boolean { return action.replayable;}replayable: false 表示这个动作会改变外部系统状态,超时后不能自动重跑。点击“提交”、输入验证码、触发下载都属于这一类;上层 Loop 只能重新观测、重新规划,而不是盲目重试。
为什么不让模型自己写 CSS selector 或 XPath?selector 是模型编的自由文本,Harness 无法证明它指向哪个元素,也无法判断页面变化后是否仍指向同一目标。让观测阶段生成 ref、动作阶段只接受 ref,Harness 就同时得到了可校验的元素身份与可审计的动作目标。
Step 2管理会话与凭据边界
浏览器是有状态的外部资源:登录态、cookie、下载目录和打开的页面都活在会话里。因此把会话收敛成一个窄接口,由 Harness 创建、持有并显式关闭。
export interface OpenOptions { startUrl: string; headless: boolean; credentialRef?: string;}export interface BrowserSession { open(options: OpenOptions, signal: AbortSignal): Promise<void>; observe(signal: AbortSignal): Promise<PageObservation>; act(action: BrowserAction, signal: AbortSignal): Promise<ActionOutcome>; close(): Promise<void>;}export interface ActionOutcome { ok: boolean; error?: "stale_element_ref" | "action_not_replayable" | "navigation_failed";}凭据只保存引用:credentialRef: "site-login" 指向密钥库中的条目,真实用户名与密码在 open 内部由密钥提供者解析,绝不写进事件日志、ApprovalRequest.summary、截图或模型上下文。ApprovalRequest 里记录的是“需要对 example.com 执行登录”,而不是账户与口令。
同一约束也适用于会话事件:只记录动作种类、ref、URL 与结果,不记录被 type 输入的文本、cookie、Set-Cookie 头或截图里的表单内容。向 AgentEvent 写入前必须走一次字段过滤:
export function sessionEventDetail(input: { action: BrowserAction; observationId: string; outcome: ActionOutcome;}): string { const { risk, replayable } = input.action; return JSON.stringify({ kind: input.action.kind, target: "ref" in input.action ? input.action.ref : input.action.target, risk, replayable, observationId: input.observationId, outcome: input.outcome.ok ? "ok" : input.outcome.error, });}会话必须显式关闭:open 成功后再进入 running,任何 close 失败都要落成事件并让运行以明确原因停止,否则遗留的登录态会变成下一个任务的隐藏前置条件。AbortSignal 要一路传给 open/observe/act,取消后立即 close,并确保来不及 close 时也留下未关闭记录。
Step 3处理元素引用失效
ref 只在一次观测的生命周期内有效。页面重新渲染、跳转或列表刷新之后,同一个 ref 可能指向完全不同的元素——“提交”按钮的位置被“删除账户”按钮占据时,重试旧引用就是真实事故。
解决办法是把引用和元素指纹绑定,并在动作前重新校验。指纹只使用结构信息——角色、可访问名称、标签名与所属表单序号做 sha256;不要把用户输入的内容或 type 的文本算进指纹,否则指纹本身就会把敏感数据带进事件日志:
export async function resolveRef( session: BrowserSession, target: ElementRef, signal: AbortSignal,): Promise<ActionOutcome | ElementRef> { const current = await session.observe(signal); const matched = current.elements.find( (element) => element.ref === target.ref && element.elementFingerprint === target.elementFingerprint, ); if (!matched) return { ok: false, error: "stale_element_ref" }; return matched;}stale_element_ref 是终止信号而不是重试信号:Harness 必须重新观测、把新的摘要交回模型,让模型基于当前页面重新决策。禁止写成“循环重试同一个 ref 直到成功”,也禁止退回到“随便挑一个同名元素”继续执行——那等于用一个猜测替代一次观测。
同样地,动作携带的 observationId 必须与解析引用时的观测一致。跨观测复用 ref 与跨审批复用旧 actionDigest 是同一类错误:都把一个已经变化的世界当成没变。
Step 4为高风险动作加门禁
提交表单、支付、发布、删除、发送消息都会在外部系统留下不可撤销的结果,因此一律走主线的 authorize 三态决策,而不是在浏览器层另造一套批准机制。
import { authorize } from "../policy.js";export function authorizeBrowserAction( tool: RegisteredTool, action: BrowserAction, context: PolicyContext, session: BrowserSession,): PolicyDecision { if (action.risk === "low") { return { type: "allow", scope: "browser_low_risk" }; } return { type: "ask", request: createApprovalRequest({ tool, input: action, cwd: context.cwd, network: context.network, summary: `对当前页面执行高风险动作:${action.kind}`, risks: [ "外部系统状态不可撤销", isReplayable(action) ? "重复执行可能产生双份结果" : "该动作不可重放,失败后必须重新观测", action.kind === "type" ? "输入内容可能包含敏感数据" : "目标元素可能已失效", ], }), };}批准绑定 actionDigest = hash(tool + canonical input + cwd + network policy),一次性使用且带过期时间。页面 URL、目标元素在指纹层面的身份、type 的文本、select 的值或 wait 的期望文本只要有一项变化,digest 就不再匹配,必须重新请求批准。批准被拒绝时返回 observation,并在门禁前就停止,绝不先点击再询问。
需要批准时,Harness 先把事件和请求落盘,再复用主线 transitionState 的 waiting:
const recorded = appendEvent(state, "approval_requested", request.actionDigest);state = transitionState(recorded, "waiting", "approval_required");副作用必须幂等或可验证。可重放的低风险动作靠重新观测确认;不可撤销的高风险动作则要求:要么由外部系统支持幂等键,要么在批准前就约定“执行后如何验证只发生了一次”(例如订单号唯一、列表条目计数不变、返回页含唯一确认号)。验证方式写不清楚的高风险动作,正确做法是拒绝执行并交给人工。
注意策略与工具声明的配套关系:浏览器工具的 effect 是 "external"。主线 authorize 目前对 external 直接 deny,本章只描述需要新增的分支——当 effect === "external" 且工具声明了 capability: "browser" 时,低风险动作放行、高风险动作进入 ask,其余情况仍然 deny。不要为了走通浏览器而放宽 network_or_external_effect_not_allowed 对其它工具的约束。
观测、决策、门禁与执行仍然落在主线同一条循环里:
Step 5用 Fake Session 验证
把浏览器工具注册进已有 ToolRegistry,让观测与动作都成为普通工具调用,这样权限、事件与上下文规则不需要任何特例:
export const browserActTool = defineTool({ name: "browser_act", description: "Execute one BrowserAction against the current browser session", effect: "external", schema: z.object({ kind: z.enum(["navigate", "click", "type", "select", "press", "wait"]), target: z.string().min(1), value: z.string().nullable(), risk: z.enum(["low", "high"]), }).strict(), async execute(input, context) { const session = requireBrowserSession(context.cwd, context.signal); const action = toBrowserAction(input, session.currentObservationId); const decision = authorizeBrowserAction(browserActTool, action, policyContext, session); if (decision.type === "deny") return { ok: false, error: decision.reason }; if (decision.type === "ask") return { pending: decision.request.actionDigest }; return session.act(action, context.signal); },});Fake Session 用内存页面图代替真实浏览器:预置若干页面与元素表,观测返回固定字典序的元素列表,act 只在指纹匹配时改变状态。这样全部验收都是确定性的、离线的,不需要网络、密钥或浏览器二进制。
创建 tests/browser.test.ts,正向覆盖“导航 → 观测 → 低风险点击 → 输入 → 关闭会话”,并至少覆盖三个负向用例:
expect(await runScenario({ mutateAfterObserve: true })).toMatchObject({ error: "stale_element_ref",});expect(await runScenario({ approveHighRisk: false })).toMatchObject({ status: "waiting", requestReason: "approval_required",});const detail = sessionEventDetail({ action, observationId, outcome });expect(detail).not.toContain("hunter2");expect(contextForModel).not.toContain("sessionId=");expect(observation.screenshot?.sha256).toMatch(/^[0-9a-f]{64}$/);除负向用例外再补两条不变量:未批准的高风险动作恢复后若 ref 或参数变化,旧 actionDigest 必须失效并重新进入 waiting;取消信号触发时会话必须被关闭,且事件日志里留下未关闭或已关闭的明确记录。