Files
OmniRoute/src/lib/skills/injection.ts
3g0r1ch 2e799b33a7 fix: skills & memory — tool-name encoding, schema normalization, warm-cache, combo id, Ponytail catalog (#9058)
* feat(skills): add Ponytail minimalism skill as external catalog entry

- Add 'external' SkillCategory + SkillArea
- Register ponytail (MIT, DietrichGebert/ponytail) in CURATED_SKILLS
- Generator: external skills carry content in custom block, no api/cli body
- Generate skills/ponytail/SKILL.md with original content preserved
- Update catalog test counts 45 -> 46

* fix(skills+memory): builtin handler fallback in executor, skip vector upsert for deleted memories

- skills: Next.js compiles SkillExecutor into multiple chunks (own singleton
  each); route chunk lacked builtin handlers registered at startup via
  instrumentation. execute() now falls back to builtinSkills registry, so
  POST /api/skills/executions works for file_read/web_fetch/etc.
- memory: scheduleVectorUpsert is fire-and-forget and embeddings are slow;
  health-check verify (create->delete test memory) left queued upserts
  failing with 'memory not found' every 30s. Check existence before embedding
  and skip quietly.

* fix(skills): encode tool names with @ and . for providers rejecting them

Skill tools were advertised as 'name@version' (e.g. test-fr2@1.0.0), but
DeepSeek/Groq/OpenAI reject function names not matching ^[a-zA-Z0-9_-]+$.
Names already valid are left untouched; invalid ones are reversibly encoded
as omr_skill_<base64url> and decoded in interception before registry lookup.

* fix(combos): include DB id column in combo records for dashboard links

getCombos() selected only data/sort_order/context_cache_protection, so
combos whose JSON blob lacked an id field returned id: undefined. The
dashboard then linked to /dashboard/combos/undefined and Combo Control
Center failed with 'Combo not found'. Merge the id column into parsed
rows (authoritative, only when the blob has no id).

* fix(skills): normalize flat skill schemas to object schema for Gemini/Claude

Stored skill schemas are flat property maps ({ text: { type: string } }),
which OpenAI-compatible providers tolerate but Gemini
(function_declarations[].parameters) rejects with 'Unknown name ... Cannot
find field'. Wrap bare maps into { type: 'object', properties: {...} } for
all three tool formats.

* fix(skills): warm registry cache before skill injection in chat path

injectSkills() lists the in-memory skillRegistry, which is empty after a
cold start until something calls loadFromDatabase(). The interception path
already warms the cache (#2815); the injection path did not, so skills
were silently skipped (no_enabled_skills) for the first requests after
restart. Warm the cache for the chat owner before injection.

---------

Co-authored-by: Egor <egorich-print@users.noreply.github.com>
2026-08-11 04:30:38 -03:00

352 lines
9.8 KiB
TypeScript

import { skillRegistry } from "./registry";
import { Skill } from "./types";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("SKILLS_INJECTION");
interface OpenAITool {
type: string;
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
interface ClaudeTool {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
interface GeminiTool {
name: string;
description: string;
parameters: Record<string, unknown>;
}
// Provider tool/function names must match ^[a-zA-Z0-9_-]+$ (OpenAI, DeepSeek,
// Groq, etc.). Skill identifiers are name@version (and names may contain any
// characters), so encode identifiers that would violate the pattern into a
// reversible base64url form. decodeSkillToolName() must be applied on the way
// back in interception before resolving against the registry.
const SKILL_TOOL_NAME_PREFIX = "omr_skill_";
export function encodeSkillToolName(name: string, version: string): string {
const identifier = `${name}@${version}`;
if (/^[a-zA-Z0-9_-]+$/.test(identifier)) {
return identifier;
}
return `${SKILL_TOOL_NAME_PREFIX}${Buffer.from(identifier, "utf8").toString("base64url")}`;
}
export function decodeSkillToolName(toolName: string): string {
if (!toolName.startsWith(SKILL_TOOL_NAME_PREFIX)) {
return toolName;
}
try {
return Buffer.from(toolName.slice(SKILL_TOOL_NAME_PREFIX.length), "base64url").toString("utf8");
} catch {
return toolName;
}
}
// Skills store a flat JSON Schema record ({ "text": { "type": "string" } }),
// but Gemini (function_declarations[].parameters) and Anthropic
// (input_schema) require a full object schema with a properties wrapper.
// Normalize to { "type": "object", "properties": {...} } when the stored
// schema is a bare property map.
function normalizeInputSchema(input: Record<string, unknown>): Record<string, unknown> {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return input ?? {};
}
if (typeof input.type === "string") {
return input;
}
return {
type: "object",
properties: input,
};
}
function skillToOpenAI(skill: Skill): OpenAITool {
return {
type: "function",
function: {
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
parameters: normalizeInputSchema(skill.schema.input),
},
};
}
function skillToClaude(skill: Skill): ClaudeTool {
return {
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
input_schema: normalizeInputSchema(skill.schema.input),
};
}
function skillToGemini(skill: Skill): GeminiTool {
return {
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
parameters: normalizeInputSchema(skill.schema.input),
};
}
export interface InjectionOptions {
provider: "openai" | "anthropic" | "google" | "other";
existingTools?: unknown[];
apiKeyId: string;
model?: string;
sourceFormat?: string;
targetFormat?: string;
backgroundReason?: string | null;
messages?: unknown[];
}
const AUTO_MIN_SCORE = 3;
const AUTO_MAX_SKILLS = 5;
const TOKEN_MIN_LEN = 3;
function toLowerText(value: unknown): string {
if (typeof value === "string") return value.toLowerCase();
return "";
}
function extractTokens(value: string): Set<string> {
const matches: string[] = value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
return new Set(matches.filter((t) => t.length >= TOKEN_MIN_LEN));
}
function splitNameTokens(name: string): Set<string> {
const expandedCamel = name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._@\-/]+/g, " ")
.toLowerCase();
return extractTokens(expandedCamel);
}
function extractMessageText(messages: unknown[]): string {
const chunks: string[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const record = message as Record<string, unknown>;
const content = record.content;
if (typeof content === "string") {
chunks.push(content);
continue;
}
if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") {
chunks.push(item);
continue;
}
if (item && typeof item === "object") {
const itemRecord = item as Record<string, unknown>;
if (typeof itemRecord.text === "string") {
chunks.push(itemRecord.text);
}
}
}
}
}
return chunks.join(" ").toLowerCase();
}
function buildContextText(options: InjectionOptions): string {
const parts = [
JSON.stringify(options.existingTools || []).toLowerCase(),
toLowerText(options.model),
toLowerText(options.sourceFormat),
toLowerText(options.targetFormat),
toLowerText(options.backgroundReason),
];
if (Array.isArray(options.messages) && options.messages.length > 0) {
parts.push(extractMessageText(options.messages));
}
return parts.filter(Boolean).join(" ");
}
function scoreAutoSkill(
skill: Skill,
options: InjectionOptions,
contextText: string,
contextTokens: Set<string>,
backgroundTokens: Set<string>
): number {
const name = skill.name.toLowerCase();
const tags = (Array.isArray(skill.tags) ? skill.tags : []).map((tag) =>
String(tag).toLowerCase()
);
const description = toLowerText(skill.description);
const nameTokens = splitNameTokens(skill.name);
const descriptionTokens = extractTokens(description);
let score = 0;
if (name && contextText.includes(name)) {
score += 6;
}
for (const token of nameTokens) {
if (contextTokens.has(token)) score += 2;
}
for (const tag of tags) {
if (!tag) continue;
if (contextText.includes(tag)) {
score += 3;
}
}
for (const token of descriptionTokens) {
if (contextTokens.has(token)) score += 1;
}
if (backgroundTokens.size > 0) {
for (const token of backgroundTokens) {
if (nameTokens.has(token)) score += 2;
if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 2;
}
}
const providerAliases: Record<InjectionOptions["provider"], string[]> = {
openai: ["openai", "gpt"],
anthropic: ["anthropic", "claude"],
google: ["google", "gemini"],
other: [],
};
const knownProviderHints = new Set(["openai", "gpt", "anthropic", "claude", "google", "gemini"]);
const skillProviderHints = tags.filter((tag) => knownProviderHints.has(tag));
if (skillProviderHints.length > 0) {
const aliases = providerAliases[options.provider];
const hasProviderMatch = skillProviderHints.some((hint) => aliases.includes(hint));
if (hasProviderMatch) {
score += 2;
} else {
score -= 2;
}
}
return score;
}
export function injectSkills(options: InjectionOptions): unknown[] {
const contextText = buildContextText(options);
const contextTokens = extractTokens(contextText);
const backgroundTokens = extractTokens(toLowerText(options.backgroundReason));
const selectedSkills = skillRegistry.list(options.apiKeyId).filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
if (mode === "off") return false;
return s.enabled;
});
const alwaysOnSkills = selectedSkills.filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
return mode === "on";
});
const autoCandidates = selectedSkills.filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
return mode === "auto";
});
const autoSkills = autoCandidates
.map((skill) => ({
skill,
score: scoreAutoSkill(skill, options, contextText, contextTokens, backgroundTokens),
}))
.filter((entry) => entry.score >= AUTO_MIN_SCORE)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const installA = typeof a.skill.installCount === "number" ? a.skill.installCount : 0;
const installB = typeof b.skill.installCount === "number" ? b.skill.installCount : 0;
if (installB !== installA) return installB - installA;
return a.skill.name.localeCompare(b.skill.name);
})
.slice(0, AUTO_MAX_SKILLS)
.map((entry) => entry.skill);
const skills = [...alwaysOnSkills, ...autoSkills];
if (skills.length === 0) {
log.info("skills.injection.skipped", {
apiKeyId: options.apiKeyId,
reason: "no_enabled_skills",
});
return options.existingTools || [];
}
log.info("skills.injection.injected", {
apiKeyId: options.apiKeyId,
provider: options.provider,
skillCount: skills.length,
});
const injectedTools = skills.map((skill) => {
switch (options.provider) {
case "openai":
return skillToOpenAI(skill);
case "anthropic":
return skillToClaude(skill);
case "google":
return skillToGemini(skill);
default:
return skillToOpenAI(skill);
}
});
if (options.existingTools && options.existingTools.length > 0) {
return [...injectedTools, ...options.existingTools];
}
return injectedTools;
}
export function injectSkillTools(
messages: any[],
provider: "openai" | "anthropic" | "google" | "other",
apiKeyId: string
): any[] {
const tools = injectSkills({ provider, apiKeyId });
if (tools.length === 0) {
return messages;
}
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "user" && !lastMessage.tools) {
return [...messages.slice(0, -1), { ...lastMessage, tools }];
}
return messages;
}
export function detectProvider(modelId: string): "openai" | "anthropic" | "google" | "other" {
const lower = modelId.toLowerCase();
if (lower.includes("gpt") || lower.includes("openai")) {
return "openai";
}
if (lower.includes("claude") || lower.includes("anthropic")) {
return "anthropic";
}
if (lower.includes("gemini") || lower.includes("google")) {
return "google";
}
return "other";
}