Files
zn-ai/electron/gateway/skill-planner.ts
DEV_DSW 4c61e93c3e 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.
2026-04-24 17:02:59 +08:00

810 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
AttachedFileMeta,
ContentBlock,
RawMessage,
ToolCallPayload,
} from '@runtime/shared/chat-model';
import {
createToolRegistry,
getRegistryEntryByName,
getRegistryEntriesByFamily,
getSpreadsheetFamilyKey,
isSpreadsheetFamilyEntry,
isSpreadsheetFileType,
matchRegistryEntriesByAlias,
registryEntrySupportsFileType,
type ToolRegistryCapabilityInput,
type ToolRegistryEntry,
} from './tool-registry';
export type PlannerDecisionReason =
| 'browser_open_url'
| 'skills_install'
| 'explicit_skill_request'
| 'spreadsheet_analysis'
| 'missing_required_attachment'
| 'missing_required_url'
| 'no_matching_capability'
| 'no_tool_needed';
export interface PlannerBlockingIssue {
code:
| 'missing_required_attachment'
| 'missing_required_url'
| 'insufficient_user_intent';
message: string;
missing?: string[];
}
export interface PlannerCapabilityMatch {
capabilityKey: string;
familyKey: string;
toolName: string;
displayName: string;
score: number;
reasons: string[];
}
export interface PlannerAttachmentContext {
current: AttachedFileMeta[];
recent: AttachedFileMeta[];
selected: AttachedFileMeta[];
spreadsheet: AttachedFileMeta[];
usedHistoryAttachments: boolean;
}
export interface PlannerSelectedCapability {
capabilityKey: string;
familyKey: string;
toolName: string;
displayName: string;
kind: ToolRegistryEntry['kind'];
}
export interface PlannerDecision {
kind: 'tool' | 'no-tool';
reason: PlannerDecisionReason;
summary: string;
thinking: string;
normalizedUserText: string;
attachmentContext: PlannerAttachmentContext;
matchedCapabilities: PlannerCapabilityMatch[];
selectedCapability?: PlannerSelectedCapability;
toolCall?: ToolCallPayload;
blockingIssue?: PlannerBlockingIssue;
}
export interface PlannerInput {
message?: RawMessage;
userText?: string;
attachments?: AttachedFileMeta[];
history?: RawMessage[];
capabilities?: ToolRegistryCapabilityInput[];
registry?: ToolRegistryEntry[];
}
type InstallPlanInput =
| {
kind: 'marketplace';
slug: string;
force?: boolean;
}
| {
kind: 'github-url';
url: string;
force?: boolean;
};
const FILE_REFERENCE_KEYWORDS = [
'this file',
'the file',
'this attachment',
'the attachment',
'this sheet',
'that sheet',
'this spreadsheet',
'this excel',
'这个文件',
'该文件',
'这个附件',
'这个表',
'这份表',
'这个表格',
'这个 excel',
'这个工作表',
];
const ANALYSIS_KEYWORDS = [
'analyze',
'analyse',
'analysis',
'inspect',
'review',
'summarize',
'summary',
'report',
'统计',
'分析',
'汇总',
'总结',
'看看',
'处理',
];
const OPEN_URL_KEYWORDS = [
'open',
'visit',
'browse',
'navigate',
'打开',
'访问',
'进入',
];
const INSTALL_KEYWORDS = [
'install',
'add skill',
'enable and install',
'安装',
'添加技能',
'安装 skill',
];
const SKILL_WORD_KEYWORDS = ['skill', 'skills', '技能'];
function normalizeText(value: string | undefined | null): string {
return String(value ?? '').trim();
}
function normalizeLookupText(value: string | undefined | null): string {
return normalizeText(value).toLowerCase();
}
function dedupeAttachments(values: AttachedFileMeta[]): AttachedFileMeta[] {
const seen = new Set<string>();
const result: AttachedFileMeta[] = [];
for (const attachment of values) {
const key = [
normalizeLookupText(attachment.filePath),
normalizeLookupText(attachment.fileName),
normalizeLookupText(attachment.mimeType),
].join('|');
if (!attachment.fileName && !attachment.filePath) {
continue;
}
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(attachment);
}
return result;
}
function flattenContent(content: RawMessage['content'] | ContentBlock[] | string): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.map((block) => {
if (!block || typeof block !== 'object') {
return '';
}
if (block.type === 'text' && typeof block.text === 'string') {
return block.text;
}
if (block.type === 'thinking' && typeof block.thinking === 'string') {
return block.thinking;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.summary === 'string') {
return block.summary;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.content === 'string') {
return block.content;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && Array.isArray(block.content)) {
return flattenContent(block.content);
}
return '';
})
.filter(Boolean)
.join('\n');
}
function extractMessageText(message?: RawMessage): string {
if (!message) {
return '';
}
return flattenContent(message.content);
}
function parseAttachmentRefsFromText(text: string): AttachedFileMeta[] {
const matches = text.matchAll(/\[media attached:\s*(.+?)\s*\((.+?)\)\s*\|\s*(.+?)\]/gi);
const attachments: AttachedFileMeta[] = [];
for (const match of matches) {
const fileName = normalizeText(match[1]);
const mimeType = normalizeText(match[2]) || 'application/octet-stream';
const filePath = normalizeText(match[3]);
if (!fileName && !filePath) {
continue;
}
attachments.push({
fileName: fileName || filePath.split(/[\\/]/).pop() || 'attachment',
mimeType,
fileSize: 0,
preview: null,
filePath,
source: 'message-ref',
});
}
return attachments;
}
function collectCurrentAttachments(input: PlannerInput, text: string): AttachedFileMeta[] {
return dedupeAttachments([
...(input.attachments || []),
...(input.message?._attachedFiles || []),
...parseAttachmentRefsFromText(text),
]);
}
function collectRecentAttachments(history: RawMessage[] | undefined): AttachedFileMeta[] {
if (!history || history.length === 0) {
return [];
}
const attachments: AttachedFileMeta[] = [];
for (let index = history.length - 1; index >= 0; index -= 1) {
const message = history[index];
if (message.role !== 'user') {
continue;
}
attachments.push(...(message._attachedFiles || []));
attachments.push(...parseAttachmentRefsFromText(extractMessageText(message)));
if (attachments.length >= 8) {
break;
}
}
return dedupeAttachments(attachments);
}
function hasAnyKeyword(text: string, keywords: string[]): boolean {
const normalizedText = normalizeLookupText(text);
return keywords.some((keyword) => normalizedText.includes(normalizeLookupText(keyword)));
}
function extractUrls(text: string): string[] {
const urls = text.match(/https?:\/\/[^\s)\]]+/gi) || [];
return Array.from(new Set(urls.map((item) => item.trim())));
}
function isSpreadsheetAttachment(attachment: AttachedFileMeta): boolean {
const fileName = normalizeLookupText(attachment.fileName);
const mimeType = normalizeLookupText(attachment.mimeType);
const filePath = normalizeLookupText(attachment.filePath);
return isSpreadsheetFileType(fileName) || isSpreadsheetFileType(filePath) || isSpreadsheetFileType(mimeType);
}
function shouldReuseHistoryAttachments(text: string, explicitSpreadsheetMention: boolean): boolean {
return explicitSpreadsheetMention
|| hasAnyKeyword(text, FILE_REFERENCE_KEYWORDS)
|| hasAnyKeyword(text, ANALYSIS_KEYWORDS);
}
function pickSelectedAttachments(
current: AttachedFileMeta[],
recent: AttachedFileMeta[],
text: string,
explicitSpreadsheetMention: boolean,
): PlannerAttachmentContext {
const usedHistoryAttachments = current.length === 0 && recent.length > 0 && shouldReuseHistoryAttachments(text, explicitSpreadsheetMention);
const selected = current.length > 0 ? current : (usedHistoryAttachments ? recent : []);
const spreadsheet = selected.filter((attachment) => isSpreadsheetAttachment(attachment));
return {
current,
recent,
selected,
spreadsheet,
usedHistoryAttachments,
};
}
function buildCapabilityMatches(
registry: ToolRegistryEntry[],
text: string,
attachmentContext: PlannerAttachmentContext,
hasOpenUrlIntent: boolean,
hasInstallIntent: boolean,
): PlannerCapabilityMatch[] {
const aliasMatches = matchRegistryEntriesByAlias(registry, text);
const aliasByCapability = new Map<string, string[]>();
for (const match of aliasMatches) {
const existing = aliasByCapability.get(match.entry.capabilityKey) || [];
existing.push(match.alias);
aliasByCapability.set(match.entry.capabilityKey, existing);
}
return registry
.map((entry): PlannerCapabilityMatch => {
const reasons: string[] = [];
let score = 0;
const aliases = aliasByCapability.get(entry.capabilityKey) || [];
if (aliases.length > 0) {
score += 12;
reasons.push(`alias:${aliases[0]}`);
}
if (entry.triggerHints.some((hint) => normalizeLookupText(text).includes(normalizeLookupText(hint)))) {
score += 4;
reasons.push('trigger-hint');
}
if (hasOpenUrlIntent && entry.toolName === 'browser.open_url') {
score += 9;
reasons.push('open-url-intent');
}
if (hasInstallIntent && entry.toolName === 'skills.install') {
score += 9;
reasons.push('install-intent');
}
if (entry.requiresFiles && attachmentContext.selected.length > 0) {
score += 3;
reasons.push('attachments-available');
}
if (isSpreadsheetFamilyEntry(entry) && attachmentContext.spreadsheet.length > 0) {
score += 8;
reasons.push('spreadsheet-attachments');
}
if (entry.requiresFiles && attachmentContext.selected.length === 0) {
score -= 6;
reasons.push('missing-required-attachments');
}
return {
capabilityKey: entry.capabilityKey,
familyKey: entry.familyKey,
toolName: entry.toolName,
displayName: entry.displayName,
score,
reasons,
};
})
.sort((left, right) => right.score - left.score);
}
function buildSelectedCapability(entry: ToolRegistryEntry): PlannerSelectedCapability {
return {
capabilityKey: entry.capabilityKey,
familyKey: entry.familyKey,
toolName: entry.toolName,
displayName: entry.displayName,
kind: entry.kind,
};
}
function buildNoToolDecision(
reason: PlannerDecisionReason,
summary: string,
thinking: string,
normalizedUserText: string,
attachmentContext: PlannerAttachmentContext,
matchedCapabilities: PlannerCapabilityMatch[],
blockingIssue?: PlannerBlockingIssue,
selectedEntry?: ToolRegistryEntry,
): PlannerDecision {
return {
kind: 'no-tool',
reason,
summary,
thinking,
normalizedUserText,
attachmentContext,
matchedCapabilities,
selectedCapability: selectedEntry ? buildSelectedCapability(selectedEntry) : undefined,
blockingIssue,
};
}
function buildToolDecision(
reason: PlannerDecisionReason,
summary: string,
thinking: string,
normalizedUserText: string,
attachmentContext: PlannerAttachmentContext,
matchedCapabilities: PlannerCapabilityMatch[],
entry: ToolRegistryEntry,
toolCall: ToolCallPayload,
): PlannerDecision {
return {
kind: 'tool',
reason,
summary,
thinking,
normalizedUserText,
attachmentContext,
matchedCapabilities,
selectedCapability: buildSelectedCapability(entry),
toolCall,
};
}
function choosePreferredEntry(entries: ToolRegistryEntry[], text: string): ToolRegistryEntry | undefined {
if (entries.length === 0) {
return undefined;
}
const exactText = normalizeLookupText(text);
const exactMatch = entries.find((entry) =>
normalizeLookupText(entry.capabilityKey) === exactText
|| normalizeLookupText(entry.toolName) === exactText,
);
if (exactMatch) {
return exactMatch;
}
const aliasMatch = matchRegistryEntriesByAlias(entries, text)[0];
if (aliasMatch) {
return aliasMatch.entry;
}
const minimaxEntry = entries.find((entry) => normalizeLookupText(entry.capabilityKey) === 'minimax-xlsx');
if (minimaxEntry) {
return minimaxEntry;
}
return entries[0];
}
function buildAttachmentReference(attachment: AttachedFileMeta): Record<string, unknown> {
return {
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
filePath: attachment.filePath,
source: attachment.source,
};
}
function buildSpreadsheetToolCall(
entry: ToolRegistryEntry,
text: string,
attachmentContext: PlannerAttachmentContext,
): ToolCallPayload {
return {
name: entry.toolName,
input: {
prompt: text,
skillKey: entry.capabilityKey,
intent: 'spreadsheet-analysis',
attachments: attachmentContext.spreadsheet.map((attachment) => buildAttachmentReference(attachment)),
filePaths: attachmentContext.spreadsheet
.map((attachment) => attachment.filePath)
.filter((filePath): filePath is string => Boolean(filePath)),
reuseHistoryAttachment: attachmentContext.usedHistoryAttachments,
},
summary: `Use ${entry.displayName} to analyze ${attachmentContext.spreadsheet.length} spreadsheet attachment(s).`,
};
}
function buildGenericToolCall(
entry: ToolRegistryEntry,
text: string,
attachmentContext: PlannerAttachmentContext,
): ToolCallPayload {
return {
name: entry.toolName,
input: {
prompt: text,
capabilityKey: entry.capabilityKey,
attachments: attachmentContext.selected.map((attachment) => buildAttachmentReference(attachment)),
reuseHistoryAttachment: attachmentContext.usedHistoryAttachments,
},
summary: `Use ${entry.displayName} because the user explicitly requested this capability.`,
};
}
function detectInstallPlan(text: string): InstallPlanInput | undefined {
if (!hasAnyKeyword(text, INSTALL_KEYWORDS)) {
return undefined;
}
const urls = extractUrls(text);
const githubUrl = urls.find((url) => /github\.com/i.test(url));
if (githubUrl) {
return {
kind: 'github-url',
url: githubUrl,
};
}
const normalizedText = normalizeLookupText(text);
if (!hasAnyKeyword(text, SKILL_WORD_KEYWORDS) && !normalizedText.includes('skills.install')) {
return undefined;
}
const slugPatterns = [
/install\s+(?:the\s+)?(?:skill\s+)?([a-z0-9][a-z0-9-_./]{1,127})/i,
/安装\s*([a-z0-9][a-z0-9-_./]{1,127})\s*(?:这个)?\s*skill/i,
/安装\s*skill\s*[:]?\s*([a-z0-9][a-z0-9-_./]{1,127})/i,
];
for (const pattern of slugPatterns) {
const match = text.match(pattern);
const slug = normalizeText(match?.[1]);
if (slug && slug !== 'skill' && slug !== 'skills') {
return {
kind: 'marketplace',
slug,
};
}
}
return undefined;
}
function detectBrowserUrl(text: string): string | undefined {
const urls = extractUrls(text);
if (urls.length === 0) {
return undefined;
}
if (hasAnyKeyword(text, OPEN_URL_KEYWORDS) || normalizeLookupText(text).includes('browser.open_url')) {
return urls[0];
}
return undefined;
}
function filterCompatibleAttachments(
entry: ToolRegistryEntry,
attachments: AttachedFileMeta[],
): AttachedFileMeta[] {
if (attachments.length === 0) {
return [];
}
if (entry.supportedFileTypes.length === 0) {
return attachments;
}
return attachments.filter((attachment) =>
registryEntrySupportsFileType(entry, attachment.fileName)
|| registryEntrySupportsFileType(entry, attachment.mimeType)
|| registryEntrySupportsFileType(entry, attachment.filePath || ''),
);
}
function resolveRegistry(input: PlannerInput): ToolRegistryEntry[] {
if (input.registry && input.registry.length > 0) {
return [...input.registry];
}
return createToolRegistry({
capabilities: input.capabilities,
});
}
export function planToolCall(input: PlannerInput): PlannerDecision {
const messageText = normalizeText(input.userText || extractMessageText(input.message));
const normalizedUserText = normalizeLookupText(messageText);
const registry = resolveRegistry(input);
const spreadsheetFamilyEntries = getRegistryEntriesByFamily(registry, getSpreadsheetFamilyKey());
const explicitSpreadsheetMention = matchRegistryEntriesByAlias(spreadsheetFamilyEntries, messageText).length > 0;
const currentAttachments = collectCurrentAttachments(input, messageText);
const recentAttachments = collectRecentAttachments(input.history);
const attachmentContext = pickSelectedAttachments(
currentAttachments,
recentAttachments,
messageText,
explicitSpreadsheetMention,
);
const browserUrl = detectBrowserUrl(messageText);
const installPlan = detectInstallPlan(messageText);
const matchedCapabilities = buildCapabilityMatches(
registry,
messageText,
attachmentContext,
Boolean(browserUrl),
Boolean(installPlan),
);
if (browserUrl) {
const browserEntry = getRegistryEntryByName(registry, 'browser.open_url');
if (browserEntry) {
return buildToolDecision(
'browser_open_url',
`Plan to open ${browserUrl} with browser.open_url.`,
'The user explicitly asked to open a URL, so the browser tool should run before generating any follow-up response.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
browserEntry,
{
name: browserEntry.toolName,
input: { url: browserUrl },
summary: `Open ${browserUrl} in the managed browser.`,
},
);
}
}
if (installPlan) {
const installEntry = getRegistryEntryByName(registry, 'skills.install');
if (installEntry) {
return buildToolDecision(
'skills_install',
`Plan to install a skill through ${installEntry.toolName}.`,
'The user explicitly asked to install a skill, so the install tool should be called with the parsed marketplace slug or GitHub URL.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
installEntry,
{
name: installEntry.toolName,
input: installPlan,
summary: installPlan.kind === 'github-url'
? `Install a skill from ${installPlan.url}.`
: `Install marketplace skill ${installPlan.slug}.`,
},
);
}
}
if (spreadsheetFamilyEntries.length > 0 && (explicitSpreadsheetMention || attachmentContext.spreadsheet.length > 0)) {
const spreadsheetEntry = choosePreferredEntry(spreadsheetFamilyEntries, messageText);
if (spreadsheetEntry) {
if (attachmentContext.spreadsheet.length === 0) {
return buildNoToolDecision(
'missing_required_attachment',
`Cannot call ${spreadsheetEntry.displayName} yet because no spreadsheet attachment is available.`,
'A spreadsheet analysis skill is available and mentioned, but no .xlsx/.xls/.csv/.tsv attachment was found in the current message or recent history.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
{
code: 'missing_required_attachment',
message: 'Spreadsheet analysis requires at least one spreadsheet attachment.',
missing: ['attachment'],
},
spreadsheetEntry,
);
}
return buildToolDecision(
explicitSpreadsheetMention ? 'explicit_skill_request' : 'spreadsheet_analysis',
`Plan to analyze ${attachmentContext.spreadsheet.length} spreadsheet attachment(s) with ${spreadsheetEntry.displayName}.`,
explicitSpreadsheetMention
? 'The user explicitly referenced the spreadsheet-analysis skill, and compatible spreadsheet attachments are available.'
: 'Compatible spreadsheet attachments are available and the latest user turn looks like a data-analysis request.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
spreadsheetEntry,
buildSpreadsheetToolCall(spreadsheetEntry, messageText, attachmentContext),
);
}
}
const explicitMatches = matchRegistryEntriesByAlias(registry, messageText)
.map((match) => match.entry)
.filter((entry) => entry.toolName !== 'browser.open_url' && entry.toolName !== 'skills.install');
const explicitEntry = choosePreferredEntry(dedupeEntries(explicitMatches), messageText);
if (explicitEntry) {
const compatibleAttachments = filterCompatibleAttachments(explicitEntry, attachmentContext.selected);
if (explicitEntry.requiresFiles && compatibleAttachments.length === 0) {
return buildNoToolDecision(
'missing_required_attachment',
`Cannot call ${explicitEntry.displayName} yet because the required attachment input is missing.`,
'The user explicitly referenced a capability that expects files, but no compatible attachment was found in the current turn or recent history.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
{
code: 'missing_required_attachment',
message: `${explicitEntry.displayName} requires a compatible attachment before it can run.`,
missing: ['attachment'],
},
explicitEntry,
);
}
return buildToolDecision(
'explicit_skill_request',
`Plan to call ${explicitEntry.displayName} because the user explicitly referenced it.`,
'An enabled capability was explicitly mentioned in the latest user message, so the planner should route the turn into that tool or skill.',
normalizedUserText,
{
...attachmentContext,
selected: compatibleAttachments.length > 0 ? compatibleAttachments : attachmentContext.selected,
},
matchedCapabilities,
explicitEntry,
buildGenericToolCall(
explicitEntry,
messageText,
{
...attachmentContext,
selected: compatibleAttachments.length > 0 ? compatibleAttachments : attachmentContext.selected,
},
),
);
}
if (!messageText) {
return buildNoToolDecision(
'no_tool_needed',
'No tool call was planned because the latest user text is empty.',
'There is no user text to classify for tool use, so the caller can fall back to the normal assistant response path.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
);
}
if (matchedCapabilities.length === 0 || matchedCapabilities[0]?.score <= 0) {
return buildNoToolDecision(
'no_matching_capability',
'No tool call was planned because no enabled capability matched the latest user turn strongly enough.',
'The latest user turn does not contain an explicit tool request or a high-confidence capability match, so the chat runtime can continue on the no-tool path.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
);
}
return buildNoToolDecision(
'no_tool_needed',
'A capability match exists, but the current turn does not require an immediate tool call.',
'The planner found possible capabilities, but none of them crossed the threshold for an explicit tool-first action on this turn.',
normalizedUserText,
attachmentContext,
matchedCapabilities,
{
code: 'insufficient_user_intent',
message: 'No explicit tool-first intent was detected for this turn.',
},
);
}
function dedupeEntries(entries: ToolRegistryEntry[]): ToolRegistryEntry[] {
const seen = new Set<string>();
const result: ToolRegistryEntry[] = [];
for (const entry of entries) {
if (seen.has(entry.capabilityKey)) {
continue;
}
seen.add(entry.capabilityKey);
result.push(entry);
}
return result;
}