From 302258dda2d6bb9c8712cb5fbc5d36dd91c6c261 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Wed, 16 Apr 2025 09:53:53 +0300 Subject: [PATCH] feat(editor): Align `DynamicStructuredTool` and `DynamicTool` name fields (#14604) feat(Code Tool Node): Use node's name instead of separate name field as tool name feat(Vector Store Tool Node): Use node's name instead of separate name field as tool name feat(Custom n8n Workflow Tool Node): Use node's name instead of separate name field as tool name --- cypress/e2e/5-ndv.cy.ts | 8 +- .../tools/ToolCode/ToolCode.node.test.ts | 81 +++++++++++++++++ .../nodes/tools/ToolCode/ToolCode.node.ts | 12 ++- .../ToolVectorStore.node.test.ts | 91 +++++++++++++++++++ .../ToolVectorStore/ToolVectorStore.node.ts | 15 ++- .../ToolWorkflow/ToolWorkflow.node.test.ts | 72 +++++++++++++++ .../tools/ToolWorkflow/ToolWorkflow.node.ts | 3 +- .../ToolWorkflow/v2/ToolWorkflowV2.node.ts | 11 ++- .../ToolWorkflow/v2/versionDescription.ts | 7 +- .../@n8n/nodes-langchain/utils/helpers.ts | 5 + .../utils/create-node-as-tool.ts | 6 +- 11 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 4b67a0d988..436e11f855 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -599,7 +599,8 @@ describe('NDV', () => { cy.getByTestId(`add-subnode-${group.id}`).click(); cy.getByTestId('nodes-list-header').contains(group.title).should('exist'); - nodeCreator.getters.getNthCreatorItem(1).click(); + // Add HTTP Request tool + nodeCreator.getters.getNthCreatorItem(2).click(); getFloatingNodeByPosition('outputSub').should('exist'); getFloatingNodeByPosition('outputSub').click({ force: true }); @@ -610,7 +611,8 @@ describe('NDV', () => { // Expand the subgroup cy.getByTestId('subnode-connection-group-ai_tool').click(); cy.getByTestId(`add-subnode-${group.id}`).click(); - nodeCreator.getters.getNthCreatorItem(1).click(); + // Add HTTP Request tool + nodeCreator.getters.getNthCreatorItem(2).click(); getFloatingNodeByPosition('outputSub').click({ force: true }); cy.getByTestId('subnode-connection-group-ai_tool') .findChildByTestId('floating-subnode') @@ -619,7 +621,7 @@ describe('NDV', () => { }); // Since language model has no credentials set, it should show an error - // Sinse code tool require alphanumeric tool name it would also show an error(2 errors, 1 for each tool node) + // Since HTTP Request tool requires URL it would also show an error(2 errors, 1 for each tool node) cy.get('[class*=hasIssues]').should('have.length', 3); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts new file mode 100644 index 0000000000..93c09e9a14 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts @@ -0,0 +1,81 @@ +import { mock } from 'jest-mock-extended'; +import { DynamicTool } from 'langchain/tools'; +import { type INode, type ISupplyDataFunctions } from 'n8n-workflow'; + +import { ToolCode } from './ToolCode.node'; + +describe('ToolCode', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should read name from node name on version >=1.2', async () => { + const node = new ToolCode(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'wrong_field'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return 1;'; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(DynamicTool); + + const tool = supplyDataResult.response as DynamicTool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toBe('description text'); + expect(tool.func).toBeInstanceOf(Function); + }); + + it('should read name from name parameter on version <1.2', async () => { + const node = new ToolCode(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1.1, name: 'wrong name' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'test_tool'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return 1;'; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(DynamicTool); + + const tool = supplyDataResult.response as DynamicTool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toBe('description text'); + expect(tool.func).toBeInstanceOf(Function); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index 02dec8cc71..f4de8c370f 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -20,6 +20,7 @@ import { buildJsonSchemaExampleField, schemaTypeField, } from '@utils/descriptions'; +import { nodeNameToToolName } from '@utils/helpers'; import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; @@ -32,7 +33,7 @@ export class ToolCode implements INodeType { icon: 'fa:code', iconColor: 'black', group: ['transform'], - version: [1, 1.1], + version: [1, 1.1, 1.2], description: 'Write a tool in JS or Python', defaults: { name: 'Code Tool', @@ -88,7 +89,7 @@ export class ToolCode implements INodeType { 'The name of the function to be called, could contain letters, numbers, and underscores only', displayOptions: { show: { - '@version': [{ _cnd: { gte: 1.1 } }], + '@version': [1.1], }, }, }, @@ -181,7 +182,12 @@ export class ToolCode implements INodeType { const node = this.getNode(); const workflowMode = this.getMode(); - const name = this.getNodeParameter('name', itemIndex) as string; + const { typeVersion } = node; + const name = + typeVersion <= 1.1 + ? (this.getNodeParameter('name', itemIndex) as string) + : nodeNameToToolName(node); + const description = this.getNodeParameter('description', itemIndex) as string; const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts new file mode 100644 index 0000000000..cefd873b3f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts @@ -0,0 +1,91 @@ +import { mock } from 'jest-mock-extended'; +import { VectorStoreQATool } from 'langchain/tools'; +import { NodeConnectionTypes, type INode, type ISupplyDataFunctions } from 'n8n-workflow'; + +import { ToolVectorStore } from './ToolVectorStore.node'; + +describe('ToolVectorStore', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should read name from node name on version >=1.1', async () => { + const node = new ToolVectorStore(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'name': + return 'wrong_field'; + case 'topK': + return 4; + default: + return; + } + }), + getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => { + switch (inputName) { + case NodeConnectionTypes.AiVectorStore: + return jest.fn(); + case NodeConnectionTypes.AiLanguageModel: + return { + _modelType: jest.fn(), + }; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(VectorStoreQATool); + + const tool = supplyDataResult.response as VectorStoreQATool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toContain('test_tool'); + }); + + it('should read name from name parameter on version <1.2', async () => { + const node = new ToolVectorStore(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 1, name: 'wrong name' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'name': + return 'test_tool'; + case 'topK': + return 4; + default: + return; + } + }), + getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => { + switch (inputName) { + case NodeConnectionTypes.AiVectorStore: + return jest.fn(); + case NodeConnectionTypes.AiLanguageModel: + return { + _modelType: jest.fn(), + }; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(VectorStoreQATool); + + const tool = supplyDataResult.response as VectorStoreQATool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toContain('test_tool'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts index 7a9e4a7f1a..fe66036872 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -10,6 +10,7 @@ import type { } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { nodeNameToToolName } from '@utils/helpers'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; @@ -20,7 +21,7 @@ export class ToolVectorStore implements INodeType { icon: 'fa:database', iconColor: 'black', group: ['transform'], - version: [1], + version: [1, 1.1], description: 'Answer questions with a vector store', defaults: { name: 'Answer questions with a vector store', @@ -68,6 +69,11 @@ export class ToolVectorStore implements INodeType { validateType: 'string-alphanumeric', description: 'Name of the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.', + displayOptions: { + show: { + '@version': [1], + }, + }, }, { displayName: 'Description of Data', @@ -92,7 +98,12 @@ export class ToolVectorStore implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const name = this.getNodeParameter('name', itemIndex) as string; + const node = this.getNode(); + const { typeVersion } = node; + const name = + typeVersion <= 1 + ? (this.getNodeParameter('name', itemIndex) as string) + : nodeNameToToolName(node); const toolDescription = this.getNodeParameter('description', itemIndex) as string; const topK = this.getNodeParameter('topK', itemIndex, 4) as number; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts new file mode 100644 index 0000000000..d1508fa969 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.test.ts @@ -0,0 +1,72 @@ +import { mock } from 'jest-mock-extended'; +import { DynamicTool } from 'langchain/tools'; +import { type INode, type ISupplyDataFunctions } from 'n8n-workflow'; + +import { ToolWorkflow } from './ToolWorkflow.node'; +import type { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node'; + +describe('ToolWorkflowV2', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should read name from node name on version >=2.2', async () => { + const toolWorkflowNode = new ToolWorkflow(); + const node = toolWorkflowNode.nodeVersions[2.2] as ToolWorkflowV2; + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 2.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'wrong_field'; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(DynamicTool); + + const tool = supplyDataResult.response as DynamicTool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toBe('description text'); + expect(tool.func).toBeInstanceOf(Function); + }); + + it('should read name from name parameter on version <2.2', async () => { + const toolWorkflowNode = new ToolWorkflow(); + const node = toolWorkflowNode.nodeVersions[2.1] as ToolWorkflowV2; + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ typeVersion: 2.1, name: 'wrong name' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'test_tool'; + default: + return; + } + }), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(DynamicTool); + + const tool = supplyDataResult.response as DynamicTool; + expect(tool.name).toBe('test_tool'); + expect(tool.description).toBe('description text'); + expect(tool.func).toBeInstanceOf(Function); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 295060de21..b9feecd42b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -28,7 +28,7 @@ export class ToolWorkflow extends VersionedNodeType { ], }, }, - defaultVersion: 2.1, + defaultVersion: 2.2, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { @@ -38,6 +38,7 @@ export class ToolWorkflow extends VersionedNodeType { 1.3: new ToolWorkflowV1(baseDescription), 2: new ToolWorkflowV2(baseDescription), 2.1: new ToolWorkflowV2(baseDescription), + 2.2: new ToolWorkflowV2(baseDescription), }; super(nodeVersions, baseDescription); } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts index df682ce040..9efd6bb625 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -6,6 +6,8 @@ import type { INodeTypeDescription, } from 'n8n-workflow'; +import { nodeNameToToolName } from '@utils/helpers'; + import { localResourceMapping } from './methods'; import { WorkflowToolService } from './utils/WorkflowToolService'; import { versionDescription } from './versionDescription'; @@ -25,10 +27,15 @@ export class ToolWorkflowV2 implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const returnAllItems = this.getNode().typeVersion > 2; + const node = this.getNode(); + const { typeVersion } = node; + const returnAllItems = typeVersion > 2; const workflowToolService = new WorkflowToolService(this, { returnAllItems }); - const name = this.getNodeParameter('name', itemIndex) as string; + const name = + typeVersion <= 2.1 + ? (this.getNodeParameter('name', itemIndex) as string) + : nodeNameToToolName(node); const description = this.getNodeParameter('description', itemIndex) as string; const tool = await workflowToolService.createTool({ diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts index 2004a9337f..6e33e46e89 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -12,7 +12,7 @@ export const versionDescription: INodeTypeDescription = { defaults: { name: 'Call n8n Workflow Tool', }, - version: [2, 2.1], + version: [2, 2.1, 2.2], inputs: [], outputs: [NodeConnectionTypes.AiTool], outputNames: ['Tool'], @@ -34,6 +34,11 @@ export const versionDescription: INodeTypeDescription = { validateType: 'string-alphanumeric', description: 'The name of the function to be called, could contain letters, numbers, and underscores only', + displayOptions: { + show: { + '@version': [{ _cnd: { lte: 2.1 } }], + }, + }, }, { displayName: 'Description', diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 92fea1b285..994f247fe9 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -8,6 +8,7 @@ import type { BaseChatMemory } from 'langchain/memory'; import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { AiEvent, + INode, IDataObject, IExecuteFunctions, ISupplyDataFunctions, @@ -249,3 +250,7 @@ export function unwrapNestedOutput(output: Record): Record