diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 44bdd8d922b..ca5a3e41fa3 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -241,6 +241,8 @@ export type { RuntimeSkillLoader, RuntimeSkillMcpServerDependency, RuntimeSkillPolicyContract, + RuntimeSkillReferenceContract, + RuntimeSkillReferenceLocation, RuntimeSkillRegistry, RuntimeSkillRegistryEntry, RuntimeSkillSource, diff --git a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts index 7e357c0fe50..62f329d6a00 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts @@ -111,6 +111,51 @@ describe('active skills', () => { ]); }); + it('restores a reference loaded by its owner file path', async () => { + const referenceSource = createRuntimeSkillSource([ + { + id: 'builder', + name: 'builder', + description: 'Build workflows.', + instructions: 'Build one workflow.', + }, + { + id: 'models', + name: 'models', + description: 'Load before choosing a model.', + instructions: 'Model rules.', + parents: ['builder'], + reference: { owner: 'builder', path: 'references/models.md' }, + dependencies: { tools: ['searchModels'] }, + }, + ]); + const memory = new InMemoryMemory(); + const list = new AgentMessageList(); + list.addHistory([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'load_skill', + toolCallId: 'reference-load', + input: { skillId: 'builder', filePath: 'references/models.md' }, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'Model rules.' }] }, + }, + ], + }, + ]); + const active = new ActiveSkills(referenceSource, 'assistant', memory.skillState); + + await active.restore(list, scope); + + expect(list.activeSkillIds).toEqual(['models']); + expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain( + '[Reference of: \\"builder\\"]', + ); + }); + it('recovers a skill into the system block once observation masks its load', async () => { const memory = new InMemoryMemory(); const list = new AgentMessageList(); diff --git a/packages/@n8n/agents/src/runtime/skills/active-skills.ts b/packages/@n8n/agents/src/runtime/skills/active-skills.ts index 66a356fec7e..d82042438fe 100644 --- a/packages/@n8n/agents/src/runtime/skills/active-skills.ts +++ b/packages/@n8n/agents/src/runtime/skills/active-skills.ts @@ -1,6 +1,6 @@ import type { ModelMessage, ToolResultPart } from 'ai'; -import { formatActiveSkill } from '../../skills/tools'; +import { findReferenceByPath, formatActiveSkill } from '../../skills/tools'; import { SKILL_LOAD_TOOL_NAME, type RuntimeSkillContent, @@ -240,7 +240,7 @@ export class ActiveSkills { private formatSkill(skillId: string): string | undefined { const skill = this.loaded.get(skillId); const entry = this.source.registry.skills.find((candidate) => candidate.id === skillId); - return skill && entry ? formatActiveSkill(skill, entry) : undefined; + return skill && entry ? formatActiveSkill(skill, entry, this.source.registry) : undefined; } private recordedLoads(list: AgentMessageList): Map { @@ -263,14 +263,20 @@ export class ActiveSkills { continue; if (part.output.success !== true && part.output.type !== 'content') continue; const { skillId, name, filePath } = part.input; - if (typeof filePath === 'string' && !['', '/', '.', 'SKILL.md'].includes(filePath.trim())) - continue; const requested = typeof skillId === 'string' ? skillId : typeof name === 'string' ? name : undefined; if (!requested) continue; const entry = this.source.registry.skills.find( (skill) => skill.id === requested || skill.name === requested, ); + if (typeof filePath === 'string' && !['', '/', '.', 'SKILL.md'].includes(filePath.trim())) { + // Only a path to a reference skill is an activation; other linked files are plain reads. + const reference = entry + ? findReferenceByPath(this.source.registry, entry.id, filePath) + : undefined; + if (reference) loads.set(part.toolCallId, reference.id); + continue; + } loads.set(part.toolCallId, entry?.id ?? requested); } } diff --git a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts index 15c83b1dd74..6bad6a12a90 100644 --- a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts @@ -733,3 +733,277 @@ Use the workflow SDK.`, ); }); }); + +describe('reference skills', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'n8n-reference-skills-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function writeSkill(dir: string, frontmatter: string, body: string) { + mkdirSync(join(root, dir, 'references'), { recursive: true }); + writeFileSync(join(root, dir, 'SKILL.md'), `---\n${frontmatter}\n---\n\n${body}`); + } + + function writeReference(dir: string, file: string, frontmatter: string, body: string) { + writeFileSync(join(root, dir, 'references', file), `---\n${frontmatter}\n---\n\n${body}`); + } + + function writeBuilderWithReferences() { + writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.'); + writeReference( + 'builder', + 'models.md', + 'name: models\ndescription: Load before choosing a model.\ndependencies:\n tools:\n - searchModels', + 'Model rules.', + ); + writeFileSync(join(root, 'builder', 'references', 'notes.md'), 'Plain notes.'); + writeSkill( + 'agents', + 'name: agents\ndescription: Build agents.\nshared_references:\n - models', + 'Agent steps.', + ); + } + + it('loads references with frontmatter as hidden skills of their owner and sharers', () => { + writeBuilderWithReferences(); + + const source = loadRuntimeSkillSourceFromDirectory(root); + const models = source.registry.skills.find((skill) => skill.id === 'models'); + + expect(models).toMatchObject({ + description: 'Load before choosing a model.', + parents: ['builder', 'agents'], + reference: { owner: 'builder', path: 'references/models.md' }, + dependencies: { tools: ['searchModels'] }, + }); + expect(source.registry.skills.map((skill) => skill.id)).not.toContain('notes'); + + const catalog = renderSkillCatalogPrompt(source.registry); + expect(catalog).not.toContain('name: "builder"'); + expect(catalog).toContain('id: "builder"'); + expect(catalog).toContain('id: "agents"'); + expect(catalog).not.toContain('id: "models"'); + expect(catalog).toContain('Load a reference with'); + }); + + it('does not change the catalog protocol when no references exist', () => { + writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.'); + + const catalog = renderSkillCatalogPrompt(loadRuntimeSkillSourceFromDirectory(root).registry); + + expect(catalog).not.toContain('Load a reference with'); + }); + + it('lists references with descriptions when a parent skill loads', async () => { + writeBuilderWithReferences(); + const loadTool = createSkillLoadTool(loadRuntimeSkillSourceFromDirectory(root)); + + const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {})); + expect(builder).toContain('[References'); + expect(builder).toContain('- "models": "Load before choosing a model."'); + expect(builder).toContain( + '[Linked files — load via load_skill with filePath: "references/notes.md"]', + ); + expect(builder).not.toContain('filePath: "references/models.md"'); + + const agents = skillLoadText(await loadTool.handler?.({ skillId: 'agents' }, {})); + expect(agents).toContain('- "models": "Load before choosing a model."'); + }); + + it('records references with descriptions in the activation result', async () => { + writeBuilderWithReferences(); + const source = loadRuntimeSkillSourceFromDirectory(root); + const loadTool = createSkillLoadTool(source); + + const result = await loadTool.handler?.( + { skillId: 'builder' }, + { loadSkill: async (skillId: string) => await source.loadSkill(skillId) }, + ); + + expect(result).toMatchObject({ + success: true, + skillId: 'builder', + references: [{ skillId: 'models', description: 'Load before choosing a model.' }], + linkedFiles: { references: [expect.objectContaining({ path: 'references/notes.md' })] }, + }); + }); + + it('loads a reference by id or by its owner file path', async () => { + writeBuilderWithReferences(); + const loadTool = createSkillLoadTool(loadRuntimeSkillSourceFromDirectory(root)); + + const byId = skillLoadText(await loadTool.handler?.({ skillId: 'models' }, {})); + expect(byId).toContain('[Reference of: "builder", "agents"]'); + expect(byId).toContain('Model rules.'); + expect(byId).not.toContain('name: models'); + + const byPath = skillLoadText( + await loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}), + ); + expect(byPath).toBe(byId); + }); + + it('hides references whose parents are all filtered out', async () => { + writeBuilderWithReferences(); + const source = loadRuntimeSkillSourceFromDirectory(root); + + const withoutAgents = filterRuntimeSkillSource(source, ['agents']); + expect(withoutAgents.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([ + 'builder', + ]); + + const withoutOwner = filterRuntimeSkillSource(source, ['builder']); + expect(withoutOwner.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([ + 'agents', + ]); + await expect(withoutOwner.loadSkill('models')).resolves.toMatchObject({ id: 'models' }); + + const withoutBoth = filterRuntimeSkillSource(source, ['agents', 'builder']); + expect(withoutBoth.registry.skills).toEqual([]); + await expect(withoutBoth.loadSkill('models')).resolves.toBeNull(); + }); + + it('hides a filtered reference from its parent listing', async () => { + writeBuilderWithReferences(); + const filtered = filterRuntimeSkillSource(loadRuntimeSkillSourceFromDirectory(root), [ + 'models', + ]); + + const loadTool = createSkillLoadTool(filtered); + const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {})); + expect(builder).not.toContain('[References'); + expect(builder).not.toContain('references/models.md'); + await expect(filtered.loadFile?.('builder', 'references/models.md')).resolves.toBeNull(); + await expect( + loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}), + ).resolves.toMatchObject({ success: false }); + }); + + it('hides a reference excluded by the directory loader from its owner file path', async () => { + writeBuilderWithReferences(); + const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['models'] }); + + expect(source.registry.skills.map((skill) => skill.id)).not.toContain('models'); + await expect(source.loadSkill('models')).resolves.toBeNull(); + await expect(source.loadFile?.('builder', 'references/models.md')).resolves.toBeNull(); + await expect(source.loadFile?.('builder', 'references/notes.md')).resolves.toMatchObject({ + content: 'Plain notes.', + }); + + const loadTool = createSkillLoadTool(source); + const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {})); + expect(builder).not.toContain('references/models.md'); + await expect( + loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}), + ).resolves.toMatchObject({ success: false }); + }); + + it('hides references excluded by the directory loader when all their parents are excluded', async () => { + writeBuilderWithReferences(); + const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['builder', 'agents'] }); + + expect(source.registry.skills).toEqual([]); + await expect(source.loadSkill('models')).resolves.toBeNull(); + }); + + it('keeps a shared reference when the directory loader excludes its owner', async () => { + writeBuilderWithReferences(); + const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['builder'] }); + + expect(source.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([ + 'agents', + ]); + await expect(source.loadSkill('models')).resolves.toMatchObject({ parents: ['agents'] }); + + const loadTool = createSkillLoadTool(source); + const agents = skillLoadText(await loadTool.handler?.({ skillId: 'agents' }, {})); + expect(agents).toContain('- "models": "Load before choosing a model."'); + }); + + it('rejects references with a name that does not match the file', () => { + writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.'); + writeReference('builder', 'models.md', 'name: other\ndescription: Wrong name.', 'Body.'); + + expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow( + 'Reference at builder/references/models.md must be named "models", got "other"', + ); + }); + + it('rejects references without a description', () => { + writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.'); + writeReference('builder', 'models.md', 'name: models', 'Body.'); + + expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow(InvalidRuntimeSkillError); + }); + + it('rejects shared references that do not exist', () => { + writeSkill( + 'agents', + 'name: agents\ndescription: Build agents.\nshared_references:\n - missing', + 'Agent steps.', + ); + + expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow( + 'Skill "agents" shares unknown reference "missing"', + ); + }); + + describe('in-memory reference metadata', () => { + const owner = { id: 'builder', name: 'builder', description: 'Build.', instructions: 'Build.' }; + const reference = { + id: 'models', + name: 'models', + description: 'Models.', + instructions: 'Models.', + }; + + it('accepts parents and reference together', () => { + const registry = createRuntimeSkillRegistry([ + owner, + { + ...reference, + parents: ['builder'], + reference: { owner: 'builder', path: 'references/models.md' }, + }, + ]); + + expect(registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual(['builder']); + }); + + it('rejects parents without a reference location', () => { + expect(() => + createRuntimeSkillSource([owner, { ...reference, parents: ['builder'] }]), + ).toThrow('Reference "models" must set both parents and reference'); + }); + + it('rejects a reference location without parents', () => { + expect(() => + createRuntimeSkillSource([ + owner, + { ...reference, reference: { owner: 'builder', path: 'references/models.md' } }, + ]), + ).toThrow('Reference "models" must set both parents and reference'); + }); + + it('keeps a reference whose owner is not in the skill set while another parent is', () => { + const source = createRuntimeSkillSource([ + { id: 'agents', name: 'agents', description: 'Agents.', instructions: 'Agents.' }, + { + ...reference, + parents: ['builder', 'agents'], + reference: { owner: 'builder', path: 'references/models.md' }, + }, + ]); + + expect(source.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([ + 'agents', + ]); + }); + }); +}); diff --git a/packages/@n8n/agents/src/skills/index.ts b/packages/@n8n/agents/src/skills/index.ts index 3c4c9ebb026..53b8ddb4a18 100644 --- a/packages/@n8n/agents/src/skills/index.ts +++ b/packages/@n8n/agents/src/skills/index.ts @@ -19,6 +19,8 @@ export type { RuntimeSkillLoader, RuntimeSkillMcpServerDependency, RuntimeSkillPolicyContract, + RuntimeSkillReferenceContract, + RuntimeSkillReferenceLocation, RuntimeSkillRegistry, RuntimeSkillRegistryEntry, RuntimeSkillSource, diff --git a/packages/@n8n/agents/src/skills/prompt.ts b/packages/@n8n/agents/src/skills/prompt.ts index 282d48eba43..e08bb881f18 100644 --- a/packages/@n8n/agents/src/skills/prompt.ts +++ b/packages/@n8n/agents/src/skills/prompt.ts @@ -8,14 +8,22 @@ export function renderSkillCatalogPrompt( registry: RuntimeSkillRegistry, options: RenderSkillCatalogOptions = {}, ): string { - if (registry.skills.length === 0) return ''; + const topLevel = registry.skills.filter((skill) => !skill.parents); + if (topLevel.length === 0) return ''; + const hasReferences = topLevel.length < registry.skills.length; - const catalog = registry.skills + const catalog = topLevel .map((skill) => [ - `- name: ${promptString(skill.name)}`, - ` description: ${promptString(skill.description)}`, - ` id: ${promptString(skill.id)}`, + // Folder skills derive the id from the name, so the name adds nothing. + // Generated ids need the name to be readable. + ...(skill.name === skill.id + ? [`- id: ${promptString(skill.id)}`, ` description: ${promptString(skill.description)}`] + : [ + `- name: ${promptString(skill.name)}`, + ` description: ${promptString(skill.description)}`, + ` id: ${promptString(skill.id)}`, + ]), ...(skill.category ? [` category: ${promptString(skill.category)}`] : []), ...(skill.recommendedTools?.length ? [` recommendedTools: ${promptStringArray(skill.recommendedTools)}`] @@ -35,7 +43,12 @@ ${catalog} When deciding whether to load a skill: - Match the user's request against the skill name and description. - If one skill clearly matches, call load_skill once with \`{ "skillId": "" }\`, then follow the returned instructions. -- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "", "filePath": "" }\`. +- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "", "filePath": "" }\`.${ + hasReferences + ? ` +- A loaded skill can list references with a description of when each applies. Load a reference with \`{ "skillId": "" }\` only when its description matches the current step.` + : '' + } - If the relevant skill was already loaded for this request, do not call load_skill again. - If no skill clearly matches, do not call load_skill. - Do not load a skill just because it is listed here.`; diff --git a/packages/@n8n/agents/src/skills/registry.ts b/packages/@n8n/agents/src/skills/registry.ts index 3f42c9c647e..20ab65b35ec 100644 --- a/packages/@n8n/agents/src/skills/registry.ts +++ b/packages/@n8n/agents/src/skills/registry.ts @@ -70,8 +70,10 @@ export function filterRuntimeSkillSource( source: RuntimeSkillSource, excludeSkillIds: string[], ): RuntimeSkillSource { - const excluded = new Set(excludeSkillIds); - const skills = source.registry.skills.filter((skill) => !excluded.has(skill.id)); + const { skills, excluded, hiddenFiles } = excludeRuntimeSkills( + source.registry.skills, + excludeSkillIds, + ); const { loadFile } = source; return { @@ -85,7 +87,10 @@ export function filterRuntimeSkillSource( ...(loadFile ? { loadFile: async (skillId: string, filePath: string) => - excluded.has(skillId) ? null : await loadFile(skillId, filePath), + excluded.has(skillId) || + hiddenFiles.has(linkedFileKey(skillId, posix.normalize(filePath))) + ? null + : await loadFile(skillId, filePath), } : {}), }; @@ -95,20 +100,20 @@ export function loadRuntimeSkillSourceFromDirectory( rootDir: string, options: LoadRuntimeSkillSourceFromDirectoryOptions = {}, ): RuntimeSkillSource { - const excludedSkillIds = new Set(options.exclude ?? []); const { transformInstructions = (instructions) => instructions } = options; - const skills = loadRuntimeSkillsFromDirectory(rootDir) - .filter((skill) => !excludedSkillIds.has(skill.id)) - .map((skill) => { - return { - ...skill, - instructions: transformInstructions(skill.instructions), - }; - }); + // Exclude before building the source, so hashes, parents, and linked files match the result. + const { skills } = excludeRuntimeSkills( + resolveReferenceParents(loadRuntimeSkillsFromDirectory(rootDir)), + options.exclude ?? [], + ); + const transformedSkills = skills.map((skill) => ({ + ...skill, + instructions: transformInstructions(skill.instructions), + })); - const source = createRuntimeSkillSource(skills); - const skillsById = new Map(skills.map((skill) => [skill.id, skill])); + const source = createRuntimeSkillSource(transformedSkills); + const skillsById = new Map(transformedSkills.map((skill) => [skill.id, skill])); return { ...source, @@ -133,10 +138,63 @@ export function loadRuntimeSkillSourceFromDirectory( }; } +type ExcludableSkill = Pick; + +/** + * Removes the excluded skills, plus references left without a parent. A hidden + * reference's file is also removed from its owner's linked files, so it cannot + * be read through the owner. + */ +function excludeRuntimeSkills( + allSkills: T[], + excludeSkillIds: string[], +): { skills: T[]; excluded: Set; hiddenFiles: Set } { + const excluded = new Set(excludeSkillIds); + if (excluded.size === 0) return { skills: allSkills, excluded, hiddenFiles: new Set() }; + + const remaining = new Set( + allSkills.filter((skill) => !excluded.has(skill.id)).map((skill) => skill.id), + ); + const skills = allSkills.flatMap((skill) => { + if (excluded.has(skill.id)) return []; + if (!skill.parents) return [skill]; + const parents = skill.parents.filter((id) => remaining.has(id)); + // A reference with no remaining parent cannot be discovered, so it is hidden too. + if (parents.length === 0) { + excluded.add(skill.id); + return []; + } + return [parents.length === skill.parents.length ? skill : { ...skill, parents }]; + }); + const hiddenFiles = new Set( + allSkills.flatMap((skill) => + skill.reference && excluded.has(skill.id) + ? [linkedFileKey(skill.reference.owner, skill.reference.path)] + : [], + ), + ); + if (hiddenFiles.size === 0) return { skills, excluded, hiddenFiles }; + + const visibleSkills = skills.map((skill) => + skill.linkedFiles + ? { + ...skill, + linkedFiles: { + ...skill.linkedFiles, + references: skill.linkedFiles.references.filter( + (file) => !hiddenFiles.has(linkedFileKey(skill.id, file.path)), + ), + }, + } + : skill, + ); + return { skills: visibleSkills, excluded, hiddenFiles }; +} + export function loadRuntimeSkillsFromDirectory(rootDir: string): RuntimeSkill[] { if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) return []; - return collectSkillFiles(rootDir).map((skillPath) => { + const skills = collectSkillFiles(rootDir).flatMap((skillPath) => { const skillDir = dirname(skillPath); const sourceDirectory = toPosixPath(relative(rootDir, skillDir)); validateRuntimeSkillFolder(skillDir, skillPath, sourceDirectory); @@ -157,10 +215,107 @@ export function loadRuntimeSkillsFromDirectory(rootDir: string): RuntimeSkill[] ); } - return { - ...parsed.skill, - linkedFiles: loadLinkedFiles(skillDir), - }; + const skill: RuntimeSkill = { ...parsed.skill, linkedFiles: loadLinkedFiles(skillDir) }; + return [skill, ...loadReferenceSkills(skill, skillDir, sourceDirectory)]; + }); + assertSharedReferencesExist(skills); + return skills; +} + +const REFERENCE_SKILL_PATH = /^references\/[^/]+\.md$/; + +function linkedFileKey(skillId: string, path: string): string { + return `${skillId}\0${path}`; +} + +/** `references/*.md` files that start with frontmatter become reference skills of their owner. */ +function loadReferenceSkills( + owner: RuntimeSkill, + skillDir: string, + sourceDirectory: string, +): RuntimeSkill[] { + return (owner.linkedFiles?.references ?? []).flatMap((file) => { + if (!REFERENCE_SKILL_PATH.test(file.path)) return []; + const referencePath = join(skillDir, file.path); + const content = readFileSync(referencePath, 'utf-8'); + if (content.split(/\r?\n/, 1)[0]?.trim() !== '---') return []; + + const location = `${sourceDirectory}/${file.path}`; + const expectedName = posix.basename(file.path, '.md'); + const parsed = parseRuntimeSkillMarkdown(content, { + sourceName: expectedName, + path: toPosixPath(referencePath), + sourcePath: toPosixPath(referencePath), + directory: toPosixPath(skillDir), + category: categoryFor(sourceDirectory), + }); + if (!parsed.ok) { + throw new InvalidRuntimeSkillError( + `Invalid reference at ${location}: ${formatSkillValidationErrors(parsed.errors)}`, + ); + } + if (parsed.skill.name !== expectedName) { + throw new InvalidRuntimeSkillError( + `Reference at ${location} must be named "${expectedName}", got "${parsed.skill.name}"`, + ); + } + if (parsed.skill.sharedReferences) { + throw new InvalidRuntimeSkillError( + `Reference at ${location} cannot declare shared_references`, + ); + } + + return [ + { + ...parsed.skill, + parents: [owner.id], + reference: { owner: owner.id, path: file.path }, + }, + ]; + }); +} + +function assertSharedReferencesExist(skills: RuntimeSkill[]): void { + const references = new Set(skills.filter((skill) => skill.reference).map((skill) => skill.id)); + for (const skill of skills) { + for (const id of skill.sharedReferences ?? []) { + if (!references.has(id)) { + throw new InvalidRuntimeSkillError(`Skill "${skill.id}" shares unknown reference "${id}"`); + } + } + } +} + +/** + * Adds skills that share a reference to its parents, drops parents that are + * not in the set, and drops references left without a parent. + */ +function resolveReferenceParents(skills: RuntimeSkill[]): RuntimeSkill[] { + const ids = new Set(skills.map((skill) => skill.id)); + const sharers = new Map(); + for (const skill of skills) { + // The catalog hides a skill by `parents`; materialization and file lookups key on `reference`. + if (!skill.parents !== !skill.reference) { + throw new InvalidRuntimeSkillError( + `Reference "${skill.id}" must set both parents and reference`, + ); + } + if (skill.parents && skill.sharedReferences?.length) { + throw new InvalidRuntimeSkillError(`Reference "${skill.id}" cannot share other references`); + } + for (const id of skill.sharedReferences ?? []) { + sharers.set(id, [...(sharers.get(id) ?? []), skill.id]); + } + } + + return skills.flatMap((skill) => { + const shared = sharers.get(skill.id); + if (shared && !skill.parents) { + throw new InvalidRuntimeSkillError(`Shared skill "${skill.id}" is not a reference`); + } + if (!skill.parents) return [skill]; + const parents = [...new Set([...skill.parents, ...(shared ?? [])])].filter((id) => ids.has(id)); + return parents.length > 0 ? [{ ...skill, parents }] : []; }); } @@ -178,7 +333,7 @@ export function formatSkillValidationErrors( } function normalizeRuntimeSkills(skills: RuntimeSkill[]): RuntimeSkill[] { - const sortedSkills = [...skills].sort(compareRuntimeSkills); + const sortedSkills = resolveReferenceParents(skills).sort(compareRuntimeSkills); const seenIds = new Set(); const seenNames = new Set(); const seenSourceDirectories = new Set(); @@ -235,6 +390,9 @@ function toRegistryEntry(skill: RuntimeSkill): RuntimeSkillRegistryEntry { ...(skill.interface ? { interface: stableRecord(skill.interface) } : {}), ...(skill.policy ? { policy: stableRecord(skill.policy) } : {}), ...(skill.dependencies ? { dependencies: stableRecord(skill.dependencies) } : {}), + ...(skill.parents ? { parents: [...skill.parents] } : {}), + ...(skill.reference ? { reference: { ...skill.reference } } : {}), + ...(skill.sharedReferences ? { sharedReferences: [...skill.sharedReferences] } : {}), ...(skill.version ? { version: skill.version } : {}), ...(skill.license ? { license: skill.license } : {}), ...(skill.compatibility ? { compatibility: skill.compatibility } : {}), @@ -255,6 +413,9 @@ function hashSkill(skill: RuntimeSkill): string { interface: skill.interface, policy: skill.policy, dependencies: skill.dependencies, + parents: skill.parents, + reference: skill.reference, + sharedReferences: skill.sharedReferences, version: skill.version, license: skill.license, compatibility: skill.compatibility, diff --git a/packages/@n8n/agents/src/skills/tools.ts b/packages/@n8n/agents/src/skills/tools.ts index 9c2fe780318..a994b1cdece 100644 --- a/packages/@n8n/agents/src/skills/tools.ts +++ b/packages/@n8n/agents/src/skills/tools.ts @@ -1,4 +1,5 @@ import { scrubSecretsInText } from '@n8n/utils/scrub-secrets'; +import { posix } from 'path'; import { z } from 'zod'; import { Tool } from '../sdk/tool'; @@ -82,6 +83,7 @@ const skillLoadOutputSchema = z.object({ bytes: z.number().optional(), sha256: z.string().optional(), activation: z.string().optional(), + references: z.array(z.object({ skillId: z.string(), description: z.string() })).optional(), linkedFiles: linkedFilesSchema.optional(), error: z.string().optional(), availableSkills: z.array(z.string()).optional(), @@ -142,9 +144,16 @@ async function loadSkill( }, activate?: RuntimeSkillLoader, ): Promise { - const { skillId, name, filePath, loadFile } = input; + const { skillId, name, loadFile } = input; await source.prepare?.(); - const skillEntry = findSkillEntry(source.registry, { skillId, name }); + const requestedEntry = findSkillEntry(source.registry, { skillId, name }); + // A path to a reference file activates the reference instead of returning raw text. + const referenceEntry = + requestedEntry && input.filePath !== undefined + ? findReferenceByPath(source.registry, requestedEntry.id, input.filePath) + : undefined; + const skillEntry = referenceEntry ?? requestedEntry; + const filePath = referenceEntry ? undefined : input.filePath; if (!skillEntry) { return { ok: false, @@ -224,31 +233,53 @@ async function loadSkill( } if (activate) { + const { references, linkedFiles } = skillReferences(skillEntry, source.registry); return { success: true, skillId: skillEntry.id, name: skillEntry.name, hash: skillEntry.hash, content: 'The skill instructions are active. Follow them for this task.', - linkedFiles: skillEntry.linkedFiles, + ...(references.length > 0 + ? { + references: references.map((entry) => ({ + skillId: entry.id, + description: entry.description, + })), + } + : {}), + linkedFiles, }; } return { type: 'content', - value: [{ type: 'text', text: formatActiveSkill(skill, skillEntry) }], + value: [{ type: 'text', text: formatActiveSkill(skill, skillEntry, source.registry) }], }; } export function formatActiveSkill( skill: RuntimeSkillContent, skillEntry: RuntimeSkillRegistryEntry, + registry?: RuntimeSkillRegistry, ): string { const content = cap(skill.instructions); - const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => skillEntry.linkedFiles[group]).map( + const { references, linkedFiles } = skillReferences(skillEntry, registry); + const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => linkedFiles[group]).map( (file) => file.path, ); const header = [ activationEnvelope(skillEntry), + ...(references.length > 0 + ? [ + [ + '[References — load one via load_skill with { "skillId": "" } only when its description matches the current step:', + ...references.map( + (entry) => `- ${envelopeValue(entry.id)}: ${envelopeValue(entry.description)}`, + ), + ']', + ].join('\n'), + ] + : []), ...(linkedFilePaths.length > 0 ? [ `[Linked files — load via load_skill with filePath: ${linkedFilePaths.map(envelopeValue).join(', ')}]`, @@ -258,6 +289,43 @@ export function formatActiveSkill( return `${header.join('\n')}\n\n${content}`; } +/** A skill's references, and its linked files without the files those references come from. */ +function skillReferences( + skillEntry: RuntimeSkillRegistryEntry, + registry: RuntimeSkillRegistry | undefined, +): { references: RuntimeSkillRegistryEntry[]; linkedFiles: RuntimeSkillLinkedFiles } { + const references = (registry?.skills ?? []).filter((entry) => + entry.parents?.includes(skillEntry.id), + ); + const referencePaths = new Set( + references.flatMap((entry) => + entry.reference?.owner === skillEntry.id ? [entry.reference.path] : [], + ), + ); + if (referencePaths.size === 0) return { references, linkedFiles: skillEntry.linkedFiles }; + return { + references, + linkedFiles: { + ...skillEntry.linkedFiles, + references: skillEntry.linkedFiles.references.filter( + (file) => !referencePaths.has(file.path), + ), + }, + }; +} + +/** The reference skill that a parent's linked file path points to, if any. */ +export function findReferenceByPath( + registry: RuntimeSkillRegistry, + ownerId: string, + filePath: string, +): RuntimeSkillRegistryEntry | undefined { + const normalized = posix.normalize(filePath.trim()); + return registry.skills.find( + (entry) => entry.reference?.owner === ownerId && entry.reference.path === normalized, + ); +} + function findSkillEntry( registry: RuntimeSkillRegistry, input: { skillId?: string; name?: string }, @@ -277,6 +345,9 @@ function findSkillEntry( function activationEnvelope(skill: RuntimeSkillRegistryEntry): string { return [ `[Skill: ${envelopeValue(skill.name)}]`, + ...(skill.parents?.length + ? [`[Reference of: ${skill.parents.map(envelopeValue).join(', ')}]`] + : []), ...(skill.category ? [`[Skill category: ${envelopeValue(skill.category)}]`] : []), ...(skill.directory ? [`[Skill directory: ${envelopeValue(skill.directory)}]`] : []), ...((skill.path ?? skill.sourcePath) diff --git a/packages/@n8n/agents/src/skills/types.ts b/packages/@n8n/agents/src/skills/types.ts index 7d2be46a705..b2d8b073f4d 100644 --- a/packages/@n8n/agents/src/skills/types.ts +++ b/packages/@n8n/agents/src/skills/types.ts @@ -68,7 +68,26 @@ export interface RuntimeSkillDependenciesContract { mcpServers?: RuntimeSkillMcpServerDependency[]; } -export interface RuntimeSkill extends RuntimeSkillIndexEntry { +/** Where a reference skill's file lives: a path relative to the owning skill's directory. */ +export interface RuntimeSkillReferenceLocation { + owner: string; + path: string; +} + +/** + * A reference skill is a `references/*.md` file with skill frontmatter. It is + * hidden from the catalog and listed, with its description, when a parent + * skill loads. It activates like any other skill. + */ +export interface RuntimeSkillReferenceContract { + /** Skills that list this reference. The owner comes first. */ + parents?: string[]; + reference?: RuntimeSkillReferenceLocation; + /** Ids of references owned by other skills that this skill also lists. */ + sharedReferences?: string[]; +} + +export interface RuntimeSkill extends RuntimeSkillIndexEntry, RuntimeSkillReferenceContract { id: string; instructions: string; sourceName?: string; @@ -89,7 +108,9 @@ export interface RuntimeSkill extends RuntimeSkillIndexEntry { linkedFiles?: RuntimeSkillLinkedFiles; } -export interface RuntimeSkillRegistryEntry extends RuntimeSkillIndexEntry { +export interface RuntimeSkillRegistryEntry + extends RuntimeSkillIndexEntry, + RuntimeSkillReferenceContract { id: string; hash: string; sourceName?: string; @@ -116,7 +137,7 @@ export interface RuntimeSkillRegistry { skills: RuntimeSkillRegistryEntry[]; } -export interface RuntimeSkillContent extends RuntimeSkillIndexEntry { +export interface RuntimeSkillContent extends RuntimeSkillIndexEntry, RuntimeSkillReferenceContract { id: string; instructions: string; sourceName?: string; diff --git a/packages/@n8n/agents/src/skills/validator.ts b/packages/@n8n/agents/src/skills/validator.ts index 8abb2765965..7670a6bafe1 100644 --- a/packages/@n8n/agents/src/skills/validator.ts +++ b/packages/@n8n/agents/src/skills/validator.ts @@ -21,6 +21,7 @@ const RUNTIME_SKILL_FRONTMATTER_FIELDS = [ 'interface', 'policy', 'dependencies', + 'shared_references', 'platforms', 'version', 'license', @@ -82,6 +83,7 @@ export function parseRuntimeSkillMarkdown( const skillInterface = optionalSkillInterface(frontmatter.data, errors); const policy = optionalSkillPolicy(frontmatter.data, errors); const dependencies = optionalSkillDependencies(frontmatter.data, errors); + const sharedReferences = optionalStringArray(frontmatter.data, 'shared_references', errors); const platforms = optionalStringArray(frontmatter.data, 'platforms', errors)?.map((platform) => platform.toLowerCase(), ); @@ -108,6 +110,7 @@ export function parseRuntimeSkillMarkdown( ...(skillInterface ? { interface: skillInterface } : {}), ...(policy ? { policy } : {}), ...(dependencies ? { dependencies } : {}), + ...(sharedReferences?.length ? { sharedReferences } : {}), ...(version ? { version } : {}), ...(license ? { license } : {}), ...(compatibility ? { compatibility } : {}), diff --git a/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md b/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md index 7b54e128a44..6b11143ea5c 100644 --- a/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/agent-builder/SKILL.md @@ -7,6 +7,8 @@ description: >- creation, faithful handoff, targeting, testing, and publishing. Use directly for routine Agent follow-ups; rerun intent-recognition only when the requested artifact is no longer clear. +shared_references: + - credential-setup-with-computer-use recommended_tools: - build-agent - agent-context diff --git a/packages/@n8n/instance-ai/skills/data-table-manager/SKILL.md b/packages/@n8n/instance-ai/skills/data-table-manager/SKILL.md index e966946ca6c..31603bec3ca 100644 --- a/packages/@n8n/instance-ai/skills/data-table-manager/SKILL.md +++ b/packages/@n8n/instance-ai/skills/data-table-manager/SKILL.md @@ -174,5 +174,5 @@ fit a wrong schema. ## More Detail -Use [references/data-table-playbook.md](references/data-table-playbook.md) for -tool recipes, schema patterns, import edge cases, and output examples. +Load the `data-table-playbook` reference for tool recipes, schema patterns, +import edge cases, and output examples. diff --git a/packages/@n8n/instance-ai/skills/data-table-manager/references/data-table-playbook.md b/packages/@n8n/instance-ai/skills/data-table-manager/references/data-table-playbook.md index 9aa581c9e47..fc900317ec9 100644 --- a/packages/@n8n/instance-ai/skills/data-table-manager/references/data-table-playbook.md +++ b/packages/@n8n/instance-ai/skills/data-table-manager/references/data-table-playbook.md @@ -1,3 +1,10 @@ +--- +name: data-table-playbook +description: >- + Load when a data table task needs schema design, import mapping, cleanup, or + a careful bulk mutation. +--- + # Data Table Playbook Use this reference when the table needs design judgment, import mapping, diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index 57103f30dd4..48ff857a568 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -11,7 +11,9 @@ description: >- or shared cross-task data tables require a dependency-aware task graph. Don't use this skill for explicit one-off tasks that can be done by a single node execution: load one-off-operations and run the node with - nodes(action="execute"). + nodes(action="execute"). Also load for a workflow's evaluations, model + choices, credential setup, and post-build verification: this skill lists the + references for those steps. recommended_tools: - read_file - write_file @@ -195,8 +197,8 @@ Trigger — never a name, placeholder, `activeVersionId`, or local SDK id). n8n has no global error workflow setting; mention that only if the user asks about global behavior. Do not offer or build an error workflow before the primary workflow is published. Before building or attaching an error -workflow, load this skill's `references/error-workflows.md` linked file and -follow its build → publish → assign steps. +workflow, load the `error-workflows` reference and follow its build → publish +→ assign steps. ## Mandatory Process @@ -571,9 +573,8 @@ explicit input schema, built with `isSupportingWorkflow: true`) referenced from the main workflow's `executeWorkflow` node (`source: 'database'`, real returned `workflowId`), main workflow saved last. This is part of the approved build task — not a reason to create a new plan, and simple -workflows stay in one workflow. Before writing multi-workflow code, load this -skill's `references/compositional-workflows.md` linked file for the required -steps and SDK examples. +workflows stay in one workflow. Before writing multi-workflow code, load the +`compositional-workflows` reference for the required steps and SDK examples. ## Data Tables diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/references/compositional-workflows.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/compositional-workflows.md index dc608b5f175..b769d5bc0c6 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/references/compositional-workflows.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/compositional-workflows.md @@ -1,3 +1,11 @@ +--- +name: compositional-workflows +description: >- + Load before splitting a large workflow into supporting sub-workflows and a + main workflow, when the work has reusable chunks or benefits from independent + testing. Simple workflows stay in one workflow. +--- + # Compositional Workflows For complex workflows, you may decompose work into supporting sub-workflows and diff --git a/packages/@n8n/instance-ai/skills/config-evals/references/config-eval-playbook.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/config-eval-playbook.md similarity index 95% rename from packages/@n8n/instance-ai/skills/config-evals/references/config-eval-playbook.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/config-eval-playbook.md index adabc7d2059..5fe74b51cfe 100644 --- a/packages/@n8n/instance-ai/skills/config-evals/references/config-eval-playbook.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/config-eval-playbook.md @@ -1,3 +1,11 @@ +--- +name: config-eval-playbook +description: >- + Load after config-evals when you need exact `eval-config` action fields, + config shapes, or worked examples to list, create, change, run, or remove a + config evaluation. +--- + # Config Eval Playbook Recipes and worked examples for the `eval-config` tool. All actions are diff --git a/packages/@n8n/instance-ai/skills/config-evals/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/config-evals.md similarity index 97% rename from packages/@n8n/instance-ai/skills/config-evals/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/config-evals.md index 5ef75bcb43e..1d5e8fe2d6a 100644 --- a/packages/@n8n/instance-ai/skills/config-evals/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/config-evals.md @@ -116,5 +116,5 @@ relative to the request. Use `prompt` only to override the default judge prompt. ## More Detail -Use [references/config-eval-playbook.md](references/config-eval-playbook.md) for -tool-call recipes, worked examples, and output shapes. +Load the `config-eval-playbook` reference for tool-call recipes, worked +examples, and output shapes. diff --git a/packages/@n8n/instance-ai/skills/credential-recipe-research/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/credential-recipe-research.md similarity index 100% rename from packages/@n8n/instance-ai/skills/credential-recipe-research/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/credential-recipe-research.md diff --git a/packages/@n8n/instance-ai/skills/credential-setup-with-computer-use/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/credential-setup-with-computer-use.md similarity index 100% rename from packages/@n8n/instance-ai/skills/credential-setup-with-computer-use/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/credential-setup-with-computer-use.md diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/references/error-workflows.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/error-workflows.md index 5e561faa5bd..50cbb0e1453 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/references/error-workflows.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/error-workflows.md @@ -1,3 +1,11 @@ +--- +name: error-workflows +description: >- + Load before creating, assigning, or changing a workflow-level error workflow + (`settings.errorWorkflow`), including the error-workflow follow-up after a + primary workflow is published. +--- + # Workflow-Level Error Workflows n8n has no global or instance-wide error workflow setting. Error workflows are diff --git a/packages/@n8n/instance-ai/skills/model-selection/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/model-selection.md similarity index 100% rename from packages/@n8n/instance-ai/skills/model-selection/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/model-selection.md diff --git a/packages/@n8n/instance-ai/skills/one-off-operations/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/one-off-operations.md similarity index 100% rename from packages/@n8n/instance-ai/skills/one-off-operations/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/one-off-operations.md diff --git a/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/post-build-flow.md similarity index 99% rename from packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/post-build-flow.md index fb71d3319ff..dec25f6f23c 100644 --- a/packages/@n8n/instance-ai/skills/post-build-flow/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/post-build-flow.md @@ -30,8 +30,8 @@ following them stays in the user's conversation language. For trigger `inputData` shapes, read `${N8N_WORKSPACE_DIR}/knowledge-base/reference/trigger-input-data-shapes.md` in -the sandbox workspace when available, or load this skill's -`references/trigger-input-data-shapes.md` linked file. +the sandbox workspace when available, or load the `trigger-input-data-shapes` +reference. ## Setup panel diff --git a/packages/@n8n/instance-ai/skills/post-build-flow/references/trigger-input-data-shapes.md b/packages/@n8n/instance-ai/skills/workflow-builder/references/trigger-input-data-shapes.md similarity index 87% rename from packages/@n8n/instance-ai/skills/post-build-flow/references/trigger-input-data-shapes.md rename to packages/@n8n/instance-ai/skills/workflow-builder/references/trigger-input-data-shapes.md index 4c0dab17264..25f5ecc9002 100644 --- a/packages/@n8n/instance-ai/skills/post-build-flow/references/trigger-input-data-shapes.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/references/trigger-input-data-shapes.md @@ -1,3 +1,11 @@ +--- +name: trigger-input-data-shapes +description: >- + Load before passing `inputData` to `verify-built-workflow` or + `executions(action="run")`, to use the shape that matches the workflow's + trigger type. +--- + # Per-trigger `inputData` shape Used by `verify-built-workflow`, `executions(action="run")`, and checkpoint diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts index 051bcf3a6e8..6ededbeee70 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts @@ -1,7 +1,9 @@ import { RUNTIME_SKILL_MAX_OUTPUT_BYTES, RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION, + createRuntimeSkillSource, createSkillLoadTool, + filterRuntimeSkillSource, type RuntimeSkillLinkedFiles, type RuntimeSkillSource, type Workspace, @@ -237,7 +239,30 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { const text = skillLoadText(result); expect(text).toContain('[Skill: "data-table-manager"]'); expect(text).toContain(`${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager`); - expect(text).toContain('references/data-table-playbook.md'); + expect(text).toContain('- "data-table-playbook": '); + }); + + it('serves references from their owner directory with sandbox placeholders resolved', async () => { + const source = loadInstanceAiRuntimeSkillSource(); + const { workspace } = createMockWorkspace(); + const root = '/home/daytona/workspace'; + + const materialized = await materializeRuntimeSkillsIntoWorkspace({ + logger: mockLogger, + source, + workspace, + root, + }); + if (!materialized) throw new Error('Expected runtime skills to materialize'); + + const referencePath = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/workflow-builder/references/post-build-flow.md`; + const text = skillLoadText( + await createSkillLoadTool(materialized.source).handler?.({ skillId: 'post-build-flow' }, {}), + ); + expect(text).toContain(`[Skill path: "${referencePath}"]`); + expect(text).toContain(`${root}/knowledge-base/reference/trigger-input-data-shapes.md`); + expect(text).not.toContain('{N8N_WORKSPACE_DIR}'); + expect(materialized.skills.map(({ id }) => id)).not.toContain('post-build-flow'); }); it('materializes skills into the workspace before load_skill reads them', async () => { @@ -356,6 +381,70 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => { ).rejects.toThrow('Runtime skill linked file escapes skill directory'); }); + describe('reference skills', () => { + const root = '/home/daytona/workspace'; + const skillsRoot = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}`; + + function createReferenceSource(): RuntimeSkillSource { + const ownerFiles = emptyLinkedFiles(); + ownerFiles.references.push({ path: 'references/models.md', bytes: 6, sha256: 'sha' }); + return createRuntimeSkillSource([ + { + id: 'builder', + name: 'builder', + description: 'Build workflows.', + instructions: 'Build steps.', + sourceDirectory: 'builder', + linkedFiles: ownerFiles, + }, + { + id: 'agents', + name: 'agents', + description: 'Build agents.', + instructions: 'Agent steps.', + sourceDirectory: 'agents', + sharedReferences: ['models'], + }, + { + id: 'models', + name: 'models', + description: 'Load before choosing a model.', + instructions: 'Model rules in ' + '$' + '{N8N_WORKSPACE_DIR}.', + parents: ['builder'], + reference: { owner: 'builder', path: 'references/models.md' }, + }, + ]); + } + + it('materializes references without a file loader', async () => { + const bundle = await buildRuntimeSkillWorkspaceBundle({ + source: createReferenceSource(), + root, + logger: mockLogger, + }); + + expect(bundle?.files.get(`${skillsRoot}/builder/references/models.md`)).toContain( + `Model rules in ${root}.`, + ); + }); + + it('materializes a shared reference under a remaining parent when its owner is filtered out', async () => { + const source = filterRuntimeSkillSource(createReferenceSource(), ['builder']); + + const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger: mockLogger }); + if (!bundle) throw new Error('Expected a skill bundle'); + + const referencePath = `${skillsRoot}/agents/references/models.md`; + expect(bundle.files.get(referencePath)).toContain(`Model rules in ${root}.`); + expect(bundle.files.has(`${skillsRoot}/builder/SKILL.md`)).toBe(false); + const text = skillLoadText( + await createSkillLoadTool(bundle.source).handler?.({ skillId: 'models' }, {}), + ); + expect(text).toContain(`[Skill path: "${referencePath}"]`); + expect(text).toContain(`Model rules in ${root}.`); + }); + }); + it('warns when materialized skill files exceed the load_skill output limit', async () => { const source: RuntimeSkillSource = { registry: { diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/progressive-building.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/progressive-building.test.ts index 5fe403d2544..07da889bfc1 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/progressive-building.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/progressive-building.test.ts @@ -74,8 +74,11 @@ describe('progressive workflow skill variants', () => { if (!controlBundle || !progressiveBundle) throw new Error('Expected skill bundles'); expect(progressiveBundle.manifest.skillsHash).not.toBe(controlBundle.manifest.skillsHash); - for (const skillId of ['workflow-builder', 'post-build-flow']) { - const skillPath = `${root}/skills/${skillId}/SKILL.md`; + const skillPaths = { + 'workflow-builder': `${root}/skills/workflow-builder/SKILL.md`, + 'post-build-flow': `${root}/skills/workflow-builder/references/post-build-flow.md`, + }; + for (const [skillId, skillPath] of Object.entries(skillPaths)) { expect(progressiveBundle.files.get(skillPath)).toContain('# Progressive building'); expect(controlBundle.files.get(skillPath)).not.toContain('# Progressive building'); const loadTool = createSkillLoadTool(progressiveBundle.source); @@ -83,7 +86,7 @@ describe('progressive workflow skill variants', () => { expect(loaded).toHaveProperty('value.0.text', expect.stringContaining(policy.instructions)); } - const reference = `${root}/skills/post-build-flow/references/trigger-input-data-shapes.md`; + const reference = `${root}/skills/workflow-builder/references/trigger-input-data-shapes.md`; expect(progressiveBundle.files.get(reference)).toBeTruthy(); expect(progressiveBundle.files.get(reference)).toBe(controlBundle.files.get(reference)); expect(controlBundle.files.has(`${root}/skills/planning/SKILL.md`)).toBe(true); diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts index 2c186b7e56a..0e2c9a99d8d 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts @@ -34,12 +34,8 @@ describe('Instance AI runtime skills', () => { ]; const offenders: string[] = []; - for (const skillId of readdirSync(INSTANCE_AI_SKILLS_DIR, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name)) { - const skillPath = join(INSTANCE_AI_SKILLS_DIR, skillId, 'SKILL.md'); - if (!existsSync(skillPath)) continue; - const content = readFileSync(skillPath, 'utf-8'); + for (const file of skillMarkdownFiles()) { + const content = readFileSync(join(INSTANCE_AI_SKILLS_DIR, file), 'utf-8'); for (const pattern of gatePatterns) { for (const match of content.matchAll(pattern)) { @@ -47,7 +43,7 @@ describe('Instance AI runtime skills', () => { // "load it via load_tool" names the tool earlier; the second pattern catches it. if (toolName === 'it') continue; if (ALWAYS_LOADED_TOOL_NAMES.has(toolName)) { - offenders.push(`${skillId}/SKILL.md tells the model to load "${toolName}"`); + offenders.push(`${file} tells the model to load "${toolName}"`); } } } @@ -120,12 +116,86 @@ describe('Instance AI runtime skills', () => { it('routes recipe composition through the research skill', () => { const postBuildFlow = readFileSync( - join(INSTANCE_AI_SKILLS_DIR, 'post-build-flow', 'SKILL.md'), + join(INSTANCE_AI_SKILLS_DIR, 'workflow-builder', 'references', 'post-build-flow.md'), 'utf-8', ); expect(postBuildFlow).toMatch(/load the\s+`credential-recipe-research` skill/); }); + it('lists only top-level skills in the catalog', () => { + const source = loadInstanceAiRuntimeSkillSource(); + const topLevel = source.registry.skills.filter((skill) => !skill.parents).map(({ id }) => id); + + expect(topLevel.sort()).toEqual([ + 'data-table-manager', + 'debugging-executions', + 'instance-awareness', + 'n8n-docs-assistant', + 'planned-task-runtime', + 'planning', + 'progressive-building', + 'suggest-automations', + 'workflow-builder', + ]); + }); + + it('loads every reference file as a reference skill with a description', () => { + const source = loadInstanceAiRuntimeSkillSource(); + const referenceFiles = skillMarkdownFiles().filter((file) => file.includes('/references/')); + + expect(referenceFiles.length).toBeGreaterThan(0); + for (const file of referenceFiles) { + const [owner, , name] = file.split('/'); + const reference = source.registry.skills.find( + (skill) => skill.id === name.replace(/\.md$/, ''), + ); + expect(reference, file).toMatchObject({ + reference: { owner, path: `references/${name}` }, + description: expect.stringMatching(/\S/), + }); + } + }); + + it('nests the workflow sub-steps under workflow-builder', async () => { + const source = loadInstanceAiRuntimeSkillSource(); + const workflowBuilderReferences = source.registry.skills + .filter((skill) => skill.parents?.includes('workflow-builder')) + .map(({ id }) => id) + .sort(); + + expect(workflowBuilderReferences).toEqual([ + 'compositional-workflows', + 'config-eval-playbook', + 'config-evals', + 'credential-recipe-research', + 'credential-setup-with-computer-use', + 'error-workflows', + 'model-selection', + 'one-off-operations', + 'post-build-flow', + 'trigger-input-data-shapes', + ]); + + const loaded = skillLoadText( + await createSkillLoadTool(source).handler?.({ skillId: 'workflow-builder' }, {}), + ); + expect(loaded).toContain('[References'); + expect(loaded).toContain('- "post-build-flow": '); + expect(loaded).not.toContain('[Linked files'); + }); + + it('shares the Computer Use credential setup reference with agent-builder', async () => { + const source = await loadRuntimeSkillSourceWithEnabledModules('instance-ai, agents'); + const skill = source.registry.skills.find( + (entry) => entry.id === 'credential-setup-with-computer-use', + ); + + expect(skill?.parents).toEqual(['workflow-builder', 'agent-builder']); + expect( + skillLoadText(await createSkillLoadTool(source).handler?.({ skillId: 'agent-builder' }, {})), + ).toContain('- "credential-setup-with-computer-use": '); + }); + it('loads the bundled data-table-manager skill and its linked files', async () => { expect(existsSync(INSTANCE_AI_SKILLS_DIR)).toBe(true); @@ -152,25 +222,14 @@ describe('Instance AI runtime skills', () => { expect(dataTableManager?.linkedFiles.scripts).toEqual([]); const loadTool = createSkillLoadTool(source); - const loadResult = await loadTool.handler?.( - { skillId: 'data-table-manager', filePath: 'references/data-table-playbook.md' }, - {}, + const playbook = skillLoadText( + await loadTool.handler?.( + { skillId: 'data-table-manager', filePath: 'references/data-table-playbook.md' }, + {}, + ), ); - expect(loadResult).toMatchObject({ - success: true, - skillId: 'data-table-manager', - name: 'data-table-manager', - filePath: 'references/data-table-playbook.md', - }); - if ( - !loadResult || - typeof loadResult !== 'object' || - !('content' in loadResult) || - typeof loadResult.content !== 'string' - ) { - throw new Error('Expected load_skill to return file content'); - } - expect(loadResult.content).toContain('Fast Routing'); + expect(playbook).toContain('[Reference of: "data-table-manager"]'); + expect(playbook).toContain('Fast Routing'); const loaded = await source.loadSkill('data-table-manager'); expect(loaded?.instructions).toContain('## Routing'); @@ -179,7 +238,7 @@ describe('Instance AI runtime skills', () => { expect(loaded?.instructions).toContain('before `build-workflow`'); }); - it('loads the bundled config-evals skill and its linked files', async () => { + it('loads the bundled config-evals reference and its playbook', async () => { const source = loadInstanceAiRuntimeSkillSource(); const configEvals = source.registry.skills.find((skill) => skill.name === 'config-evals'); @@ -187,31 +246,19 @@ describe('Instance AI runtime skills', () => { name: 'config-evals', platforms: ['daytona'], recommendedTools: ['eval-config', 'data-tables'], + parents: ['workflow-builder'], }); - expect(configEvals?.linkedFiles.references).toEqual([ - expect.objectContaining({ path: 'references/config-eval-playbook.md' }), - ]); const loadTool = createSkillLoadTool(source); - const loadResult = await loadTool.handler?.( - { skillId: 'config-evals', filePath: 'references/config-eval-playbook.md' }, - {}, + const configEvalsText = skillLoadText( + await loadTool.handler?.({ skillId: 'config-evals' }, {}), ); - expect(loadResult).toMatchObject({ - success: true, - skillId: 'config-evals', - name: 'config-evals', - filePath: 'references/config-eval-playbook.md', - }); - if ( - !loadResult || - typeof loadResult !== 'object' || - !('content' in loadResult) || - typeof loadResult.content !== 'string' - ) { - throw new Error('Expected load_skill to return file content'); - } - expect(loadResult.content).toContain('Config Eval Playbook'); + expect(configEvalsText).toContain('`config-eval-playbook` reference'); + + const playbook = skillLoadText( + await loadTool.handler?.({ skillId: 'config-eval-playbook' }, {}), + ); + expect(playbook).toContain('Config Eval Playbook'); }); it('gates the config-evals skill by its folder id', () => { @@ -382,7 +429,7 @@ describe('Instance AI runtime skills', () => { expect(loaded?.instructions).toContain('Do not call\n `verify-built-workflow` directly'); expect(loaded?.instructions).toContain('workflows(action="get-as-code", workflowId)'); expect(loaded?.instructions).toContain('n8n has no global error workflow setting'); - expect(loaded?.instructions).toContain('references/error-workflows.md'); + expect(loaded?.instructions).toContain('the `error-workflows` reference'); expect(loaded?.instructions).toContain('settings.errorWorkflow'); expect(loaded?.instructions).toContain( 'knowledge-base/reference/workflow-builder-guardrails.md', @@ -475,9 +522,7 @@ describe('Instance AI runtime skills', () => { const skill = source.registry.skills.find((entry) => entry.name === 'post-build-flow'); expect(skill?.description).toContain('workflow-verification-follow-up'); - expect(skill?.linkedFiles.references).toEqual([ - expect.objectContaining({ path: 'references/trigger-input-data-shapes.md' }), - ]); + expect(skill?.parents).toEqual(['workflow-builder']); const loaded = await source.loadSkill('post-build-flow'); expect(loaded?.instructions).toContain('postBuildFlow.required: true'); @@ -529,20 +574,13 @@ describe('Instance AI runtime skills', () => { 'Do not offer publishing as an alternative or describe the workflow as ready to\nuse or publish', ); + expect(loaded?.instructions).toContain('`trigger-input-data-shapes`'); + const loadTool = createSkillLoadTool(source); - const reference = await loadTool.handler?.( - { skillId: 'post-build-flow', filePath: 'references/trigger-input-data-shapes.md' }, - {}, + const reference = skillLoadText( + await loadTool.handler?.({ skillId: 'trigger-input-data-shapes' }, {}), ); - if ( - !reference || - typeof reference !== 'object' || - !('content' in reference) || - typeof reference.content !== 'string' - ) { - throw new Error('Expected trigger input reference content'); - } - expect(reference.content).toContain('Do NOT wrap in `formFields`'); + expect(reference).toContain('Do NOT wrap in `formFields`'); }); it('loads the bundled planned-task-runtime skill', async () => { @@ -596,6 +634,24 @@ describe('Instance AI runtime skills', () => { }); }); +/** Skill and reference markdown files, relative to the skills directory. */ +function skillMarkdownFiles(): string[] { + return readdirSync(INSTANCE_AI_SKILLS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap(({ name }) => { + const referencesDir = join(INSTANCE_AI_SKILLS_DIR, name, 'references'); + const references = existsSync(referencesDir) + ? readdirSync(referencesDir) + .filter((file) => file.endsWith('.md')) + .map((file) => `${name}/references/${file}`) + : []; + const skill = existsSync(join(INSTANCE_AI_SKILLS_DIR, name, 'SKILL.md')) + ? [`${name}/SKILL.md`] + : []; + return [...skill, ...references]; + }); +} + function skillLoadText(output: unknown): string { const record = output as { type?: string; value?: Array<{ type: string; text: string }> }; if (record?.type !== 'content' || !Array.isArray(record.value)) { diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/skill-gates.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/skill-gates.test.ts index 528b8a0414d..0454c6449bc 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/skill-gates.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/skill-gates.test.ts @@ -1,4 +1,5 @@ import { + CONFIG_EVAL_PLAYBOOK_SKILL_ID, CONFIG_EVALS_SKILL_ID, INSTANCE_AWARENESS_SKILL_ID, disabledInstanceAiSkillIds, @@ -19,6 +20,7 @@ describe('disabledInstanceAiSkillIds', () => { instanceContextEnabled: true, }); expect(disabled.includes(CONFIG_EVALS_SKILL_ID)).toBe(!enabled); + expect(disabled.includes(CONFIG_EVAL_PLAYBOOK_SKILL_ID)).toBe(!enabled); }); it('hides the instance-awareness skill when the reader is off', () => { diff --git a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts index fb10bdce8b2..8b8673f82fe 100644 --- a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts +++ b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts @@ -106,10 +106,41 @@ function toFrontmatterSection( return isNonEmptyRecord(output) ? output : undefined; } +function referenceHost( + entry: RuntimeSkillRegistryEntry, + ownSkillsById: Map, +): MaterializedRuntimeSkill | undefined { + if (!entry.reference) return undefined; + const owner = ownSkillsById.get(entry.reference.owner); + if (owner) return owner; + for (const parentId of entry.parents ?? []) { + const parent = ownSkillsById.get(parentId); + if (parent) return parent; + } + return undefined; +} + +/** + * Materialized locations keyed by skill id. A reference ships inside its + * host's directory, so it resolves to that file. + */ function materializedSkillById( materialized: MaterializedRuntimeSkill[], + registry: RuntimeSkillRegistry, ): Map { - return new Map(materialized.map((skill) => [skill.id, skill])); + const ownSkillsById = new Map(materialized.map((skill) => [skill.id, skill])); + const byId = new Map(ownSkillsById); + for (const entry of registry.skills) { + const host = referenceHost(entry, ownSkillsById); + if (!entry.reference || !host) continue; + byId.set(entry.id, { + id: entry.id, + name: entry.name, + path: posixJoin(host.directory, entry.reference.path), + directory: host.directory, + }); + } + return byId; } function safeSkillDirectory(entry: RuntimeSkillRegistryEntry): string { @@ -138,7 +169,7 @@ function materializedSkillDirectory(skillsRoot: string, entry: RuntimeSkillRegis function safeLinkedFilePath( directory: string, entry: RuntimeSkillRegistryEntry, - linkedFile: RuntimeSkillLinkedFile, + linkedFile: Pick, ): { relativePath: string; materializedPath: string } { const raw = linkedFile.path; if ( @@ -280,7 +311,7 @@ function materializedRegistry( registry: RuntimeSkillRegistry, materialized: MaterializedRuntimeSkill[], ): RuntimeSkillRegistry { - const materializedById = materializedSkillById(materialized); + const materializedById = materializedSkillById(materialized, registry); return { ...registry, @@ -307,7 +338,7 @@ function createMaterializedRuntimeSkillSource( workspaceRoot: string, skillsRoot: string, ): RuntimeSkillSource { - const materializedById = materializedSkillById(materialized); + const materializedById = materializedSkillById(materialized, registry); const loadFile = source.loadFile; return { @@ -431,8 +462,9 @@ export async function buildRuntimeSkillWorkspaceBundle({ const files = new Map(); + const ownSkillEntries = source.registry.skills.filter((entry) => !entry.reference); const materialized = await Promise.all( - source.registry.skills.map(async (entry): Promise => { + ownSkillEntries.map(async (entry): Promise => { const skill = await source.loadSkill(entry.id); if (!skill) { throw new Error(`Runtime skill "${entry.name}" is registered but cannot be loaded`); @@ -450,31 +482,45 @@ export async function buildRuntimeSkillWorkspaceBundle({ warnIfExceedsLoadSkillLimit(logger, entry, path, skillMarkdown); files.set(path, skillMarkdown); - const linkedFiles = linkedFilesFor(entry); - if (linkedFiles.length > 0 && !source.loadFile) { + const linkedFiles = linkedFilesFor(entry).map((linkedFile) => { + const paths = safeLinkedFilePath(directory, entry, linkedFile); + const referenceEntry = source.registry.skills.find( + (candidate) => + candidate.reference?.owner === entry.id && + candidate.reference.path === paths.relativePath, + ); + return { linkedFile, referenceEntry, ...paths }; + }); + if (linkedFiles.some(({ referenceEntry }) => !referenceEntry) && !source.loadFile) { throw new Error(`Runtime skill "${entry.name}" has linked files but no file loader`); } await Promise.all( - linkedFiles.map(async (linkedFile) => { - const { relativePath, materializedPath } = safeLinkedFilePath( - directory, - entry, - linkedFile, - ); - const content = await source.loadFile?.(entry.id, relativePath); - if (!content) { + linkedFiles.map(async ({ linkedFile, referenceEntry, relativePath, materializedPath }) => { + // Render references from skill content so prompt variants reach the file too. + const reference = referenceEntry ? await source.loadSkill(referenceEntry.id) : null; + const content = reference ? null : await source.loadFile?.(entry.id, relativePath); + if (!reference && !content) { throw new Error( `Runtime skill "${entry.name}" linked file is registered but cannot be loaded: ${linkedFile.path}`, ); } - const materializedContent = substituteRuntimeSkillVars( - content.content, - directory, - workspaceRoot, - skillsRoot, - ); + const materializedContent = + reference && referenceEntry + ? renderRuntimeSkillMarkdown( + reference, + referenceEntry, + directory, + workspaceRoot, + skillsRoot, + ) + : substituteRuntimeSkillVars( + content?.content ?? '', + directory, + workspaceRoot, + skillsRoot, + ); warnIfExceedsLoadSkillLimit(logger, entry, materializedPath, materializedContent); files.set(materializedPath, materializedContent); }), @@ -484,6 +530,30 @@ export async function buildRuntimeSkillWorkspaceBundle({ }), ); + const ownSkillsById = new Map(materialized.map((skill) => [skill.id, skill])); + await Promise.all( + source.registry.skills.map(async (entry) => { + if (!entry.reference || ownSkillsById.has(entry.reference.owner)) return; + const host = referenceHost(entry, ownSkillsById); + if (!host) return; + + const reference = await source.loadSkill(entry.id); + if (!reference) { + throw new Error(`Runtime skill "${entry.name}" is registered but cannot be loaded`); + } + const { materializedPath } = safeLinkedFilePath(host.directory, entry, entry.reference); + const content = renderRuntimeSkillMarkdown( + reference, + entry, + host.directory, + workspaceRoot, + skillsRoot, + ); + warnIfExceedsLoadSkillLimit(logger, entry, materializedPath, content); + files.set(materializedPath, content); + }), + ); + const registry = materializedRegistry(source.registry, materialized); const registryPath = posixJoin(skillsRoot, SANDBOX_RUNTIME_SKILL_REGISTRY_FILE); files.set(registryPath, stringifyWorkspaceJson(registry)); diff --git a/packages/@n8n/instance-ai/src/skills/skill-gates.ts b/packages/@n8n/instance-ai/src/skills/skill-gates.ts index f7d35d1633b..cf70ca358c6 100644 --- a/packages/@n8n/instance-ai/src/skills/skill-gates.ts +++ b/packages/@n8n/instance-ai/src/skills/skill-gates.ts @@ -5,6 +5,12 @@ /** Skill folder id gated by the `088_config_evaluations` flag. */ export const CONFIG_EVALS_SKILL_ID = 'config-evals'; +/** + * The playbook is a `workflow-builder` reference next to `config-evals`, not + * inside it, so the same flag must hide it separately. + */ +export const CONFIG_EVAL_PLAYBOOK_SKILL_ID = 'config-eval-playbook'; + /** * Skill folder id gated by the instance-context reader. It is entirely about a block and a tool * that only exist when the reader is on, so listing it otherwise would advertise both. @@ -20,7 +26,9 @@ export interface InstanceAiSkillFlags { /** Skill ids to hide from a user's catalog given their resolved flags. */ export function disabledInstanceAiSkillIds(flags: InstanceAiSkillFlags): string[] { const disabled: string[] = []; - if (!flags.configEvalsEnabled) disabled.push(CONFIG_EVALS_SKILL_ID); + if (!flags.configEvalsEnabled) { + disabled.push(CONFIG_EVALS_SKILL_ID, CONFIG_EVAL_PLAYBOOK_SKILL_ID); + } if (!flags.instanceContextEnabled) disabled.push(INSTANCE_AWARENESS_SKILL_ID); return disabled; }