feat: implement PI-090 product tools
This commit is contained in:
158
electron/coding-runtime/pi/extensions/agent-browser.ts
Normal file
158
electron/coding-runtime/pi/extensions/agent-browser.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user