feat: implement PI-090 product tools
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user