diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index bd7a501fdc..ec1de3e14b 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -325,7 +325,6 @@ describe('LoadNodesAndCredentials', () => { 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', displayName: 'Description', name: 'toolDescription', - placeholder: 'e.g. A test node', required: true, type: 'string', typeOptions: { @@ -380,7 +379,6 @@ describe('LoadNodesAndCredentials', () => { typeOptions: { rows: 2 }, description: 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', - placeholder: 'e.g. A test node', }, ], codex: { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 86a00572f7..2d233dcc13 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -484,7 +484,6 @@ export class LoadNodesAndCredentials { typeOptions: { rows: 2 }, description: 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', - placeholder: `e.g. ${item.description.description}`, }; item.description.properties.unshift(descProp); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts index c43b54de80..1a014933bf 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts @@ -25,6 +25,10 @@ describe('createNodeAsTool', () => { description: { name: 'TestNode', description: 'Test node description', + defaults: { + name: 'Test Node', + }, + properties: [], }, }); const node = mock({ name: 'Test_Node' }); @@ -54,9 +58,7 @@ describe('createNodeAsTool', () => { expect(tool).toBeDefined(); expect(tool.name).toBe('Test_Node'); - expect(tool.description).toBe( - 'Test node description\n Resource: testResource\n Operation: testOperation', - ); + expect(tool.description).toBe('testOperation testResource in Test Node'); expect(tool.schema).toBeDefined(); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts b/packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts index d71b18626f..5ae12ec6ba 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts @@ -1,6 +1,11 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; -import { generateZodSchema, NodeOperationError, traverseNodeParameters } from 'n8n-workflow'; import type { IDataObject, INode, INodeType, FromAIArgument } from 'n8n-workflow'; +import { + generateZodSchema, + NodeOperationError, + traverseNodeParameters, + NodeHelpers, +} from 'n8n-workflow'; import { z } from 'zod'; export type CreateNodeAsToolOptions = { @@ -83,31 +88,6 @@ function getSchema(node: INode) { return z.object(schemaObj).required(); } -/** - * Generates a description for a node based on the provided parameters. - * @param node The node type. - * @param nodeParameters The parameters of the node. - * @returns A string description for the node. - */ -function makeDescription(node: INode, nodeType: INodeType): string { - if (node.parameters.descriptionType === 'auto') { - const resource = node.parameters.resource as string; - const operation = node.parameters.operation as string; - let description = nodeType.description.description; - if (resource) { - description += `\n Resource: ${resource}`; - } - if (operation) { - description += `\n Operation: ${operation}`; - } - return description.trim(); - } - - // Users can define custom descriptions when `descriptionType` is manual or not included - // in the node's properties, e.g. when the node has neither `operation` or `resource` - return (node.parameters.toolDescription as string) ?? nodeType.description.description; -} - /** * Converts a node name to a valid tool name by replacing special characters with underscores * and collapsing consecutive underscores into a single one. @@ -123,8 +103,9 @@ export function nodeNameToToolName(node: INode): string { */ function createTool(options: CreateNodeAsToolOptions) { const { node, nodeType, handleToolInvocation } = options; + const schema = getSchema(node); - const description = makeDescription(node, nodeType); + const description = NodeHelpers.getToolDescriptionForNode(node, nodeType); const nodeName = nodeNameToToolName(node); const name = nodeName || nodeType.description.name; diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue index bc0aba7b46..3083c25175 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue @@ -497,6 +497,7 @@ const valueChanged = (parameterData: IUpdateInformation) => { _node, nodeType, ); + const oldNodeParameters = Object.assign({}, nodeParameters); // Copy the data because it is the data of vuex so make sure that @@ -547,6 +548,18 @@ const valueChanged = (parameterData: IUpdateInformation) => { nodeType, ); + if (isToolNode.value) { + const updatedDescription = NodeHelpers.getUpdatedToolDescription( + props.nodeType, + nodeParameters, + node.value?.parameters, + ); + + if (updatedDescription && nodeParameters) { + nodeParameters.toolDescription = updatedDescription; + } + } + for (const key of Object.keys(nodeParameters as object)) { if (nodeParameters && nodeParameters[key] !== null && nodeParameters[key] !== undefined) { setValue(`parameters.${key}`, nodeParameters[key] as string); diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index f92f974bdd..a62db3a231 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -40,6 +40,7 @@ import type { import { validateFilterParameter } from './NodeParameters/FilterParameter'; import { isFilterValue, + isINodePropertyOptionsList, isResourceLocatorValue, isResourceMapperValue, isValidResourceLocatorParameterValue, @@ -1569,6 +1570,91 @@ export function isNodeWithWorkflowSelector(node: INode) { return [EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE].includes(node.type); } +/** + * Generates a human-readable description for a node based on its parameters and type definition. + * + * This function creates a descriptive string that represents what the node does, + * based on its resource, operation, and node type information. The description is + * formatted in one of the following ways: + * + * 1. "{action} in {displayName}" if the operation has a defined action + * 2. "{operation} {resource} in {displayName}" if resource and operation exist + * 3. The node type's description field as a fallback + */ +export function makeDescription( + nodeParameters: INodeParameters, + nodeTypeDescription: INodeTypeDescription, +): string { + let description = ''; + const resource = nodeParameters.resource as string; + const operation = nodeParameters.operation as string; + const nodeTypeOperation = nodeTypeDescription.properties.find( + (p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource), + ); + + if (nodeTypeOperation?.options && isINodePropertyOptionsList(nodeTypeOperation.options)) { + const foundOperation = nodeTypeOperation.options.find((option) => option.value === operation); + if (foundOperation?.action) { + description = `${foundOperation.action} in ${nodeTypeDescription.defaults.name}`; + return description; + } + } + + if (!description && resource && operation) { + description = `${operation} ${resource} in ${nodeTypeDescription.defaults.name}`; + } else { + description = nodeTypeDescription.description; + } + + return description; +} + +/** + * Determines whether a tool description should be updated and returns the new description if needed. + * Returns undefined if no update is needed. + */ + +export const getUpdatedToolDescription = ( + currentNodeType: INodeTypeDescription | null, + newParameters: INodeParameters | null, + currentParameters?: INodeParameters, +) => { + if (!currentNodeType) return; + + if (newParameters?.descriptionType === 'manual' && currentParameters) { + const previousDescription = makeDescription(currentParameters, currentNodeType); + const newDescription = makeDescription(newParameters, currentNodeType); + + if ( + newParameters.toolDescription === previousDescription || + !newParameters.toolDescription?.toString().trim() || + newParameters.toolDescription === currentNodeType.description + ) { + return newDescription; + } + } + + return; +}; + +/** + * Generates a tool description for a given node based on its parameters and type. + */ +export function getToolDescriptionForNode(node: INode, nodeType: INodeType): string { + let toolDescription; + if ( + node.parameters.descriptionType === 'auto' || + !node?.parameters.toolDescription?.toString().trim() + ) { + toolDescription = makeDescription(node.parameters, nodeType.description); + } else if (node?.parameters.toolDescription) { + toolDescription = node.parameters.toolDescription; + } else { + toolDescription = nodeType.description.description; + } + return toolDescription as string; +} + /** * Attempts to retrieve the ID of a subworkflow from a execute workflow node. */ diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index 7bd5a7db34..c022c1d2d5 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -1,3 +1,4 @@ +import type { INodeType } from '@/index'; import { NodeConnectionTypes, type NodeConnectionType, @@ -14,6 +15,9 @@ import { isTriggerNode, isExecutable, displayParameter, + makeDescription, + getUpdatedToolDescription, + getToolDescriptionForNode, } from '@/NodeHelpers'; import type { Workflow } from '@/Workflow'; @@ -4774,4 +4778,472 @@ describe('NodeHelpers', () => { }); } }); + + describe('makeDescription', () => { + let mockNodeTypeDescription: INodeTypeDescription; + + beforeEach(() => { + // Arrange a basic mock node type description + mockNodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + icon: 'fa:test', + group: ['transform'], + version: 1, + description: 'This is a test node', + defaults: { + name: 'Test Node', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }; + }); + + test('should return action-based description when action is available', () => { + // Arrange + const nodeParameters: INodeParameters = { + resource: 'user', + operation: 'create', + }; + + mockNodeTypeDescription.properties = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a new user', + }, + ], + default: 'create', + }, + ]; + + // Act + const result = makeDescription(nodeParameters, mockNodeTypeDescription); + + // Assert + expect(result).toBe('Create a new user in Test Node'); + }); + + test('should return resource-operation-based description when action is not available', () => { + // Arrange + const nodeParameters: INodeParameters = { + resource: 'user', + operation: 'create', + }; + + mockNodeTypeDescription.properties = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + // No action property + }, + ], + default: 'create', + }, + ]; + + // Act + const result = makeDescription(nodeParameters, mockNodeTypeDescription); + + // Assert + expect(result).toBe('create user in Test Node'); + }); + + test('should return default description when resource or operation is missing', () => { + // Arrange + const nodeParameters: INodeParameters = { + // No resource or operation + }; + + // Act + const result = makeDescription(nodeParameters, mockNodeTypeDescription); + + // Assert + expect(result).toBe('This is a test node'); + }); + + test('should handle case where nodeTypeOperation is not found', () => { + // Arrange + const nodeParameters: INodeParameters = { + resource: 'user', + operation: 'create', + }; + + mockNodeTypeDescription.properties = [ + // No matching operation property + ]; + + // Act + const result = makeDescription(nodeParameters, mockNodeTypeDescription); + + // Assert + expect(result).toBe('create user in Test Node'); + }); + + test('should handle case where options are not a list of INodePropertyOptions', () => { + // Arrange + const nodeParameters: INodeParameters = { + resource: 'user', + operation: 'create', + }; + + mockNodeTypeDescription.properties = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + // Options are not INodePropertyOptions[] + options: [ + //@ts-expect-error + {}, + ], + default: 'create', + }, + ]; + + // Act + const result = makeDescription(nodeParameters, mockNodeTypeDescription); + + // Assert + expect(result).toBe('create user in Test Node'); + }); + }); + + describe('getUpdatedToolDescription', () => { + let mockNodeTypeDescription: INodeTypeDescription; + + beforeEach(() => { + // Arrange a basic mock node type description + mockNodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + icon: 'fa:test', + group: ['transform'], + version: 1, + description: 'This is a test node', + defaults: { + name: 'Test Node', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + usableAsTool: true, + }; + }); + + test('should return undefined when descriptionType is not manual', () => { + // Arrange + const newParameters: INodeParameters = { + descriptionType: 'automatic', + resource: 'user', + operation: 'create', + }; + const currentParameters: INodeParameters = { + descriptionType: 'automatic', + resource: 'user', + operation: 'create', + }; + + // Act + const result = getUpdatedToolDescription( + mockNodeTypeDescription, + newParameters, + currentParameters, + ); + + // Assert + expect(result).toBeUndefined(); + }); + + test('should return new description when toolDescription matches previous description', () => { + // Arrange + mockNodeTypeDescription.properties = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a new user', + }, + ], + default: 'create', + }, + ]; + + const currentParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + }; + + const newParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'update', + toolDescription: 'Create a new user in Test Node', // Matches the previous description + }; + + // Act + const result = getUpdatedToolDescription( + mockNodeTypeDescription, + newParameters, + currentParameters, + ); + + // Assert + expect(result).toBe('update user in Test Node'); + }); + + test('should return new description when toolDescription matches node type description', () => { + // Arrange + const currentParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + }; + + const newParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'update', + toolDescription: 'This is a test node', // Matches the node type description + }; + + // Act + const result = getUpdatedToolDescription( + mockNodeTypeDescription, + newParameters, + currentParameters, + ); + + // Assert + expect(result).toBe('update user in Test Node'); + }); + + test('should return undefined when toolDescription is custom', () => { + // Arrange + const currentParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + }; + + const newParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'update', + toolDescription: 'My custom description', // Custom description + }; + + // Act + const result = getUpdatedToolDescription( + mockNodeTypeDescription, + newParameters, + currentParameters, + ); + + // Assert + expect(result).toBeUndefined(); + }); + + test('should return undefined for null inputs', () => { + // Act + const result = getUpdatedToolDescription(null, null); + + // Assert + expect(result).toBeUndefined(); + }); + + test('should return new description when toolDescription is empty or whitespace', () => { + // Arrange + const currentParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + }; + + const newParameters: INodeParameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'update', + toolDescription: ' ', // Empty/whitespace description + }; + + // Act + const result = getUpdatedToolDescription( + mockNodeTypeDescription, + newParameters, + currentParameters, + ); + + // Assert + expect(result).toBe('update user in Test Node'); + }); + }); + + describe('getToolDescriptionForNode', () => { + let mockNode: INode; + let mockNodeType: INodeType; + + beforeEach(() => { + // Arrange a basic mock node + mockNode = { + id: 'test-node-id', + name: 'Test Node', + typeVersion: 1, + type: 'test-node-type', + position: [0, 0], + parameters: {}, + }; + + // Arrange a basic mock node type + mockNodeType = { + description: { + displayName: 'Test Node Type', + name: 'testNodeType', + icon: 'fa:test', + group: ['transform'], + version: 1, + description: 'This is the default node description', + defaults: { + name: 'Test Node Type', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }, + } as INodeType; + }); + + test('should use generated description when descriptionType is auto', () => { + // Arrange + mockNode.parameters = { + descriptionType: 'auto', + resource: 'user', + operation: 'create', + }; + + mockNodeType.description.properties = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a new user', + }, + ], + default: 'create', + }, + ]; + + // Act + const result = getToolDescriptionForNode(mockNode, mockNodeType); + + // Assert + expect(result).toBe('Create a new user in Test Node Type'); + }); + + test('should use generated description when toolDescription is empty', () => { + // Arrange + mockNode.parameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + toolDescription: '', + }; + + // Act + const result = getToolDescriptionForNode(mockNode, mockNodeType); + + // Assert + expect(result).toBe('create user in Test Node Type'); + }); + + test('should use generated description when toolDescription is only whitespace', () => { + // Arrange + mockNode.parameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + toolDescription: ' ', + }; + + // Act + const result = getToolDescriptionForNode(mockNode, mockNodeType); + + // Assert + expect(result).toBe('create user in Test Node Type'); + }); + + test('should use custom toolDescription when it exists', () => { + // Arrange + mockNode.parameters = { + descriptionType: 'manual', + resource: 'user', + operation: 'create', + toolDescription: 'My custom description', + }; + + // Act + const result = getToolDescriptionForNode(mockNode, mockNodeType); + + // Assert + expect(result).toBe('My custom description'); + }); + + test('should fall back to node type description when toolDescription is undefined', () => { + // Arrange + mockNode.parameters = { + descriptionType: 'manual', + }; + + // Act + const result = getToolDescriptionForNode(mockNode, mockNodeType); + + // Assert + expect(result).toBe('This is the default node description'); + }); + }); });