feat: implement PI-090 product tools
This commit is contained in:
156
electron/coding-runtime/product-tool-protocol.ts
Normal file
156
electron/coding-runtime/product-tool-protocol.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
AgentBrowserDetailsV1,
|
||||
ChangedFileDetailsV1,
|
||||
GameAssetsDetailsV1,
|
||||
KnownToolDetails,
|
||||
RuntimeContextDetailsV1,
|
||||
TaskStateDetailsV1,
|
||||
} from './contracts';
|
||||
|
||||
const PRODUCT_TOOL_NAMES = new Set([
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
]);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown, max = 4096): string | null {
|
||||
return typeof value === 'string' && value.trim() && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
function strings(value: unknown, maxItems = 200): string[] | null {
|
||||
if (!Array.isArray(value) || value.length > maxItems) return null;
|
||||
const result: string[] = [];
|
||||
for (const item of value) {
|
||||
const normalized = text(item);
|
||||
if (!normalized) return null;
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativePaths(value: unknown): string[] | null {
|
||||
const paths = strings(value);
|
||||
if (!paths) return null;
|
||||
const result: string[] = [];
|
||||
for (const candidate of paths) {
|
||||
const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, '');
|
||||
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)
|
||||
|| normalized.split('/').some((segment) => segment === '..')) return null;
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function browserDetails(value: Record<string, unknown>): AgentBrowserDetailsV1 | null {
|
||||
const actions = new Set([
|
||||
'open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload',
|
||||
]);
|
||||
if (!actions.has(String(value.action))) return null;
|
||||
const attachmentId = value.attachmentId === undefined ? undefined : text(value.attachmentId, 128);
|
||||
const mime = value.mime === undefined ? undefined : text(value.mime, 128);
|
||||
if ((value.attachmentId !== undefined && !attachmentId) || (value.mime !== undefined && !mime)) return null;
|
||||
if (Boolean(attachmentId) !== Boolean(mime) || (attachmentId && value.action !== 'send_cdp')) return null;
|
||||
return {
|
||||
schema: 'agent-browser.v1',
|
||||
action: value.action as AgentBrowserDetailsV1['action'],
|
||||
...(attachmentId ? { attachmentId } : {}),
|
||||
...(mime ? { mime } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function gameAssetDetails(value: Record<string, unknown>): GameAssetsDetailsV1 | null {
|
||||
const invocationId = text(value.invocationId, 200);
|
||||
const candidateIds = strings(value.candidateIds);
|
||||
const pendingAssetIds = strings(value.pendingAssetIds);
|
||||
const approvedAssetIds = strings(value.approvedAssetIds);
|
||||
const discardedAssetIds = strings(value.discardedAssetIds);
|
||||
if (!invocationId || !candidateIds || !pendingAssetIds || !approvedAssetIds || !discardedAssetIds) return null;
|
||||
if (value.status !== 'pending' && value.status !== 'resolved') return null;
|
||||
return {
|
||||
schema: 'game-assets.v1',
|
||||
invocationId,
|
||||
candidateIds,
|
||||
status: value.status,
|
||||
pendingAssetIds,
|
||||
approvedAssetIds,
|
||||
discardedAssetIds,
|
||||
};
|
||||
}
|
||||
|
||||
function taskDetails(value: Record<string, unknown>): TaskStateDetailsV1 | null {
|
||||
if (!Array.isArray(value.tasks) || value.tasks.length === 0 || value.tasks.length > 100) return null;
|
||||
const tasks: TaskStateDetailsV1['tasks'] = [];
|
||||
for (const candidate of value.tasks) {
|
||||
const item = record(candidate);
|
||||
const id = text(item?.id, 128);
|
||||
const title = text(item?.title, 500);
|
||||
if (!item || !id || !title || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) {
|
||||
return null;
|
||||
}
|
||||
tasks.push({ id, title, status: item.status as TaskStateDetailsV1['tasks'][number]['status'] });
|
||||
}
|
||||
return { schema: 'task-state.v1', tasks };
|
||||
}
|
||||
|
||||
function runtimeContextDetails(value: Record<string, unknown>): RuntimeContextDetailsV1 | null {
|
||||
if (!Array.isArray(value.skills) || value.skills.length > 100
|
||||
|| !Array.isArray(value.commands) || value.commands.length > 200) return null;
|
||||
const skills: RuntimeContextDetailsV1['skills'] = [];
|
||||
const commands: RuntimeContextDetailsV1['commands'] = [];
|
||||
for (const candidate of value.skills) {
|
||||
const item = record(candidate);
|
||||
const id = text(item?.id, 128);
|
||||
const name = text(item?.name, 200);
|
||||
if (!item || !id || !name || typeof item.description !== 'string' || item.description.length > 2000
|
||||
|| typeof item.selected !== 'boolean') return null;
|
||||
skills.push({ id, name, description: item.description, selected: item.selected });
|
||||
}
|
||||
for (const candidate of value.commands) {
|
||||
const item = record(candidate);
|
||||
const name = text(item?.name, 128);
|
||||
const title = text(item?.title, 200);
|
||||
if (!item || !name || !title || typeof item.description !== 'string' || item.description.length > 2000
|
||||
|| !['makelore', 'pi', 'skill'].includes(String(item.source))) return null;
|
||||
const skillId = item.skillId === undefined ? undefined : text(item.skillId, 128);
|
||||
if (item.skillId !== undefined && !skillId) return null;
|
||||
commands.push({
|
||||
name,
|
||||
title,
|
||||
description: item.description,
|
||||
source: item.source as RuntimeContextDetailsV1['commands'][number]['source'],
|
||||
...(skillId ? { skillId } : {}),
|
||||
});
|
||||
}
|
||||
return { schema: 'runtime-context.v1', skills, commands };
|
||||
}
|
||||
|
||||
export function productToolDetails(value: unknown): Exclude<KnownToolDetails, { schema: 'subagent.v1' | 'write-lease.v1' }> | null {
|
||||
const details = record(value);
|
||||
if (!details) return null;
|
||||
if (details.schema === 'changed-file.v1') {
|
||||
const paths = relativePaths(details.paths);
|
||||
return paths ? { schema: 'changed-file.v1', paths } satisfies ChangedFileDetailsV1 : null;
|
||||
}
|
||||
if (details.schema === 'task-state.v1') return taskDetails(details);
|
||||
if (details.schema === 'agent-browser.v1') return browserDetails(details);
|
||||
if (details.schema === 'game-assets.v1') return gameAssetDetails(details);
|
||||
if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function productToolDetailsOfResult(value: unknown) {
|
||||
return productToolDetails(record(value)?.details);
|
||||
}
|
||||
|
||||
export function isProductToolName(value: string): boolean {
|
||||
return PRODUCT_TOOL_NAMES.has(value);
|
||||
}
|
||||
Reference in New Issue
Block a user