diff --git a/js/plugins/middleware/src/index.ts b/js/plugins/middleware/src/index.ts index 36fac93848..76096cc182 100644 --- a/js/plugins/middleware/src/index.ts +++ b/js/plugins/middleware/src/index.ts @@ -16,8 +16,8 @@ export { fallback } from './fallback.js'; export { retry } from './retry.js'; +export { skills } from './skills.js'; /// coming soon... // export { filesystem } from './filesystem.js'; -// export { skills } from './skills.js'; // export { toolApproval } from './toolApproval.js'; diff --git a/js/plugins/middleware/src/skills.ts b/js/plugins/middleware/src/skills.ts new file mode 100644 index 0000000000..1d15c449f7 --- /dev/null +++ b/js/plugins/middleware/src/skills.ts @@ -0,0 +1,217 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import { generateMiddleware, z, type GenerateMiddleware } from 'genkit'; +import { tool } from 'genkit/beta'; +import * as path from 'path'; + +export const SkillsOptionsSchema = z.object({ + skillPaths: z.array(z.string()).optional(), +}); + +export type SkillsOptions = z.infer; + +/** + * Creates a middleware that scans for skills in specified paths. + * Injects a system prompt listing available skills and provides a `use_skill` tool. + */ +export const skills: GenerateMiddleware = + generateMiddleware( + { + name: 'skills', + configSchema: SkillsOptionsSchema, + }, + ({ config }) => { + const skillPaths = config?.skillPaths ?? ['skills']; + const skillCache = new Map< + string, + { path: string; description: string } + >(); + + function parseFrontmatter(content: string) { + const match = /^---\r?\n([^]*?)\r?\n---/.exec(content); + if (!match) return null; + + const yaml = match[1]; + const nameMatch = /^name:\s*(.+)/m.exec(yaml); + const descriptionMatch = /^description:\s*(.+)/m.exec(yaml); + + return { + name: nameMatch ? nameMatch[1].trim() : undefined, + description: descriptionMatch + ? descriptionMatch[1].trim() + : undefined, + }; + } + + let scanPromise: Promise | null = null; + + function ensureSkillsScanned(): Promise { + if (!scanPromise) { + scanPromise = (async () => { + skillCache.clear(); + + for (const p of skillPaths) { + const dirPath = path.resolve(p); + try { + const files = await fs.promises.readdir(dirPath, { + withFileTypes: true, + }); + for (const file of files) { + if (file.isDirectory() && !file.name.startsWith('.')) { + const skillDir = path.join(dirPath, file.name); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + try { + const content = await fs.promises.readFile( + skillMdPath, + 'utf-8' + ); + let description = 'No description provided.'; + const fm = parseFrontmatter(content); + if (fm?.description) { + description = fm.description; + } + skillCache.set(file.name, { + path: skillMdPath, + description, + }); + } catch (e) { + // ignore file read errors + } + } + } + } catch (e) { + // ignore directory read errors + } + } + })(); + } + return scanPromise; + } + + const useSkillTool = tool( + { + name: 'use_skill', + description: 'Use a skill by its name.', + inputSchema: z.object({ + skillName: z.string().describe('The name of the skill to use.'), + }), + outputSchema: z.string(), + }, + async (input) => { + await ensureSkillsScanned(); + const info = skillCache.get(input.skillName); + if (!info) { + throw new Error(`Skill '${input.skillName}' not found.`); + } + + try { + return await fs.promises.readFile(info.path, 'utf-8'); + } catch (e) { + throw new Error(`Failed to read skill "${input.skillName}": ${e}`); + } + } + ); + + return { + tools: [useSkillTool], + generate: async (req, ctx, next) => { + await ensureSkillsScanned(); + if (skillCache.size === 0) return next(req, ctx); + + const skillsList = Array.from(skillCache.entries()) + .map(([name, info]) => { + if (info.description !== 'No description provided.') { + return ` - ${name} - ${info.description}`; + } + return ` - ${name}`; + }) + .join('\n'); + + const systemPromptText = + `\n` + + `You have access to a library of skills that serve as specialized instructions/personas.\n` + + `Strongly prefer to use them when working on anything related to them.\n` + + `Only use them once to load the context.\n` + + `Here are the available skills:\n` + + `${skillsList}\n` + + ``; + + const messages = [...req.messages]; + let injectedPart: any | undefined; + let injectedMsgIndex = -1; + let injectedPartIndex = -1; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + for (let j = 0; j < msg.content.length; j++) { + const p = msg.content[j]; + if (p.text && p.metadata?.['skills-instructions'] === true) { + injectedPart = p; + injectedMsgIndex = i; + injectedPartIndex = j; + break; + } + } + if (injectedPart) break; + } + + if (injectedPart) { + if (injectedPart.text !== systemPromptText) { + const newContent = [...messages[injectedMsgIndex].content]; + newContent[injectedPartIndex] = { + text: systemPromptText, + metadata: { 'skills-instructions': true }, + }; + messages[injectedMsgIndex] = { + ...messages[injectedMsgIndex], + content: newContent as any, + }; + } + } else { + const systemMsgIndex = messages.findIndex( + (m) => m.role === 'system' + ); + if (systemMsgIndex !== -1) { + messages[systemMsgIndex] = { + ...messages[systemMsgIndex], + content: [ + ...messages[systemMsgIndex].content, + { + text: systemPromptText, + metadata: { 'skills-instructions': true }, + }, + ], + }; + } else { + messages.unshift({ + role: 'system', + content: [ + { + text: systemPromptText, + metadata: { 'skills-instructions': true }, + }, + ], + }); + } + } + + return next({ ...req, messages }, ctx); + }, + }; + } + ); diff --git a/js/plugins/middleware/tests/skills_test.ts b/js/plugins/middleware/tests/skills_test.ts new file mode 100644 index 0000000000..a6d4db7814 --- /dev/null +++ b/js/plugins/middleware/tests/skills_test.ts @@ -0,0 +1,175 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import { genkit } from 'genkit'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import * as os from 'os'; +import * as path from 'path'; +import { skills } from '../src/skills.js'; + +describe('skills middleware', () => { + let tempDir: string; + let skillsDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'genkit-skills-test-')); + skillsDir = path.join(tempDir, 'skills'); + await fs.mkdir(skillsDir); + + // Create a dummy skill + const pythonSkillDir = path.join(skillsDir, 'python'); + await fs.mkdir(pythonSkillDir); + await fs.writeFile( + path.join(pythonSkillDir, 'SKILL.md'), + '---\nname: python\ndescription: A python expert skill\n---\nPython prompt content' + ); + + // Create another skill without description + const jsSkillDir = path.join(skillsDir, 'javascript'); + await fs.mkdir(jsSkillDir); + await fs.writeFile( + path.join(jsSkillDir, 'SKILL.md'), + 'Just javascript content' + ); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function createToolModel(ai: any, toolName: string, input: any) { + let turn = 0; + return ai.defineModel( + { name: `pm-${toolName}-${Math.random()}` }, + async () => { + turn++; + if (turn === 1) { + return { + message: { + role: 'model', + content: [{ toolRequest: { name: toolName, input } }], + }, + }; + } + return { message: { role: 'model', content: [{ text: 'done' }] } }; + } + ); + } + + it('injects system prompt with available skills', async () => { + const ai = genkit({}); + + // We want to see the messages passed to the model, so we can define a mock model + // that captures the messages it receives. + let capturedMessages: any[] = []; + const mockModel = ai.defineModel({ name: 'capture-model' }, async (req) => { + capturedMessages = req.messages; + return { + message: { role: 'model', content: [{ text: 'mock response' }] }, + }; + }); + + await ai.generate({ + model: mockModel, + prompt: 'hello', + use: [skills({ skillPaths: [skillsDir] })], + }); + + // Verify system message exists and contains skills + const sysMsg = capturedMessages.find((m) => m.role === 'system'); + assert.ok(sysMsg); + assert.match(sysMsg.content[0].text, /python - A python expert skill/); + assert.match(sysMsg.content[0].text, /javascript/); + }); + + it('grants access to use_skill tool', async () => { + const ai = genkit({}); + const pm = createToolModel(ai, 'use_skill', { skillName: 'python' }); + + const result = (await ai.generate({ + model: pm, + prompt: 'use python skill', + use: [skills({ skillPaths: [skillsDir] })], + })) as any; + + const toolMsg = result.messages.find((m: any) => m.role === 'tool'); + assert.ok(toolMsg); + assert.match( + toolMsg.content[0].toolResponse.output, + /Python prompt content/ + ); + }); + + it('rejects access to unknown skills', async () => { + const ai = genkit({}); + const pm = createToolModel(ai, 'use_skill', { skillName: 'nonexistent' }); + + await assert.rejects(async () => { + await ai.generate({ + model: pm, + prompt: 'use skill', + use: [skills({ skillPaths: [skillsDir] })], + }); + }, /not found/); + }); + + it('is idempotent when injecting prompt', async () => { + const ai = genkit({}); + + let capturedMessages: any[] = []; + const mockModel = ai.defineModel( + { name: 'capture-model-' + Math.random() }, + async (req) => { + capturedMessages = req.messages; + return { + message: { role: 'model', content: [{ text: 'mock response' }] }, + }; + } + ); + + // First call + const response = await ai.generate({ + model: mockModel, + prompt: 'hello', + use: [skills({ skillPaths: [skillsDir] })], + }); + + const firstSysMsg = capturedMessages.find((m) => m.role === 'system'); + assert.ok(firstSysMsg); + + // Count occurrences of "" in the first system message + const firstCount = (firstSysMsg.content[0].text.match(//g) || []) + .length; + assert.strictEqual(firstCount, 1); + + // Second call (simulating multi-turn scenario by passing messages back) + await ai.generate({ + model: mockModel, + messages: response.messages, // pass history back + use: [skills({ skillPaths: [skillsDir] })], + }); + + const secondSysMsg = capturedMessages.find((m) => m.role === 'system'); + assert.ok(secondSysMsg); + + // Count occurrences of "" in the second system message + const secondCount = (secondSysMsg.content[0].text.match(//g) || []) + .length; + assert.strictEqual(secondCount, 1, 'Should not duplicate skills block'); + }); +});