mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add an option to allow community nodes as tools (#13075)
This commit is contained in:
parent
ff8b1c1108
commit
2b133aa201
|
@ -33,6 +33,10 @@ class CommunityPackagesConfig {
|
||||||
/** Whether to reinstall any missing community packages */
|
/** Whether to reinstall any missing community packages */
|
||||||
@Env('N8N_REINSTALL_MISSING_PACKAGES')
|
@Env('N8N_REINSTALL_MISSING_PACKAGES')
|
||||||
reinstallMissing: boolean = false;
|
reinstallMissing: boolean = false;
|
||||||
|
|
||||||
|
/** Whether to allow community packages as tools for AI agents */
|
||||||
|
@Env('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE')
|
||||||
|
allowToolUsage: boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
|
|
|
@ -119,6 +119,7 @@ describe('GlobalConfig', () => {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
registry: 'https://registry.npmjs.org',
|
registry: 'https://registry.npmjs.org',
|
||||||
reinstallMissing: false,
|
reinstallMissing: false,
|
||||||
|
allowToolUsage: false,
|
||||||
},
|
},
|
||||||
errorTriggerType: 'n8n-nodes-base.errorTrigger',
|
errorTriggerType: 'n8n-nodes-base.errorTrigger',
|
||||||
include: [],
|
include: [],
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
|
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
|
@ -11,11 +12,14 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
|
||||||
describe('NodeTypes', () => {
|
describe('NodeTypes', () => {
|
||||||
|
const globalConfig = mock<GlobalConfig>({
|
||||||
|
nodes: { communityPackages: { allowToolUsage: false } },
|
||||||
|
});
|
||||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
|
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
|
||||||
convertNodeToAiTool: LoadNodesAndCredentials.prototype.convertNodeToAiTool,
|
convertNodeToAiTool: LoadNodesAndCredentials.prototype.convertNodeToAiTool,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials);
|
const nodeTypes: NodeTypes = new NodeTypes(globalConfig, loadNodesAndCredentials);
|
||||||
|
|
||||||
const nonVersionedNode: LoadedClass<INodeType> = {
|
const nonVersionedNode: LoadedClass<INodeType> = {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
|
@ -24,10 +28,11 @@ describe('NodeTypes', () => {
|
||||||
name: 'n8n-nodes-base.nonVersioned',
|
name: 'n8n-nodes-base.nonVersioned',
|
||||||
usableAsTool: undefined,
|
usableAsTool: undefined,
|
||||||
}),
|
}),
|
||||||
|
supplyData: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const v1Node = mock<INodeType>();
|
const v1Node = mock<INodeType>({ supplyData: undefined });
|
||||||
const v2Node = mock<INodeType>();
|
const v2Node = mock<INodeType>({ supplyData: undefined });
|
||||||
const versionedNode: LoadedClass<IVersionedNodeType> = {
|
const versionedNode: LoadedClass<IVersionedNodeType> = {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
|
@ -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> = {
|
const toolSupportingNode: LoadedClass<INodeType> = {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
|
@ -54,6 +70,7 @@ describe('NodeTypes', () => {
|
||||||
usableAsTool: true,
|
usableAsTool: true,
|
||||||
properties: [],
|
properties: [],
|
||||||
}),
|
}),
|
||||||
|
supplyData: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const declarativeNode: LoadedClass<INodeType> = {
|
const declarativeNode: LoadedClass<INodeType> = {
|
||||||
|
@ -70,20 +87,38 @@ describe('NodeTypes', () => {
|
||||||
trigger: undefined,
|
trigger: undefined,
|
||||||
webhook: undefined,
|
webhook: undefined,
|
||||||
methods: 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) => {
|
loadNodesAndCredentials.getNode.mockImplementation((fullNodeType) => {
|
||||||
const [packageName, nodeType] = fullNodeType.split('.');
|
const [packageName, nodeType] = fullNodeType.split('.');
|
||||||
if (nodeType === 'nonVersioned') return nonVersionedNode;
|
if (packageName === 'n8n-nodes-base') {
|
||||||
if (nodeType === 'versioned') return versionedNode;
|
if (nodeType === 'nonVersioned') return nonVersionedNode;
|
||||||
if (nodeType === 'testNode') return toolSupportingNode;
|
if (nodeType === 'versioned') return versionedNode;
|
||||||
if (nodeType === 'declarativeNode') return declarativeNode;
|
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);
|
throw new UnrecognizedNodeTypeError(packageName, nodeType);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
globalConfig.nodes.communityPackages.allowToolUsage = false;
|
||||||
|
loadNodesAndCredentials.loaded.nodes = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getByName', () => {
|
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', () => {
|
it('should return the tool node-type when requested as tool', () => {
|
||||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
|
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
|
||||||
expect(result).not.toEqual(toolSupportingNode.type);
|
expect(result).not.toEqual(toolSupportingNode.type);
|
||||||
|
@ -132,6 +173,23 @@ describe('NodeTypes', () => {
|
||||||
expect(result.description.outputs).toEqual(['ai_tool']);
|
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', () => {
|
it('should return a declarative node-type with an `.execute` method', () => {
|
||||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.declarativeNode');
|
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.declarativeNode');
|
||||||
expect(result).toBe(declarativeNode.type);
|
expect(result).toBe(declarativeNode.type);
|
||||||
|
@ -184,7 +242,6 @@ describe('NodeTypes', () => {
|
||||||
|
|
||||||
describe('getNodeTypeDescriptions', () => {
|
describe('getNodeTypeDescriptions', () => {
|
||||||
it('should return descriptions for valid node types', () => {
|
it('should return descriptions for valid node types', () => {
|
||||||
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
|
|
||||||
const result = nodeTypes.getNodeTypeDescriptions([
|
const result = nodeTypes.getNodeTypeDescriptions([
|
||||||
{ name: 'n8n-nodes-base.nonVersioned', version: 1 },
|
{ name: 'n8n-nodes-base.nonVersioned', version: 1 },
|
||||||
]);
|
]);
|
||||||
|
@ -194,7 +251,6 @@ describe('NodeTypes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for invalid node type', () => {
|
it('should throw error for invalid node type', () => {
|
||||||
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]),
|
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]),
|
||||||
).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent');
|
).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent');
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { NeededNodeType } from '@n8n/task-runner';
|
import type { NeededNodeType } from '@n8n/task-runner';
|
||||||
import type { Dirent } from 'fs';
|
import type { Dirent } from 'fs';
|
||||||
|
@ -12,7 +13,10 @@ import { LoadNodesAndCredentials } from './load-nodes-and-credentials';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class NodeTypes implements INodeTypes {
|
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.
|
* 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 {
|
getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||||
const origType = nodeType;
|
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
|
// Make sure the nodeType to actually get from disk is the un-wrapped type
|
||||||
if (toolRequested) {
|
if (allowToolUsage && toolRequested) {
|
||||||
nodeType = nodeType.replace(/Tool$/, '');
|
nodeType = nodeType.replace(/Tool$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = this.loadNodesAndCredentials.getNode(nodeType);
|
const node = this.loadNodesAndCredentials.getNode(nodeType);
|
||||||
const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version);
|
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 (
|
if (
|
||||||
!versionedNodeType.execute &&
|
!versionedNodeType.execute &&
|
||||||
!versionedNodeType.poll &&
|
!versionedNodeType.poll &&
|
||||||
|
|
Loading…
Reference in a new issue