feat(coding): add Pi Data Service product tools

This commit is contained in:
2026-08-26 19:58:38 +08:00
parent 44bcdf52fc
commit 1d63233cd0
12 changed files with 790 additions and 26 deletions

View File

@@ -207,6 +207,7 @@ export function createCodingComposition(
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
});
const dataService = createDataServiceOperations({ projects });
productTools.configureDataService(dataService);
return {
attachments,
dataService,

View File

@@ -14,6 +14,7 @@ import {
type PiSubagentScheduler,
} from './subagent';
import {
isPiProductToolName,
type PiProductToolName,
type PiProductTools,
} from './product-tools';
@@ -121,7 +122,7 @@ function bridgeRequest(value: unknown): value is BridgeRequest {
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;
return isPiProductToolName(value.toolName) && 'input' in value;
}
if (value.action === 'changes.bash') return true;
if (value.action === 'changes.touched') {

View File

@@ -14,6 +14,12 @@ const MUTATION_TOOLS = new Set([
'write',
'game_asset_browser',
'game_asset_review',
'data_service_configure',
'data_service_put_document',
'data_service_delete_document',
'data_service_remove_collection',
'data_service_reset',
'data_service_remove_project',
]);
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
const leases = new Map();
@@ -283,6 +289,127 @@ export default function makeloreRuntime(pi) {
'Read the safe selected-skill and command catalog for this managed worker.',
{ type: 'object', additionalProperties: false, properties: {} },
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_configure',
'Data Service configure',
'Configure collections for the active Makelore project Data Service instance.',
{
type: 'object', additionalProperties: false, required: ['collections'],
properties: {
collections: {
type: 'array', minItems: 0, maxItems: 20,
items: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
},
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_inspect',
'Data Service inspect',
'Inspect the active Makelore project Data Service instance.',
{ type: 'object', additionalProperties: false, properties: {} },
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_list_projects',
'Data Service projects',
'List Data Service instances available to the signed-in account.',
{ type: 'object', additionalProperties: false, properties: {} },
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_get_document',
'Data Service get document',
'Read one document from a collection in the active Makelore project.',
{
type: 'object', additionalProperties: false, required: ['collection', 'document_id'],
properties: {
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_list_documents',
'Data Service list documents',
'List documents from a collection in the active Makelore project.',
{
type: 'object', additionalProperties: false, required: ['collection'],
properties: {
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
limit: { type: 'integer', minimum: 1, maximum: 100 },
cursor: { type: 'string', minLength: 1, maxLength: 1024 },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_put_document',
'Data Service put document',
'Create or update one document in a collection in the active Makelore project.',
{
type: 'object', additionalProperties: false,
required: ['collection', 'document_id', 'data'],
properties: {
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
data: { type: 'object' },
if_revision: { type: 'integer', minimum: 1 },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_delete_document',
'Data Service delete document',
'Delete one document from the active Makelore project after explicit confirmation.',
{
type: 'object', additionalProperties: false,
required: ['collection', 'document_id', 'confirmed'],
properties: {
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
document_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$' },
if_revision: { type: 'integer', minimum: 1 },
confirmed: { type: 'boolean', const: true },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_remove_collection',
'Data Service remove collection',
'Remove a collection from the active Makelore project after explicit confirmation.',
{
type: 'object', additionalProperties: false, required: ['collection', 'confirmed'],
properties: {
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
confirmed: { type: 'boolean', const: true },
},
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_reset',
'Data Service reset',
'Reset all collections in the active Makelore project after explicit confirmation.',
{
type: 'object', additionalProperties: false, required: ['confirmed'],
properties: { confirmed: { type: 'boolean', const: true } },
},
);
if (WORKER_ROLE === 'parent') registerProductTool(
pi,
'data_service_remove_project',
'Data Service remove project',
'Remove the active Makelore project Data Service instance after explicit confirmation.',
{
type: 'object', additionalProperties: false, required: ['confirmed'],
properties: { confirmed: { type: 'boolean', const: true } },
},
);
pi.on('tool_call', async (event, ctx) => {
if (!MUTATION_TOOLS.has(event.toolName)) return;

View File

@@ -13,6 +13,14 @@ import type {
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';
@@ -25,7 +33,122 @@ export type PiProductToolName =
| 'game_asset_review'
| 'task_state'
| 'changed_file'
| 'runtime_context';
| 'runtime_context'
| DataServicePiToolName;
export { DATA_SERVICE_PI_TOOL_NAMES };
const PI_PRODUCT_TOOL_NAMES = new Set<string>([
'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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<T extends DataServiceToolData | null>(
operation: DataServicePiToolName,
result: DataServiceHostResult<T>,
): PiProductToolResult {
const details: DataServiceToolDetailsV1 = {
schema: 'data-service.v1',
operation,
...result,
};
return {
content: [{ type: 'text', text: JSON.stringify(details) }],
details,
};
}
export interface PiProductToolContext {
conversationId: string;
@@ -46,16 +169,23 @@ export interface PiProductToolsOptions {
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 }) {
@@ -107,6 +237,77 @@ export class PiProductTools {
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 = {
@@ -119,4 +320,9 @@ export class PiProductTools {
details,
};
}
private requireDataService(): DataServiceOperations {
if (!this.dataService) throw new Error('Data Service tools are unavailable');
return this.dataService;
}
}

View File

@@ -77,16 +77,16 @@ type FetchImplementation = typeof fetch;
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
export type DataServiceOperations = {
configure(input: { collections: string[] }): Promise<DataServiceHostResult<DataServiceInstanceState>>;
inspect(): Promise<DataServiceHostResult<DataServiceInstanceState>>;
configure(input: { collections: string[] }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
inspect(trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
listProjects(): Promise<DataServiceHostResult<DataServiceInstanceList>>;
getDocument(input: { collection: string; document_id: string }): Promise<DataServiceHostResult<DataServiceDocument>>;
listDocuments(input: { collection: string; limit?: number; cursor?: string }): Promise<DataServiceHostResult<DataServiceDocumentList>>;
putDocument(input: DataServicePutDocumentInput): Promise<DataServiceHostResult<DataServiceDocument>>;
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }): Promise<DataServiceHostResult<null>>;
removeCollection(input: DataServiceCollectionTargetInput): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
reset(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceState>>;
removeProject(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceRemoval>>;
getDocument(input: { collection: string; document_id: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
listDocuments(input: { collection: string; limit?: number; cursor?: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocumentList>>;
putDocument(input: DataServicePutDocumentInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }, trustedProjectPath?: string): Promise<DataServiceHostResult<null>>;
removeCollection(input: DataServiceCollectionTargetInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
reset(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
removeProject(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceRemoval>>;
};
export type DataServiceCloudClientDependencies = {
@@ -747,10 +747,11 @@ export function createDataServiceOperations(
const client = dependencies.client ?? new DataServiceCloudClient();
async function withActive<T>(
trustedProjectPath: string | undefined,
operation: (projectId: string) => Promise<DataServiceHostResult<T>>,
): Promise<DataServiceHostResult<T>> {
try {
const active = await dependencies.projects.requireActiveRealProjectWithIdentity();
const active = await dependencies.projects.requireActiveRealProjectWithIdentity(trustedProjectPath);
return await operation(active.projectId);
} catch (error) {
return projectLocalProjectError<T>(error);
@@ -759,26 +760,26 @@ export function createDataServiceOperations(
return {
listProjects: () => client.listProjects(),
configure: ({ collections }) => withActive((projectId) => client.configure(projectId, collections)),
inspect: () => withActive((projectId) => client.inspect(projectId)),
getDocument: ({ collection, document_id }) => withActive(
configure: ({ collections }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.configure(projectId, collections)),
inspect: (trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.inspect(projectId)),
getDocument: ({ collection, document_id }, trustedProjectPath) => withActive(trustedProjectPath,
(projectId) => client.getDocument(projectId, collection, document_id),
),
listDocuments: ({ collection, limit, cursor }) => withActive(
listDocuments: ({ collection, limit, cursor }, trustedProjectPath) => withActive(trustedProjectPath,
(projectId) => client.listDocuments(projectId, collection, limit, cursor),
),
putDocument: (input) => withActive((projectId) => client.putDocument(projectId, input)),
deleteDocument: ({ collection, document_id, if_revision, confirmed }) => confirmed === true
? withActive((projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
putDocument: (input, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.putDocument(projectId, input)),
deleteDocument: ({ collection, document_id, if_revision, confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
: Promise.resolve(confirmationRequired()),
removeCollection: ({ collection, confirmed }) => confirmed === true
? withActive((projectId) => client.removeCollection(projectId, collection))
removeCollection: ({ collection, confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.removeCollection(projectId, collection))
: Promise.resolve(confirmationRequired()),
reset: ({ confirmed }) => confirmed === true
? withActive((projectId) => client.reset(projectId))
reset: ({ confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.reset(projectId))
: Promise.resolve(confirmationRequired()),
removeProject: ({ confirmed }) => confirmed === true
? withActive((projectId) => client.removeProject(projectId))
removeProject: ({ confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.removeProject(projectId))
: Promise.resolve(confirmationRequired()),
};
}