feat(core): Add the Agent package export adapter (no-changelog) (#40402)

This commit is contained in:
Robin Braumann
2026-10-09 08:59:58 +00:00
committed by GitHub
parent 16a8db6675
commit 503092ccca
15 changed files with 916 additions and 21 deletions
@@ -8,7 +8,9 @@ import type { Agent } from '../entities/agent.entity';
* column holds everything except triggers, which live on `agent.integrations`.
* The builder LLM consumes the merged shape.
*/
export function composeJsonConfig(agent: Agent): AgentJsonConfig | null {
export function composeJsonConfig(
agent: Pick<Agent, 'schema' | 'integrations'>,
): AgentJsonConfig | null {
if (!agent.schema) return null;
return {
...agent.schema,
@@ -0,0 +1,198 @@
import { AgentJsonConfigSchema } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import { Container } from '@n8n/di';
import { ConflictError } from '@n8n/errors';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { mock } from 'vitest-mock-extended';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository';
import { AgentTaskSnapshotRepository } from '@/modules/agents/repositories/agent-task-snapshot.repository';
import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import type { NodeTypes } from '@/node-types';
import { N8nPackageParser } from '../engine/n8n-package-parser';
import { AgentExporter } from '../entities/agent/agent.exporter';
import type { WorkflowSerializer } from '../entities/workflow/workflow.serializer';
import { DirectoryPackageReader } from '../io/directory/directory-package-reader';
import { DirectoryPackageWriter } from '../io/directory/directory-package-writer';
import { TarPackageReader } from '../io/tar/tar-package-reader';
import { TarPackageWriter } from '../io/tar/tar-package-writer';
import { PackageImportConfig } from '../n8n-packages.config';
import { WorkflowVersionPolicy } from '../n8n-packages.types';
import type { ManifestEntry, PackageManifest } from '../spec/manifest.schema';
import { serializedAgentSchema, type SerializedAgent } from '../spec/serialized/agent.schema';
import { looseAgentsFixture } from './fixtures/agent-package-fixtures';
import { streamToBuffer } from './utils/tar-support';
const parser = new N8nPackageParser(mock<Logger>(), mock<NodeTypes>(), mock<WorkflowSerializer>());
const limits = new PackageImportConfig();
const sources: Array<{ agent: Agent; draft: SerializedAgent; published: SerializedAgent }> = [];
let project: Project;
let exporter: AgentExporter;
let agentRepository: AgentRepository;
let historyRepository: AgentHistoryRepository;
let taskRepository: AgentTaskRepository;
let taskSnapshotRepository: AgentTaskSnapshotRepository;
let directory: string;
async function readSourceRecords() {
return await Promise.all([
agentRepository.find({ order: { id: 'ASC' } }),
historyRepository.find({ order: { versionId: 'ASC' } }),
taskRepository.find({ order: { id: 'ASC' } }),
taskSnapshotRepository.find({ order: { versionId: 'ASC', taskId: 'ASC' } }),
]);
}
beforeAll(async () => {
await testModules.loadModules(['agents']);
await testDb.init();
agentRepository = Container.get(AgentRepository);
historyRepository = Container.get(AgentHistoryRepository);
taskRepository = Container.get(AgentTaskRepository);
taskSnapshotRepository = Container.get(AgentTaskSnapshotRepository);
exporter = Container.get(AgentExporter);
project = await createTeamProject('Operations');
const fixture = looseAgentsFixture();
for (const entry of fixture.manifest.agents) {
const draft = serializedAgentSchema.parse(fixture.files[`${entry.target}/agent.json`]);
const published = structuredClone(draft);
published.config = {
...AgentJsonConfigSchema.parse(published.config),
instructions: 'Published configuration',
integrations: [{ type: 'n8n_chat', credentialId: '' }],
};
published.skills['shared-skill'].instructions = 'Published skill';
published.tools.shared_tool.code = 'throw new Error("Published tool must not run");';
published.tasks[`${entry.id}_task`] = {
name: 'Published schedule',
objective: 'Published objective',
cronExpression: '0 12 * * 1',
timezone: 'Europe/Vienna',
};
const agent = await agentRepository.save({
id: draft.id,
name: draft.name,
projectId: project.id,
schema: draft.config,
skills: draft.skills,
tools: draft.tools,
availableInMCP: draft.availableInMCP,
integrations: [{ type: 'slack', credentialId: 'current-slack' }],
versionId: `${draft.id}_draft`,
activeVersionId: null,
revision: 0,
});
await taskRepository.save(
Object.entries(draft.tasks).map(([id, task]) => ({ id, agentId: agent.id, ...task })),
);
const history = await historyRepository.saveVersion({
versionId: `${agent.id}_published`,
agentId: agent.id,
schema: published.config,
skills: published.skills,
tools: published.tools,
publishedBy: 'Package test',
});
await taskSnapshotRepository.saveForVersion(
Object.entries(published.tasks).map(([taskId, task]) => ({
versionId: history.versionId,
taskId,
enabled: false,
...task,
})),
);
await agentRepository.setActiveVersionFenced(agent.id, agent.revision, {
activeVersionId: history.versionId,
versionId: agent.versionId!,
});
sources.push({ agent: (await agentRepository.findById(agent.id))!, draft, published });
}
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
directory = await mkdtemp(path.join(tmpdir(), 'n8n-agent-export-'));
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it.each([
{ prefix: '', policy: WorkflowVersionPolicy.Latest, selected: 'draft' },
{ prefix: '', policy: WorkflowVersionPolicy.PublishedStrict, selected: 'published' },
{ prefix: 'projects/operations', policy: WorkflowVersionPolicy.Latest, selected: 'draft' },
{
prefix: 'projects/operations',
policy: WorkflowVersionPolicy.PublishedStrict,
selected: 'published',
},
] as const)(
'round-trips $selected definitions at "$prefix" without changing source records',
async ({ prefix, policy, selected }) => {
const before = await readSourceRecords();
const archive = new TarPackageWriter();
const loose = new DirectoryPackageWriter(directory);
const entries: ManifestEntry[] = [];
for (const { agent } of sources) {
const snapshot = (await exporter.prepare(agent, policy))!;
expect(snapshot.projectId).toBe(project.id);
const entry = await exporter.write(snapshot, archive, prefix);
expect(await exporter.write(snapshot, loose, prefix)).toEqual(entry);
entries.push(entry);
}
const manifest: PackageManifest = {
...looseAgentsFixture().manifest,
agents: entries,
requirements: undefined,
projects: prefix ? [{ id: project.id, name: project.name, target: prefix }] : undefined,
};
for (const writer of [archive, loose]) {
await writer.writeFile('manifest.json', JSON.stringify(manifest));
if (prefix)
await writer.writeFile(
`${prefix}/project.json`,
JSON.stringify({ id: project.id, name: project.name }),
);
}
const archiveReader = new TarPackageReader(await streamToBuffer(archive.finalize()), limits);
const directoryReader = new DirectoryPackageReader(directory, limits);
await directoryReader.listEntries();
const basePrefix = prefix ? `${prefix}/` : '';
const parsed = await parser.getAgents(archiveReader, basePrefix);
expect(await parser.getAgents(directoryReader, basePrefix)).toEqual(parsed);
expect(parsed).toHaveLength(2);
for (const [index, source] of sources.entries()) {
const { id, ...content } = source[selected];
const integrations = [{ type: 'slack', credentialId: 'current-slack' }];
if (selected === 'published') integrations.push({ type: 'n8n_chat', credentialId: '' });
expect(parsed[index]).toEqual({
...content,
sourceAgentId: id,
config: { ...content.config, integrations },
metadata: {
versionId: selected === 'draft' ? source.agent.versionId : source.agent.activeVersionId,
publishedVersionId: source.agent.activeVersionId,
},
});
}
expect(await readSourceRecords()).toEqual(before);
},
);
it('rejects a stale draft through the definition service', async () => {
const agent = sources[0].agent;
await expect(exporter.prepare({ ...agent, revision: agent.revision - 1 })).rejects.toThrow(
ConflictError,
);
});
@@ -0,0 +1,139 @@
import {
AI_GATEWAY_MANAGED_TAG,
MANAGED_CREDENTIAL_TOKEN,
type AgentJsonConfig,
type AgentJsonNodeToolConfig,
} from '@n8n/api-types';
import { looseAgentsFixture } from '../../../__tests__/fixtures/agent-package-fixtures';
import { serializedAgentSchema } from '../../../spec/serialized/agent.schema';
import { CredentialRequirementsExtractor } from '../../credential/credential-requirements.extractor';
import type { PreparedAgentExport } from '../agent-export.types';
import { AgentRequirementsExtractor } from '../agent-requirements.extractor';
import { AgentSerializer } from '../agent.serializer';
const extractor = new AgentRequirementsExtractor(new CredentialRequirementsExtractor());
function prepare(config: AgentJsonConfig | null): PreparedAgentExport {
const source = serializedAgentSchema.parse(
looseAgentsFixture().files['agents/support/agent.json'],
);
return {
projectId: 'source-project',
content: new AgentSerializer().serialize(
source,
{
schema: config,
skills: source.skills,
tools: source.tools,
tasks: new Map(Object.entries(source.tasks)),
},
config?.integrations ?? [],
),
metadata: { versionId: null, publishedVersionId: null },
};
}
describe('AgentRequirementsExtractor', () => {
it('attributes references, deduplicates credentials, and keeps unresolved tool definitions', () => {
const nodeTool: AgentJsonNodeToolConfig = {
type: 'node',
name: 'Fetch',
enabled: false,
node: {
nodeType: 'n8n-nodes-base.httpRequest',
nodeTypeVersion: 4,
nodeParameters: {
credentialId: 'opaque-node-input',
workflowId: 'opaque-workflow',
expression: '={{ $vars.TEST }}',
},
credentials: {
httpHeaderAuth: { id: 'shared-credential', name: 'Header account' },
httpBasicAuth: { id: 'typed-credential', name: '' },
},
},
};
const workflowTools: NonNullable<AgentJsonConfig['tools']> = [
{ type: 'workflow', workflow: 'Lookup', workflowId: 'lookup-id', enabled: false },
{
type: 'workflow',
workflow: 'Resolve by name',
inputs: { data: { mode: 'fixed', value: { credential: 'opaque-workflow-input' } } },
},
];
const snapshot = prepare({
name: 'Support',
model: 'openai/gpt-4o',
credential: 'model-credential',
instructions: '',
integrations: [{ type: 'slack', credentialId: 'shared-credential' }],
tools: [...workflowTools, nodeTool, { ...nodeTool, name: 'Fetch again' }],
subAgents: { agents: [{ agentId: 'child', enabled: false }, { agentId: 'child' }] },
providerTools: { search: { credentialId: 'opaque-provider-input' } },
});
const before = structuredClone(snapshot);
expect(extractor.extract(snapshot)).toEqual({
agentId: 'support_source',
projectId: 'source-project',
credentials: [
{ credentialId: 'model-credential' },
{
credentialId: 'shared-credential',
credentialName: 'Header account',
credentialType: 'httpHeaderAuth',
},
{ credentialId: 'typed-credential', credentialType: 'httpBasicAuth' },
],
workflowTools,
agentIds: ['child'],
nodeTools: [nodeTool, { ...nodeTool, name: 'Fetch again' }],
});
expect(snapshot).toEqual(before);
});
it.each([AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN, ''])(
'preserves marker %j in the package and excludes it from credential requirements',
(credential) => {
const snapshot = prepare({
name: 'Managed model',
model: 'openai/gpt-4o',
credential,
instructions: '',
tools: [
{
type: 'node',
name: 'Model',
node: {
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: { openAiApi: { id: null, name: '', __aiGatewayManaged: true } },
},
},
],
});
expect(extractor.extract(snapshot).credentials).toEqual([]);
expect(snapshot.content.config).toMatchObject({
credential,
tools: [
{
node: { credentials: { openAiApi: { id: null, name: '', __aiGatewayManaged: true } } },
},
],
});
},
);
it('returns no references for a null configuration', () => {
expect(extractor.extract(prepare(null))).toEqual({
agentId: 'support_source',
projectId: 'source-project',
credentials: [],
workflowTools: [],
agentIds: [],
nodeTools: [],
});
});
});
@@ -0,0 +1,279 @@
import { AgentJsonConfigSchema } from '@n8n/api-types';
import { ConflictError } from '@n8n/errors';
import { mock } from 'vitest-mock-extended';
import { ZodError } from 'zod';
import type { AgentDefinitionService } from '@/modules/agents/agent-definition.service';
import type { AgentHistory } from '@/modules/agents/entities/agent-history.entity';
import { Agent } from '@/modules/agents/entities/agent.entity';
import type { AgentDefinition } from '@/modules/agents/utils/agent-definition';
import { looseAgentsFixture } from '../../../__tests__/fixtures/agent-package-fixtures';
import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer';
import { HashingPackageWriter } from '../../../io/hashing-package-writer';
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
import { serializedAgentSchema } from '../../../spec/serialized/agent.schema';
import { AgentExporter } from '../agent.exporter';
import { AgentSerializer } from '../agent.serializer';
const taskId = 'support_source_task';
function definitionFixture(version: string): AgentDefinition {
const fixture = serializedAgentSchema.parse(
looseAgentsFixture().files['agents/support/agent.json'],
);
const config = AgentJsonConfigSchema.parse(fixture.config);
config.instructions = `${version} instructions`;
fixture.skills['shared-skill'].instructions = `${version} skill`;
fixture.tools.shared_tool.code = `throw new Error("${version} tool must not run");`;
fixture.tasks[taskId].objective = `${version} task`;
return {
schema: config,
skills: fixture.skills,
tools: fixture.tools,
tasks: new Map(Object.entries(fixture.tasks)),
};
}
function setup() {
const draft = definitionFixture('Draft');
const published = definitionFixture('Published');
published.schema = {
...AgentJsonConfigSchema.parse(published.schema),
integrations: [
{ type: 'n8n_chat', credentialId: '' },
{ type: 'slack', credentialId: 'old-slack' },
],
};
const agent: Agent = Object.assign(new Agent(), {
id: 'support_source',
name: 'Current name',
projectId: 'source-project',
availableInMCP: true,
versionId: 'draft-version',
activeVersionId: 'published-version',
activeVersion: mock<AgentHistory>({ versionId: 'published-version' }),
integrations: [{ type: 'slack', credentialId: 'current-slack' }],
schema: draft.schema,
skills: draft.skills,
tools: draft.tools,
});
const definitions = mock<AgentDefinitionService>();
definitions.readDraft.mockResolvedValue(draft);
definitions.readVersion.mockResolvedValue(published);
return {
agent,
draft,
published,
definitions,
exporter: new AgentExporter(definitions, new AgentSerializer()),
};
}
describe('AgentExporter', () => {
it.each([
{ policy: undefined, published: true, selected: 'draft' },
{ policy: WorkflowVersionPolicy.Latest, published: false, selected: 'draft' },
{ policy: WorkflowVersionPolicy.PublishedStrict, published: true, selected: 'published' },
{ policy: WorkflowVersionPolicy.PreferPublished, published: true, selected: 'published' },
{ policy: WorkflowVersionPolicy.PreferPublished, published: false, selected: 'draft' },
{ policy: WorkflowVersionPolicy.IgnoreUnpublished, published: true, selected: 'published' },
] as const)(
'prepares $selected for policy $policy with published=$published',
async (testCase) => {
const source = setup();
if (!testCase.published) source.agent.activeVersionId = null;
const snapshot = await source.exporter.prepare(source.agent, testCase.policy);
const selected = source[testCase.selected];
const integrations = [{ type: 'slack', credentialId: 'current-slack' }];
if (testCase.selected === 'published')
integrations.push({ type: 'n8n_chat', credentialId: '' });
expect(snapshot).toEqual({
projectId: 'source-project',
content: {
id: 'support_source',
name: 'Current name',
availableInMCP: true,
config: { ...selected.schema, integrations },
skills: selected.skills,
tools: selected.tools,
tasks: { [taskId]: selected.tasks.get(taskId) },
},
metadata: {
versionId: `${testCase.selected}-version`,
publishedVersionId: testCase.published ? 'published-version' : null,
},
});
},
);
it('skips an unpublished Agent under ignore-unpublished', async () => {
const { agent, exporter } = setup();
agent.activeVersionId = null;
await expect(
exporter.prepare(agent, WorkflowVersionPolicy.IgnoreUnpublished),
).resolves.toBeUndefined();
});
it('rejects an unpublished Agent under published-strict', async () => {
const { agent, exporter } = setup();
agent.activeVersionId = null;
await expect(exporter.prepare(agent, WorkflowVersionPolicy.PublishedStrict)).rejects.toThrow(
'Agent "support_source" has no published version',
);
});
it('fails when the selected published snapshot is missing', async () => {
const { agent, exporter } = setup();
agent.activeVersion = null;
await expect(exporter.prepare(agent, WorkflowVersionPolicy.PreferPublished)).rejects.toThrow(
'Published version was not loaded for Agent',
);
});
it('propagates draft revision conflicts', async () => {
const { agent, definitions, exporter } = setup();
const conflict = new ConflictError('Agent was modified concurrently; please retry');
definitions.readDraft.mockRejectedValue(conflict);
await expect(exporter.prepare(agent)).rejects.toBe(conflict);
});
it('does not add current Chat settings to a published definition without Chat', async () => {
const { agent, published, exporter } = setup();
agent.integrations.push({ type: 'n8n_chat', credentialId: '' });
published.schema!.integrations = [];
const snapshot = await exporter.prepare(agent, WorkflowVersionPolicy.PublishedStrict);
expect(snapshot?.content.config?.integrations).toEqual([
{ type: 'slack', credentialId: 'current-slack' },
]);
});
it.each([
{
kind: 'skill',
id: 'shared-skill',
remove: (definition: AgentDefinition) => {
definition.skills = {};
},
},
{
kind: 'custom tool',
id: 'shared_tool',
remove: (definition: AgentDefinition) => {
definition.tools = {};
},
},
{
kind: 'task',
id: taskId,
remove: (definition: AgentDefinition) => {
definition.tasks = new Map();
},
},
])('requires a body for a disabled $kind reference', async ({ kind, id, remove }) => {
const { agent, draft, exporter } = setup();
remove(draft);
await expect(exporter.prepare(agent)).rejects.toThrow(
`Agent "support_source" references missing ${kind} "${id}"`,
);
});
it.each([
{
kind: 'skill',
corrupt: (definition: AgentDefinition) =>
Object.assign(definition.skills['shared-skill'], { instructions: 42 }),
},
{
kind: 'tool descriptor',
corrupt: (definition: AgentDefinition) =>
Object.assign(definition.tools.shared_tool.descriptor, { hasResume: 'yes' }),
},
{
kind: 'task',
corrupt: (definition: AgentDefinition) =>
Object.assign(definition.tasks.get(taskId)!, { timezone: 'invalid-zone' }),
},
])('rejects an invalid $kind before writing', async ({ corrupt }) => {
const { agent, draft, exporter } = setup();
corrupt(draft);
await expect(exporter.prepare(agent)).rejects.toThrow(ZodError);
});
it('omits unused bodies and normalizes an omitted timezone', async () => {
const { agent, draft, exporter } = setup();
draft.skills.unused = { ...draft.skills['shared-skill'] };
draft.tools.unused = { ...draft.tools.shared_tool };
const task = draft.tasks.get(taskId)!;
delete task.timezone;
draft.tasks = new Map([
[taskId, task],
['unused', { ...task }],
]);
const snapshot = await exporter.prepare(agent);
expect(Object.keys(snapshot!.content.skills)).toEqual(['shared-skill']);
expect(Object.keys(snapshot!.content.tools)).toEqual(['shared_tool']);
expect(snapshot?.content.tasks).toEqual({ [taskId]: { ...task, timezone: null } });
});
it('exports empty body maps for a null configuration', async () => {
const { agent, draft, exporter } = setup();
draft.schema = null;
expect((await exporter.prepare(agent))?.content).toEqual({
id: agent.id,
name: agent.name,
availableInMCP: true,
config: null,
skills: {},
tools: {},
tasks: {},
});
});
it('writes the detached snapshot after source changes', async () => {
const { agent, draft, definitions, exporter } = setup();
const snapshot = (await exporter.prepare(agent))!;
const expected = structuredClone(snapshot);
draft.schema!.instructions = 'Changed after preparation';
draft.skills['shared-skill'].references![0].content = 'Changed reference';
Object.assign(draft.tools.shared_tool.descriptor.inputSchema!, { properties: {} });
Object.assign(draft.tools.shared_tool.descriptor.providerOptions!, {
provider: { cache: true },
});
draft.tasks.get(taskId)!.objective = 'Changed task';
agent.integrations[0].credentialId = 'changed-credential';
agent.name = 'Changed name';
definitions.readDraft.mockRejectedValue(new Error('Do not reload the definition'));
const writer = new CapturingWriter();
const entry = await exporter.write(snapshot, writer);
expect(snapshot).toEqual(expected);
expect(entry).toEqual({
id: 'support_source',
name: 'Current name',
target: 'agents/current-name-support_source',
});
expect(writer.files).toHaveLength(2);
expect(JSON.parse(writer.files[0].content)).toEqual(expected.content);
expect(JSON.parse(writer.files[1].content)).toEqual(expected.metadata);
});
it('keeps authored bytes and hashes stable when only publication metadata changes', async () => {
const { agent, exporter } = setup();
const before = (await exporter.prepare(agent))!;
agent.versionId = 'new-draft-version';
agent.activeVersionId = null;
const after = (await exporter.prepare(agent))!;
const captures = [new CapturingWriter(), new CapturingWriter()];
const hashes = [new HashingPackageWriter(), new HashingPackageWriter()];
for (const [index, snapshot] of [before, after].entries()) {
await exporter.write(snapshot, captures[index]);
await exporter.write(snapshot, hashes[index]);
}
expect(captures[0].files[0]).toEqual(captures[1].files[0]);
expect(hashes[0].finalize()[0]).toEqual(hashes[1].finalize()[0]);
expect(captures[0].files[1]).not.toEqual(captures[1].files[1]);
expect(hashes[0].finalize()[1]).not.toEqual(hashes[1].finalize()[1]);
});
});
@@ -0,0 +1,19 @@
import type { AgentJsonNodeToolConfig, AgentJsonWorkflowToolConfig } from '@n8n/api-types';
import type { SerializedAgent, SerializedAgentMetadata } from '../../spec/serialized/agent.schema';
import type { AgentCredentialRequirement } from '../credential/credential.types';
export interface PreparedAgentExport {
projectId: string;
content: SerializedAgent;
metadata: SerializedAgentMetadata;
}
export interface AgentExportRequirements {
agentId: string;
projectId: string;
credentials: AgentCredentialRequirement[];
workflowTools: AgentJsonWorkflowToolConfig[];
agentIds: string[];
nodeTools: AgentJsonNodeToolConfig[];
}
@@ -0,0 +1,24 @@
import { Service } from '@n8n/di';
import { extractAgentWorkflowRefs } from '@/modules/agents/utils/extract-agent-workflow-refs';
import type { AgentExportRequirements, PreparedAgentExport } from './agent-export.types';
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
@Service()
export class AgentRequirementsExtractor {
constructor(private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor) {}
extract({ content, projectId }: PreparedAgentExport): AgentExportRequirements {
const config = content.config;
const nodeTools = (config?.tools ?? []).filter((tool) => tool.type === 'node');
return {
agentId: content.id,
projectId,
credentials: this.credentialRequirementsExtractor.extractFromAgent(config),
workflowTools: extractAgentWorkflowRefs(config),
agentIds: [...new Set((config?.subAgents?.agents ?? []).map(({ agentId }) => agentId))],
nodeTools,
};
}
}
@@ -0,0 +1,93 @@
import { isCredentialAgentIntegration, N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import { AgentDefinitionService } from '@/modules/agents/agent-definition.service';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import type { AgentDefinition } from '@/modules/agents/utils/agent-definition';
import type { PreparedAgentExport } from './agent-export.types';
import { AgentSerializer } from './agent.serializer';
import { formatEntityFile } from '../../io/entity-file-format';
import {
agentMetadataFilePath,
packageDirectory,
writeManifestEntry,
} from '../../io/manifest-entry';
import type { PackageWriter } from '../../io/package-writer';
import { WorkflowVersionPolicy } from '../../n8n-packages.types';
import type { ManifestEntry } from '../../spec/manifest.schema';
import { PackageExportBlockedError } from '../package-export.errors';
@Service()
export class AgentExporter {
constructor(
private readonly definitionService: AgentDefinitionService,
private readonly serializer: AgentSerializer,
) {}
async prepare(
agent: Agent,
policy: WorkflowVersionPolicy = WorkflowVersionPolicy.Latest,
): Promise<PreparedAgentExport | undefined> {
if (agent.activeVersionId === null) {
if (policy === WorkflowVersionPolicy.IgnoreUnpublished) return undefined;
if (policy === WorkflowVersionPolicy.PublishedStrict) {
throw new PackageExportBlockedError(
`Agent "${agent.id}" has no published version. Export aborted.`,
);
}
}
let definition: AgentDefinition;
let versionId = agent.versionId;
let integrations = agent.integrations ?? [];
if (policy !== WorkflowVersionPolicy.Latest && agent.activeVersionId !== null) {
const version = agent.activeVersion;
if (!version) {
throw new UnexpectedError('Published version was not loaded for Agent', {
extra: { agentId: agent.id, activeVersionId: agent.activeVersionId },
});
}
definition = await this.definitionService.readVersion(version);
versionId = version.versionId;
integrations = [
...integrations.filter(isCredentialAgentIntegration),
...(definition.schema?.integrations ?? []).filter(
({ type }) => type === N8N_CHAT_INTEGRATION_TYPE,
),
];
} else {
definition = await this.definitionService.readDraft(agent);
}
const content = this.serializer.serialize(agent, definition, integrations);
return {
projectId: agent.projectId,
content,
metadata: this.serializer.serializeMetadata({
versionId,
activeVersionId: agent.activeVersionId,
}),
};
}
async write(
snapshot: PreparedAgentExport,
writer: PackageWriter,
basePrefix = '',
): Promise<ManifestEntry> {
const entry = await writeManifestEntry(
writer,
'agents',
packageDirectory('agents', basePrefix),
snapshot.content,
snapshot.content,
);
await writer.writeFile(
agentMetadataFilePath(entry.target),
formatEntityFile(snapshot.metadata),
);
return entry;
}
}
@@ -0,0 +1,117 @@
import { Service } from '@n8n/di';
import type { z } from 'zod';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition';
import type { AgentDefinition } from '@/modules/agents/utils/agent-definition';
import {
serializedAgentMetadataSchema,
serializedAgentSchema,
type SerializedAgent,
type SerializedAgentMetadata,
} from '../../spec/serialized/agent.schema';
import { PackageExportBlockedError } from '../package-export.errors';
import { definePackageSerializationPayload } from '../package-serialization.types';
type AgentPackageKeyHandling = {
id: 'copy';
name: 'copy';
schema: 'transform';
integrations: 'transform';
tools: 'transform';
skills: 'transform';
availableInMCP: 'copy';
project: 'exclude';
projectId: 'exclude';
createdAt: 'exclude';
updatedAt: 'exclude';
versionId: 'exclude';
activeVersionId: 'exclude';
activeVersion: 'exclude';
revision: 'exclude';
setupCompletedAt: 'exclude';
};
const serializePayload = definePackageSerializationPayload<
Agent,
z.input<typeof serializedAgentSchema>,
AgentPackageKeyHandling
>();
type AgentMetadataKeyHandling = Record<
Exclude<keyof AgentPackageKeyHandling, 'versionId' | 'activeVersionId'>,
'exclude'
> & {
versionId: 'copy';
activeVersionId: 'transform';
};
const serializeMetadataPayload = definePackageSerializationPayload<
Agent,
SerializedAgentMetadata,
AgentMetadataKeyHandling
>();
@Service()
export class AgentSerializer {
serialize(
agent: Pick<Agent, 'id' | 'name' | 'availableInMCP'>,
definition: AgentDefinition,
integrations: Agent['integrations'],
): SerializedAgent {
const config = composeJsonConfig({ schema: definition.schema, integrations });
const content = serializedAgentSchema.parse(
serializePayload({
id: agent.id,
name: agent.name,
config,
availableInMCP: agent.availableInMCP,
skills: this.referencedBodies(agent.id, 'skill', config?.skills ?? [], definition.skills),
tools: this.referencedBodies(
agent.id,
'custom tool',
config?.tools?.filter((tool) => tool.type === 'custom') ?? [],
definition.tools,
),
tasks: this.referencedBodies(
agent.id,
'task',
config?.tasks ?? [],
Object.fromEntries(definition.tasks),
),
}),
);
// Tool JSON schemas and provider options can retain references after validation.
return structuredClone(content);
}
serializeMetadata(source: Pick<Agent, 'versionId' | 'activeVersionId'>): SerializedAgentMetadata {
return serializedAgentMetadataSchema.parse(
serializeMetadataPayload({
versionId: source.versionId,
publishedVersionId: source.activeVersionId,
}),
);
}
private referencedBodies<T>(
agentId: string,
kind: string,
references: Array<{ id: string }>,
bodies: Record<string, T>,
): Record<string, T> {
return Object.fromEntries(
references.map(({ id }) => {
const body = Object.hasOwn(bodies, id) ? bodies[id] : undefined;
if (body === undefined) {
throw new PackageExportBlockedError(
`Agent "${agentId}" references missing ${kind} "${id}". Export aborted.`,
);
}
return [id, body];
}),
);
}
}
@@ -35,7 +35,7 @@ describe('CredentialRequirementsExtractor', () => {
],
});
expect(extractor.extract(workflow)).toEqual([]);
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
});
it('emits one requirement per node credential slot, keyed by credential type', () => {
@@ -59,7 +59,7 @@ describe('CredentialRequirementsExtractor', () => {
],
});
expect(extractor.extract(workflow)).toEqual(
expect(extractor.extractFromWorkflow(workflow)).toEqual(
expect.arrayContaining([
{
workflowId: 'wf-creds',
@@ -75,7 +75,7 @@ describe('CredentialRequirementsExtractor', () => {
},
]),
);
expect(extractor.extract(workflow)).toHaveLength(2);
expect(extractor.extractFromWorkflow(workflow)).toHaveLength(2);
});
it('dedupes when the same credential id appears in two nodes of one workflow', () => {
@@ -107,7 +107,7 @@ describe('CredentialRequirementsExtractor', () => {
],
});
expect(extractor.extract(workflow)).toEqual([
expect(extractor.extractFromWorkflow(workflow)).toEqual([
{
workflowId: 'wf-dup',
credentialId: 'cred-shared',
@@ -144,7 +144,7 @@ describe('CredentialRequirementsExtractor', () => {
],
});
expect(extractor.extract(workflow)).toEqual([
expect(extractor.extractFromWorkflow(workflow)).toEqual([
{
workflowId: 'wf-inline',
credentialId: 'cred-inline',
@@ -172,7 +172,7 @@ describe('CredentialRequirementsExtractor', () => {
],
});
expect(extractor.extract(workflow)).toEqual([]);
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
});
it('returns an empty list when the workflow has no nodes array at all', () => {
@@ -180,6 +180,6 @@ describe('CredentialRequirementsExtractor', () => {
// `nodes` may be absent rather than empty.
const workflow = makeWorkflow({ id: 'wf-no-nodes', nodes: undefined as unknown as [] });
expect(extractor.extract(workflow)).toEqual([]);
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
});
});
@@ -1,15 +1,15 @@
import type { AgentJsonConfig } from '@n8n/api-types';
import type { WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import type { WorkflowCredentialRequirement } from './credential.types';
import { extractAgentCredentialIds } from '@/modules/agents/utils/extract-agent-credential-ids';
import type { AgentCredentialRequirement, WorkflowCredentialRequirement } from './credential.types';
import { visitWorkflowCredentials } from './workflow-credential-references';
import type { RequirementsExtractor } from '../requirements-extractor';
@Service()
export class CredentialRequirementsExtractor
implements RequirementsExtractor<WorkflowCredentialRequirement>
{
extract(workflow: WorkflowEntity): WorkflowCredentialRequirement[] {
export class CredentialRequirementsExtractor {
extractFromWorkflow(workflow: WorkflowEntity): WorkflowCredentialRequirement[] {
const byId = new Map<string, WorkflowCredentialRequirement>();
visitWorkflowCredentials(workflow.nodes, (credentialType, details) => {
@@ -26,4 +26,20 @@ export class CredentialRequirementsExtractor
return [...byId.values()];
}
extractFromAgent(config: AgentJsonConfig | null): AgentCredentialRequirement[] {
const credentials = new Map<string, AgentCredentialRequirement>(
[...extractAgentCredentialIds(config)].map((id) => [id, { credentialId: id }]),
);
const nodeCredentials = (config?.tools ?? [])
.filter((tool) => tool.type === 'node')
.flatMap(({ node }) => Object.entries(node.credentials ?? {}));
for (const [type, details] of nodeCredentials) {
const credential = details.id === null ? undefined : credentials.get(details.id);
if (!credential) continue;
if (details.name) credential.credentialName ??= details.name;
if (type) credential.credentialType ??= type;
}
return [...credentials.values()];
}
}
@@ -5,6 +5,12 @@ import type {
} from '../../n8n-packages.types';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
export interface AgentCredentialRequirement {
credentialId: string;
credentialName?: string;
credentialType?: string;
}
export interface WorkflowCredentialRequirement {
workflowId: string;
credentialId: string;
@@ -250,7 +250,7 @@ describe('AutoIncludedWorkflowExporter', () => {
it('extracts credential, data-table, and variable requirements from each workflow', async () => {
const credentialExtractor = mock<CredentialRequirementsExtractor>();
credentialExtractor.extract.mockReturnValue([
credentialExtractor.extractFromWorkflow.mockReturnValue([
{
workflowId: 'wf_1',
credentialId: 'cred-1',
@@ -308,7 +308,7 @@ describe('AutoIncludedWorkflowExporter', () => {
it('does not extract requirements from a skipped (already-exported) workflow', async () => {
const credentialExtractor = mock<CredentialRequirementsExtractor>();
credentialExtractor.extract.mockReturnValue([]);
credentialExtractor.extractFromWorkflow.mockReturnValue([]);
const exporter = makeExporter(credentialExtractor);
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf_dup', name: 'Already Here' });
@@ -320,7 +320,7 @@ describe('AutoIncludedWorkflowExporter', () => {
],
});
expect(credentialExtractor.extract).not.toHaveBeenCalled();
expect(credentialExtractor.extractFromWorkflow).not.toHaveBeenCalled();
expect(result.requirements.nodeTypes).toEqual([]);
});
@@ -330,7 +330,7 @@ describe('WorkflowExporter', () => {
const a = makeWorkflow({ id: 'wf_a' });
const b = makeWorkflow({ id: 'wf_b' });
const extractor = mock<CredentialRequirementsExtractor>();
extractor.extract.mockImplementation((workflow) => [
extractor.extractFromWorkflow.mockImplementation((workflow) => [
{
workflowId: workflow.id,
credentialId: `cred-from-${workflow.id}`,
@@ -349,7 +349,7 @@ describe('WorkflowExporter', () => {
workflowVersionPolicy: 'latest',
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
expect(extractor.extractFromWorkflow).toHaveBeenCalledTimes(2);
expect(requirements.credentials).toEqual<WorkflowCredentialRequirement[]>([
{
workflowId: 'wf_a',
@@ -110,7 +110,9 @@ export class AutoIncludedWorkflowExporter {
);
workflowEntries.push(entry);
workflowEntriesById.set(entry.id, entry);
credentials.push(...this.credentialRequirementsExtractor.extract(included.workflow));
credentials.push(
...this.credentialRequirementsExtractor.extractFromWorkflow(included.workflow),
);
dataTables.push(...this.dataTableRequirementsExtractor.extract(included.workflow));
variables.push(...this.variableRequirementsExtractor.extract(included.workflow));
tags.push(...this.tagRequirementsExtractor.extract(included.workflow));
@@ -93,7 +93,7 @@ export class WorkflowExporter {
),
);
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
credentials.push(...this.credentialRequirementsExtractor.extractFromWorkflow(workflow));
dataTables.push(...this.dataTableRequirementsExtractor.extract(workflow));
variables.push(...this.variableRequirementsExtractor.extract(workflow));
tags.push(...this.tagRequirementsExtractor.extract(workflow));