- 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.
810 lines
23 KiB
TypeScript
810 lines
23 KiB
TypeScript
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;
|
||
}
|