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

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

View File

@@ -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) {

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

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

View File

@@ -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(

View File

@@ -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',
}];
}

View File

@@ -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,

View File

@@ -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 [