mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Load Instance AI skill references progressively (no-changelog) (#40296)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
883ade4b9d
commit
90b8205b62
|
|
@ -241,6 +241,8 @@ export type {
|
|||
RuntimeSkillLoader,
|
||||
RuntimeSkillMcpServerDependency,
|
||||
RuntimeSkillPolicyContract,
|
||||
RuntimeSkillReferenceContract,
|
||||
RuntimeSkillReferenceLocation,
|
||||
RuntimeSkillRegistry,
|
||||
RuntimeSkillRegistryEntry,
|
||||
RuntimeSkillSource,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- agent-context
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user