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
This commit is contained in:
Jaakko Husso
2025-04-16 09:53:53 +03:00
committed by GitHub
parent d42e61bc35
commit 302258dda2
11 changed files with 298 additions and 13 deletions

View File

@@ -599,7 +599,8 @@ describe('NDV', () => {
cy.getByTestId(`add-subnode-${group.id}`).click(); cy.getByTestId(`add-subnode-${group.id}`).click();
cy.getByTestId('nodes-list-header').contains(group.title).should('exist'); 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').should('exist');
getFloatingNodeByPosition('outputSub').click({ force: true }); getFloatingNodeByPosition('outputSub').click({ force: true });
@@ -610,7 +611,8 @@ describe('NDV', () => {
// Expand the subgroup // Expand the subgroup
cy.getByTestId('subnode-connection-group-ai_tool').click(); cy.getByTestId('subnode-connection-group-ai_tool').click();
cy.getByTestId(`add-subnode-${group.id}`).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 }); getFloatingNodeByPosition('outputSub').click({ force: true });
cy.getByTestId('subnode-connection-group-ai_tool') cy.getByTestId('subnode-connection-group-ai_tool')
.findChildByTestId('floating-subnode') .findChildByTestId('floating-subnode')
@@ -619,7 +621,7 @@ describe('NDV', () => {
}); });
// Since language model has no credentials set, it should show an error // 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); cy.get('[class*=hasIssues]').should('have.length', 3);
}); });

View File

@@ -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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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);
});
});
});

View File

@@ -20,6 +20,7 @@ import {
buildJsonSchemaExampleField, buildJsonSchemaExampleField,
schemaTypeField, schemaTypeField,
} from '@utils/descriptions'; } from '@utils/descriptions';
import { nodeNameToToolName } from '@utils/helpers';
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing'; import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { getConnectionHintNoticeField } from '@utils/sharedFields';
@@ -32,7 +33,7 @@ export class ToolCode implements INodeType {
icon: 'fa:code', icon: 'fa:code',
iconColor: 'black', iconColor: 'black',
group: ['transform'], group: ['transform'],
version: [1, 1.1], version: [1, 1.1, 1.2],
description: 'Write a tool in JS or Python', description: 'Write a tool in JS or Python',
defaults: { defaults: {
name: 'Code Tool', 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', 'The name of the function to be called, could contain letters, numbers, and underscores only',
displayOptions: { displayOptions: {
show: { show: {
'@version': [{ _cnd: { gte: 1.1 } }], '@version': [1.1],
}, },
}, },
}, },
@@ -181,7 +182,12 @@ export class ToolCode implements INodeType {
const node = this.getNode(); const node = this.getNode();
const workflowMode = this.getMode(); 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 description = this.getNodeParameter('description', itemIndex) as string;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;

View File

@@ -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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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');
});
});
});

View File

@@ -10,6 +10,7 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow';
import { nodeNameToToolName } from '@utils/helpers';
import { logWrapper } from '@utils/logWrapper'; import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { getConnectionHintNoticeField } from '@utils/sharedFields';
@@ -20,7 +21,7 @@ export class ToolVectorStore implements INodeType {
icon: 'fa:database', icon: 'fa:database',
iconColor: 'black', iconColor: 'black',
group: ['transform'], group: ['transform'],
version: [1], version: [1, 1.1],
description: 'Answer questions with a vector store', description: 'Answer questions with a vector store',
defaults: { defaults: {
name: 'Answer questions with a vector store', name: 'Answer questions with a vector store',
@@ -68,6 +69,11 @@ export class ToolVectorStore implements INodeType {
validateType: 'string-alphanumeric', validateType: 'string-alphanumeric',
description: 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.', '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', displayName: 'Description of Data',
@@ -92,7 +98,12 @@ export class ToolVectorStore implements INodeType {
}; };
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
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 toolDescription = this.getNodeParameter('description', itemIndex) as string;
const topK = this.getNodeParameter('topK', itemIndex, 4) as number; const topK = this.getNodeParameter('topK', itemIndex, 4) as number;

View File

@@ -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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ 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);
});
});
});

View File

@@ -28,7 +28,7 @@ export class ToolWorkflow extends VersionedNodeType {
], ],
}, },
}, },
defaultVersion: 2.1, defaultVersion: 2.2,
}; };
const nodeVersions: IVersionedNodeType['nodeVersions'] = { const nodeVersions: IVersionedNodeType['nodeVersions'] = {
@@ -38,6 +38,7 @@ export class ToolWorkflow extends VersionedNodeType {
1.3: new ToolWorkflowV1(baseDescription), 1.3: new ToolWorkflowV1(baseDescription),
2: new ToolWorkflowV2(baseDescription), 2: new ToolWorkflowV2(baseDescription),
2.1: new ToolWorkflowV2(baseDescription), 2.1: new ToolWorkflowV2(baseDescription),
2.2: new ToolWorkflowV2(baseDescription),
}; };
super(nodeVersions, baseDescription); super(nodeVersions, baseDescription);
} }

View File

@@ -6,6 +6,8 @@ import type {
INodeTypeDescription, INodeTypeDescription,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { nodeNameToToolName } from '@utils/helpers';
import { localResourceMapping } from './methods'; import { localResourceMapping } from './methods';
import { WorkflowToolService } from './utils/WorkflowToolService'; import { WorkflowToolService } from './utils/WorkflowToolService';
import { versionDescription } from './versionDescription'; import { versionDescription } from './versionDescription';
@@ -25,10 +27,15 @@ export class ToolWorkflowV2 implements INodeType {
}; };
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const returnAllItems = this.getNode().typeVersion > 2; const node = this.getNode();
const { typeVersion } = node;
const returnAllItems = typeVersion > 2;
const workflowToolService = new WorkflowToolService(this, { returnAllItems }); 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 description = this.getNodeParameter('description', itemIndex) as string;
const tool = await workflowToolService.createTool({ const tool = await workflowToolService.createTool({

View File

@@ -12,7 +12,7 @@ export const versionDescription: INodeTypeDescription = {
defaults: { defaults: {
name: 'Call n8n Workflow Tool', name: 'Call n8n Workflow Tool',
}, },
version: [2, 2.1], version: [2, 2.1, 2.2],
inputs: [], inputs: [],
outputs: [NodeConnectionTypes.AiTool], outputs: [NodeConnectionTypes.AiTool],
outputNames: ['Tool'], outputNames: ['Tool'],
@@ -34,6 +34,11 @@ export const versionDescription: INodeTypeDescription = {
validateType: 'string-alphanumeric', validateType: 'string-alphanumeric',
description: description:
'The name of the function to be called, could contain letters, numbers, and underscores only', 'The name of the function to be called, could contain letters, numbers, and underscores only',
displayOptions: {
show: {
'@version': [{ _cnd: { lte: 2.1 } }],
},
},
}, },
{ {
displayName: 'Description', displayName: 'Description',

View File

@@ -8,6 +8,7 @@ import type { BaseChatMemory } from 'langchain/memory';
import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError, jsonStringify } from 'n8n-workflow';
import type { import type {
AiEvent, AiEvent,
INode,
IDataObject, IDataObject,
IExecuteFunctions, IExecuteFunctions,
ISupplyDataFunctions, ISupplyDataFunctions,
@@ -249,3 +250,7 @@ export function unwrapNestedOutput(output: Record<string, unknown>): Record<stri
return output; return output;
} }
export function nodeNameToToolName(node: INode): string {
return node.name.replace(/ /g, '_');
}

View File

@@ -111,6 +111,10 @@ function makeDescription(node: INode, nodeType: INodeType): string {
return nodeType.description.description; return nodeType.description.description;
} }
export function nodeNameToToolName(node: INode): string {
return node.name.replace(/ /g, '_');
}
/** /**
* Creates a DynamicStructuredTool from a node. * Creates a DynamicStructuredTool from a node.
* @returns A DynamicStructuredTool instance. * @returns A DynamicStructuredTool instance.
@@ -119,7 +123,7 @@ function createTool(options: CreateNodeAsToolOptions) {
const { node, nodeType, handleToolInvocation } = options; const { node, nodeType, handleToolInvocation } = options;
const schema = getSchema(node); const schema = getSchema(node);
const description = makeDescription(node, nodeType); const description = makeDescription(node, nodeType);
const nodeName = node.name.replace(/ /g, '_'); const nodeName = nodeNameToToolName(node);
const name = nodeName || nodeType.description.name; const name = nodeName || nodeType.description.name;
return new DynamicStructuredTool({ return new DynamicStructuredTool({