feat(browser): vision automation loop with screenshots, coordinate actions and bounded challenge solving

- browser.screenshot returns the page as an image content block (≤1280px wide, JPEG quality
  configurable) for vision-capable upstreams; non-vision upstreams get a redacted text snapshot.
  Login pages / password fields are marked sensitive and withheld unless a
  browser.sensitive_screenshot grant exists and allowSensitive is passed.
- browser.click_at / type_at / scroll / wait act in screenshot pixels; the server converts via
  viewport/DPR, requires automation control and an allowlisted host, and writes a thumbnail
  evidence file (DATA_DIR/tmp/evidence) referenced from agent_run_event.
- browser.solve_challenge runs a bounded child agent_run (vision caps only, maxSteps ≤ 12,
  budget), judges success from page facts, stops immediately on 2FA / SMS / device confirmation,
  and otherwise moves the session to waiting with evidence and a takeover link.
- PiAgentRuntime passes modelImage outputs as pi-ai image content when model.input has image;
  the ledger never stores image data; coordinate actions use per-invocation idempotency keys.
- Tests: image codec, runtime image content, E14 e2e (login solved, 2FA stop, step limit,
  sensitive withheld). Real camofox 1.17.0 check on example.com recorded in the runbook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:27:48 +08:00
1 parent 0765264a47
commit c437c6bff6
34 files changed
+2194 -35

No files matched your search

+164
View File
@@ -445,6 +445,164 @@ export const BrowserDownloadOutput = z.object({
note: z.string().nullable(),
});
// ── VIS-1 视觉自动化闭环 ──
// 截图以图片内容块交给模型:能力输出里的 modelImage 由 Agent 运行时摘出,按上游是否支持图片输入转成
// image content(不支持时降级为文字快照并提示);JSON 文本里不再带图片数据。
/** 能力输出里交给模型的图片(运行时摘出为 image content,不进 JSON 文本、不进操作账本) */
export const ModelImage = z.object({
mimeType: z.enum(["image/png", "image/jpeg"]),
/** base64 */
data: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export type ModelImage = z.infer<typeof ModelImage>;
/** 运行时识别图片的键名 */
export const MODEL_IMAGE_KEY = "modelImage" as const;
/** 每步证据:截图缩略图存 DATA_DIR/tmp/evidence,这里只给引用 */
export const BrowserEvidenceRef = z.object({
id: Id,
/** 相对 DATA_DIR/tmp 的路径,如 evidence/<id>.jpg */
ref: z.string(),
capturedAt: z.string(),
});
export type BrowserEvidenceRef = z.infer<typeof BrowserEvidenceRef>;
/** 截图像素 ↔ 页面 CSS 像素的换算依据 */
export const BrowserCoordinateSpace = z.object({
/** 交给模型的截图尺寸(坐标以它为准) */
imageWidth: z.number().int().positive(),
imageHeight: z.number().int().positive(),
/** 浏览器原始截图尺寸 */
sourceWidth: z.number().int().positive(),
sourceHeight: z.number().int().positive(),
/** 页面视口(CSS 像素)与设备像素比;取不到时按原始截图尺寸、DPR 1 推定 */
viewportWidth: z.number().positive(),
viewportHeight: z.number().positive(),
devicePixelRatio: z.number().positive(),
assumed: z.boolean(),
});
export type BrowserCoordinateSpace = z.infer<typeof BrowserCoordinateSpace>;
const BrowserShotFields = {
evidence: BrowserEvidenceRef.nullable(),
/** 已脱敏的页面地址 */
url: z.string().nullable(),
controller: BrowserController,
/** 登录页 / 含密码框 / 地址带凭据:截图敏感,默认不交给模型 */
sensitive: z.boolean(),
/** 本次截图是否作为图片交给了模型 */
imageForModel: z.boolean(),
/** 没交给模型时的原因与替代:上游不支持图片输入时附文字快照 */
note: z.string().nullable(),
textSnapshot: z.string().nullable(),
coordinates: BrowserCoordinateSpace.nullable(),
/** 会话转为等待处理的原因(二次验证等);非 null 时自动化动作已停止 */
waitingReason: z.string().nullable(),
modelImage: ModelImage.nullable(),
};
export const BrowserScreenshotInput = z.object({
sessionId: Id,
tabId: z.string().min(1).optional(),
/** 敏感截图也交给模型:需要 browser.sensitive_screenshot 持续授权,否则忽略 */
allowSensitive: z.boolean().optional(),
});
export const BrowserScreenshotOutput = z.object(BrowserShotFields);
const Coord = z.number().min(0).max(10_000);
export const BrowserClickAtInput = z.object({
sessionId: Id,
x: Coord,
y: Coord,
/** camofox 1.17.0 只支持左键 */
button: z.enum(["left", "right", "middle"]).optional(),
allowSensitive: z.boolean().optional(),
});
export const BrowserTypeAtInput = z.object({
sessionId: Id,
/** 不给坐标 = 输入到当前焦点 */
x: Coord.optional(),
y: Coord.optional(),
text: z.string().min(1).max(500),
pressEnter: z.boolean().optional(),
allowSensitive: z.boolean().optional(),
});
export const BrowserScrollInput = z.object({
sessionId: Id,
dx: z.number().int().min(-5000).max(5000).default(0),
dy: z.number().int().min(-5000).max(5000).default(0),
allowSensitive: z.boolean().optional(),
});
export const BrowserWaitInput = z.object({
sessionId: Id,
ms: z.number().int().min(0).max(5000),
allowSensitive: z.boolean().optional(),
});
const BrowserActionOutput = z.object({
ok: z.literal(true),
/** 换算后实际下发给浏览器的页面坐标(CSS 像素) */
pageX: z.number().nullable(),
pageY: z.number().nullable(),
...BrowserShotFields,
});
export const BrowserClickAtOutput = BrowserActionOutput;
export const BrowserTypeAtOutput = BrowserActionOutput;
export const BrowserScrollOutput = BrowserActionOutput;
export const BrowserWaitOutput = BrowserActionOutput;
/** 成功条件(调用方给出,服务端按页面事实判定;至少给一项,全部满足才算成功) */
export const BrowserChallengeSuccess = z
.object({
/** 地址包含 */
urlContains: z.string().min(1).max(500).optional(),
/** 地址与开始时不同 */
urlChanged: z.boolean().optional(),
/** 页面文字包含 */
textPresent: z.string().min(1).max(200).optional(),
/** 页面文字不再包含 */
textAbsent: z.string().min(1).max(200).optional(),
})
.refine((v) => !!v.urlContains || v.urlChanged === true || !!v.textPresent || !!v.textAbsent, {
message: "成功条件至少给一项",
});
export type BrowserChallengeSuccess = z.infer<typeof BrowserChallengeSuccess>;
export const BrowserSolveChallengeInput = z.object({
sessionId: Id,
/** 要达成的目标(给子流程模型看),如"通过 Cloudflare 人机验证进入种子列表" */
goal: z.string().trim().min(1).max(500),
success: BrowserChallengeSuccess,
/** 子流程的工具调用步数上限(一步 = 一次截图 / 点击 / 输入 / 滚动 / 等待) */
maxSteps: z.number().int().min(1).max(12).default(8),
budget: z
.object({
maxTokens: z.number().int().min(1000).max(2_000_000).optional(),
maxCostUsd: z.number().min(0).max(50).optional(),
maxDurationSec: z.number().int().min(10).max(600).optional(),
})
.default({}),
/** 敏感截图(登录页 / 含密码框)也交给模型:仍需 browser.sensitive_screenshot 持续授权 */
allowSensitive: z.boolean().optional(),
});
export const BrowserSolveChallengeOutput = z.object({
/** solved 成功条件已满足;needs_human 需要人工(二次验证、模型判定、上游不支持图片);exhausted 步数 / 预算用尽;failed 出错 */
status: z.enum(["solved", "needs_human", "exhausted", "failed"]),
reason: z.string(),
steps: NonNegInt,
/** 子流程的 Agent 运行(事件与每步证据在它的事件流里) */
childRunId: Id.nullable(),
url: z.string().nullable(),
controller: BrowserController,
evidence: z.array(BrowserEvidenceRef),
/** 转人工时的待处理事项与原站视图接管入口 */
decisionId: Id.nullable(),
takeover: z.object({ sessionId: Id, siteId: Id, path: z.string() }).nullable(),
});
// ── WEB-1 网页搜索(REQ §10.2、§10.5):结果与正文一律标为不可信数据 ──
/** 给模型看的不可信标注(工具输出里原样带上) */
@@ -540,6 +698,12 @@ export const CAPABILITY_IO = {
"browser.navigate": { input: BrowserNavigateInput, output: BrowserNavigateOutput },
"browser.interact": { input: BrowserInteractInput, output: BrowserInteractOutput },
"browser.download": { input: BrowserDownloadInput, output: BrowserDownloadOutput },
"browser.screenshot": { input: BrowserScreenshotInput, output: BrowserScreenshotOutput },
"browser.click_at": { input: BrowserClickAtInput, output: BrowserClickAtOutput },
"browser.type_at": { input: BrowserTypeAtInput, output: BrowserTypeAtOutput },
"browser.scroll": { input: BrowserScrollInput, output: BrowserScrollOutput },
"browser.wait": { input: BrowserWaitInput, output: BrowserWaitOutput },
"browser.solve_challenge": { input: BrowserSolveChallengeInput, output: BrowserSolveChallengeOutput },
"agent.plan": { input: AgentPlanInput, output: AgentPlanOutput },
"agent.propose_options": { input: AgentProposeOptionsInput, output: AgentProposeOptionsOutput },
"web.search": { input: WebSearchInput, output: WebSearchOutput },
+75
View File
@@ -76,6 +76,12 @@ export const CAPABILITY_IDS = [
"browser.navigate",
"browser.interact",
"browser.download",
"browser.screenshot",
"browser.click_at",
"browser.type_at",
"browser.scroll",
"browser.wait",
"browser.solve_challenge",
"agent.plan",
"agent.propose_options",
"web.search",
@@ -402,6 +408,75 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
false,
false,
),
// VIS-1 视觉自动化闭环:截图(图片内容块)→ 视觉模型 → 坐标操作 → 再截图。坐标以最近一次截图的像素为准,
// 服务端按视口 / DPR 换算;动作只在自动化控制权下、站点域名内执行,每步写证据缩略图;
// 二次验证 / 短信 / 本人设备确认不自动处理,立即转人工
"browser.screenshot": d(
"browser.screenshot",
"截取原站页面",
"截取站点浏览器会话当前页面(缩放到不超过 1280 像素宽,作为图片交给支持视觉的模型);后续 click_at / type_at 的坐标以这张截图的像素为准。登录页或含密码框的截图标为敏感,默认不交给模型",
"read",
"BrowserScreenshot",
"read",
"low",
true,
false,
),
"browser.click_at": d(
"browser.click_at",
"按坐标点击",
"在最近一次 browser.screenshot 的像素坐标处点击,返回点击后的新截图;只在自动化控制权下、站点域名内执行",
"execute",
"BrowserClickAt",
"admin",
"medium",
false,
false,
),
"browser.type_at": d(
"browser.type_at",
"按坐标输入",
"先点击截图坐标处的输入框(不给坐标则输入到当前焦点),再输入文本,可选回车提交;返回输入后的新截图。账号密码不要用它输入,用 browser.interact 的 credential",
"execute",
"BrowserTypeAt",
"admin",
"medium",
false,
false,
),
"browser.scroll": d(
"browser.scroll",
"滚动页面",
"按像素滚动当前页面(dy 正数向下、dx 正数向右),返回滚动后的新截图",
"execute",
"BrowserScroll",
"admin",
"low",
false,
false,
),
"browser.wait": d(
"browser.wait",
"等待页面",
"等待页面加载或动画完成(最多 5 秒),返回等待后的新截图",
"read",
"BrowserWait",
"read",
"low",
true,
false,
),
"browser.solve_challenge": d(
"browser.solve_challenge",
"处理页面挑战",
"起一个有界的视觉子流程(只能截图、按坐标点击 / 输入、滚动、等待、读页面),识别并处理页面上的挑战,直到调用方给出的成功条件满足;超出步数或预算、遇到二次验证 / 短信 / 本人设备确认时停止,会话转等待处理并附证据截图,由人在原站视图接管",
"execute",
"BrowserSolveChallenge",
"admin",
"medium",
false,
false,
),
// AG-2 自动化优先的会话交互:步骤清单与方案选择。只在 Agent 会话里可用(HTTP / MCP 调用直接报错)
"agent.plan": d(
"agent.plan",