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:
809
electron/gateway/skill-planner.ts
Normal file
809
electron/gateway/skill-planner.ts
Normal file
@@ -0,0 +1,809 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user