159 lines
5.9 KiB
TypeScript
159 lines
5.9 KiB
TypeScript
import type { AgentBrowserModule } from '../../../agent-browser';
|
|
import type { AgentBrowserCdpResult, AgentBrowserSnapshot } from '../../../../shared/agent-browser';
|
|
import type { CodingAttachmentStore } from '../../../coding-projects/attachment-store';
|
|
import type { AgentBrowserDetailsV1 } from '../../contracts';
|
|
|
|
export interface AgentBrowserToolContext {
|
|
projectId: string;
|
|
projectPath: string;
|
|
}
|
|
|
|
export interface AgentBrowserToolResult {
|
|
content: Array<{ type: 'text'; text: string }>;
|
|
details: AgentBrowserDetailsV1;
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('Agent browser input is invalid');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function string(value: unknown, required = false): string | undefined {
|
|
const result = typeof value === 'string' ? value.trim() : '';
|
|
if (required && !result) throw new Error('Agent browser input is incomplete');
|
|
return result || undefined;
|
|
}
|
|
|
|
function integer(value: unknown): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error('Agent browser number is invalid');
|
|
return value as number;
|
|
}
|
|
|
|
function publicSnapshot(snapshot: AgentBrowserSnapshot): Omit<AgentBrowserSnapshot, 'projectPath'> {
|
|
const { projectPath: _projectPath, ...safe } = snapshot;
|
|
return safe;
|
|
}
|
|
|
|
function result(
|
|
action: AgentBrowserDetailsV1['action'],
|
|
value: unknown,
|
|
attachment?: { attachmentId: string; mime: string },
|
|
): AgentBrowserToolResult {
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
details: {
|
|
schema: 'agent-browser.v1',
|
|
action,
|
|
...(attachment ?? {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function readPayloadJson(
|
|
browser: AgentBrowserModule,
|
|
projectPath: string,
|
|
payload: Extract<AgentBrowserCdpResult, { kind: 'payload' }>,
|
|
): Promise<unknown> {
|
|
let offset = 0;
|
|
let data = '';
|
|
while (offset < payload.byteLength) {
|
|
const chunk = await browser.readPayload({ projectPath, handle: payload.handle, offset });
|
|
if (chunk.encoding !== 'utf8') throw new Error('Browser screenshot payload encoding is invalid');
|
|
data += chunk.data;
|
|
offset = chunk.nextOffset;
|
|
if (chunk.done) break;
|
|
}
|
|
return JSON.parse(data);
|
|
}
|
|
|
|
export class PiAgentBrowserTool {
|
|
constructor(
|
|
private readonly browser: AgentBrowserModule,
|
|
private readonly attachments: CodingAttachmentStore,
|
|
) {}
|
|
|
|
async execute(
|
|
context: AgentBrowserToolContext,
|
|
input: unknown,
|
|
): Promise<AgentBrowserToolResult> {
|
|
const body = record(input);
|
|
const action = string(body.action, true) as AgentBrowserDetailsV1['action'];
|
|
if (action === 'status') {
|
|
return result(action, publicSnapshot(await this.browser.getSnapshot(context.projectPath)));
|
|
}
|
|
if (action === 'open') {
|
|
const snapshot = await this.browser.open({
|
|
projectId: context.projectId,
|
|
projectPath: context.projectPath,
|
|
url: string(body.url, true) as string,
|
|
visible: false,
|
|
});
|
|
return result(action, publicSnapshot(snapshot));
|
|
}
|
|
if (action === 'close') {
|
|
return result(action, publicSnapshot(await this.browser.close(context.projectPath)));
|
|
}
|
|
if (action === 'reset_profile') {
|
|
return result(action, publicSnapshot(await this.browser.resetProfile(context.projectPath)));
|
|
}
|
|
if (action === 'navigate') {
|
|
const navigation = string(body.navigation, true);
|
|
if (!navigation || !['url', 'back', 'forward', 'reload'].includes(navigation)) {
|
|
throw new Error('Agent browser navigation is invalid');
|
|
}
|
|
const snapshot = await this.browser.navigate({
|
|
projectPath: context.projectPath,
|
|
action: navigation as 'url' | 'back' | 'forward' | 'reload',
|
|
...(body.url === undefined ? {} : { url: string(body.url) }),
|
|
});
|
|
return result(action, publicSnapshot(snapshot));
|
|
}
|
|
if (action === 'read_events') {
|
|
const methods = body.methods === undefined
|
|
? undefined
|
|
: Array.isArray(body.methods) && body.methods.every((item) => typeof item === 'string')
|
|
? body.methods.map((item) => item.trim()).filter(Boolean)
|
|
: (() => { throw new Error('Agent browser event methods are invalid'); })();
|
|
return result(action, await this.browser.readEvents({
|
|
projectPath: context.projectPath,
|
|
after: integer(body.after),
|
|
methods,
|
|
limit: integer(body.limit),
|
|
waitMs: integer(body.waitMs),
|
|
}));
|
|
}
|
|
if (action === 'read_payload') {
|
|
return result(action, await this.browser.readPayload({
|
|
projectPath: context.projectPath,
|
|
handle: string(body.handle, true) as string,
|
|
offset: integer(body.offset),
|
|
maxBytes: integer(body.maxBytes),
|
|
}));
|
|
}
|
|
if (action !== 'send_cdp') throw new Error('Agent browser action is invalid');
|
|
const method = string(body.method, true) as string;
|
|
const params = body.params === undefined ? undefined : record(body.params);
|
|
const cdp = await this.browser.sendCdp({
|
|
projectPath: context.projectPath,
|
|
method,
|
|
params,
|
|
sessionRef: string(body.sessionRef),
|
|
timeoutMs: integer(body.timeoutMs),
|
|
});
|
|
if (method !== 'Page.captureScreenshot') return result(action, cdp);
|
|
const projected = cdp.kind === 'inline' ? cdp.value : await readPayloadJson(this.browser, context.projectPath, cdp);
|
|
const data = record(projected).data;
|
|
if (typeof data !== 'string' || !data) throw new Error('Browser screenshot result is invalid');
|
|
const format = string(params?.format) ?? 'png';
|
|
const mime = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
|
|
const attachment = await this.attachments.put(Buffer.from(data, 'base64'), mime);
|
|
return result(action, { attachmentId: attachment.attachmentId, mime }, {
|
|
attachmentId: attachment.attachmentId,
|
|
mime,
|
|
});
|
|
}
|
|
}
|