mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
progressive skill loading
This commit is contained in:
parent
faa88fa63c
commit
e6d3fce2fa
|
|
@ -220,6 +220,8 @@ export type {
|
|||
RuntimeSkillLoader,
|
||||
RuntimeSkillMcpServerDependency,
|
||||
RuntimeSkillPolicyContract,
|
||||
RuntimeSkillReferenceContract,
|
||||
RuntimeSkillReferenceLocation,
|
||||
RuntimeSkillRegistry,
|
||||
RuntimeSkillRegistryEntry,
|
||||
RuntimeSkillSource,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,52 @@ describe('active skills', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('restores a reference loaded by its owner file path and adds its tool dependencies', 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(active.toolDependencies()).toEqual(['searchModels']);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -246,7 +246,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> {
|
||||
|
|
@ -269,14 +269,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,3 +743,177 @@ 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 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('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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type {
|
|||
RuntimeSkillLoader,
|
||||
RuntimeSkillMcpServerDependency,
|
||||
RuntimeSkillPolicyContract,
|
||||
RuntimeSkillReferenceContract,
|
||||
RuntimeSkillReferenceLocation,
|
||||
RuntimeSkillRegistry,
|
||||
RuntimeSkillRegistryEntry,
|
||||
RuntimeSkillSource,
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -71,21 +71,57 @@ export function filterRuntimeSkillSource(
|
|||
excludeSkillIds: string[],
|
||||
): RuntimeSkillSource {
|
||||
const excluded = new Set(excludeSkillIds);
|
||||
const skills = source.registry.skills.filter((skill) => !excluded.has(skill.id));
|
||||
const remaining = new Set(
|
||||
source.registry.skills.filter((skill) => !excluded.has(skill.id)).map((skill) => skill.id),
|
||||
);
|
||||
const skills = source.registry.skills.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 }];
|
||||
});
|
||||
// A hidden reference's file must not stay reachable as its owner's linked file.
|
||||
const hiddenFiles = new Set(
|
||||
source.registry.skills.flatMap((skill) =>
|
||||
skill.reference && excluded.has(skill.id)
|
||||
? [linkedFileKey(skill.reference.owner, skill.reference.path)]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const visibleSkills =
|
||||
hiddenFiles.size === 0
|
||||
? skills
|
||||
: skills.map((skill) => ({
|
||||
...skill,
|
||||
linkedFiles: {
|
||||
...skill.linkedFiles,
|
||||
references: skill.linkedFiles.references.filter(
|
||||
(file) => !hiddenFiles.has(linkedFileKey(skill.id, file.path)),
|
||||
),
|
||||
},
|
||||
}));
|
||||
const { loadFile } = source;
|
||||
|
||||
return {
|
||||
...source,
|
||||
registry: {
|
||||
...source.registry,
|
||||
skillsHash: hashRegistry(skills),
|
||||
skills,
|
||||
skillsHash: hashRegistry(visibleSkills),
|
||||
skills: visibleSkills,
|
||||
},
|
||||
loadSkill: async (skillId) => (excluded.has(skillId) ? null : await source.loadSkill(skillId)),
|
||||
...(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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
|
@ -136,7 +172,7 @@ export function loadRuntimeSkillSourceFromDirectory(
|
|||
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 +193,101 @@ 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) {
|
||||
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 +305,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 +362,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 +385,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- build-workflow
|
||||
|
|
|
|||
|
|
@ -173,5 +173,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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ description: >-
|
|||
them one at a time with this skill, dependencies first.
|
||||
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
|
||||
|
|
@ -180,8 +182,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
|
||||
|
||||
|
|
@ -546,9 +548,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,8 +29,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
|
||||
|
||||
|
|
@ -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` and `executions(action="run")`. The pin-data
|
||||
|
|
@ -237,7 +237,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 () => {
|
||||
|
|
|
|||
|
|
@ -71,8 +71,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);
|
||||
|
|
@ -80,7 +83,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));
|
||||
await expect(progressive.loadSkill('progressive-building')).resolves.toBeNull();
|
||||
|
|
|
|||
|
|
@ -33,12 +33,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)) {
|
||||
|
|
@ -46,7 +42,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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -119,12 +115,83 @@ 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',
|
||||
'progressive-building',
|
||||
'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);
|
||||
|
||||
|
|
@ -151,25 +218,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');
|
||||
|
|
@ -178,7 +234,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');
|
||||
|
||||
|
|
@ -186,31 +242,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', () => {
|
||||
|
|
@ -381,7 +425,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',
|
||||
|
|
@ -433,9 +477,7 @@ describe('Instance AI runtime skills', () => {
|
|||
const skill = source.registry.skills.find((entry) => entry.name === 'post-build-flow');
|
||||
|
||||
expect(skill?.description).toContain('workflow-setup-required');
|
||||
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');
|
||||
|
|
@ -487,20 +529,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 instance-awareness skill', async () => {
|
||||
|
|
@ -529,6 +564,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)) {
|
||||
|
|
|
|||
|
|
@ -106,10 +106,26 @@ function toFrontmatterSection<T>(
|
|||
return isNonEmptyRecord(output) ? output : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialized locations keyed by skill id. A reference ships inside its
|
||||
* owner's directory as a linked file, 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 byId = new Map(materialized.map((skill) => [skill.id, skill]));
|
||||
for (const entry of registry.skills) {
|
||||
const owner = entry.reference ? byId.get(entry.reference.owner) : undefined;
|
||||
if (!entry.reference || !owner) continue;
|
||||
byId.set(entry.id, {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
path: posixJoin(owner.directory, entry.reference.path),
|
||||
directory: owner.directory,
|
||||
});
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
function safeSkillDirectory(entry: RuntimeSkillRegistryEntry): string {
|
||||
|
|
@ -280,7 +296,7 @@ function materializedRegistry(
|
|||
registry: RuntimeSkillRegistry,
|
||||
materialized: MaterializedRuntimeSkill[],
|
||||
): RuntimeSkillRegistry {
|
||||
const materializedById = materializedSkillById(materialized);
|
||||
const materializedById = materializedSkillById(materialized, registry);
|
||||
|
||||
return {
|
||||
...registry,
|
||||
|
|
@ -307,7 +323,7 @@ function createMaterializedRuntimeSkillSource(
|
|||
workspaceRoot: string,
|
||||
skillsRoot: string,
|
||||
): RuntimeSkillSource {
|
||||
const materializedById = materializedSkillById(materialized);
|
||||
const materializedById = materializedSkillById(materialized, registry);
|
||||
const loadFile = source.loadFile;
|
||||
|
||||
return {
|
||||
|
|
@ -431,8 +447,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`);
|
||||
|
|
@ -462,19 +479,34 @@ export async function buildRuntimeSkillWorkspaceBundle({
|
|||
entry,
|
||||
linkedFile,
|
||||
);
|
||||
const content = await source.loadFile?.(entry.id, relativePath);
|
||||
if (!content) {
|
||||
const referenceEntry = source.registry.skills.find(
|
||||
(candidate) =>
|
||||
candidate.reference?.owner === entry.id && candidate.reference.path === relativePath,
|
||||
);
|
||||
// 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);
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user