feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+379
View File
@@ -0,0 +1,379 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type {
ModelToolDefinition,
ModelToolResult
} from '../agent/model-tool-provider'
import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service'
const MAX_REF_LENGTH = 64
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const refSchema = z
.string()
.min(1)
.max(MAX_REF_LENGTH)
.regex(/^b_[A-Za-z0-9_-]{1,61}$/u, '')
export const browserNavigateInputSchema = z
.object({
url: z.string().min(1).max(8_192)
})
.strict()
export const browserSnapshotInputSchema = z.object({}).strict()
export const browserClickInputSchema = z
.object({
ref: refSchema
})
.strict()
export const browserTypeInputSchema = z
.object({
ref: refSchema,
text: z.string().min(1).max(MAX_INPUT_LENGTH)
})
.strict()
export const browserSelectInputSchema = z
.object({
ref: refSchema,
value: z.string().min(1).max(MAX_SELECT_LENGTH)
})
.strict()
export const browserBackInputSchema = z.object({}).strict()
export const browserScreenshotInputSchema = z.object({}).strict()
type BrowserToolName =
| 'browser_navigate'
| 'browser_snapshot'
| 'browser_click'
| 'browser_type'
| 'browser_select'
| 'browser_back'
| 'browser_screenshot'
function getBrowserToolMetadata(name: BrowserToolName) {
const summary = builtinModelTools.find((tool) => tool.name === name)
if (!summary) {
throw new Error(`缺少内置浏览器工具定义:${name}`)
}
return {
name,
displayName: summary.displayName,
description: summary.description,
source: 'builtin' as const
}
}
const definitions = [
{
...getBrowserToolMetadata('browser_navigate'),
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
minLength: 1,
maxLength: 8_192,
description: '完整的公开 HTTP(S) URL'
}
},
required: ['url'],
additionalProperties: false
},
},
{
...getBrowserToolMetadata('browser_snapshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_click'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
}
},
required: ['ref'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_type'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
text: {
type: 'string',
minLength: 1,
maxLength: MAX_INPUT_LENGTH,
description: '要输入的文本(审批界面不会显示内容)'
}
},
required: ['ref', 'text'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_select'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
value: {
type: 'string',
minLength: 1,
maxLength: MAX_SELECT_LENGTH
}
},
required: ['ref', 'value'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_back'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_screenshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
}
] as const satisfies readonly ModelToolDefinition[]
export type BrowserToolService = Pick<
BrowserService,
| 'getOrigin'
| 'navigate'
| 'snapshot'
| 'click'
| 'type'
| 'select'
| 'back'
| 'screenshot'
| 'releaseConversation'
>
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
}
function createTextResult(value: unknown): ModelToolResult {
const text = JSON.stringify(value)
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
function safeOrigin(value: string | undefined): string {
return value ?? '尚未导航'
}
function navigationLabel(url: URL): string {
const pathname =
url.pathname.length > 500 ? `${url.pathname.slice(0, 500)}` : url.pathname
return `${url.origin}${pathname}${url.search ? '?[查询参数已隐藏]' : ''}`
}
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
}
listTools(): ModelToolDefinition[] {
return definitions.map((definition) => ({
...definition,
inputSchema: { ...definition.inputSchema }
}))
}
ownsTool(name: string): name is BrowserToolName {
return definitions.some((definition) => definition.name === name)
}
getApproval(
tool: ModelToolDefinition | BrowserToolName,
argumentsValue: Record<string, unknown>,
argumentSummaryFromRuntime?: string
): RuntimeApprovalRequest {
void argumentSummaryFromRuntime
const name = typeof tool === 'string' ? tool : tool.name
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
)
let description: string
let argumentSummary: string
let scopeKey: string
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
const target = canonicalizeBrowserUrl(input.url)
const label = navigationLabel(target)
description = `将在隔离浏览器中访问 ${label}。仅允许公开 HTTP(S) 地址。`
argumentSummary = label
scopeKey = `model:browser:navigate:${target.origin}`
} else if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
description = `读取 ${currentOrigin} 的页面结构;可编辑字段值会被隐藏。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:snapshot:${currentOrigin}`
} else if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
description = `点击 ${currentOrigin} 页面中的元素 ${input.ref}`
argumentSummary = `元素:${input.ref};来源:${currentOrigin}`
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
} else if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
// A session approval must never authorize a later value, even for the
// same element. The nonce intentionally makes this invocation-only.
scopeKey = `model:browser:type:${randomUUID()}`
} else if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的选择控件 ${input.ref} 选择已隐藏的值。`
argumentSummary = `元素:${input.ref};选项值:[已隐藏,${input.value.length} 个字符]`
scopeKey = `model:browser:select:${randomUUID()}`
} else if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
description = `${currentOrigin} 返回浏览器历史记录中的上一页。目标仍需通过 URL 安全策略。`
argumentSummary = `当前来源:${currentOrigin}`
scopeKey = `model:browser:back:${randomUUID()}`
} else {
browserScreenshotInputSchema.parse(argumentsValue)
description = `截取 ${currentOrigin} 当前可见页面区域。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:screenshot:${currentOrigin}`
}
const definition = definitions.find((item) => item.name === name)
if (!definition) {
throw new Error(`未知浏览器工具:${name}`)
}
return {
scopeKey,
title: `允许${definition.displayName}`,
description,
toolName: definition.displayName,
argumentSummary,
allowPermanent: false
}
}
async callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
await this.service.type(
this.conversationId,
input.ref,
input.text,
signal
)
return createTextResult({
typed: input.ref,
text: '[已隐藏]',
characters: input.text.length
})
}
if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
await this.service.select(
this.conversationId,
input.ref,
input.value,
signal
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
)
return {
parts: [
{
type: 'image',
mimeType: screenshot.mimeType,
data: screenshot.data
}
],
contextBytes: Buffer.byteLength(screenshot.data)
}
}
async release(): Promise<void> {
await this.service.releaseConversation(this.conversationId)
}
}