Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Add an option to allow community nodes as tools #13075

Merged
merged 2 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/@n8n/config/src/configs/nodes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class CommunityPackagesConfig {
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;

/** Whether to allow community packages as tools for AI agents */
@Env('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE')
allowToolUsage: boolean = false;
}

@Config
Expand Down
1 change: 1 addition & 0 deletions packages/@n8n/config/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('GlobalConfig', () => {
enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false,
allowToolUsage: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
Expand Down
74 changes: 65 additions & 9 deletions packages/cli/src/__tests__/node-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
import type {
Expand All @@ -11,11 +12,14 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';

describe('NodeTypes', () => {
const globalConfig = mock<GlobalConfig>({
nodes: { communityPackages: { allowToolUsage: false } },
});
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
convertNodeToAiTool: LoadNodesAndCredentials.prototype.convertNodeToAiTool,
});

const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials);
const nodeTypes: NodeTypes = new NodeTypes(globalConfig, loadNodesAndCredentials);

const nonVersionedNode: LoadedClass<INodeType> = {
sourcePath: '',
Expand All @@ -24,10 +28,11 @@ describe('NodeTypes', () => {
name: 'n8n-nodes-base.nonVersioned',
usableAsTool: undefined,
}),
supplyData: undefined,
},
};
const v1Node = mock<INodeType>();
const v2Node = mock<INodeType>();
const v1Node = mock<INodeType>({ supplyData: undefined });
const v2Node = mock<INodeType>({ supplyData: undefined });
const versionedNode: LoadedClass<IVersionedNodeType> = {
sourcePath: '',
type: {
Expand All @@ -45,6 +50,17 @@ describe('NodeTypes', () => {
},
},
};
const toolNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-base.toolNode',
displayName: 'TestNode',
properties: [],
}),
supplyData: jest.fn(),
},
};
const toolSupportingNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
Expand All @@ -54,6 +70,7 @@ describe('NodeTypes', () => {
usableAsTool: true,
properties: [],
}),
supplyData: undefined,
},
};
const declarativeNode: LoadedClass<INodeType> = {
Expand All @@ -70,20 +87,38 @@ describe('NodeTypes', () => {
trigger: undefined,
webhook: undefined,
methods: undefined,
supplyData: undefined,
},
};
const communityNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-community.testNode',
displayName: 'TestNode',
usableAsTool: true,
properties: [],
}),
supplyData: undefined,
},
};

loadNodesAndCredentials.getNode.mockImplementation((fullNodeType) => {
const [packageName, nodeType] = fullNodeType.split('.');
if (nodeType === 'nonVersioned') return nonVersionedNode;
if (nodeType === 'versioned') return versionedNode;
if (nodeType === 'testNode') return toolSupportingNode;
if (nodeType === 'declarativeNode') return declarativeNode;
if (packageName === 'n8n-nodes-base') {
if (nodeType === 'nonVersioned') return nonVersionedNode;
if (nodeType === 'versioned') return versionedNode;
if (nodeType === 'testNode') return toolSupportingNode;
if (nodeType === 'declarativeNode') return declarativeNode;
if (nodeType === 'toolNode') return toolNode;
} else if (fullNodeType === 'n8n-nodes-community.testNode') return communityNode;
throw new UnrecognizedNodeTypeError(packageName, nodeType);
});

beforeEach(() => {
jest.clearAllMocks();
globalConfig.nodes.communityPackages.allowToolUsage = false;
loadNodesAndCredentials.loaded.nodes = {};
});

describe('getByName', () => {
Expand Down Expand Up @@ -122,6 +157,12 @@ describe('NodeTypes', () => {
);
});

it('should throw when a node-type is requested as tool, but the original node is already a tool', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.toolNodeTool')).toThrow(
'Node already has a `supplyData` method',
);
});

it('should return the tool node-type when requested as tool', () => {
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
expect(result).not.toEqual(toolSupportingNode.type);
Expand All @@ -132,6 +173,23 @@ describe('NodeTypes', () => {
expect(result.description.outputs).toEqual(['ai_tool']);
});

it('should throw when a node-type is requested as tool, but is a community package', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-community.testNodeTool')).toThrow(
'Unrecognized node type: n8n-nodes-community.testNodeTool',
);
});

it('should return a tool node-type from a community node, when requested as tool', () => {
globalConfig.nodes.communityPackages.allowToolUsage = true;
const result = nodeTypes.getByNameAndVersion('n8n-nodes-community.testNodeTool');
expect(result).not.toEqual(toolSupportingNode.type);
expect(result.description.name).toEqual('n8n-nodes-community.testNodeTool');
expect(result.description.displayName).toEqual('TestNode Tool');
expect(result.description.codex?.categories).toContain('AI');
expect(result.description.inputs).toEqual([]);
expect(result.description.outputs).toEqual(['ai_tool']);
});

it('should return a declarative node-type with an `.execute` method', () => {
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.declarativeNode');
expect(result).toBe(declarativeNode.type);
Expand Down Expand Up @@ -184,7 +242,6 @@ describe('NodeTypes', () => {

describe('getNodeTypeDescriptions', () => {
it('should return descriptions for valid node types', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
const result = nodeTypes.getNodeTypeDescriptions([
{ name: 'n8n-nodes-base.nonVersioned', version: 1 },
]);
Expand All @@ -194,7 +251,6 @@ describe('NodeTypes', () => {
});

it('should throw error for invalid node type', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
expect(() =>
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]),
).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent');
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/node-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { NeededNodeType } from '@n8n/task-runner';
import type { Dirent } from 'fs';
Expand All @@ -12,7 +13,10 @@ import { LoadNodesAndCredentials } from './load-nodes-and-credentials';

@Service()
export class NodeTypes implements INodeTypes {
constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) {}
constructor(
private readonly globalConfig: GlobalConfig,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
) {}

/**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
Expand All @@ -33,14 +37,24 @@ export class NodeTypes implements INodeTypes {

getByNameAndVersion(nodeType: string, version?: number): INodeType {
const origType = nodeType;
const toolRequested = nodeType.startsWith('n8n-nodes-base') && nodeType.endsWith('Tool');

const { communityPackages } = this.globalConfig.nodes;
const allowToolUsage = communityPackages.allowToolUsage
? true
: nodeType.startsWith('n8n-nodes-base');
const toolRequested = nodeType.endsWith('Tool');

// Make sure the nodeType to actually get from disk is the un-wrapped type
if (toolRequested) {
if (allowToolUsage && toolRequested) {
nodeType = nodeType.replace(/Tool$/, '');
}

const node = this.loadNodesAndCredentials.getNode(nodeType);
const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version);
if (toolRequested && typeof versionedNodeType.supplyData === 'function') {
throw new ApplicationError('Node already has a `supplyData` method', { extra: { nodeType } });
}

if (
!versionedNodeType.execute &&
!versionedNodeType.poll &&
Expand Down
Loading