diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts index 21e1b6352ad1a..1466f57d093d9 100644 --- a/packages/core/src/CreateNodeAsTool.ts +++ b/packages/core/src/CreateNodeAsTool.ts @@ -1,296 +1,435 @@ -/** - * @module NodeAsTool - * @description This module converts n8n nodes into LangChain tools by analyzing node parameters, - * identifying placeholders, and generating a Zod schema. It then creates a DynamicStructuredTool - * that can be used in LangChain workflows. - * - * General approach: - * 1. Recursively traverse node parameters to find placeholders, including in nested structures - * 2. Generate a Zod schema based on these placeholders, preserving the nested structure - * 3. Create a DynamicStructuredTool with the schema and a function that executes the n8n node - * - * Example: - * - Node parameters: - * { - * "inputText": "{{ '__PLACEHOLDER: Enter main text to process' }}", - * "options": { - * "language": "{{ '__PLACEHOLDER: Specify language' }}", - * "advanced": { - * "maxLength": "{{ '__PLACEHOLDER: Enter maximum length' }}" - * } - * } - * } - * - * - Generated Zod schema: - * z.object({ - * "inputText": z.string().describe("Enter main text to process"), - * "options__language": z.string().describe("Specify language"), - * "options__advanced__maxLength": z.string().describe("Enter maximum length") - * }).required() - * - * - Resulting tool can be called with: - * { - * "inputText": "Hello, world!", - * "options__language": "en", - * "options__advanced__maxLength": "100" - * } - * - * Note: Nested properties are flattened with double underscores in the schema, - * but the tool reconstructs the original nested structure when executing the node. - */ - import { DynamicStructuredTool } from '@langchain/core/tools'; -import { - NodeConnectionType, - type IExecuteFunctions, - type INodeParameters, - type INodeType, -} from 'n8n-workflow'; +import type { IExecuteFunctions, INodeParameters, INodeType } from 'n8n-workflow'; +import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { z } from 'zod'; -/** Represents a nested object structure */ -type NestedObject = { [key: string]: unknown }; - -/** - * Encodes a dot-notated key to a format safe for use as an object key. - * @param {string} key - The dot-notated key to encode. - * @returns {string} The encoded key. - */ -function encodeDotNotation(key: string): string { - // Replace dots with double underscores, then handle special case for '__value' for complicated params - return key.replace(/\./g, '__').replace('__value', ''); +type AllowedTypes = 'string' | 'number' | 'boolean' | 'json'; +interface FromAIArgument { + key: string; + description?: string; + type?: AllowedTypes; + defaultValue?: string | number | boolean | Record; } /** - * Decodes an encoded key back to its original dot-notated form. - * @param {string} key - The encoded key to decode. - * @returns {string} The decoded, dot-notated key. + * AIParametersParser + * + * This class encapsulates the logic for parsing node parameters, extracting $fromAI calls, + * generating Zod schemas, and creating LangChain tools. */ -function decodeDotNotation(key: string): string { - // Simply replace double underscores with dots - return key.replace(/__/g, '.'); -} +class AIParametersParser { + private ctx: IExecuteFunctions; -/** - * Recursively traverses an object to find placeholder values. - * @param {NestedObject} obj - The object to traverse. - * @param {string[]} path - The current path in the object. - * @param {Map} results - Map to store found placeholders. - * @returns {Map} Updated map of placeholders. - */ -function traverseObject( - obj: NestedObject, - path: string[] = [], - results: Map = new Map(), -): Map { - for (const [key, value] of Object.entries(obj)) { - const currentPath = [...path, key]; - const fullPath = currentPath.join('.'); - - if (typeof value === 'string' && value.startsWith("{{ '__PLACEHOLDER")) { - // Store placeholder values with their full path - results.set(encodeDotNotation(fullPath), value); - } else if (Array.isArray(value)) { - // Recursively traverse arrays - // eslint-disable-next-line @typescript-eslint/no-use-before-define - traverseArray(value, currentPath, results); - } else if (typeof value === 'object' && value !== null) { - // Recursively traverse nested objects, but only if they're not empty - if (Object.keys(value).length > 0) { - traverseObject(value as NestedObject, currentPath, results); + /** + * Constructs an instance of AIParametersParser. + * @param ctx The execution context. + */ + constructor(ctx: IExecuteFunctions) { + this.ctx = ctx; + } + + /** + * Generates a Zod schema based on the provided FromAIArgument placeholder. + * @param placeholder The FromAIArgument object containing key, type, description, and defaultValue. + * @returns A Zod schema corresponding to the placeholder's type and constraints. + */ + private generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny { + let schema: z.ZodTypeAny; + + switch (placeholder.type?.toLowerCase()) { + case 'string': + schema = z.string(); + break; + case 'number': + schema = z.number(); + break; + case 'boolean': + schema = z.boolean(); + break; + case 'json': + schema = z.record(z.any()); + break; + default: + schema = z.string(); + } + + if (placeholder.description) { + schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim()); + } + + if (placeholder.defaultValue !== undefined) { + schema = schema.default(placeholder.defaultValue); + } + + return schema; + } + + /** + * Recursively traverses the nodeParameters object to find all $fromAI calls. + * @param payload The current object or value being traversed. + * @param collectedArgs The array collecting FromAIArgument objects. + */ + private traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) { + if (typeof payload === 'string') { + const fromAICalls = this.extractFromAICalls(payload); + fromAICalls.forEach((call) => collectedArgs.push(call)); + } else if (Array.isArray(payload)) { + payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs)); + } else if (typeof payload === 'object' && payload !== null) { + Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs)); + } + } + + /** + * Extracts all $fromAI calls from a given string + * @param str The string to search for $fromAI calls. + * @returns An array of FromAIArgument objects. + * + * This method uses a regular expression to find the start of each $fromAI function call + * in the input string. It then employs a character-by-character parsing approach to + * accurately extract the arguments of each call, handling nested parentheses and quoted strings. + * + * The parsing process: + * 1. Finds the starting position of a $fromAI call using regex. + * 2. Iterates through characters, keeping track of parentheses depth and quote status. + * 3. Handles escaped characters within quotes to avoid premature quote closing. + * 4. Builds the argument string until the matching closing parenthesis is found. + * 5. Parses the extracted argument string into a FromAIArgument object. + * 6. Repeats the process for all $fromAI calls in the input string. + * + */ + private extractFromAICalls(str: string): FromAIArgument[] { + const args: FromAIArgument[] = []; + // Regular expression to match the start of a $fromAI function call + const pattern = /\$fromAI\s*\(\s*/gi; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(str)) !== null) { + const startIndex = match.index + match[0].length; + let current = startIndex; + let inQuotes = false; + let quoteChar = ''; + let parenthesesCount = 1; + let argsString = ''; + + // Parse the arguments string, handling nested parentheses and quotes + while (current < str.length && parenthesesCount > 0) { + const char = str[current]; + + if (inQuotes) { + // Handle characters inside quotes, including escaped characters + if (char === '\\' && current + 1 < str.length) { + argsString += char + str[current + 1]; + current += 2; + continue; + } + + if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + } + argsString += char; + } else { + // Handle characters outside quotes + if (['"', "'", '`'].includes(char)) { + inQuotes = true; + quoteChar = char; + } else if (char === '(') { + parenthesesCount++; + } else if (char === ')') { + parenthesesCount--; + } + + // Only add characters if we're still inside the main parentheses + if (parenthesesCount > 0 || char !== ')') { + argsString += char; + } + } + + current++; + } + + // If parentheses are balanced, parse the arguments + if (parenthesesCount === 0) { + try { + const parsedArgs = this.parseArguments(argsString); + args.push(parsedArgs); + } catch (error) { + // If parsing fails, throw an ApplicationError with details + throw new NodeOperationError( + this.ctx.getNode(), + `Failed to parse $fromAI arguments: ${argsString}: ${error}`, + ); + } + } else { + // Log an error if parentheses are unbalanced + throw new NodeOperationError( + this.ctx.getNode(), + `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, + ); } } + + return args; } - return results; -} + /** + * Parses the arguments of a single $fromAI function call. + * @param argsString The string containing the function arguments. + * @returns A FromAIArgument object. + */ + private parseArguments(argsString: string): FromAIArgument { + // Split arguments by commas not inside quotes + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + let quoteChar = ''; + let escapeNext = false; -/** - * Recursively traverses an array to find placeholder values. - * @param {unknown[]} arr - The array to traverse. - * @param {string[]} path - The current path in the array. - * @param {Map} results - Map to store found placeholders. - */ -function traverseArray(arr: unknown[], path: string[], results: Map): void { - arr.forEach((item, index) => { - const currentPath = [...path, index.toString()]; - const fullPath = currentPath.join('.'); - - if (typeof item === 'string' && item.startsWith("{{ '__PLACEHOLDER")) { - // Store placeholder values with their full path - results.set(encodeDotNotation(fullPath), item); - } else if (Array.isArray(item)) { - // Recursively traverse nested arrays - traverseArray(item, currentPath, results); - } else if (typeof item === 'object' && item !== null) { - // Recursively traverse nested objects - traverseObject(item as NestedObject, currentPath, results); + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (escapeNext) { + currentArg += char; + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (['"', "'", '`'].includes(char)) { + if (!inQuotes) { + inQuotes = true; + quoteChar = char; + currentArg += char; + } else if (char === quoteChar) { + inQuotes = false; + quoteChar = ''; + currentArg += char; + } else { + currentArg += char; + } + continue; + } + + if (char === ',' && !inQuotes) { + args.push(currentArg.trim()); + currentArg = ''; + continue; + } + + currentArg += char; } - }); -} -/** - * Builds a nested object structure from matching keys and their values. - * @param {string} baseKey - The base key to start building from. - * @param {string[]} matchingKeys - Array of matching keys. - * @param {Record} values - Object containing values for the keys. - * @returns {Record} The built nested object structure. - */ -function buildStructureFromMatches( - baseKey: string, - matchingKeys: string[], - values: Record, -): Record { - const result = {}; - - for (const matchingKey of matchingKeys) { - const decodedKey = decodeDotNotation(matchingKey); - // Extract the part of the key after the base key - const remainingPath = decodedKey - .slice(baseKey.length) - .split('.') - .filter((k) => k !== ''); - let current: Record = result; - - // Build the nested structure - for (let i = 0; i < remainingPath.length - 1; i++) { - if (!(remainingPath[i] in current)) { - current[remainingPath[i]] = {}; + if (currentArg) { + args.push(currentArg.trim()); + } + + // Remove surrounding quotes if present + const cleanArgs = args.map((arg) => { + const trimmed = arg.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('`') && trimmed.endsWith('`')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed + .slice(1, -1) + .replace(/\\'/g, "'") + .replace(/\\`/g, '`') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); } - current = current[remainingPath[i]] as Record; + return trimmed; + }); + + const type = cleanArgs?.[2] || 'string'; + + if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { + throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`); } - // Set the value at the deepest level - const lastKey = remainingPath[remainingPath.length - 1]; - current[lastKey ?? matchingKey] = values[matchingKey]; + return { + key: cleanArgs[0] || '', + description: cleanArgs[1], + type: (cleanArgs?.[2] ?? 'string') as AllowedTypes, + defaultValue: this.parseDefaultValue(cleanArgs[3]), + }; } - // If no nested structure was created, return the direct value - return Object.keys(result).length === 0 ? values[encodeDotNotation(baseKey)] : result; -} + /** + * Parses the default value, preserving its original type. + * @param value The default value as a string. + * @returns The parsed default value in its appropriate type. + */ + private parseDefaultValue( + value: string | undefined, + ): string | number | boolean | Record | undefined { + if (value === undefined || value === '') return undefined; + const lowerValue = value.toLowerCase(); + if (lowerValue === 'true') return true; + if (lowerValue === 'false') return false; + if (!isNaN(Number(value))) return Number(value); + try { + return jsonParse(value); + } catch { + return value; + } + } -/** - * Extracts the description from a placeholder string. - * @param {string} value - The placeholder string. - * @returns {string} The extracted description or a default message. - */ -function extractPlaceholderDescription(value: string): string { - const match = value.match(/{{ '__PLACEHOLDER:\s*(.+?)\s*' }}/); - return match ? match[1] : 'No description provided'; -} + /** + * 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. + */ + private getDescription(node: INodeType, nodeParameters: INodeParameters): string { + const manualDescription = nodeParameters.toolDescription as string; -/** - * Creates a DynamicStructuredTool from an n8n node. - * @param {INodeType} node - The n8n node to convert. - * @param {IExecuteFunctions} ctx - The execution context. - * @param {INodeParameters} nodeParameters - The node parameters. - * @returns {DynamicStructuredTool} The created tool. - */ -export function createNodeAsTool( - node: INodeType, - ctx: IExecuteFunctions, - nodeParameters: INodeParameters, -): DynamicStructuredTool { - // Find all placeholder values in the node parameters - const placeholderValues = traverseObject(nodeParameters); - - // Generate Zod schema from placeholder values - const schemaObj: { [key: string]: z.ZodString } = {}; - for (const [key, value] of placeholderValues.entries()) { - const description = extractPlaceholderDescription(value); - schemaObj[key] = z.string().describe(description); + if (nodeParameters.descriptionType === 'auto') { + const resource = nodeParameters.resource as string; + const operation = nodeParameters.operation as string; + let description = node.description.description; + if (resource) { + description += `\n Resource: ${resource}`; + } + if (operation) { + description += `\n Operation: ${operation}`; + } + return description.trim(); + } + if (nodeParameters.descriptionType === 'manual') { + return manualDescription ?? node.description.description; + } + + return node.description.description; } - const schema = z.object(schemaObj).required(); - - // Get the tool description from node parameters or use the default - const toolDescription = ctx.getNodeParameter( - 'toolDescription', - 0, - node.description.description, - ) as string; - type GetNodeParameterMethod = IExecuteFunctions['getNodeParameter']; - - const tool = new DynamicStructuredTool({ - name: node.description.name, - description: toolDescription ? toolDescription : node.description.description, - schema, - func: async (functionArgs: z.infer) => { - // Create a proxy for ctx to soft-override parameters with values from the LLM - const ctxProxy = new Proxy(ctx, { - get(target: IExecuteFunctions, prop: string | symbol, receiver: unknown) { - if (prop === 'getNodeParameter') { - // Override getNodeParameter method - // eslint-disable-next-line @typescript-eslint/unbound-method - return new Proxy(target.getNodeParameter, { - apply( - targetMethod: GetNodeParameterMethod, - thisArg: unknown, - argumentsList: Parameters, - ): ReturnType { - const [key] = argumentsList; - if (typeof key !== 'string') { - // If key is not a string, use the original method - return Reflect.apply(targetMethod, thisArg, argumentsList); - } - - const encodedKey = encodeDotNotation(key); - // Check if the full key or any more specific key is a placeholder - const matchingKeys = Array.from(placeholderValues.keys()).filter((k) => - k.startsWith(encodedKey), - ); - - if (matchingKeys.length > 0) { - // If there are matching keys, build the structure using args - const res = buildStructureFromMatches(encodedKey, matchingKeys, functionArgs); - // Return either the specific value or the entire built structure - return res?.[decodeDotNotation(key)] ?? res; - } - - // If no placeholder is found, use the original function - return Reflect.apply(targetMethod, thisArg, argumentsList); - }, - }); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver); - }, - }); - // Add input data to the context - ctxProxy.addInputData(NodeConnectionType.AiTool, [[{ json: functionArgs }]]); + /** + * Creates a DynamicStructuredTool from a node. + * @param node The node type. + * @param nodeParameters The parameters of the node. + * @returns A DynamicStructuredTool instance. + */ + public createTool(node: INodeType, nodeParameters: INodeParameters): DynamicStructuredTool { + const collectedArguments: FromAIArgument[] = []; + this.traverseNodeParameters(nodeParameters, collectedArguments); + + // Validate each collected argument + const nameValidationRegex = /^[a-zA-Z0-9_-]{1,64}$/; + const keyMap = new Map(); + for (const argument of collectedArguments) { + if (argument.key.length === 0 || !nameValidationRegex.test(argument.key)) { + const isEmptyError = 'You must specify a key when using $fromAI()'; + const isInvalidError = `Parameter key \`${argument.key}\` is invalid`; + const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError); + throw new NodeOperationError(this.ctx.getNode(), error, { + description: + 'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens', + }); + } - // Execute the node with the proxied context - const result = await node.execute?.bind(ctxProxy)(); + if (keyMap.has(argument.key)) { + // If the key already exists in the Map + const existingArg = keyMap.get(argument.key)!; - // Process and map the results - const mappedResults = result?.[0]?.flatMap((item) => item.json); + // Check if the existing argument has the same description and type + if ( + existingArg.description !== argument.description || + existingArg.type !== argument.type + ) { + // If not, throw an error for inconsistent duplicate keys + throw new NodeOperationError( + this.ctx.getNode(), + `Duplicate key '${argument.key}' found with different description or type`, + { + description: + 'Ensure all $fromAI() calls with the same key have consistent descriptions and types', + }, + ); + } + // If the duplicate key has consistent description and type, it's allowed (no action needed) + } else { + // If the key doesn't exist in the Map, add it + keyMap.set(argument.key, argument); + } + } - // Add output data to the context - ctxProxy.addOutputData(NodeConnectionType.AiTool, 0, [ - [{ json: { response: mappedResults } }], - ]); + // Remove duplicate keys, latest occurrence takes precedence + const uniqueArgsMap = collectedArguments.reduce((map, arg) => { + map.set(arg.key, arg); + return map; + }, new Map()); - // Return the stringified results - return JSON.stringify(mappedResults); - }, - }); + const uniqueArguments = Array.from(uniqueArgsMap.values()); - return tool; + // Generate Zod schema from unique arguments + const schemaObj = uniqueArguments.reduce((acc: Record, placeholder) => { + acc[placeholder.key] = this.generateZodSchema(placeholder); + return acc; + }, {}); + + const schema = z.object(schemaObj).required(); + const description = this.getDescription(node, nodeParameters); + const nodeName = this.ctx.getNode().name.replace(/ /g, '_'); + const name = nodeName || node.description.name; + + const tool = new DynamicStructuredTool({ + name, + description, + schema, + func: async (functionArgs: z.infer) => { + const { index } = this.ctx.addInputData(NodeConnectionType.AiTool, [ + [{ json: functionArgs }], + ]); + + try { + // Execute the node with the proxied context + const result = await node.execute?.bind(this.ctx)(); + + // Process and map the results + const mappedResults = result?.[0]?.flatMap((item) => item.json); + + // Add output data to the context + this.ctx.addOutputData(NodeConnectionType.AiTool, index, [ + [{ json: { response: mappedResults } }], + ]); + + // Return the stringified results + return JSON.stringify(mappedResults); + } catch (error) { + const nodeError = new NodeOperationError(this.ctx.getNode(), error as Error); + this.ctx.addOutputData(NodeConnectionType.AiTool, index, nodeError); + return 'Error during node execution: ' + nodeError.description; + } + }, + }); + + return tool; + } } /** - * Asynchronously creates a DynamicStructuredTool from an n8n node. - * @param {IExecuteFunctions} ctx - The execution context. - * @param {INodeType} node - The n8n node to convert. - * @param {INodeParameters} nodeParameters - The node parameters. - * @returns {Promise<{response: DynamicStructuredTool}>} A promise that resolves to an object containing the created tool. + * Converts node into LangChain tool by analyzing node parameters, + * identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates + * a DynamicStructuredTool that can be used in LangChain workflows. + * + * @param ctx The execution context. + * @param node The node type. + * @param nodeParameters The parameters of the node. + * @returns An object containing the DynamicStructuredTool instance. */ -export function getNodeAsTool( +export function createNodeAsTool( ctx: IExecuteFunctions, node: INodeType, nodeParameters: INodeParameters, ) { + const parser = new AIParametersParser(ctx); + return { - response: createNodeAsTool(node, ctx, nodeParameters), + response: parser.createTool(node, nodeParameters), }; } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index af428026b7fc0..fc671424b8d17 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -147,7 +147,7 @@ import { UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from './Constants'; -import { getNodeAsTool } from './CreateNodeAsTool'; +import { createNodeAsTool } from './CreateNodeAsTool'; import { getAllWorkflowExecutionMetadata, getWorkflowExecutionMetadata, @@ -2851,7 +2851,7 @@ async function getInputConnectionData( if (!nodeType.supplyData) { if (nodeType.description.outputs.includes(NodeConnectionType.AiTool)) { nodeType.supplyData = async function (this: IExecuteFunctions) { - return getNodeAsTool(this, nodeType, this.getNode().parameters); + return createNodeAsTool(this, nodeType, this.getNode().parameters); }; } else { throw new ApplicationError('Node does not have a `supplyData` method defined', { diff --git a/packages/core/test/CreateNodeAsTool.test.ts b/packages/core/test/CreateNodeAsTool.test.ts index 8081113b16c62..5c485b983718f 100644 --- a/packages/core/test/CreateNodeAsTool.test.ts +++ b/packages/core/test/CreateNodeAsTool.test.ts @@ -1,5 +1,5 @@ import type { IExecuteFunctions, INodeParameters, INodeType } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { z } from 'zod'; import { createNodeAsTool } from '@/CreateNodeAsTool'; @@ -19,10 +19,12 @@ describe('createNodeAsTool', () => { let mockNodeParameters: INodeParameters; beforeEach(() => { + // Setup mock objects mockCtx = { getNodeParameter: jest.fn(), - addInputData: jest.fn(), + addInputData: jest.fn().mockReturnValue({ index: 0 }), addOutputData: jest.fn(), + getNode: jest.fn().mockReturnValue({ name: 'Test_Node' }), } as unknown as IExecuteFunctions; mockNode = { @@ -34,60 +36,456 @@ describe('createNodeAsTool', () => { } as unknown as INodeType; mockNodeParameters = { - param1: "{{ '__PLACEHOLDER: Test parameter' }}", + param1: "={{$fromAI('param1', 'Test parameter', 'string') }}", param2: 'static value', nestedParam: { - subParam: "{{ '__PLACEHOLDER: Nested parameter' }}", + subParam: "={{ $fromAI('subparam', 'Nested parameter', 'string') }}", }, + descriptionType: 'auto', + resource: 'testResource', + operation: 'testOperation', }; + jest.clearAllMocks(); }); - it('should create a DynamicStructuredTool with correct properties', () => { - const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + describe('Tool Creation and Basic Properties', () => { + it('should create a DynamicStructuredTool with correct properties', () => { + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool).toBeDefined(); + expect(tool.name).toBe('Test_Node'); + expect(tool.description).toBe( + 'Test node description\n Resource: testResource\n Operation: testOperation', + ); + expect(tool.schema).toBeDefined(); + }); + + it('should use toolDescription if provided', () => { + mockNodeParameters.descriptionType = 'manual'; + mockNodeParameters.toolDescription = 'Custom tool description'; - expect(tool).toBeDefined(); - expect(tool.name).toBe('TestNode'); - expect(tool.description).toBe('Test node description'); - expect(tool.schema).toBeDefined(); + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.description).toBe('Custom tool description'); + }); }); - it('should use toolDescription if provided', () => { - const customDescription = 'Custom tool description'; - (mockCtx.getNodeParameter as jest.Mock).mockReturnValue(customDescription); + describe('Schema Creation and Parameter Handling', () => { + it('should create a schema based on fromAI arguments in nodeParameters', () => { + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema).toBeDefined(); + expect(tool.schema.shape).toHaveProperty('param1'); + expect(tool.schema.shape).toHaveProperty('subparam'); + expect(tool.schema.shape).not.toHaveProperty('param2'); + }); + + it('should handle fromAI arguments correctly', () => { + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString); + }); + + it('should handle default values correctly', () => { + mockNodeParameters = { + paramWithDefault: + "={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}", + numberWithDefault: + "={{ $fromAI('numberWithDefault', 'Number with default', 'number', 42) }}", + booleanWithDefault: + "={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default'); + expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default'); + expect(tool.schema.shape.booleanWithDefault.description).toBe('Boolean with default'); + }); + + it('should handle nested parameters correctly', () => { + mockNodeParameters = { + topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}", + nested: { + level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}", + deeperNested: { + level2: "={{ $fromAI('level2', 'Nested level 2', 'number') }}", + }, + }, + }; - const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; - expect(tool.description).toBe(customDescription); + expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.level2).toBeInstanceOf(z.ZodNumber); + }); + + it('should handle array parameters correctly', () => { + mockNodeParameters = { + arrayParam: [ + "={{ $fromAI('item1', 'First item', 'string') }}", + "={{ $fromAI('item2', 'Second item', 'number') }}", + ], + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber); + }); }); - it('should create a schema based on placeholder values in nodeParameters', () => { - const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + describe('Error Handling and Edge Cases', () => { + it('should handle error during node execution', async () => { + mockNode.execute = jest.fn().mockRejectedValue(new Error('Execution failed')); + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + const result = await tool.func({ param1: 'test value' }); + + expect(result).toContain('Error during node execution:'); + expect(mockCtx.addOutputData).toHaveBeenCalledWith( + NodeConnectionType.AiTool, + 0, + expect.any(NodeOperationError), + ); + }); + + it('should throw an error for invalid parameter names', () => { + mockNodeParameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')"; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + 'Parameter key `invalid param` is invalid', + ); + }); + + it('should throw an error for $fromAI calls with unsupported types', () => { + mockNodeParameters = { + invalidTypeParam: + "={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}", + }; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + 'Invalid type: unsupportedType', + ); + }); + + it('should handle empty parameters and parameters with no fromAI calls', () => { + mockNodeParameters = { + param1: 'static value 1', + param2: 'static value 2', + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; - expect(tool.schema).toBeDefined(); - expect(tool.schema.shape).toHaveProperty('param1'); - expect(tool.schema.shape).toHaveProperty('nestedParam__subParam'); - expect(tool.schema.shape).not.toHaveProperty('param2'); + expect(tool.schema.shape).toEqual({}); + }); }); - it('should handle nested parameters correctly', () => { - const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + describe('Parameter Name and Description Handling', () => { + it('should accept parameter names with underscores and hyphens', () => { + mockNodeParameters = { + validName1: + "={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}", + validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape['param_name-1'].description).toBe( + 'Valid name with underscore and hyphen', + ); + + expect(tool.schema.shape.param_name_2).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.param_name_2.description).toBe('Another valid name'); + }); + + it('should throw an error for parameter names with invalid special characters', () => { + mockNodeParameters = { + invalidNameParam: + "={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}", + }; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + 'Parameter key `param@name!` is invalid', + ); + }); + + it('should throw an error for empty parameter name', () => { + mockNodeParameters = { + invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}", + }; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + 'You must specify a key when using $fromAI()', + ); + }); + + it('should handle parameter names with exact and exceeding character limits', () => { + const longName = 'a'.repeat(64); + const tooLongName = 'a'.repeat(65); + mockNodeParameters = { + longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`, + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape[longName].description).toBe('Param with 64 character name'); + + expect(() => + createNodeAsTool(mockCtx, mockNode, { + tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`, + }), + ).toThrow(`Parameter key \`${tooLongName}\` is invalid`); + }); + + it('should handle $fromAI calls with empty description', () => { + mockNodeParameters = { + emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.emptyDescription.description).toBeUndefined(); + }); + + it('should throw an error for calls with the same parameter but different descriptions', () => { + mockNodeParameters = { + duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}", + duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}", + }; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + "Duplicate key 'duplicate' found with different description or type", + ); + }); + it('should throw an error for calls with the same parameter but different types', () => { + mockNodeParameters = { + duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}", + duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}", + }; + + expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( + "Duplicate key 'duplicate' found with different description or type", + ); + }); + }); + + describe('Complex Parsing Scenarios', () => { + it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => { + mockNodeParameters = { + varyingSpacing1: "={{$fromAI('param1','Description1','string')}}", + varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}", + varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}", + wrongCapitalization: "={{$fromai('param4','Description4','number')}}", + templateLiteralParam: + // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string + "={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.param1.description).toBe('Description1'); + + expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.param2.description).toBe('Description2'); + + expect(tool.schema.shape.param3).toBeInstanceOf(z.ZodBoolean); + expect(tool.schema.shape.param3.description).toBe('Description3'); + + expect(tool.schema.shape.param4).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.param4.description).toBe('Description4'); + + expect(tool.schema.shape.templatedParam).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.templatedParam.description).toBe('Templated param description'); + }); + + it('should correctly parse multiple $fromAI calls interleaved with regular text', () => { + mockNodeParameters = { + interleavedParams: + "={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.param1.description).toBe('First param'); + + expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.param2.description).toBe('Second param'); + }); + + it('should correctly parse $fromAI calls with complex JSON default values', () => { + mockNodeParameters = { + complexJsonDefault: + '={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}', + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord); + expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default'); + expect(tool.schema.shape.complexJson._def.defaultValue()).toEqual({ + nested: { key: 'value' }, + array: [1, 2, 3], + }); + }); + + it('should ignore $fromAI calls embedded in non-string node parameters', () => { + mockNodeParameters = { + numberParam: 42, + booleanParam: false, + objectParam: { + innerString: "={{ $fromAI('innerParam', 'Inner param', 'string') }}", + innerNumber: 100, + innerObject: { + deepParam: "={{ $fromAI('deepParam', 'Deep param', 'number') }}", + }, + }, + arrayParam: [ + "={{ $fromAI('arrayParam1', 'First array param', 'string') }}", + 200, + "={{ $fromAI('nestedArrayParam', 'Nested array param', 'boolean') }}", + ], + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.innerParam.description).toBe('Inner param'); + + expect(tool.schema.shape.deepParam).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.deepParam.description).toBe('Deep param'); + + expect(tool.schema.shape.arrayParam1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.arrayParam1.description).toBe('First array param'); + + expect(tool.schema.shape.nestedArrayParam).toBeInstanceOf(z.ZodBoolean); + expect(tool.schema.shape.nestedArrayParam.description).toBe('Nested array param'); + }); + }); + + describe('Escaping and Special Characters', () => { + it('should handle escaped single quotes in parameter names and descriptions', () => { + mockNodeParameters = { + escapedQuotesParam: + "={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes"); + }); + + it('should handle escaped double quotes in parameter names and descriptions', () => { + mockNodeParameters = { + escapedQuotesParam: + '={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}', + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes'); + }); + + it('should handle escaped backslashes in parameter names and descriptions', () => { + mockNodeParameters = { + escapedBackslashesParam: + "={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes'); + }); + + it('should handle mixed escaped characters in parameter names and descriptions', () => { + mockNodeParameters = { + mixedEscapesParam: + '={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}', + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber); + expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters'); + }); + }); + + describe('Edge Cases and Limitations', () => { + it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => { + mockNodeParameters = { + excessArgsParam: + "={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments'); + expect(tool.schema.shape.excessArgs._def.defaultValue()).toBe('default'); + }); + + it('should correctly parse $fromAI calls with nested parentheses', () => { + mockNodeParameters = { + nestedParenthesesParam: + "={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.paramWithNested.description).toBe( + 'Description with ((nested)) parentheses', + ); + }); + + it('should handle $fromAI calls with very long descriptions', () => { + const longDescription = 'A'.repeat(1000); + mockNodeParameters = { + longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`, + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + + expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.longParam.description).toBe(longDescription); + }); + + it('should handle $fromAI calls with only some parameters', () => { + mockNodeParameters = { + partialParam1: "={{ $fromAI('partial1') }}", + partialParam2: "={{ $fromAI('partial2', 'Description only') }}", + partialParam3: "={{ $fromAI('partial3', '', 'number') }}", + }; + + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; - expect(tool.schema.shape.nestedParam__subParam).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.partial3).toBeInstanceOf(z.ZodNumber); + }); }); - it('should create a function that wraps the node execution', async () => { - const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + describe('Unicode and Internationalization', () => { + it('should handle $fromAI calls with unicode characters', () => { + mockNodeParameters = { + unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}", + }; - const result = await tool.func({ param1: 'test value', nestedParam__subParam: 'nested value' }); + const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; - expect(mockCtx.addInputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, [ - [{ json: { param1: 'test value', nestedParam__subParam: 'nested value' } }], - ]); - expect(mockNode.execute).toHaveBeenCalled(); - expect(mockCtx.addOutputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, 0, [ - [{ json: { response: [{ result: 'test' }] } }], - ]); - expect(result).toBe(JSON.stringify([{ result: 'test' }])); + expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString); + expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好'); + }); }); }); diff --git a/packages/editor-ui/src/composables/useToast.ts b/packages/editor-ui/src/composables/useToast.ts index ea0a367de3af9..1ee8f4a2b4e88 100644 --- a/packages/editor-ui/src/composables/useToast.ts +++ b/packages/editor-ui/src/composables/useToast.ts @@ -164,7 +164,7 @@ export function useToast() { } function causedByCredential(message: string | undefined) { - if (!message) return false; + if (!message || typeof message !== 'string') return false; return message.includes('Credentials for') && message.includes('are not set'); } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 04d025abf2ce2..ed8254911995a 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -524,6 +524,7 @@ export const MAPPING_PARAMS = [ '$input', '$item', '$jmespath', + '$fromAI', '$json', '$node', '$now', diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts index cce673e48bf7d..bed05f2c31dc0 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts @@ -75,7 +75,7 @@ describe('Top-level completions', () => { section: METADATA_SECTION, }), ); - expect(result?.[14]).toEqual( + expect(result?.[15]).toEqual( expect.objectContaining({ label: '$max()', section: METHODS_SECTION }), ); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts index 7d01899b24658..3cc673147d627 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -276,6 +276,58 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ true, ), }, + { + label: '$fromAI()', + section: METHODS_SECTION, + info: createInfoBoxRenderer( + { + name: '$fromAI', + returnType: 'any', + description: 'Populate this with the parameter passed from the large language model', + args: [ + { + name: 'key', + description: + 'The key or name of the argument, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens', + type: 'string', + }, + { + name: 'description', + description: 'Description of the argument', + type: 'string', + optional: true, + }, + { + name: 'type', + description: 'Type of the argument', + type: 'string | number | boolean | json', + optional: true, + }, + { + name: 'defaultValue', + description: 'Default value for the argument', + type: 'any', + optional: true, + }, + ], + examples: [ + { + example: '$fromAI("name")', + description: 'Get the name of the person', + }, + { + example: '$fromAI("age", "The age of the person", "number", 18)', + description: 'Get the age of the person as number with default value 18', + }, + { + example: '$fromAI("isStudent", "Is the person a student", "boolean", false)', + description: 'Get the student status of the person as boolean with default value false', + }, + ], + }, + true, + ), + }, { label: '$max()', section: METHODS_SECTION, diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 4eb6382dc525f..00de6a0136983 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -307,6 +307,7 @@ &.cm-completionInfo-right { background-color: var(--color-infobox-background); + min-width: 200px; } } diff --git a/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts b/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts index 1fe3e9d0c51d1..4395f690ebc69 100644 --- a/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts +++ b/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts @@ -16,6 +16,7 @@ export class AirtableV2 implements INodeType { this.description = { ...baseDescription, ...versionDescription, + usableAsTool: true, }; } diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts index 7c526bfa821cf..f9b5287f4b8e1 100644 --- a/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts @@ -54,7 +54,7 @@ export const description: INodeProperties[] = [ action: 'Update record', }, ], - default: 'read', + default: 'get', displayOptions: { show: { resource: ['record'], diff --git a/packages/nodes-base/nodes/Baserow/Baserow.node.ts b/packages/nodes-base/nodes/Baserow/Baserow.node.ts index 803a5710febdc..bb378a97daf66 100644 --- a/packages/nodes-base/nodes/Baserow/Baserow.node.ts +++ b/packages/nodes-base/nodes/Baserow/Baserow.node.ts @@ -41,6 +41,7 @@ export class Baserow implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'baserowApi', diff --git a/packages/nodes-base/nodes/EmailSend/v2/EmailSendV2.node.ts b/packages/nodes-base/nodes/EmailSend/v2/EmailSendV2.node.ts index f2670607c3b91..13caad5b5d62f 100644 --- a/packages/nodes-base/nodes/EmailSend/v2/EmailSendV2.node.ts +++ b/packages/nodes-base/nodes/EmailSend/v2/EmailSendV2.node.ts @@ -22,6 +22,7 @@ const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'smtp', diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts index dfdc9698b7473..aa3927e756be4 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts @@ -42,6 +42,7 @@ export class GoogleCalendar implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'googleCalendarOAuth2Api', diff --git a/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts index 29089a9166512..2c5fb28682ee8 100644 --- a/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts +++ b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts @@ -36,6 +36,7 @@ export class GoogleDocs implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'googleApi', diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts index 07d229714a0e2..003c3a087bb5a 100644 --- a/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts @@ -19,6 +19,7 @@ export const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'googleApi', diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts index aa1e1622da225..2e25b96c120fb 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts @@ -46,6 +46,7 @@ const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'googleApi', diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts index 5ac6b0a1956bf..37c1b6c2ba9aa 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts @@ -17,6 +17,7 @@ export const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, hints: [ { message: diff --git a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts index 55ed1f47fa17e..d91fa35d44cee 100644 --- a/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts +++ b/packages/nodes-base/nodes/HackerNews/HackerNews.node.ts @@ -24,6 +24,7 @@ export class HackerNews implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, properties: [ // ---------------------------------- // Resources diff --git a/packages/nodes-base/nodes/Jira/Jira.node.ts b/packages/nodes-base/nodes/Jira/Jira.node.ts index 8911daeb58d45..2c21328f78818 100644 --- a/packages/nodes-base/nodes/Jira/Jira.node.ts +++ b/packages/nodes-base/nodes/Jira/Jira.node.ts @@ -53,6 +53,7 @@ export class Jira implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'jiraSoftwareCloudApi', diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/node.description.ts b/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/node.description.ts index 439b2fdcb3b23..cbcc328fd0034 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/node.description.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/node.description.ts @@ -22,6 +22,7 @@ export const description: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'microsoftOutlookOAuth2Api', diff --git a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts index 619ff95a335ac..4317dde0e45a5 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -37,6 +37,7 @@ export class MicrosoftSql implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, parameterPane: 'wide', credentials: [ { diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index b9c0a47bb22a9..756dd72deb0c7 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -45,6 +45,7 @@ export class MongoDb implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'mongoDb', diff --git a/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts index 103d270cbf9e4..e655acf6f06a5 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/versionDescription.ts @@ -16,6 +16,7 @@ export const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'mySql', diff --git a/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts index 390c3e2791441..0cadbc5b1d6df 100644 --- a/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts +++ b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts @@ -29,6 +29,7 @@ export class NocoDB implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'nocoDb', diff --git a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts index ff26f28735790..4114d681dbc31 100644 --- a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts +++ b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts @@ -26,6 +26,7 @@ export const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'notionApi', diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts index 5abe6f913a1a2..c5e491159b0fb 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts @@ -16,6 +16,7 @@ export const versionDescription: INodeTypeDescription = { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'postgres', diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index f547804b4fe2d..59d1582fdb309 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -29,6 +29,7 @@ export class Redis implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'redis', diff --git a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts index 14d62c697159f..7bde9cec1d107 100644 --- a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts +++ b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts @@ -39,6 +39,7 @@ export class SlackV2 implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'slackApi', diff --git a/packages/nodes-base/nodes/Supabase/Supabase.node.ts b/packages/nodes-base/nodes/Supabase/Supabase.node.ts index 41b9a3760a629..929da0ad306bf 100644 --- a/packages/nodes-base/nodes/Supabase/Supabase.node.ts +++ b/packages/nodes-base/nodes/Supabase/Supabase.node.ts @@ -43,6 +43,7 @@ export class Supabase implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'supabaseApi', diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.ts b/packages/nodes-base/nodes/Telegram/Telegram.node.ts index 6ecc26ed79709..5ae67465bf35c 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.ts +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.ts @@ -10,8 +10,8 @@ import type { } from 'n8n-workflow'; import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; -import { addAdditionalFields, apiRequest, getPropertyName } from './GenericFunctions'; import { appendAttributionOption } from '../../utils/descriptions'; +import { addAdditionalFields, apiRequest, getPropertyName } from './GenericFunctions'; export class Telegram implements INodeType { description: INodeTypeDescription = { @@ -25,6 +25,7 @@ export class Telegram implements INodeType { defaults: { name: 'Telegram', }, + usableAsTool: true, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], credentials: [ diff --git a/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts b/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts index 055d6ea6abb6e..3fb4ac5830dd0 100644 --- a/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts +++ b/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts @@ -44,6 +44,7 @@ export class WooCommerce implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + usableAsTool: true, credentials: [ { name: 'wooCommerceApi', diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 701b50128c788..85431c6897fdb 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -359,7 +359,7 @@ const declarativeNodeOptionParameters: INodeProperties = { export function convertNodeToAiTool< T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription }, >(item: T): T { - // quick helper function for typeguard down below + // quick helper function for type-guard down below function isFullDescription(obj: unknown): obj is INodeTypeDescription { return typeof obj === 'object' && obj !== null && 'properties' in obj; } @@ -368,9 +368,33 @@ export function convertNodeToAiTool< item.description.name += 'Tool'; item.description.inputs = []; item.description.outputs = [NodeConnectionType.AiTool]; - item.description.displayName += ' Tool (wrapped)'; + item.description.displayName += ' Tool'; delete item.description.usableAsTool; + + const hasResource = item.description.properties.some((prop) => prop.name === 'resource'); + const hasOperation = item.description.properties.some((prop) => prop.name === 'operation'); + if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) { + const descriptionType: INodeProperties = { + displayName: 'Tool Description', + name: 'descriptionType', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Set Automatically', + value: 'auto', + description: 'Automatically set based on resource and operation', + }, + { + name: 'Set Manually', + value: 'manual', + description: 'Manually set the description', + }, + ], + default: 'auto', + }; + const descProp: INodeProperties = { displayName: 'Description', name: 'toolDescription', @@ -382,7 +406,29 @@ export function convertNodeToAiTool< '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}`, }; + + const noticeProp: INodeProperties = { + displayName: 'Use the expression {{ $fromAI() }} for any data to be filled by the model', + name: 'notice', + type: 'notice', + default: '', + }; + item.description.properties.unshift(descProp); + + // If node has resource or operation we can determine pre-populate tool description based on it + // so we add the descriptionType property as the first property + if (hasResource || hasOperation) { + item.description.properties.unshift(descriptionType); + + descProp.displayOptions = { + show: { + descriptionType: ['manual'], + }, + }; + } + + item.description.properties.unshift(noticeProp); } } diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 1ceb62fb7b549..3cb7db901b976 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -961,6 +961,43 @@ export class WorkflowDataProxy { return taskData.data!.main[previousNodeOutput]![pairedItem.item]; }; + const handleFromAi = ( + name: string, + _description?: string, + _type: string = 'string', + defaultValue?: unknown, + ) => { + if (!name || name === '') { + throw new ExpressionError('Please provide a key', { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + }); + } + const nameValidationRegex = /^[a-zA-Z0-9_-]{0,64}$/; + if (!nameValidationRegex.test(name)) { + throw new ExpressionError( + 'Invalid parameter key, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens', + { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + }, + ); + } + const placeholdersDataInputData = + that.runExecutionData?.resultData.runData[that.activeNodeName]?.[0].inputOverride?.[ + NodeConnectionType.AiTool + ]?.[0]?.[0].json; + + if (Boolean(!placeholdersDataInputData)) { + throw new ExpressionError('No execution data available', { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + type: 'no_execution_data', + }); + } + return placeholdersDataInputData?.[name] ?? defaultValue; + }; + const base = { $: (nodeName: string) => { if (!nodeName) { @@ -1303,6 +1340,10 @@ export class WorkflowDataProxy { ); return dataProxy.getDataProxy(); }, + $fromAI: handleFromAi, + // Make sure mis-capitalized $fromAI is handled correctly even though we don't auto-complete it + $fromai: handleFromAi, + $fromAi: handleFromAi, $items: (nodeName?: string, outputIndex?: number, runIndex?: number) => { if (nodeName === undefined) { nodeName = (that.prevNodeGetter() as { name: string }).name; diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index f3ecc161ca376..81b7af5de6524 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -3660,7 +3660,7 @@ describe('NodeHelpers', () => { it('should modify the name and displayName correctly', () => { const result = convertNodeToAiTool(fullNodeWrapper); expect(result.description.name).toBe('testNodeTool'); - expect(result.description.displayName).toBe('Test Node Tool (wrapped)'); + expect(result.description.displayName).toBe('Test Node Tool'); }); it('should update inputs and outputs', () => { @@ -3685,19 +3685,6 @@ describe('NodeHelpers', () => { expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description); }); - it('should not add toolDescription property if it already exists', () => { - const toolDescriptionProp: INodeProperties = { - displayName: 'Tool Description', - name: 'toolDescription', - type: 'string', - default: 'Existing description', - }; - fullNodeWrapper.description.properties = [toolDescriptionProp]; - const result = convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(1); - expect(result.description.properties[0]).toEqual(toolDescriptionProp); - }); - it('should set codex categories correctly', () => { const result = convertNodeToAiTool(fullNodeWrapper); expect(result.description.codex).toEqual({ @@ -3718,8 +3705,102 @@ describe('NodeHelpers', () => { }; fullNodeWrapper.description.properties = [existingProp]; const result = convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription + expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice expect(result.description.properties).toContainEqual(existingProp); }); + + it('should handle nodes with resource property', () => { + const resourceProp: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [{ name: 'User', value: 'user' }], + default: 'user', + }; + fullNodeWrapper.description.properties = [resourceProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(resourceProp); + }); + + it('should handle nodes with operation property', () => { + const operationProp: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [{ name: 'Create', value: 'create' }], + default: 'create', + }; + fullNodeWrapper.description.properties = [operationProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(operationProp); + }); + + it('should handle nodes with both resource and operation properties', () => { + const resourceProp: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [{ name: 'User', value: 'user' }], + default: 'user', + }; + const operationProp: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [{ name: 'Create', value: 'create' }], + default: 'create', + }; + fullNodeWrapper.description.properties = [resourceProp, operationProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties[1].name).toBe('descriptionType'); + expect(result.description.properties[2].name).toBe('toolDescription'); + expect(result.description.properties[3]).toEqual(resourceProp); + expect(result.description.properties[4]).toEqual(operationProp); + }); + + it('should handle nodes with empty properties', () => { + fullNodeWrapper.description.properties = []; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(2); + expect(result.description.properties[1].name).toBe('toolDescription'); + }); + + it('should handle nodes with existing codex property', () => { + fullNodeWrapper.description.codex = { + categories: ['Existing'], + subcategories: { + Existing: ['Category'], + }, + }; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.codex).toEqual({ + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + }); + }); + + it('should handle nodes with very long names', () => { + fullNodeWrapper.description.name = 'veryLongNodeNameThatExceedsNormalLimits'.repeat(10); + fullNodeWrapper.description.displayName = + 'Very Long Node Name That Exceeds Normal Limits'.repeat(10); + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name.endsWith('Tool')).toBe(true); + expect(result.description.displayName.endsWith('Tool')).toBe(true); + }); + + it('should handle nodes with special characters in name and displayName', () => { + fullNodeWrapper.description.name = 'special@#$%Node'; + fullNodeWrapper.description.displayName = 'Special @#$% Node'; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name).toBe('special@#$%NodeTool'); + expect(result.description.displayName).toBe('Special @#$% Node Tool'); + }); }); });