feat: implement PI-090 product tools
This commit is contained in:
@@ -8,6 +8,10 @@ import type {
|
||||
} from '../contracts';
|
||||
import type { PiRpcEvent } from './rpc-client';
|
||||
import { subagentDetailsOfResult } from '../subagent-protocol';
|
||||
import {
|
||||
isProductToolName,
|
||||
productToolDetailsOfResult,
|
||||
} from '../product-tool-protocol';
|
||||
|
||||
export interface PiEventProjectorOptions {
|
||||
createId(): string;
|
||||
@@ -96,16 +100,19 @@ function projectToolResult(
|
||||
status: ConversationToolNode['status'],
|
||||
value: unknown,
|
||||
): ConversationPatch[] {
|
||||
const details = subagentDetailsOfResult(value);
|
||||
const subagentDetails = subagentDetailsOfResult(value);
|
||||
const details = subagentDetails ?? productToolDetailsOfResult(value);
|
||||
const result = asRecord(value);
|
||||
const unknownSubagentDetails = tool.toolName === 'subagent'
|
||||
const unknownProductDetails = (tool.toolName === 'subagent' || isProductToolName(tool.toolName))
|
||||
&& result?.details !== undefined
|
||||
&& !details;
|
||||
const output = unknownSubagentDetails
|
||||
const output = unknownProductDetails
|
||||
? [{
|
||||
kind: 'text' as const,
|
||||
id: `${tool.id}:output:unavailable`,
|
||||
text: 'Subagent details are unavailable for this version.',
|
||||
text: tool.toolName === 'subagent'
|
||||
? 'Subagent details are unavailable for this version.'
|
||||
: 'Tool details are unavailable for this version.',
|
||||
status: 'complete' as const,
|
||||
}]
|
||||
: outputBlocks(tool.id, value);
|
||||
@@ -119,14 +126,14 @@ function projectToolResult(
|
||||
},
|
||||
}];
|
||||
const runId = snapshot.run.runId;
|
||||
if (details && runId) {
|
||||
if (subagentDetails && runId) {
|
||||
patches.push({
|
||||
op: 'subagent.upsert',
|
||||
node: {
|
||||
kind: 'subagent',
|
||||
id: `subagent:${details.dispatchId}`,
|
||||
id: `subagent:${subagentDetails.dispatchId}`,
|
||||
runId,
|
||||
details,
|
||||
details: subagentDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
parsePiSubagentDispatchRequest,
|
||||
type PiSubagentScheduler,
|
||||
} from './subagent';
|
||||
import {
|
||||
type PiProductToolName,
|
||||
type PiProductTools,
|
||||
} from './product-tools';
|
||||
|
||||
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
|
||||
@@ -21,6 +25,8 @@ interface WorkerRegistrationRecord {
|
||||
conversationId: string;
|
||||
generation: number;
|
||||
projectId: string;
|
||||
projectPath: string | null;
|
||||
skillIds: string[];
|
||||
role: 'parent' | 'child';
|
||||
contextFile: string;
|
||||
runId: string | null;
|
||||
@@ -39,6 +45,8 @@ export interface RegisterPiExtensionWorkerInput {
|
||||
conversationId: string;
|
||||
generation: number;
|
||||
projectId: string;
|
||||
projectPath?: string;
|
||||
skillIds?: readonly string[];
|
||||
extensionsDir: string;
|
||||
role?: 'parent' | 'child';
|
||||
runId?: string;
|
||||
@@ -62,7 +70,25 @@ interface SubagentBridgeRequest {
|
||||
request: unknown;
|
||||
}
|
||||
|
||||
type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest;
|
||||
interface ProductToolBridgeRequest {
|
||||
action: 'product.invoke';
|
||||
conversationId: string;
|
||||
workerGeneration: number;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
toolName: PiProductToolName;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
interface ChangeRefreshBridgeRequest {
|
||||
action: 'changes.bash';
|
||||
conversationId: string;
|
||||
workerGeneration: number;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest | ProductToolBridgeRequest | ChangeRefreshBridgeRequest;
|
||||
|
||||
export interface PiExtensionSubagentBridge {
|
||||
scheduler: PiSubagentScheduler;
|
||||
@@ -81,6 +107,10 @@ function bridgeRequest(value: unknown): value is BridgeRequest {
|
||||
&& typeof value.resourceId === 'string';
|
||||
if (!common) return false;
|
||||
if (value.action === 'subagent.dispatch') return 'request' in value;
|
||||
if (value.action === 'product.invoke') {
|
||||
return typeof value.toolName === 'string' && 'input' in value;
|
||||
}
|
||||
if (value.action === 'changes.bash') return true;
|
||||
return (value.action === 'lease.acquire' || value.action === 'lease.release')
|
||||
&& (value.leaseId === undefined || typeof value.leaseId === 'string');
|
||||
}
|
||||
@@ -91,6 +121,7 @@ export class PiManagedExtensionHost {
|
||||
private readonly runBindings = new Map<string, string>();
|
||||
private readonly requestFlights = new Set<Promise<void>>();
|
||||
private subagentBridge: PiExtensionSubagentBridge | undefined;
|
||||
private productTools: PiProductTools | undefined;
|
||||
private server: Server | null = null;
|
||||
private bridgeUrl: string | null = null;
|
||||
private startFlight: Promise<string> | null = null;
|
||||
@@ -104,6 +135,10 @@ export class PiManagedExtensionHost {
|
||||
this.subagentBridge = bridge;
|
||||
}
|
||||
|
||||
configureProductTools(productTools: PiProductTools): void {
|
||||
this.productTools = productTools;
|
||||
}
|
||||
|
||||
async registerWorker(input: RegisterPiExtensionWorkerInput): Promise<PiExtensionWorkerRegistration> {
|
||||
if (!Number.isSafeInteger(input.generation) || input.generation <= 0) {
|
||||
throw new Error('Worker generation must be a positive safe integer');
|
||||
@@ -115,6 +150,9 @@ export class PiManagedExtensionHost {
|
||||
if (role === 'child' && !input.runId?.trim()) {
|
||||
throw new Error('Child extension registration requires a parent run id');
|
||||
}
|
||||
if (this.productTools && !input.projectPath?.trim()) {
|
||||
throw new Error('Product tools require a worker project path');
|
||||
}
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`);
|
||||
const record: WorkerRegistrationRecord = {
|
||||
@@ -122,6 +160,8 @@ export class PiManagedExtensionHost {
|
||||
conversationId: input.conversationId,
|
||||
generation: input.generation,
|
||||
projectId: input.projectId,
|
||||
projectPath: input.projectPath?.trim() || null,
|
||||
skillIds: [...new Set((input.skillIds ?? []).map((id) => id.trim()).filter(Boolean))],
|
||||
role,
|
||||
contextFile,
|
||||
runId: role === 'child'
|
||||
@@ -154,6 +194,9 @@ export class PiManagedExtensionHost {
|
||||
async bindRun(conversationId: string, generation: number, runId: string): Promise<void> {
|
||||
const record = this.findWorker(conversationId, generation);
|
||||
if (!record) throw new Error('Pi extension worker registration is unavailable');
|
||||
if (this.productTools && record.projectPath) {
|
||||
await this.productTools.beginRun({ conversationId, runId, projectPath: record.projectPath });
|
||||
}
|
||||
this.releaseWorkerResources(record);
|
||||
this.runBindings.set(conversationId, runId);
|
||||
record.runId = runId;
|
||||
@@ -166,6 +209,9 @@ export class PiManagedExtensionHost {
|
||||
this.runBindings.delete(conversationId);
|
||||
}
|
||||
if (!record || (runId && record.runId !== runId)) return;
|
||||
if (this.productTools && record.runId) {
|
||||
await this.productTools.settleRun(conversationId, record.runId).catch(() => null);
|
||||
}
|
||||
this.releaseWorkerResources(record);
|
||||
record.runId = null;
|
||||
await this.writeContext(record);
|
||||
@@ -249,6 +295,35 @@ export class PiManagedExtensionHost {
|
||||
await this.dispatchSubagents(request, response, record, value);
|
||||
return;
|
||||
}
|
||||
if (value.action === 'changes.bash') {
|
||||
if (!this.productTools) {
|
||||
this.respond(response, 503, { error: 'Conversation change tracker is unavailable' });
|
||||
return;
|
||||
}
|
||||
await this.productTools.markBash(record.conversationId, value.runId);
|
||||
this.respond(response, 200, { marked: true });
|
||||
return;
|
||||
}
|
||||
if (value.action === 'product.invoke') {
|
||||
if (record.role !== 'parent') {
|
||||
this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' });
|
||||
return;
|
||||
}
|
||||
if (!this.productTools || !record.projectPath) {
|
||||
this.respond(response, 503, { error: 'Product tools are unavailable' });
|
||||
return;
|
||||
}
|
||||
const productResult = await this.productTools.execute(value.toolName, {
|
||||
conversationId: record.conversationId,
|
||||
runId: value.runId,
|
||||
resourceId: value.resourceId,
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
skillIds: record.skillIds,
|
||||
}, value.input);
|
||||
this.respond(response, 200, { result: productResult });
|
||||
return;
|
||||
}
|
||||
if (value.action === 'lease.release') {
|
||||
const lease = record.leases.get(value.resourceId);
|
||||
if (!lease || !value.leaseId || lease.id !== value.leaseId) {
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
35
electron/coding-runtime/pi/extensions/changed-file.ts
Normal file
35
electron/coding-runtime/pi/extensions/changed-file.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
electron/coding-runtime/pi/extensions/game-assets.ts
Normal file
85
electron/coding-runtime/pi/extensions/game-assets.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
41
electron/coding-runtime/pi/extensions/task-state.ts
Normal file
41
electron/coding-runtime/pi/extensions/task-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
102
electron/coding-runtime/pi/product-tools.ts
Normal file
102
electron/coding-runtime/pi/product-tools.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { AgentBrowserModule } from '../../agent-browser';
|
||||
import type { CodingAttachmentStore } from '../../coding-projects/attachment-store';
|
||||
import {
|
||||
ConversationChangeTracker,
|
||||
type ConversationChangesSnapshot,
|
||||
} from '../../coding-projects/conversation-change-tracker';
|
||||
import {
|
||||
buildProductCodingCommandCatalog,
|
||||
listProductCodingSkills,
|
||||
} from '../../coding-projects/skill-registry';
|
||||
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
|
||||
import { PiAgentBrowserTool } from './extensions/agent-browser';
|
||||
import { reportChangedFiles } from './extensions/changed-file';
|
||||
import { PiGameAssetTools } from './extensions/game-assets';
|
||||
import { projectTaskState } from './extensions/task-state';
|
||||
|
||||
export type PiProductToolName =
|
||||
| 'agent_browser'
|
||||
| 'game_asset_browser'
|
||||
| 'game_asset_review'
|
||||
| 'task_state'
|
||||
| 'changed_file'
|
||||
| 'runtime_context';
|
||||
|
||||
export interface PiProductToolContext {
|
||||
conversationId: string;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
skillIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface PiProductToolResult {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details: KnownToolDetails;
|
||||
}
|
||||
|
||||
export interface PiProductToolsOptions {
|
||||
browser: AgentBrowserModule;
|
||||
attachments: CodingAttachmentStore;
|
||||
bundledSkillsDir: string;
|
||||
changeTracker?: ConversationChangeTracker;
|
||||
}
|
||||
|
||||
export class PiProductTools {
|
||||
readonly changeTracker: ConversationChangeTracker;
|
||||
private readonly browser: PiAgentBrowserTool;
|
||||
private readonly gameAssets = new PiGameAssetTools();
|
||||
|
||||
constructor(private readonly options: PiProductToolsOptions) {
|
||||
this.changeTracker = options.changeTracker ?? new ConversationChangeTracker();
|
||||
this.browser = new PiAgentBrowserTool(options.browser, options.attachments);
|
||||
}
|
||||
|
||||
beginRun(input: { conversationId: string; runId: string; projectPath: string }) {
|
||||
return this.changeTracker.beginRun(input);
|
||||
}
|
||||
|
||||
settleRun(conversationId: string, runId: string) {
|
||||
return this.changeTracker.settleRun(conversationId, runId);
|
||||
}
|
||||
|
||||
getChanges(conversationId: string): ConversationChangesSnapshot | null {
|
||||
return this.changeTracker.getSnapshot(conversationId);
|
||||
}
|
||||
|
||||
async markBash(conversationId: string, runId: string): Promise<void> {
|
||||
await this.changeTracker.markProjectRefresh(conversationId, runId);
|
||||
}
|
||||
|
||||
async execute(
|
||||
toolName: PiProductToolName,
|
||||
context: PiProductToolContext,
|
||||
input: unknown,
|
||||
): Promise<PiProductToolResult> {
|
||||
if (toolName === 'agent_browser') {
|
||||
return await this.browser.execute(context, input);
|
||||
}
|
||||
if (toolName === 'game_asset_browser') {
|
||||
return await this.gameAssets.browse(context.projectPath, input, context.resourceId);
|
||||
}
|
||||
if (toolName === 'game_asset_review') {
|
||||
return await this.gameAssets.review(context.projectPath, input, context.resourceId);
|
||||
}
|
||||
if (toolName === 'task_state') return projectTaskState(input);
|
||||
if (toolName === 'changed_file') {
|
||||
return await reportChangedFiles(this.changeTracker, context, input);
|
||||
}
|
||||
if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable');
|
||||
const skills = await listProductCodingSkills(this.options.bundledSkillsDir, context.skillIds);
|
||||
const details: RuntimeContextDetailsV1 = {
|
||||
schema: 'runtime-context.v1',
|
||||
skills,
|
||||
commands: buildProductCodingCommandCatalog(skills),
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,8 @@ export function createPiManagedWorkerOpener(
|
||||
conversationId: input.conversation.conversationId,
|
||||
generation: input.generation,
|
||||
projectId: input.conversation.projectId,
|
||||
projectPath: registered.projectPath,
|
||||
skillIds: registered.agent.skillIds,
|
||||
extensionsDir: managedPaths.extensionsDir,
|
||||
});
|
||||
recordManagedMilestone(
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PiImageProjectionInput,
|
||||
PiProjectedAttachment,
|
||||
} from './event-projector';
|
||||
import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol';
|
||||
import { subagentDetailsOfResult } from '../subagent-protocol';
|
||||
|
||||
export interface PiSessionSnapshotInput {
|
||||
@@ -213,24 +214,29 @@ async function projectEntries(
|
||||
'output',
|
||||
);
|
||||
tool.status = message.isError === true ? 'error' : 'complete';
|
||||
const details = subagentDetailsOfResult(message);
|
||||
if (details) {
|
||||
tool.details = details;
|
||||
const id = `subagent:${details.dispatchId}`;
|
||||
const subagentDetails = subagentDetailsOfResult(message);
|
||||
const details = subagentDetails ?? productToolDetailsOfResult(message);
|
||||
if (details) tool.details = details;
|
||||
if (subagentDetails) {
|
||||
const id = `subagent:${subagentDetails.dispatchId}`;
|
||||
if (!subagentIds.has(id)) {
|
||||
subagentIds.add(id);
|
||||
nodes.push({
|
||||
kind: 'subagent',
|
||||
id,
|
||||
runId: input.snapshot.run.runId ?? `session:${entry.id}`,
|
||||
details,
|
||||
details: subagentDetails,
|
||||
});
|
||||
}
|
||||
} else if (tool.toolName === 'subagent' && message.details !== undefined) {
|
||||
} else if ((tool.toolName === 'subagent' || isProductToolName(tool.toolName))
|
||||
&& message.details !== undefined
|
||||
&& !details) {
|
||||
tool.output = [{
|
||||
kind: 'text',
|
||||
id: `${tool.id}:output:unavailable`,
|
||||
text: 'Subagent details are unavailable for this version.',
|
||||
text: tool.toolName === 'subagent'
|
||||
? 'Subagent details are unavailable for this version.'
|
||||
: 'Tool details are unavailable for this version.',
|
||||
status: 'complete',
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@ export function createPiManagedSubagentChildOpener(
|
||||
conversationId: input.conversationId,
|
||||
generation: input.workerGeneration,
|
||||
projectId: input.projectId,
|
||||
projectPath: project.path,
|
||||
skillIds: agent.skillIds,
|
||||
extensionsDir: managedPaths.extensionsDir,
|
||||
role: 'child',
|
||||
runId: input.runId,
|
||||
|
||||
@@ -67,6 +67,8 @@ export function buildPiRpcArgs(
|
||||
additionalArgs: readonly string[] = [],
|
||||
tools: readonly string[] = [
|
||||
'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent',
|
||||
'agent_browser', 'game_asset_browser', 'game_asset_review',
|
||||
'task_state', 'changed_file', 'runtime_context',
|
||||
],
|
||||
): string[] {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user