From 1f85a2d95eec82003aadfef73e20f6b31bbcc794 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Wed, 1 Apr 2026 15:25:38 -0400 Subject: [PATCH 1/5] feat(js/plugins/middleware): implemented skills middleware --- js/plugins/middleware/src/index.ts | 4 +- js/plugins/middleware/src/skills.ts | 211 +++++++++++++++++++++ js/plugins/middleware/tests/skills_test.ts | 160 ++++++++++++++++ 3 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 js/plugins/middleware/src/skills.ts create mode 100644 js/plugins/middleware/tests/skills_test.ts diff --git a/js/plugins/middleware/src/index.ts b/js/plugins/middleware/src/index.ts index 36fac93848..5e7fd13cdc 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... +/// cooming // 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..72c803bfc7 --- /dev/null +++ b/js/plugins/middleware/src/skills.ts @@ -0,0 +1,211 @@ +/** + * 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, + }, + (options) => { + const skillPaths = options?.skillPaths ?? ['skills']; + const skillCache = new Map< + string, + { path: string; description: string } + >(); + + function parseFrontmatter(content: string) { + const match = /^---\n([^]*?)\n---/.exec(content); + if (!match) return null; + + const yaml = match[1]; + const nameMatch = /name:\s*(.+)/.exec(yaml); + const descriptionMatch = /description:\s*(.+)/.exec(yaml); + + return { + name: nameMatch ? nameMatch[1].trim() : undefined, + description: descriptionMatch + ? descriptionMatch[1].trim() + : undefined, + }; + } + + let scanned = false; + + async function ensureSkillsScanned() { + if (scanned) return; + scanned = true; + skillCache.clear(); + + for (const p of skillPaths) { + const dirPath = path.resolve(p); + if (!fs.existsSync(dirPath)) continue; + + const files = fs.readdirSync(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'); + if (fs.existsSync(skillMdPath)) { + let description = 'No description provided.'; + try { + const content = fs.readFileSync(skillMdPath, 'utf-8'); + const fm = parseFrontmatter(content); + if (fm?.description) { + description = fm.description; + } + } catch (e) { + // ignore + } + skillCache.set(file.name, { + path: skillMdPath, + description, + }); + } + } + } + } + } + + 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( + 'Access denied: Path is outside of skills directory or skill not found.' + ); + } + + try { + return fs.readFileSync(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..355cca8c11 --- /dev/null +++ b/js/plugins/middleware/tests/skills_test.ts @@ -0,0 +1,160 @@ +/** + * 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] })], + }); + }, /Access denied/); + }); + + 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'); + }); +}); From 3b97c54ebcbaebe598d91914d20f14ff14a65cbd Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Thu, 2 Apr 2026 12:36:05 -0400 Subject: [PATCH 2/5] update --- js/plugins/middleware/src/skills.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/plugins/middleware/src/skills.ts b/js/plugins/middleware/src/skills.ts index 72c803bfc7..68fd7674cc 100644 --- a/js/plugins/middleware/src/skills.ts +++ b/js/plugins/middleware/src/skills.ts @@ -35,8 +35,8 @@ export const skills: GenerateMiddleware = name: 'skills', configSchema: SkillsOptionsSchema, }, - (options) => { - const skillPaths = options?.skillPaths ?? ['skills']; + ({ config }) => { + const skillPaths = config?.skillPaths ?? ['skills']; const skillCache = new Map< string, { path: string; description: string } From 009e4fa1f3b40670bf21ffe62a0f503db64deb80 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Thu, 2 Apr 2026 12:37:50 -0400 Subject: [PATCH 3/5] fmt --- js/plugins/middleware/tests/skills_test.ts | 57 ++++++++++++++-------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/js/plugins/middleware/tests/skills_test.ts b/js/plugins/middleware/tests/skills_test.ts index 355cca8c11..083c5d20cc 100644 --- a/js/plugins/middleware/tests/skills_test.ts +++ b/js/plugins/middleware/tests/skills_test.ts @@ -54,29 +54,34 @@ describe('skills middleware', () => { 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 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' }] } }; } - 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' }] } }; + return { + message: { role: 'model', content: [{ text: 'mock response' }] }, + }; }); await ai.generate({ @@ -104,7 +109,10 @@ describe('skills middleware', () => { const toolMsg = result.messages.find((m: any) => m.role === 'tool'); assert.ok(toolMsg); - assert.match(toolMsg.content[0].toolResponse.output, /Python prompt content/); + assert.match( + toolMsg.content[0].toolResponse.output, + /Python prompt content/ + ); }); it('rejects access to unknown skills', async () => { @@ -124,10 +132,15 @@ describe('skills middleware', () => { 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' }] } }; - }); + 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({ @@ -138,9 +151,10 @@ describe('skills middleware', () => { 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; + const firstCount = (firstSysMsg.content[0].text.match(//g) || []) + .length; assert.strictEqual(firstCount, 1); // Second call (simulating multi-turn scenario by passing messages back) @@ -154,7 +168,8 @@ describe('skills middleware', () => { assert.ok(secondSysMsg); // Count occurrences of "" in the second system message - const secondCount = (secondSysMsg.content[0].text.match(//g) || []).length; + const secondCount = (secondSysMsg.content[0].text.match(//g) || []) + .length; assert.strictEqual(secondCount, 1, 'Should not duplicate skills block'); }); }); From 58ba79b1f9ef6e591790f06b2866427c8e216945 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Thu, 2 Apr 2026 19:36:13 -0400 Subject: [PATCH 4/5] feedback --- js/plugins/middleware/src/skills.ts | 80 ++++++++++++---------- js/plugins/middleware/tests/skills_test.ts | 2 +- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/js/plugins/middleware/src/skills.ts b/js/plugins/middleware/src/skills.ts index 68fd7674cc..1d15c449f7 100644 --- a/js/plugins/middleware/src/skills.ts +++ b/js/plugins/middleware/src/skills.ts @@ -43,12 +43,12 @@ export const skills: GenerateMiddleware = >(); function parseFrontmatter(content: string) { - const match = /^---\n([^]*?)\n---/.exec(content); + const match = /^---\r?\n([^]*?)\r?\n---/.exec(content); if (!match) return null; const yaml = match[1]; - const nameMatch = /name:\s*(.+)/.exec(yaml); - const descriptionMatch = /description:\s*(.+)/.exec(yaml); + const nameMatch = /^name:\s*(.+)/m.exec(yaml); + const descriptionMatch = /^description:\s*(.+)/m.exec(yaml); return { name: nameMatch ? nameMatch[1].trim() : undefined, @@ -58,41 +58,49 @@ export const skills: GenerateMiddleware = }; } - let scanned = false; - - async function ensureSkillsScanned() { - if (scanned) return; - scanned = true; - skillCache.clear(); - - for (const p of skillPaths) { - const dirPath = path.resolve(p); - if (!fs.existsSync(dirPath)) continue; - - const files = fs.readdirSync(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'); - if (fs.existsSync(skillMdPath)) { - let description = 'No description provided.'; - try { - const content = fs.readFileSync(skillMdPath, 'utf-8'); - const fm = parseFrontmatter(content); - if (fm?.description) { - description = fm.description; + 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 } - skillCache.set(file.name, { - path: skillMdPath, - description, - }); + } catch (e) { + // ignore directory read errors } } - } + })(); } + return scanPromise; } const useSkillTool = tool( @@ -108,13 +116,11 @@ export const skills: GenerateMiddleware = await ensureSkillsScanned(); const info = skillCache.get(input.skillName); if (!info) { - throw new Error( - 'Access denied: Path is outside of skills directory or skill not found.' - ); + throw new Error(`Skill '${input.skillName}' not found.`); } try { - return fs.readFileSync(info.path, 'utf-8'); + return await fs.promises.readFile(info.path, 'utf-8'); } catch (e) { throw new Error(`Failed to read skill "${input.skillName}": ${e}`); } diff --git a/js/plugins/middleware/tests/skills_test.ts b/js/plugins/middleware/tests/skills_test.ts index 083c5d20cc..a6d4db7814 100644 --- a/js/plugins/middleware/tests/skills_test.ts +++ b/js/plugins/middleware/tests/skills_test.ts @@ -125,7 +125,7 @@ describe('skills middleware', () => { prompt: 'use skill', use: [skills({ skillPaths: [skillsDir] })], }); - }, /Access denied/); + }, /not found/); }); it('is idempotent when injecting prompt', async () => { From b5565fb2eee92ff6c2308d9236afcd012f5e65a1 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Thu, 2 Apr 2026 19:54:47 -0400 Subject: [PATCH 5/5] fix comment --- js/plugins/middleware/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/plugins/middleware/src/index.ts b/js/plugins/middleware/src/index.ts index 5e7fd13cdc..76096cc182 100644 --- a/js/plugins/middleware/src/index.ts +++ b/js/plugins/middleware/src/index.ts @@ -18,6 +18,6 @@ export { fallback } from './fallback.js'; export { retry } from './retry.js'; export { skills } from './skills.js'; -/// cooming +/// coming soon... // export { filesystem } from './filesystem.js'; // export { toolApproval } from './toolApproval.js';