From c2fb881d61291209802438d95892d052f5c82d43 Mon Sep 17 00:00:00 2001 From: jeanpaul Date: Tue, 15 Oct 2024 17:17:25 +0200 Subject: [PATCH] fix(core): Wrap nodes for tool use at a suitable time (#11238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- packages/cli/src/__tests__/node-types.test.ts | 100 ++++++++++++++++++ packages/cli/src/node-types.ts | 37 +++++-- 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/__tests__/node-types.test.ts diff --git a/packages/cli/src/__tests__/node-types.test.ts b/packages/cli/src/__tests__/node-types.test.ts new file mode 100644 index 0000000000..11e2c5ba2b --- /dev/null +++ b/packages/cli/src/__tests__/node-types.test.ts @@ -0,0 +1,100 @@ +import { mock } from 'jest-mock-extended'; +import type { INodeType, IVersionedNodeType } from 'n8n-workflow'; + +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; + +import { NodeTypes } from '../node-types'; + +describe('NodeTypes', () => { + let nodeTypes: NodeTypes; + const loadNodesAndCredentials = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + nodeTypes = new NodeTypes(loadNodesAndCredentials); + }); + + describe('getByNameAndVersion', () => { + const nodeTypeName = 'n8n-nodes-base.testNode'; + + it('should throw an error if the node-type does not exist', () => { + const nodeTypeName = 'unknownNode'; + + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.loadedNodes = {}; + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.knownNodes = {}; + + expect(() => nodeTypes.getByNameAndVersion(nodeTypeName)).toThrow( + 'Unrecognized node type: unknownNode', + ); + }); + + it('should return a regular node-type without version', () => { + const nodeType = mock(); + + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.loadedNodes = { + [nodeTypeName]: { type: nodeType }, + }; + + const result = nodeTypes.getByNameAndVersion(nodeTypeName); + + expect(result).toEqual(nodeType); + }); + + it('should return a regular node-type with version', () => { + const nodeTypeV1 = mock(); + const nodeType = mock({ + nodeVersions: { 1: nodeTypeV1 }, + getNodeType: () => nodeTypeV1, + }); + + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.loadedNodes = { + [nodeTypeName]: { type: nodeType }, + }; + + const result = nodeTypes.getByNameAndVersion(nodeTypeName); + + expect(result).toEqual(nodeTypeV1); + }); + + it('should throw when a node-type is requested as tool, but does not support being used as one', () => { + const nodeType = mock(); + + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.loadedNodes = { + [nodeTypeName]: { type: nodeType }, + }; + + expect(() => nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`)).toThrow( + 'Node cannot be used as a tool', + ); + }); + + it('should return the tool node-type when requested as tool', () => { + const nodeType = mock(); + // @ts-expect-error can't use a mock here + nodeType.description = { + name: nodeTypeName, + displayName: 'TestNode', + usableAsTool: true, + properties: [], + }; + + // @ts-expect-error overwriting a readonly property + loadNodesAndCredentials.loadedNodes = { + [nodeTypeName]: { type: nodeType }, + }; + + const result = nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`); + expect(result).not.toEqual(nodeType); + expect(result.description.name).toEqual('n8n-nodes-base.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']); + }); + }); +}); diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 84b406001e..26b1b61e36 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -44,15 +44,38 @@ export class NodeTypes implements INodeTypes { } getByNameAndVersion(nodeType: string, version?: number): INodeType { - const versionedNodeType = NodeHelpers.getVersionedNodeType( - this.getNode(nodeType).type, - version, - ); - if (versionedNodeType.description.usableAsTool) { - return NodeHelpers.convertNodeToAiTool(versionedNodeType); + const origType = nodeType; + const toolRequested = nodeType.startsWith('n8n-nodes-base') && nodeType.endsWith('Tool'); + // Make sure the nodeType to actually get from disk is the un-wrapped type + if (toolRequested) { + nodeType = nodeType.replace(/Tool$/, ''); } - return versionedNodeType; + const node = this.getNode(nodeType); + const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version); + if (!toolRequested) return versionedNodeType; + + if (!versionedNodeType.description.usableAsTool) + throw new ApplicationError('Node cannot be used as a tool', { extra: { nodeType } }); + + const { loadedNodes } = this.loadNodesAndCredentials; + if (origType in loadedNodes) { + return loadedNodes[origType].type as INodeType; + } + + // Instead of modifying the existing type, we extend it into a new type object + const clonedProperties = Object.create( + versionedNodeType.description.properties, + ) as INodeTypeDescription['properties']; + const clonedDescription = Object.create(versionedNodeType.description, { + properties: { value: clonedProperties }, + }) as INodeTypeDescription; + const clonedNode = Object.create(versionedNodeType, { + description: { value: clonedDescription }, + }) as INodeType; + const tool = NodeHelpers.convertNodeToAiTool(clonedNode); + loadedNodes[nodeType + 'Tool'] = { sourcePath: '', type: tool }; + return tool; } /* Some nodeTypes need to get special parameters applied like the polling nodes the polling times */