Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID. - Create tests for runtime context capabilities, validating the injection of enabled skill capabilities. - Add tests for skill capability parsing, including classification and command example extraction. - Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements. - Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
550
electron/gateway/tool-registry.ts
Normal file
550
electron/gateway/tool-registry.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
export type ToolRegistryEntryKind = 'builtin-tool' | 'skill';
|
||||
|
||||
export type ToolRiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export type ToolInputKind =
|
||||
| 'text'
|
||||
| 'url'
|
||||
| 'file'
|
||||
| 'attachment'
|
||||
| 'history'
|
||||
| 'structured';
|
||||
|
||||
export type ToolOutputKind =
|
||||
| 'text'
|
||||
| 'json'
|
||||
| 'file'
|
||||
| 'artifacts'
|
||||
| 'browser-state'
|
||||
| 'status';
|
||||
|
||||
export interface ToolRegistryCapabilityInput {
|
||||
capabilityKey?: string;
|
||||
familyKey?: string;
|
||||
toolName?: string;
|
||||
skillKey?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
kind?: ToolRegistryEntryKind;
|
||||
aliases?: string[];
|
||||
inputKinds?: string[];
|
||||
outputKinds?: string[];
|
||||
triggerHints?: string[];
|
||||
supportedFileTypes?: string[];
|
||||
requiresFiles?: boolean;
|
||||
requiresExplicitUserIntent?: boolean;
|
||||
enabled?: boolean;
|
||||
disabled?: boolean;
|
||||
riskLevel?: ToolRiskLevel;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolRegistryEntry {
|
||||
capabilityKey: string;
|
||||
familyKey: string;
|
||||
toolName: string;
|
||||
kind: ToolRegistryEntryKind;
|
||||
displayName: string;
|
||||
description: string;
|
||||
aliases: string[];
|
||||
inputKinds: ToolInputKind[];
|
||||
outputKinds: ToolOutputKind[];
|
||||
triggerHints: string[];
|
||||
supportedFileTypes: string[];
|
||||
requiresFiles: boolean;
|
||||
requiresExplicitUserIntent: boolean;
|
||||
riskLevel: ToolRiskLevel;
|
||||
enabled: boolean;
|
||||
source?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateToolRegistryOptions {
|
||||
capabilities?: ToolRegistryCapabilityInput[];
|
||||
includeBuiltins?: boolean;
|
||||
}
|
||||
|
||||
const SPREADSHEET_FAMILY_KEY = 'spreadsheet.analysis';
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv', '.ods'];
|
||||
|
||||
const SPREADSHEET_MIME_PATTERNS = [
|
||||
'spreadsheet',
|
||||
'excel',
|
||||
'sheet',
|
||||
'csv',
|
||||
'tab-separated-values',
|
||||
'officedocument.spreadsheetml',
|
||||
];
|
||||
|
||||
const DEFAULT_BUILTIN_ENTRIES: ToolRegistryEntry[] = [
|
||||
{
|
||||
capabilityKey: 'browser.open_url',
|
||||
familyKey: 'browser.open_url',
|
||||
toolName: 'browser.open_url',
|
||||
kind: 'builtin-tool',
|
||||
displayName: 'Browser Open URL',
|
||||
description: 'Open an explicit URL in the managed browser when the user clearly asks to open or visit a page.',
|
||||
aliases: ['browser.open_url', 'open url', 'open link', 'visit url'],
|
||||
inputKinds: ['url'],
|
||||
outputKinds: ['browser-state', 'text'],
|
||||
triggerHints: ['open this url', 'open the link', 'visit the page', '打开链接', '打开网页', '访问网页'],
|
||||
supportedFileTypes: [],
|
||||
requiresFiles: false,
|
||||
requiresExplicitUserIntent: true,
|
||||
riskLevel: 'medium',
|
||||
enabled: true,
|
||||
metadata: {
|
||||
intent: 'open_url',
|
||||
sideEffect: 'browser-navigation',
|
||||
},
|
||||
},
|
||||
{
|
||||
capabilityKey: 'skills.install',
|
||||
familyKey: 'skills.install',
|
||||
toolName: 'skills.install',
|
||||
kind: 'builtin-tool',
|
||||
displayName: 'Skills Install',
|
||||
description: 'Install a skill from the marketplace slug or a GitHub skill URL when the user explicitly asks to install one.',
|
||||
aliases: ['skills.install', 'install skill', 'skill install', 'add skill'],
|
||||
inputKinds: ['text', 'url'],
|
||||
outputKinds: ['status', 'text'],
|
||||
triggerHints: ['install this skill', 'install skill', 'github skill url', '安装 skill', '安装技能'],
|
||||
supportedFileTypes: [],
|
||||
requiresFiles: false,
|
||||
requiresExplicitUserIntent: true,
|
||||
riskLevel: 'medium',
|
||||
enabled: true,
|
||||
metadata: {
|
||||
intent: 'install_skill',
|
||||
sideEffect: 'skill-installation',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeTextValue(value: string | undefined | null): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function normalizeLookupValue(value: string | undefined | null): string {
|
||||
return normalizeTextValue(value).toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeStrings(values: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = normalizeTextValue(value);
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const lookup = trimmed.toLowerCase();
|
||||
if (seen.has(lookup)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(lookup);
|
||||
result.push(trimmed);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeStringList(values: unknown, fallback: string[] = []): string[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
return dedupeStrings(values.map((item) => (typeof item === 'string' ? item : '')));
|
||||
}
|
||||
|
||||
function normalizeInputKinds(values: unknown): ToolInputKind[] {
|
||||
const allowed: ToolInputKind[] = ['text', 'url', 'file', 'attachment', 'history', 'structured'];
|
||||
return normalizeStringList(values)
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is ToolInputKind => allowed.includes(value as ToolInputKind));
|
||||
}
|
||||
|
||||
function normalizeOutputKinds(values: unknown): ToolOutputKind[] {
|
||||
const allowed: ToolOutputKind[] = ['text', 'json', 'file', 'artifacts', 'browser-state', 'status'];
|
||||
return normalizeStringList(values)
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is ToolOutputKind => allowed.includes(value as ToolOutputKind));
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function getMetadataStringArray(
|
||||
metadata: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string[] {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeStringList(value);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getMetadataBoolean(
|
||||
metadata: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): boolean | undefined {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferCapabilityKind(input: ToolRegistryCapabilityInput, capabilityKey: string): ToolRegistryEntryKind {
|
||||
if (input.kind === 'builtin-tool' || input.kind === 'skill') {
|
||||
return input.kind;
|
||||
}
|
||||
|
||||
if (capabilityKey === 'browser.open_url' || capabilityKey === 'skills.install') {
|
||||
return 'builtin-tool';
|
||||
}
|
||||
|
||||
return 'skill';
|
||||
}
|
||||
|
||||
function normalizeRiskLevel(value: string | undefined): ToolRiskLevel {
|
||||
switch (normalizeLookupValue(value)) {
|
||||
case 'high':
|
||||
return 'high';
|
||||
case 'medium':
|
||||
return 'medium';
|
||||
default:
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
function isSpreadsheetCapability(
|
||||
capabilityKey: string,
|
||||
displayName: string,
|
||||
description: string,
|
||||
aliases: string[],
|
||||
supportedFileTypes: string[],
|
||||
): boolean {
|
||||
const haystack = [
|
||||
capabilityKey,
|
||||
displayName,
|
||||
description,
|
||||
...aliases,
|
||||
...supportedFileTypes,
|
||||
].map((item) => item.toLowerCase());
|
||||
|
||||
if (haystack.some((item) => item === 'minimax-xlsx' || item === 'xlsx')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (supportedFileTypes.some((item) => isSpreadsheetFileType(item))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return haystack.some((item) =>
|
||||
item.includes('spreadsheet')
|
||||
|| item.includes('excel')
|
||||
|| item.includes('sheet')
|
||||
|| item.includes('csv')
|
||||
|| item.includes('表格')
|
||||
|| item.includes('excel')
|
||||
|| item.includes('工作表'),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSupportedFileTypes(
|
||||
input: ToolRegistryCapabilityInput,
|
||||
metadata: Record<string, unknown>,
|
||||
): string[] {
|
||||
const explicit = dedupeStrings([
|
||||
...normalizeStringList(input.supportedFileTypes),
|
||||
...getMetadataStringArray(metadata, 'supportedFileTypes', 'supported_file_types'),
|
||||
]);
|
||||
|
||||
if (explicit.length > 0) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const capabilityKey = normalizeLookupValue(
|
||||
input.capabilityKey || input.toolName || input.skillKey || input.slug || input.name,
|
||||
);
|
||||
if (capabilityKey === 'xlsx' || capabilityKey === 'minimax-xlsx') {
|
||||
return [...SPREADSHEET_EXTENSIONS];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeAliases(
|
||||
input: ToolRegistryCapabilityInput,
|
||||
capabilityKey: string,
|
||||
displayName: string,
|
||||
toolName: string,
|
||||
isSpreadsheet: boolean,
|
||||
): string[] {
|
||||
return dedupeStrings([
|
||||
capabilityKey,
|
||||
toolName,
|
||||
displayName,
|
||||
input.skillKey,
|
||||
input.slug,
|
||||
input.name,
|
||||
...normalizeStringList(input.aliases),
|
||||
...(isSpreadsheet ? ['minimax-xlsx', 'xlsx', 'spreadsheet', 'excel analyzer', 'excel analysis'] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeTriggerHints(
|
||||
input: ToolRegistryCapabilityInput,
|
||||
metadata: Record<string, unknown>,
|
||||
isSpreadsheet: boolean,
|
||||
): string[] {
|
||||
const hints = dedupeStrings([
|
||||
...normalizeStringList(input.triggerHints),
|
||||
...getMetadataStringArray(metadata, 'triggerHints', 'trigger_hints'),
|
||||
]);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
return dedupeStrings([
|
||||
...hints,
|
||||
'analyze spreadsheet',
|
||||
'analyze excel',
|
||||
'analyze table file',
|
||||
'summarize worksheet',
|
||||
'分析表格',
|
||||
'分析 excel',
|
||||
'分析工作表',
|
||||
'分析这个表',
|
||||
]);
|
||||
}
|
||||
|
||||
function mergeEntries(base: ToolRegistryEntry, incoming: ToolRegistryEntry): ToolRegistryEntry {
|
||||
return {
|
||||
...base,
|
||||
...incoming,
|
||||
aliases: dedupeStrings([...base.aliases, ...incoming.aliases]),
|
||||
inputKinds: Array.from(new Set([...base.inputKinds, ...incoming.inputKinds])),
|
||||
outputKinds: Array.from(new Set([...base.outputKinds, ...incoming.outputKinds])),
|
||||
triggerHints: dedupeStrings([...base.triggerHints, ...incoming.triggerHints]),
|
||||
supportedFileTypes: dedupeStrings([...base.supportedFileTypes, ...incoming.supportedFileTypes]),
|
||||
metadata: {
|
||||
...base.metadata,
|
||||
...incoming.metadata,
|
||||
},
|
||||
enabled: base.enabled || incoming.enabled,
|
||||
requiresFiles: base.requiresFiles || incoming.requiresFiles,
|
||||
requiresExplicitUserIntent: base.requiresExplicitUserIntent || incoming.requiresExplicitUserIntent,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryEntry(input: ToolRegistryCapabilityInput): ToolRegistryEntry | null {
|
||||
const metadata = isPlainRecord(input.metadata) ? input.metadata : {};
|
||||
const capabilityKey = normalizeTextValue(
|
||||
input.capabilityKey || input.toolName || input.skillKey || input.slug || input.name,
|
||||
);
|
||||
if (!capabilityKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = normalizeTextValue(input.toolName || capabilityKey);
|
||||
const displayName = normalizeTextValue(input.displayName || input.name || input.skillKey || input.slug || toolName);
|
||||
const description = normalizeTextValue(input.description);
|
||||
const supportedFileTypes = normalizeSupportedFileTypes(input, metadata);
|
||||
const isSpreadsheet = isSpreadsheetCapability(
|
||||
capabilityKey,
|
||||
displayName,
|
||||
description,
|
||||
normalizeStringList(input.aliases),
|
||||
supportedFileTypes,
|
||||
);
|
||||
const aliases = normalizeAliases(input, capabilityKey, displayName, toolName, isSpreadsheet);
|
||||
const triggerHints = normalizeTriggerHints(input, metadata, isSpreadsheet);
|
||||
const inputKinds = normalizeInputKinds(input.inputKinds);
|
||||
const outputKinds = normalizeOutputKinds(input.outputKinds);
|
||||
const inferredRequiresFiles = isSpreadsheet
|
||||
|| supportedFileTypes.length > 0
|
||||
|| inputKinds.includes('file')
|
||||
|| inputKinds.includes('attachment');
|
||||
const requiresFiles = input.requiresFiles
|
||||
?? getMetadataBoolean(metadata, 'requiresFiles', 'requires_files')
|
||||
?? inferredRequiresFiles;
|
||||
const requiresExplicitUserIntent = input.requiresExplicitUserIntent
|
||||
?? getMetadataBoolean(metadata, 'requiresExplicitUserIntent', 'requires_explicit_user_intent')
|
||||
?? false;
|
||||
const familyKey = normalizeTextValue(input.familyKey)
|
||||
|| (isSpreadsheet ? SPREADSHEET_FAMILY_KEY : capabilityKey);
|
||||
|
||||
return {
|
||||
capabilityKey,
|
||||
familyKey,
|
||||
toolName,
|
||||
kind: inferCapabilityKind(input, capabilityKey),
|
||||
displayName,
|
||||
description,
|
||||
aliases,
|
||||
inputKinds: inputKinds.length > 0 ? inputKinds : (requiresFiles ? ['text', 'file', 'attachment'] : ['text']),
|
||||
outputKinds: outputKinds.length > 0 ? outputKinds : ['text'],
|
||||
triggerHints,
|
||||
supportedFileTypes,
|
||||
requiresFiles,
|
||||
requiresExplicitUserIntent,
|
||||
riskLevel: normalizeRiskLevel(input.riskLevel),
|
||||
enabled: input.disabled === true ? false : input.enabled !== false,
|
||||
source: normalizeTextValue(input.source) || undefined,
|
||||
metadata: {
|
||||
...metadata,
|
||||
familyKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolRegistry(options: CreateToolRegistryOptions = {}): ToolRegistryEntry[] {
|
||||
const includeBuiltins = options.includeBuiltins !== false;
|
||||
const merged = new Map<string, ToolRegistryEntry>();
|
||||
|
||||
if (includeBuiltins) {
|
||||
for (const entry of DEFAULT_BUILTIN_ENTRIES) {
|
||||
merged.set(entry.capabilityKey, { ...entry, aliases: [...entry.aliases], triggerHints: [...entry.triggerHints] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const capability of options.capabilities || []) {
|
||||
if (capability.disabled === true || capability.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = buildRegistryEntry(capability);
|
||||
if (!entry || entry.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = merged.get(entry.capabilityKey);
|
||||
merged.set(entry.capabilityKey, existing ? mergeEntries(existing, entry) : entry);
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((left, right) => {
|
||||
if (left.kind !== right.kind) {
|
||||
return left.kind === 'builtin-tool' ? -1 : 1;
|
||||
}
|
||||
return left.displayName.localeCompare(right.displayName);
|
||||
});
|
||||
}
|
||||
|
||||
export function getRegistryEntryByName(
|
||||
registry: ToolRegistryEntry[],
|
||||
reference: string,
|
||||
): ToolRegistryEntry | undefined {
|
||||
const lookup = normalizeLookupValue(reference);
|
||||
if (!lookup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return registry.find((entry) =>
|
||||
normalizeLookupValue(entry.capabilityKey) === lookup
|
||||
|| normalizeLookupValue(entry.toolName) === lookup
|
||||
|| normalizeLookupValue(entry.familyKey) === lookup
|
||||
|| entry.aliases.some((alias) => normalizeLookupValue(alias) === lookup),
|
||||
);
|
||||
}
|
||||
|
||||
export function getRegistryEntriesByFamily(
|
||||
registry: ToolRegistryEntry[],
|
||||
familyKey: string,
|
||||
): ToolRegistryEntry[] {
|
||||
const lookup = normalizeLookupValue(familyKey);
|
||||
if (!lookup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return registry.filter((entry) => normalizeLookupValue(entry.familyKey) === lookup);
|
||||
}
|
||||
|
||||
export function matchRegistryEntriesByAlias(
|
||||
registry: ToolRegistryEntry[],
|
||||
text: string,
|
||||
): Array<{ entry: ToolRegistryEntry; alias: string }> {
|
||||
const normalizedText = normalizeLookupValue(text);
|
||||
if (!normalizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{ entry: ToolRegistryEntry; alias: string }> = [];
|
||||
|
||||
for (const entry of registry) {
|
||||
for (const alias of entry.aliases) {
|
||||
const normalizedAlias = normalizeLookupValue(alias);
|
||||
if (!normalizedAlias) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedText.includes(normalizedAlias)) {
|
||||
results.push({ entry, alias });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function isSpreadsheetFileType(value: string): boolean {
|
||||
const normalized = normalizeLookupValue(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SPREADSHEET_EXTENSIONS.some((ext) => normalized.endsWith(ext))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return SPREADSHEET_MIME_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
}
|
||||
|
||||
export function registryEntrySupportsFileType(
|
||||
entry: ToolRegistryEntry,
|
||||
fileType: string,
|
||||
): boolean {
|
||||
if (!fileType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.supportedFileTypes.length === 0) {
|
||||
return !entry.requiresFiles;
|
||||
}
|
||||
|
||||
const normalizedFileType = normalizeLookupValue(fileType);
|
||||
return entry.supportedFileTypes.some((candidate) => {
|
||||
const normalizedCandidate = normalizeLookupValue(candidate);
|
||||
if (!normalizedCandidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedCandidate.startsWith('.')) {
|
||||
return normalizedFileType.endsWith(normalizedCandidate);
|
||||
}
|
||||
|
||||
return normalizedFileType.includes(normalizedCandidate);
|
||||
});
|
||||
}
|
||||
|
||||
export function isSpreadsheetFamilyEntry(entry: ToolRegistryEntry): boolean {
|
||||
return normalizeLookupValue(entry.familyKey) === SPREADSHEET_FAMILY_KEY;
|
||||
}
|
||||
|
||||
export function getSpreadsheetFamilyKey(): string {
|
||||
return SPREADSHEET_FAMILY_KEY;
|
||||
}
|
||||
Reference in New Issue
Block a user