feat(core): Load Instance AI skill references progressively (no-changelog) (#40296)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir 2026-10-06 20:03:33 +00:00 committed by GitHub
parent 883ade4b9d
commit 90b8205b62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1008 additions and 140 deletions

View File

@ -241,6 +241,8 @@ export type {
RuntimeSkillLoader,
RuntimeSkillMcpServerDependency,
RuntimeSkillPolicyContract,
RuntimeSkillReferenceContract,
RuntimeSkillReferenceLocation,
RuntimeSkillRegistry,
RuntimeSkillRegistryEntry,
RuntimeSkillSource,

View File

@ -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();

View File

@ -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<string, string> {
@ -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);
}
}

View File

@ -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',
]);
});
});
});

View File

@ -19,6 +19,8 @@ export type {
RuntimeSkillLoader,
RuntimeSkillMcpServerDependency,
RuntimeSkillPolicyContract,
RuntimeSkillReferenceContract,
RuntimeSkillReferenceLocation,
RuntimeSkillRegistry,
RuntimeSkillRegistryEntry,
RuntimeSkillSource,

View File

@ -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": "<id>" }\`, then follow the returned instructions.
- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "<id>", "filePath": "<relative path>" }\`.
- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "<id>", "filePath": "<relative path>" }\`.${
hasReferences
? `
- A loaded skill can list references with a description of when each applies. Load a reference with \`{ "skillId": "<reference id>" }\` 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.`;

View File

@ -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<RuntimeSkill, 'id' | 'parents' | 'reference' | 'linkedFiles'>;
/**
* 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<T extends ExcludableSkill>(
allSkills: T[],
excludeSkillIds: string[],
): { skills: T[]; excluded: Set<string>; hiddenFiles: Set<string> } {
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<string, string[]>();
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<string>();
const seenNames = new Set<string>();
const seenSourceDirectories = new Set<string>();
@ -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,

View File

@ -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<SkillLoadOutput | SkillLoadContentOutput> {
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": "<id>" } 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)

View File

@ -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;

View File

@ -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 } : {}),

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: {

View File

@ -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);

View File

@ -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)) {

View File

@ -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', () => {

View File

@ -106,10 +106,41 @@ function toFrontmatterSection<T>(
return isNonEmptyRecord(output) ? output : undefined;
}
function referenceHost(
entry: RuntimeSkillRegistryEntry,
ownSkillsById: Map<string, MaterializedRuntimeSkill>,
): 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<string, MaterializedRuntimeSkill> {
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<RuntimeSkillLinkedFile, 'path'>,
): { 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<string, string>();
const ownSkillEntries = source.registry.skills.filter((entry) => !entry.reference);
const materialized = await Promise.all(
source.registry.skills.map(async (entry): Promise<MaterializedRuntimeSkill> => {
ownSkillEntries.map(async (entry): Promise<MaterializedRuntimeSkill> => {
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));

View File

@ -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;
}