feat: implement PI-090 product tools

This commit is contained in:
2026-08-23 15:14:50 +08:00
parent 77cdee7e73
commit 13ab383e53
32 changed files with 2035 additions and 49 deletions

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

View File

@@ -0,0 +1,35 @@
import type { ConversationChangeTracker } from '../../../coding-projects/conversation-change-tracker';
import type { ChangedFileDetailsV1 } from '../../contracts';
export interface ChangedFileToolContext {
conversationId: string;
runId: string;
}
export async function reportChangedFiles(
tracker: ConversationChangeTracker,
context: ChangedFileToolContext,
input: unknown,
) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Changed file report is invalid');
}
const record = input as Record<string, unknown>;
const rawPaths = record.paths ?? (record.path === undefined ? undefined : [record.path]);
if (!Array.isArray(rawPaths) || rawPaths.length === 0 || rawPaths.length > 200
|| rawPaths.some((item) => typeof item !== 'string')) {
throw new Error('Changed file report requires relative paths');
}
const snapshot = await tracker.recordTouchedPaths(
context.conversationId,
context.runId,
rawPaths as string[],
record.refresh !== false,
);
const paths = snapshot.files.map((file) => file.path);
const details: ChangedFileDetailsV1 = { schema: 'changed-file.v1', paths };
return {
content: [{ type: 'text' as const, text: JSON.stringify({ paths, changes: snapshot.files }) }],
details,
};
}

View File

@@ -0,0 +1,85 @@
import {
loadGameAssetCandidates,
type GameAssetCandidate,
} from '../../../coding-projects/game-asset-browser';
import {
loadGameAssetReview,
type GameAssetReviewSnapshot,
} from '../../../coding-projects/game-asset-review';
import type { GameAssetsDetailsV1 } from '../../contracts';
export interface GameAssetToolResult {
content: Array<{ type: 'text'; text: string }>;
details: GameAssetsDetailsV1;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function ids(value: unknown): string[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 200 || value.some((item) => typeof item !== 'string')) {
throw new Error('Game asset candidate ids are invalid');
}
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
}
function details(review: GameAssetReviewSnapshot): GameAssetsDetailsV1 {
return {
schema: 'game-assets.v1',
invocationId: review.invocationId,
candidateIds: review.candidateIds,
status: review.status,
pendingAssetIds: review.pendingAssetIds,
approvedAssetIds: review.approvedAssetIds,
discardedAssetIds: review.discardedAssetIds,
};
}
function safeCandidate(candidate: GameAssetCandidate) {
return {
id: candidate.id,
name: candidate.name,
category: candidate.category,
status: candidate.status,
purpose: candidate.purpose,
source: candidate.source,
license: candidate.license,
mediaKind: candidate.mediaKind,
manifest: candidate.manifest,
...(candidate.unavailableReason ? { unavailableReason: candidate.unavailableReason } : {}),
};
}
export class PiGameAssetTools {
async browse(projectPath: string, input: unknown, fallbackInvocationId: string): Promise<GameAssetToolResult> {
const body = record(input);
const invocationId = typeof body.invocationId === 'string' && body.invocationId.trim()
? body.invocationId.trim()
: fallbackInvocationId;
const candidates = await loadGameAssetCandidates(projectPath);
const review = await loadGameAssetReview(projectPath, invocationId, candidates.map(({ id }) => id));
return {
content: [{
type: 'text',
text: JSON.stringify({ candidates: candidates.map(safeCandidate), review: details(review) }),
}],
details: details(review),
};
}
async review(projectPath: string, input: unknown, fallbackInvocationId: string): Promise<GameAssetToolResult> {
const body = record(input);
const invocationId = typeof body.invocationId === 'string' && body.invocationId.trim()
? body.invocationId.trim()
: fallbackInvocationId;
const review = await loadGameAssetReview(projectPath, invocationId, ids(body.candidateIds));
return {
content: [{ type: 'text', text: JSON.stringify(details(review)) }],
details: details(review),
};
}
}

View File

@@ -1,15 +1,23 @@
import path from 'node:path';
import { atomicWriteText } from '../../../coding-projects/atomic-json';
export const MAKELORE_PI_EXTENSION_VERSION = 2;
export const MAKELORE_PI_EXTENSION_VERSION = 3;
export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`;
const BUNDLE_SOURCE = String.raw`
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const MUTATION_TOOLS = new Set(['bash', 'edit', 'write']);
const MUTATION_TOOLS = new Set([
'bash',
'edit',
'write',
'game_asset_browser',
'game_asset_review',
]);
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
const leases = new Map();
const touchedPaths = new Map();
async function runtimeContext() {
const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8'));
@@ -84,6 +92,37 @@ async function releaseLease(toolCallId) {
async function releaseAll() {
await Promise.all([...leases.keys()].map(releaseLease));
touchedPaths.clear();
}
function projectRelativePath(value) {
if (typeof value !== 'string' || !value.trim()) return undefined;
const projectPath = path.resolve(process.cwd());
const targetPath = path.resolve(projectPath, value);
const relative = path.relative(projectPath, targetPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return undefined;
return relative.split(path.sep).join('/');
}
async function invokeProduct(toolCallId, toolName, input, signal) {
const response = await bridge('product.invoke', {
resourceId: toolCallId,
toolName,
input,
}, signal);
return response.result;
}
function registerProductTool(pi, name, label, description, parameters) {
pi.registerTool({
name,
label,
description,
parameters,
async execute(toolCallId, params, signal) {
return await invokeProduct(toolCallId, name, params, signal);
},
});
}
export default function makeloreRuntime(pi) {
@@ -166,8 +205,94 @@ export default function makeloreRuntime(pi) {
},
});
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'agent_browser',
'Agent browser',
'Open, navigate, inspect, or close the Main-owned Makelore development browser.',
{
type: 'object', additionalProperties: true, required: ['action'],
properties: {
action: { type: 'string', enum: ['open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload'] },
url: { type: 'string' }, navigation: { type: 'string', enum: ['url', 'back', 'forward', 'reload'] },
method: { type: 'string' }, params: { type: 'object' }, sessionRef: { type: 'string' },
timeoutMs: { type: 'number' }, after: { type: 'number' }, methods: { type: 'array', items: { type: 'string' } },
limit: { type: 'number' }, waitMs: { type: 'number' }, handle: { type: 'string' },
offset: { type: 'number' }, maxBytes: { type: 'number' },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'game_asset_browser',
'Game assets',
'Load product-owned game asset candidates and their current review state.',
{ type: 'object', additionalProperties: false, properties: { invocationId: { type: 'string' } } },
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'game_asset_review',
'Game asset review',
'Load one versioned game asset review interaction without encoding decisions in message text.',
{
type: 'object', additionalProperties: false,
properties: {
invocationId: { type: 'string' },
candidateIds: { type: 'array', maxItems: 200, items: { type: 'string' } },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'task_state',
'Task state',
'Publish versioned task steps and progress to the Makelore product timeline.',
{
type: 'object', additionalProperties: false, required: ['tasks'],
properties: {
tasks: {
type: 'array', minItems: 1, maxItems: 100,
items: {
type: 'object', additionalProperties: false, required: ['id', 'title', 'status'],
properties: {
id: { type: 'string' }, title: { type: 'string' },
status: { type: 'string', enum: ['pending', 'running', 'complete', 'error'] },
},
},
},
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'changed_file',
'Changed file',
'Report project-relative paths touched by managed tools and refresh Conversation changes.',
{
type: 'object', additionalProperties: false,
properties: {
path: { type: 'string' }, paths: { type: 'array', maxItems: 200, items: { type: 'string' } },
refresh: { type: 'boolean' },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'runtime_context',
'Runtime context',
'Read the safe selected-skill and command catalog for this managed worker.',
{ type: 'object', additionalProperties: false, properties: {} },
);
pi.on('tool_call', async (event, ctx) => {
if (!MUTATION_TOOLS.has(event.toolName)) return;
const input = event.input || event.arguments || event.args || {};
const touchedPath = (event.toolName === 'write' || event.toolName === 'edit')
? projectRelativePath(input.path) : undefined;
if (touchedPath) touchedPaths.set(event.toolCallId, [touchedPath]);
if (event.toolName === 'bash') {
await bridge('changes.bash', { resourceId: event.toolCallId }, ctx.signal).catch(() => undefined);
}
ctx.ui.setStatus('makelore.write-lease', '等待项目写入');
try {
const result = await bridge('lease.acquire', { resourceId: event.toolCallId }, ctx.signal);
@@ -176,7 +301,14 @@ export default function makeloreRuntime(pi) {
ctx.ui.setStatus('makelore.write-lease', undefined);
}
});
pi.on('tool_result', async (event) => releaseLease(event.toolCallId));
pi.on('tool_result', async (event) => {
await releaseLease(event.toolCallId);
const paths = touchedPaths.get(event.toolCallId);
touchedPaths.delete(event.toolCallId);
if (paths) {
await invokeProduct(event.toolCallId, 'changed_file', { paths, refresh: true }).catch(() => undefined);
}
});
pi.on('agent_end', releaseAll);
pi.on('session_shutdown', releaseAll);
}

View File

@@ -0,0 +1,41 @@
import type { TaskStateDetailsV1 } from '../../contracts';
export interface TaskStateToolResult {
content: Array<{ type: 'text'; text: string }>;
details: TaskStateDetailsV1;
}
export function projectTaskState(input: unknown): TaskStateToolResult {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Task state is invalid');
const tasks = (input as { tasks?: unknown }).tasks;
if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > 100) {
throw new Error('Task state requires one to one hundred tasks');
}
const ids = new Set<string>();
const projected: TaskStateDetailsV1['tasks'] = tasks.map((candidate) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
throw new Error('Task state item is invalid');
}
const item = candidate as Record<string, unknown>;
const id = typeof item.id === 'string' ? item.id.trim() : '';
const title = typeof item.title === 'string' ? item.title.trim() : '';
if (!id || id.length > 128 || ids.has(id) || !title || title.length > 500
|| !['pending', 'running', 'complete', 'error'].includes(String(item.status))) {
throw new Error('Task state item is invalid');
}
ids.add(id);
return {
id,
title,
status: item.status as TaskStateDetailsV1['tasks'][number]['status'],
};
});
const details: TaskStateDetailsV1 = { schema: 'task-state.v1', tasks: projected };
return {
content: [{
type: 'text',
text: `${projected.filter(({ status }) => status === 'complete').length}/${projected.length} tasks complete`,
}],
details,
};
}