Files
goodbuddy/src/main/browser/browser-model-tools.ts
T
2026-08-05 19:40:40 +08:00

382 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
const MAX_REF_LENGTH = 64
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)
}
}