From 13652c5ee21f8a92e9580f3a283e7054c30d904c Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 28 Jan 2025 10:47:50 +0100 Subject: [PATCH] chore: Move AIParametersParser to n8n-workflow (no-changelog) (#12671) --- .../utils/create-node-as-tool.ts | 483 ++++-------------- packages/workflow/package.json | 3 +- packages/workflow/src/FromAIParseUtils.ts | 265 ++++++++++ packages/workflow/src/index.ts | 1 + .../workflow/test/FromAIParseUtils.test.ts | 84 +++ pnpm-lock.yaml | 3 + 6 files changed, 457 insertions(+), 382 deletions(-) create mode 100644 packages/workflow/src/FromAIParseUtils.ts create mode 100644 packages/workflow/test/FromAIParseUtils.test.ts 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 da34b377df..9f1e707a67 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,411 +1,133 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; -import type { IDataObject, INode, INodeType } from 'n8n-workflow'; -import { jsonParse, NodeOperationError } from 'n8n-workflow'; +import { generateZodSchema, NodeOperationError, traverseNodeParameters } from 'n8n-workflow'; +import type { IDataObject, INode, INodeType, FromAIArgument } from 'n8n-workflow'; import { z } from 'zod'; -type AllowedTypes = 'string' | 'number' | 'boolean' | 'json'; -interface FromAIArgument { - key: string; - description?: string; - type?: AllowedTypes; - defaultValue?: string | number | boolean | Record; -} - -type ParserOptions = { +export type CreateNodeAsToolOptions = { node: INode; nodeType: INodeType; handleToolInvocation: (toolArgs: IDataObject) => Promise; }; -// This file is temporarily duplicated in `packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts` -// Please apply any changes in both files - /** - * AIParametersParser + * Retrieves and validates the Zod schema for the tool. * - * This class encapsulates the logic for parsing node parameters, extracting $fromAI calls, - * generating Zod schemas, and creating LangChain tools. + * This method: + * 1. Collects all $fromAI arguments from node parameters + * 2. Validates parameter keys against naming rules + * 3. Checks for duplicate keys and ensures consistency + * 4. Generates a Zod schema from the validated arguments + * + * @throws {NodeOperationError} When parameter keys are invalid or when duplicate keys have inconsistent definitions + * @returns {z.ZodObject} A Zod schema object representing the structure and validation rules for the node parameters */ -class AIParametersParser { - /** - * Constructs an instance of AIParametersParser. - */ - constructor(private readonly options: ParserOptions) {} - - /** - * 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; +function getSchema(node: INode) { + const collectedArguments: FromAIArgument[] = []; + try { + traverseNodeParameters(node.parameters, collectedArguments); + } catch (error) { + throw new NodeOperationError(node, error as Error); } - /** - * 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)); + // 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(node, error, { + description: + 'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens', + }); } - } - /** - * 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; + if (keyMap.has(argument.key)) { + // If the key already exists in the Map + const existingArg = keyMap.get(argument.key)!; - 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.options.node, - `Failed to parse $fromAI arguments: ${argsString}: ${error}`, - ); - } - } else { - // Log an error if parentheses are unbalanced + // 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.options.node, - `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, + node, + `Duplicate key '${argument.key}' found with different description or type`, + { + description: + 'Ensure all $fromAI() calls with the same key have consistent descriptions and types', + }, ); } - } - - return args; - } - - /** - * 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; - - 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; - } - - 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, '\\'); - } - return trimmed; - }); - - const type = cleanArgs?.[2] || 'string'; - - if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { - throw new NodeOperationError(this.options.node, `Invalid type: ${type}`); - } - - return { - key: cleanArgs[0] || '', - description: cleanArgs[1], - type: (cleanArgs?.[2] ?? 'string') as AllowedTypes, - defaultValue: this.parseDefaultValue(cleanArgs[3]), - }; - } - - /** - * 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; + // 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); } } - /** - * Retrieves and validates the Zod schema for the tool. - * - * This method: - * 1. Collects all $fromAI arguments from node parameters - * 2. Validates parameter keys against naming rules - * 3. Checks for duplicate keys and ensures consistency - * 4. Generates a Zod schema from the validated arguments - * - * @throws {NodeOperationError} When parameter keys are invalid or when duplicate keys have inconsistent definitions - * @returns {z.ZodObject} A Zod schema object representing the structure and validation rules for the node parameters - */ - private getSchema() { - const { node } = this.options; - const collectedArguments: FromAIArgument[] = []; - this.traverseNodeParameters(node.parameters, collectedArguments); + // Remove duplicate keys, latest occurrence takes precedence + const uniqueArgsMap = collectedArguments.reduce((map, arg) => { + map.set(arg.key, arg); + return map; + }, new Map()); - // 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(node, error, { - description: - 'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens', - }); - } + const uniqueArguments = Array.from(uniqueArgsMap.values()); - if (keyMap.has(argument.key)) { - // If the key already exists in the Map - const existingArg = keyMap.get(argument.key)!; + // Generate Zod schema from unique arguments + const schemaObj = uniqueArguments.reduce((acc: Record, placeholder) => { + acc[placeholder.key] = generateZodSchema(placeholder); + return acc; + }, {}); - // 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( - node, - `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); - } + 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 { + const manualDescription = node.parameters.toolDescription as 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}`; } - - // Remove duplicate keys, latest occurrence takes precedence - const uniqueArgsMap = collectedArguments.reduce((map, arg) => { - map.set(arg.key, arg); - return map; - }, new Map()); - - const uniqueArguments = Array.from(uniqueArgsMap.values()); - - // Generate Zod schema from unique arguments - const schemaObj = uniqueArguments.reduce((acc: Record, placeholder) => { - acc[placeholder.key] = this.generateZodSchema(placeholder); - return acc; - }, {}); - - return z.object(schemaObj).required(); + if (operation) { + description += `\n Operation: ${operation}`; + } + return description.trim(); + } + if (node.parameters.descriptionType === 'manual') { + return manualDescription ?? nodeType.description.description; } - /** - * 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(): string { - const { node, nodeType } = this.options; - const manualDescription = node.parameters.toolDescription as string; + return nodeType.description.description; +} - 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(); - } - if (node.parameters.descriptionType === 'manual') { - return manualDescription ?? nodeType.description.description; - } +/** + * Creates a DynamicStructuredTool from a node. + * @returns A DynamicStructuredTool instance. + */ +function createTool(options: CreateNodeAsToolOptions) { + const { node, nodeType, handleToolInvocation } = options; + const schema = getSchema(node); + const description = makeDescription(node, nodeType); + const nodeName = node.name.replace(/ /g, '_'); + const name = nodeName || nodeType.description.name; - return nodeType.description.description; - } - - /** - * Creates a DynamicStructuredTool from a node. - * @returns A DynamicStructuredTool instance. - */ - createTool(): DynamicStructuredTool { - const { node, nodeType } = this.options; - const schema = this.getSchema(); - const description = this.getDescription(); - const nodeName = node.name.replace(/ /g, '_'); - const name = nodeName || nodeType.description.name; - - return new DynamicStructuredTool({ - name, - description, - schema, - func: async (toolArgs: z.infer) => - await this.options.handleToolInvocation(toolArgs), - }); - } + return new DynamicStructuredTool({ + name, + description, + schema, + func: async (toolArgs: z.infer) => await handleToolInvocation(toolArgs), + }); } /** @@ -413,7 +135,6 @@ class AIParametersParser { * identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates * a DynamicStructuredTool that can be used in LangChain workflows. */ -export function createNodeAsTool(options: ParserOptions) { - const parser = new AIParametersParser(options); - return { response: parser.createTool() }; +export function createNodeAsTool(options: CreateNodeAsToolOptions) { + return { response: createTool(options) }; } diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 3e5cfdcc2a..511b254fd8 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -58,6 +58,7 @@ "recast": "0.21.5", "title-case": "3.0.3", "transliteration": "2.3.5", - "xml2js": "catalog:" + "xml2js": "catalog:", + "zod": "catalog:" } } diff --git a/packages/workflow/src/FromAIParseUtils.ts b/packages/workflow/src/FromAIParseUtils.ts new file mode 100644 index 0000000000..4d86040b23 --- /dev/null +++ b/packages/workflow/src/FromAIParseUtils.ts @@ -0,0 +1,265 @@ +import { z } from 'zod'; + +import { jsonParse } from './utils'; + +/** + * This file contains the logic for parsing node parameters and extracting $fromAI calls + */ + +export type FromAIArgumentType = 'string' | 'number' | 'boolean' | 'json'; +export type FromAIArgument = { + key: string; + description?: string; + type?: FromAIArgumentType; + defaultValue?: string | number | boolean | Record; +}; + +class ParseError extends Error {} + +/** + * 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. + */ +export function 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; +} + +/** + * 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. + */ +function 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; + } +} + +/** + * Parses the arguments of a single $fromAI function call. + * @param argsString The string containing the function arguments. + * @returns A FromAIArgument object. + */ +function parseArguments(argsString: string): FromAIArgument { + // Split arguments by commas not inside quotes + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + let quoteChar = ''; + let escapeNext = false; + + 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; + } + + 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, '\\'); + } + return trimmed; + }); + + const type = cleanArgs?.[2] || 'string'; + + if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { + throw new ParseError(`Invalid type: ${type}`); + } + + return { + key: cleanArgs[0] || '', + description: cleanArgs[1], + type: (cleanArgs?.[2] ?? 'string') as FromAIArgumentType, + defaultValue: parseDefaultValue(cleanArgs[3]), + }; +} + +/** + * 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. + * + */ +export function 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 = parseArguments(argsString); + args.push(parsedArgs); + } catch (error) { + // If parsing fails, throw an ParseError with details + throw new ParseError(`Failed to parse $fromAI arguments: ${argsString}: ${String(error)}`); + } + } else { + // Log an error if parentheses are unbalanced + throw new ParseError( + `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, + ); + } + } + + return args; +} + +/** + * 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. + */ +export function traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) { + if (typeof payload === 'string') { + const fromAICalls = extractFromAICalls(payload); + fromAICalls.forEach((call) => collectedArgs.push(call)); + } else if (Array.isArray(payload)) { + payload.forEach((item: unknown) => traverseNodeParameters(item, collectedArgs)); + } else if (typeof payload === 'object' && payload !== null) { + Object.values(payload).forEach((value) => traverseNodeParameters(value, collectedArgs)); + } +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 992a27921f..4b32c2f601 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -13,6 +13,7 @@ export * from './Interfaces'; export * from './MessageEventBus'; export * from './ExecutionStatus'; export * from './Expression'; +export * from './FromAIParseUtils'; export * from './NodeHelpers'; export * from './Workflow'; export * from './WorkflowDataProxy'; diff --git a/packages/workflow/test/FromAIParseUtils.test.ts b/packages/workflow/test/FromAIParseUtils.test.ts new file mode 100644 index 0000000000..4c07f33fca --- /dev/null +++ b/packages/workflow/test/FromAIParseUtils.test.ts @@ -0,0 +1,84 @@ +import { + extractFromAICalls, + type FromAIArgument, + traverseNodeParameters, +} from '@/FromAIParseUtils'; + +// Note that for historic reasons a lot of testing of this file happens indirectly in `packages/core/test/CreateNodeAsTool.test.ts` + +describe('extractFromAICalls', () => { + test.each<[string, [unknown, unknown, unknown, unknown]]>([ + ['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]], + ['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]], + ['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]], + ])('should parse args as expected for %s', (formula, [key, description, type, defaultValue]) => { + expect(extractFromAICalls(formula)).toEqual([ + { + key, + description, + type, + defaultValue, + }, + ]); + }); + + test.each([ + ['$fromAI("a", "b", "c")'], + ['$fromAI("a", "b", "string"'], + ['$fromAI("a", "b", "string, "d")'], + ])('should throw as expected for %s', (formula) => { + expect(() => extractFromAICalls(formula)).toThrowError(); + }); + + it('supports multiple calls', () => { + const code = '$fromAI("a", "b", "number"); $fromAI("c", "d", "string")'; + + expect(extractFromAICalls(code)).toEqual([ + { + key: 'a', + description: 'b', + type: 'number', + defaultValue: undefined, + }, + { + key: 'c', + description: 'd', + type: 'string', + defaultValue: undefined, + }, + ]); + }); + + it('supports no calls', () => { + const code = 'fromAI("a", "b", "number")'; + + expect(extractFromAICalls(code)).toEqual([]); + }); +}); + +describe('traverseNodeParameters', () => { + test.each<[string | string[] | Record, [unknown, unknown, unknown, unknown]]>([ + ['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]], + ['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]], + ['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]], + [{ a: '{{ $fromAI("a", "b", "boolean") }}', b: 'five' }, ['a', 'b', 'boolean', undefined]], + [ + ['red', '{{ $fromAI("a", "b", "boolean") }}'], + ['a', 'b', 'boolean', undefined], + ], + ])( + 'should parse args as expected for %s', + (parameters, [key, description, type, defaultValue]) => { + const out: FromAIArgument[] = []; + traverseNodeParameters(parameters, out); + expect(out).toEqual([ + { + key, + description, + type, + defaultValue, + }, + ]); + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98383c0ec5..e6d81d4e06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2020,6 +2020,9 @@ importers: xml2js: specifier: 'catalog:' version: 0.6.2 + zod: + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@langchain/core': specifier: 'catalog:'