From 2b133aa201325e27baddaa2bfd5995dca2093728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 6 Feb 2025 14:17:15 +0100 Subject: [PATCH] feat(core): Add an option to allow community nodes as tools (#13075) --- .../@n8n/config/src/configs/nodes.config.ts | 4 + packages/@n8n/config/test/config.test.ts | 1 + packages/cli/src/__tests__/node-types.test.ts | 74 ++++++++++++++++--- packages/cli/src/node-types.ts | 20 ++++- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/@n8n/config/src/configs/nodes.config.ts b/packages/@n8n/config/src/configs/nodes.config.ts index 577c4055ab622..f5e190e2896d8 100644 --- a/packages/@n8n/config/src/configs/nodes.config.ts +++ b/packages/@n8n/config/src/configs/nodes.config.ts @@ -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 diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 2cefd5568368f..1f471b8c72dcf 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -119,6 +119,7 @@ describe('GlobalConfig', () => { enabled: true, registry: 'https://registry.npmjs.org', reinstallMissing: false, + allowToolUsage: false, }, errorTriggerType: 'n8n-nodes-base.errorTrigger', include: [], diff --git a/packages/cli/src/__tests__/node-types.test.ts b/packages/cli/src/__tests__/node-types.test.ts index 4868364d037d3..5e26cc5665abe 100644 --- a/packages/cli/src/__tests__/node-types.test.ts +++ b/packages/cli/src/__tests__/node-types.test.ts @@ -1,3 +1,4 @@ +import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core'; import type { @@ -11,11 +12,14 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { NodeTypes } from '@/node-types'; describe('NodeTypes', () => { + const globalConfig = mock({ + nodes: { communityPackages: { allowToolUsage: false } }, + }); const loadNodesAndCredentials = mock({ convertNodeToAiTool: LoadNodesAndCredentials.prototype.convertNodeToAiTool, }); - const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials); + const nodeTypes: NodeTypes = new NodeTypes(globalConfig, loadNodesAndCredentials); const nonVersionedNode: LoadedClass = { sourcePath: '', @@ -24,10 +28,11 @@ describe('NodeTypes', () => { name: 'n8n-nodes-base.nonVersioned', usableAsTool: undefined, }), + supplyData: undefined, }, }; - const v1Node = mock(); - const v2Node = mock(); + const v1Node = mock({ supplyData: undefined }); + const v2Node = mock({ supplyData: undefined }); const versionedNode: LoadedClass = { sourcePath: '', type: { @@ -45,6 +50,17 @@ describe('NodeTypes', () => { }, }, }; + const toolNode: LoadedClass = { + sourcePath: '', + type: { + description: mock({ + name: 'n8n-nodes-base.toolNode', + displayName: 'TestNode', + properties: [], + }), + supplyData: jest.fn(), + }, + }; const toolSupportingNode: LoadedClass = { sourcePath: '', type: { @@ -54,6 +70,7 @@ describe('NodeTypes', () => { usableAsTool: true, properties: [], }), + supplyData: undefined, }, }; const declarativeNode: LoadedClass = { @@ -70,20 +87,38 @@ describe('NodeTypes', () => { trigger: undefined, webhook: undefined, methods: undefined, + supplyData: undefined, + }, + }; + const communityNode: LoadedClass = { + sourcePath: '', + type: { + description: mock({ + 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', () => { @@ -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); @@ -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); @@ -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 }, ]); @@ -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'); diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 80ae9850f1a25..bb0a5fbbc7458 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -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'; @@ -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. @@ -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 &&