Files
makelore/electron/coding-runtime/pi/extensions/agent-browser.ts

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,
});
}
}