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 { ProductCodingCommand, ProductCodingSkill, ProductPiCommandInput, } from '../../../shared/coding-product-tools'; import { DATA_SERVICE_PI_TOOL_NAMES, type DataServiceHostResult, type DataServicePiToolName, type DataServiceToolData, type DataServiceToolDetailsV1, } from '../../../shared/data-service'; import type { DataServiceOperations } from '../../services/data-service-client'; 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' | DataServicePiToolName; export { DATA_SERVICE_PI_TOOL_NAMES }; const PI_PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', 'game_asset_browser', 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', ...DATA_SERVICE_PI_TOOL_NAMES, ]); export function isPiProductToolName(value: unknown): value is PiProductToolName { return typeof value === 'string' && PI_PRODUCT_TOOL_NAMES.has(value); } const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; const MAX_REQUEST_BYTES = 98_304; const MAX_CURSOR_LENGTH = 1_024; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function invalidDataServiceInput(): never { throw new Error('Data Service tool input is invalid'); } function exactInput( value: unknown, required: readonly string[], optional: readonly string[] = [], ): Record { if (!isRecord(value)) return invalidDataServiceInput(); const allowed = new Set([...required, ...optional]); if (Object.keys(value).some((key) => !allowed.has(key)) || required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) { return invalidDataServiceInput(); } return value; } function collection(value: unknown): string { if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) return invalidDataServiceInput(); return value; } function documentId(value: unknown): string { if (typeof value !== 'string' || value === '.' || value === '..' || !DOCUMENT_ID_PATTERN.test(value)) return invalidDataServiceInput(); return value; } function revision(value: unknown): number | undefined { if (value === undefined) return undefined; if (!Number.isSafeInteger(value) || (value as number) < 1) return invalidDataServiceInput(); return value as number; } function collections(value: unknown): string[] { if (!Array.isArray(value) || value.length > 20) return invalidDataServiceInput(); return value.map(collection); } function data(value: unknown): Record { if (!isRecord(value)) return invalidDataServiceInput(); try { const encoded = JSON.stringify(value); if (typeof encoded !== 'string' || Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) { return invalidDataServiceInput(); } } catch { return invalidDataServiceInput(); } return value; } function limit(value: unknown): number | undefined { if (value === undefined) return undefined; if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 100) { return invalidDataServiceInput(); } return value as number; } function cursor(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== 'string' || !value || value.length > MAX_CURSOR_LENGTH) { return invalidDataServiceInput(); } return value; } function confirmation(value: unknown): true { if (value !== true) return invalidDataServiceInput(); return true; } function dataServiceResult( operation: DataServicePiToolName, result: DataServiceHostResult, ): PiProductToolResult { const details: DataServiceToolDetailsV1 = { schema: 'data-service.v1', operation, ...result, }; return { content: [{ type: 'text', text: JSON.stringify(details) }], details, }; } 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; dataService?: DataServiceOperations; } export class PiProductTools { readonly changeTracker: ConversationChangeTracker; private readonly browser: PiAgentBrowserTool; private readonly gameAssets = new PiGameAssetTools(); private dataService: DataServiceOperations | undefined; constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); this.browser = new PiAgentBrowserTool(options.browser, options.attachments); this.dataService = options.dataService; } configureDataService(dataService: DataServiceOperations): void { this.dataService = dataService; } 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); } listSkills(skillIds: readonly string[]): Promise { return listProductCodingSkills(this.options.bundledSkillsDir, skillIds); } async listCommands( skillIds: readonly string[], piCommands: readonly ProductPiCommandInput[] = [], ): Promise { return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands); } async markBash(conversationId: string, runId: string): Promise { await this.changeTracker.markProjectRefresh(conversationId, runId); } recordTouchedPaths(conversationId: string, runId: string, paths: readonly string[]) { return this.changeTracker.recordTouchedPaths(conversationId, runId, paths); } async execute( toolName: PiProductToolName, context: PiProductToolContext, input: unknown, ): Promise { 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 === 'data_service_configure') { const body = exactInput(input, ['collections']); const result = await this.requireDataService().configure( { collections: collections(body.collections) }, context.projectPath, ); return dataServiceResult(toolName, result); } if (toolName === 'data_service_inspect') { exactInput(input, []); return dataServiceResult(toolName, await this.requireDataService().inspect(context.projectPath)); } if (toolName === 'data_service_list_projects') { exactInput(input, []); return dataServiceResult(toolName, await this.requireDataService().listProjects()); } if (toolName === 'data_service_get_document') { const body = exactInput(input, ['collection', 'document_id']); return dataServiceResult(toolName, await this.requireDataService().getDocument({ collection: collection(body.collection), document_id: documentId(body.document_id), }, context.projectPath)); } if (toolName === 'data_service_list_documents') { const body = exactInput(input, ['collection'], ['limit', 'cursor']); const requestedLimit = limit(body.limit); const requestedCursor = cursor(body.cursor); return dataServiceResult(toolName, await this.requireDataService().listDocuments({ collection: collection(body.collection), ...(requestedLimit === undefined ? {} : { limit: requestedLimit }), ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), }, context.projectPath)); } if (toolName === 'data_service_put_document') { const body = exactInput(input, ['collection', 'document_id', 'data'], ['if_revision']); const requestedRevision = revision(body.if_revision); return dataServiceResult(toolName, await this.requireDataService().putDocument({ collection: collection(body.collection), document_id: documentId(body.document_id), data: data(body.data), ...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }), }, context.projectPath)); } if (toolName === 'data_service_delete_document') { const body = exactInput(input, ['collection', 'document_id', 'confirmed'], ['if_revision']); const requestedRevision = revision(body.if_revision); return dataServiceResult(toolName, await this.requireDataService().deleteDocument({ collection: collection(body.collection), document_id: documentId(body.document_id), ...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }), confirmed: confirmation(body.confirmed), }, context.projectPath)); } if (toolName === 'data_service_remove_collection') { const body = exactInput(input, ['collection', 'confirmed']); return dataServiceResult(toolName, await this.requireDataService().removeCollection({ collection: collection(body.collection), confirmed: confirmation(body.confirmed), }, context.projectPath)); } if (toolName === 'data_service_reset') { const body = exactInput(input, ['confirmed']); return dataServiceResult(toolName, await this.requireDataService().reset({ confirmed: confirmation(body.confirmed), }, context.projectPath)); } if (toolName === 'data_service_remove_project') { const body = exactInput(input, ['confirmed']); return dataServiceResult(toolName, await this.requireDataService().removeProject({ confirmed: confirmation(body.confirmed), }, context.projectPath)); } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); const skills = await this.listSkills(context.skillIds); const details: RuntimeContextDetailsV1 = { schema: 'runtime-context.v1', skills, commands: buildProductCodingCommandCatalog(skills), }; return { content: [{ type: 'text', text: JSON.stringify(details) }], details, }; } private requireDataService(): DataServiceOperations { if (!this.dataService) throw new Error('Data Service tools are unavailable'); return this.dataService; } }