From da44fe4b8967055b7b1f849750e1fafa0ba67218 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 4 Sep 2024 12:06:17 +0200 Subject: [PATCH] feat(core): Implement wrapping of regular nodes as AI Tools (#10641) Co-authored-by: JP van Oosten --- .../@n8n/nodes-langchain/utils/helpers.ts | 2 +- .../cli/src/load-nodes-and-credentials.ts | 36 ++- packages/cli/src/node-types.ts | 15 +- packages/core/package.json | 4 +- packages/core/src/CreateNodeAsTool.ts | 296 ++++++++++++++++++ packages/core/src/DirectoryLoader.ts | 6 + packages/core/src/NodeExecuteFunctions.ts | 19 +- packages/core/test/CreateNodeAsTool.test.ts | 92 ++++++ .../composables/useActionsGeneration.ts | 1 + packages/workflow/src/Interfaces.ts | 5 + packages/workflow/src/NodeHelpers.ts | 53 ++++ packages/workflow/test/NodeHelpers.test.ts | 86 +++++ pnpm-lock.yaml | 18 +- 13 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/CreateNodeAsTool.ts create mode 100644 packages/core/test/CreateNodeAsTool.test.ts diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 05b4a5cadc6f7..bdac2048b2d9c 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,10 +1,10 @@ +import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions, IWebhookFunctions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseOutputParser } from '@langchain/core/output_parsers'; import type { BaseMessage } from '@langchain/core/messages'; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 5b2cc284e93dc..eb6c55fa5dd7e 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -13,11 +13,12 @@ import { } from 'n8n-core'; import type { KnownNodesAndCredentials, + INodeTypeBaseDescription, INodeTypeDescription, INodeTypeData, ICredentialTypeData, } from 'n8n-workflow'; -import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { CUSTOM_API_CALL_KEY, @@ -38,8 +39,11 @@ interface LoadedNodesAndCredentials { export class LoadNodesAndCredentials { private known: KnownNodesAndCredentials = { nodes: {}, credentials: {} }; + // This contains the actually loaded objects, and their source paths loaded: LoadedNodesAndCredentials = { nodes: {}, credentials: {} }; + // For nodes, this only contains the descriptions, loaded from either the + // actual file, or the lazy loaded json types: Types = { nodes: [], credentials: [] }; loaders: Record = {}; @@ -260,6 +264,34 @@ export class LoadNodesAndCredentials { return loader; } + /** + * This creates all AI Agent tools by duplicating the node descriptions for + * all nodes that are marked as `usableAsTool`. It basically modifies the + * description. The actual wrapping happens in the langchain code for getting + * the connected tools. + */ + createAiTools() { + const usableNodes: Array = + this.types.nodes.filter((nodetype) => nodetype.usableAsTool === true); + + for (const usableNode of usableNodes) { + const description: INodeTypeBaseDescription | INodeTypeDescription = + structuredClone(usableNode); + const wrapped = NodeHelpers.convertNodeToAiTool({ description }).description; + + this.types.nodes.push(wrapped); + this.known.nodes[wrapped.name] = structuredClone(this.known.nodes[usableNode.name]); + + const credentialNames = Object.entries(this.known.credentials) + .filter(([_, credential]) => credential?.supportedNodes?.includes(usableNode.name)) + .map(([credentialName]) => credentialName); + + credentialNames.forEach((name) => + this.known.credentials[name]?.supportedNodes?.push(wrapped.name), + ); + } + } + async postProcessLoaders() { this.known = { nodes: {}, credentials: {} }; this.loaded = { nodes: {}, credentials: {} }; @@ -307,6 +339,8 @@ export class LoadNodesAndCredentials { } } + this.createAiTools(); + this.injectCustomApiCallOptions(); for (const postProcessor of this.postProcessors) { diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index dc8ea2860cda9..550b836a16c66 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -43,7 +43,15 @@ export class NodeTypes implements INodeTypes { } getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(this.getNode(nodeType).type, version); + const versionedNodeType = NodeHelpers.getVersionedNodeType( + this.getNode(nodeType).type, + version, + ); + if (versionedNodeType.description.usableAsTool) { + return NodeHelpers.convertNodeToAiTool(versionedNodeType); + } + + return versionedNodeType; } /* Some nodeTypes need to get special parameters applied like the polling nodes the polling times */ @@ -66,8 +74,9 @@ export class NodeTypes implements INodeTypes { if (type in knownNodes) { const { className, sourcePath } = knownNodes[type]; - const loaded: INodeType = loadClassInIsolation(sourcePath, className); - NodeHelpers.applySpecialNodeParameters(loaded); + const loaded: INodeType | IVersionedNodeType = loadClassInIsolation(sourcePath, className); + if (NodeHelpers.isINodeType(loaded)) NodeHelpers.applySpecialNodeParameters(loaded); + loadedNodes[type] = { sourcePath, type: loaded }; return loadedNodes[type]; } diff --git a/packages/core/package.json b/packages/core/package.json index 04412c4c8ce14..f0de12c0f49bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ "file-type": "16.5.4", "form-data": "catalog:", "lodash": "catalog:", + "@langchain/core": "0.2.18", "luxon": "catalog:", "mime-types": "2.1.35", "n8n-workflow": "workspace:*", @@ -54,6 +55,7 @@ "ssh2": "1.15.0", "typedi": "catalog:", "uuid": "catalog:", - "xml2js": "catalog:" + "xml2js": "catalog:", + "zod": "catalog:" } } diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts new file mode 100644 index 0000000000000..21e1b6352ad1a --- /dev/null +++ b/packages/core/src/CreateNodeAsTool.ts @@ -0,0 +1,296 @@ +/** + * @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 { 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', ''); +} + +/** + * 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. + */ +function decodeDotNotation(key: string): string { + // Simply replace double underscores with dots + return key.replace(/__/g, '.'); +} + +/** + * 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); + } + } + } + + return results; +} + +/** + * 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); + } + }); +} + +/** + * 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]] = {}; + } + current = current[remainingPath[i]] as Record; + } + + // Set the value at the deepest level + const lastKey = remainingPath[remainingPath.length - 1]; + current[lastKey ?? matchingKey] = values[matchingKey]; + } + + // If no nested structure was created, return the direct value + return Object.keys(result).length === 0 ? values[encodeDotNotation(baseKey)] : result; +} + +/** + * 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'; +} + +/** + * 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); + } + 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 }]]); + + // Execute the node with the proxied context + const result = await node.execute?.bind(ctxProxy)(); + + // Process and map the results + const mappedResults = result?.[0]?.flatMap((item) => item.json); + + // Add output data to the context + ctxProxy.addOutputData(NodeConnectionType.AiTool, 0, [ + [{ json: { response: mappedResults } }], + ]); + + // Return the stringified results + return JSON.stringify(mappedResults); + }, + }); + + 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. + */ +export function getNodeAsTool( + ctx: IExecuteFunctions, + node: INodeType, + nodeParameters: INodeParameters, +) { + return { + response: createNodeAsTool(node, ctx, nodeParameters), + }; +} diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 77c107ef732a6..717edd5359de8 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -40,14 +40,20 @@ export type Types = { export abstract class DirectoryLoader { isLazyLoaded = false; + // Another way of keeping track of the names and versions of a node. This + // seems to only be used by the installedPackages repository loadedNodes: INodeTypeNameVersion[] = []; + // Stores the loaded descriptions and sourcepaths nodeTypes: INodeTypeData = {}; credentialTypes: ICredentialTypeData = {}; + // Stores the location and classnames of the nodes and credentials that are + // loaded; used to actually load the files in lazy-loading scenario. known: KnownNodesAndCredentials = { nodes: {}, credentials: {} }; + // Stores the different versions with their individual descriptions types: Types = { nodes: [], credentials: [] }; protected nodesByCredential: Record = {}; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index c4c00bd3c8013..3f7be2a7050df 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -159,6 +159,7 @@ import { InstanceSettings } from './InstanceSettings'; import { ScheduledTaskManager } from './ScheduledTaskManager'; import { SSHClientsManager } from './SSHClientsManager'; import { binaryToBuffer } from './BinaryData/utils'; +import { getNodeAsTool } from './CreateNodeAsTool'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -2780,12 +2781,6 @@ async function getInputConnectionData( connectedNode.typeVersion, ); - if (!nodeType.supplyData) { - throw new ApplicationError('Node does not have a `supplyData` method defined', { - extra: { nodeName: connectedNode.name }, - }); - } - const context = Object.assign({}, this); context.getNodeParameter = ( @@ -2853,6 +2848,18 @@ 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); + }; + } else { + throw new ApplicationError('Node does not have a `supplyData` method defined', { + extra: { nodeName: connectedNode.name }, + }); + } + } + try { const response = await nodeType.supplyData.call(context, itemIndex); if (response.closeFunction) { diff --git a/packages/core/test/CreateNodeAsTool.test.ts b/packages/core/test/CreateNodeAsTool.test.ts new file mode 100644 index 0000000000000..c4509e08be239 --- /dev/null +++ b/packages/core/test/CreateNodeAsTool.test.ts @@ -0,0 +1,92 @@ +import { createNodeAsTool } from '@/CreateNodeAsTool'; +import type { IExecuteFunctions, INodeParameters, INodeType } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; +import { z } from 'zod'; + +jest.mock('@langchain/core/tools', () => ({ + DynamicStructuredTool: jest.fn().mockImplementation((config) => ({ + name: config.name, + description: config.description, + schema: config.schema, + func: config.func, + })), +})); + +describe('createNodeAsTool', () => { + let mockCtx: IExecuteFunctions; + let mockNode: INodeType; + let mockNodeParameters: INodeParameters; + + beforeEach(() => { + mockCtx = { + getNodeParameter: jest.fn(), + addInputData: jest.fn(), + addOutputData: jest.fn(), + } as unknown as IExecuteFunctions; + + mockNode = { + description: { + name: 'TestNode', + description: 'Test node description', + }, + execute: jest.fn().mockResolvedValue([[{ json: { result: 'test' } }]]), + } as unknown as INodeType; + + mockNodeParameters = { + param1: "{{ '__PLACEHOLDER: Test parameter' }}", + param2: 'static value', + nestedParam: { + subParam: "{{ '__PLACEHOLDER: Nested parameter' }}", + }, + }; + jest.clearAllMocks(); + }); + + it('should create a DynamicStructuredTool with correct properties', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool).toBeDefined(); + expect(tool.name).toBe('TestNode'); + expect(tool.description).toBe('Test node description'); + expect(tool.schema).toBeDefined(); + }); + + it('should use toolDescription if provided', () => { + const customDescription = 'Custom tool description'; + (mockCtx.getNodeParameter as jest.Mock).mockReturnValue(customDescription); + + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.description).toBe(customDescription); + }); + + it('should create a schema based on placeholder values in nodeParameters', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.schema).toBeDefined(); + expect(tool.schema.shape).toHaveProperty('param1'); + expect(tool.schema.shape).toHaveProperty('nestedParam__subParam'); + expect(tool.schema.shape).not.toHaveProperty('param2'); + }); + + it('should handle nested parameters correctly', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.schema.shape.nestedParam__subParam).toBeInstanceOf(z.ZodString); + }); + + it('should create a function that wraps the node execution', async () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + const result = await tool.func({ param1: 'test value', nestedParam__subParam: 'nested value' }); + + 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' }])); + }); +}); diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts index 7385100141e85..85c306cdf6c5f 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts @@ -229,6 +229,7 @@ function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTy export function useActionsGenerator() { function generateNodeActions(node: INodeTypeDescription | undefined) { if (!node) return []; + if (node.codex?.subcategories?.AI?.includes('Tools')) return []; return [...triggersCategory(node), ...operationsCategory(node), ...resourceCategories(node)]; } function filterActions(actions: ActionTypeDescription[]) { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index d828fa58b4665..ae6ce96f70a68 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1658,6 +1658,11 @@ export interface INodeTypeBaseDescription { * due to deprecation or as a special case (e.g. Start node) */ hidden?: true; + + /** + * Whether the node will be wrapped for tool-use by AI Agents + */ + usableAsTool?: true; } export interface INodePropertyRouting { diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 635bccddfe93b..1a58de2302a3a 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -36,6 +36,7 @@ import type { NodeParameterValue, ResourceMapperValue, INodeTypeDescription, + INodeTypeBaseDescription, INodeOutputConfiguration, INodeInputConfiguration, GenericValue, @@ -351,6 +352,58 @@ const declarativeNodeOptionParameters: INodeProperties = { ], }; +/** + * Determines if the node is of INodeType + */ +export function isINodeType(obj: unknown): obj is INodeType { + return typeof obj === 'object' && obj !== null && 'execute' in obj; +} + +/** + * Modifies the description of the passed in object, such that it can be used + * as an AI Agent Tool. + * Returns the modified item (not copied) + */ +export function convertNodeToAiTool< + T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription }, +>(item: T): T { + // quick helper function for typeguard down below + function isFullDescription(obj: unknown): obj is INodeTypeDescription { + return typeof obj === 'object' && obj !== null && 'properties' in obj; + } + + if (isFullDescription(item.description)) { + item.description.name += 'Tool'; + item.description.inputs = []; + item.description.outputs = [NodeConnectionType.AiTool]; + item.description.displayName += ' Tool (wrapped)'; + delete item.description.usableAsTool; + if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) { + const descProp: INodeProperties = { + displayName: 'Description', + name: 'toolDescription', + type: 'string', + default: item.description.description, + required: true, + typeOptions: { rows: 2 }, + description: + 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', + placeholder: `e.g. ${item.description.description}`, + }; + item.description.properties.unshift(descProp); + } + } + + item.description.codex = { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + }; + return item; +} + /** * Determines if the provided node type has any output types other than the main connection type. * @param typeDescription The node's type description to check. diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index 7dd360313026c..583d423c60c74 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -13,6 +13,7 @@ import { isSingleExecution, isSubNodeType, applyDeclarativeNodeOptionParameters, + convertNodeToAiTool, } from '@/NodeHelpers'; describe('NodeHelpers', () => { @@ -3636,4 +3637,89 @@ describe('NodeHelpers', () => { expect(nodeType.description.properties).toEqual([]); }); }); + + describe('convertNodeToAiTool', () => { + let fullNodeWrapper: { description: INodeTypeDescription }; + + beforeEach(() => { + fullNodeWrapper = { + description: { + displayName: 'Test Node', + name: 'testNode', + group: ['test'], + description: 'A test node', + version: 1, + defaults: {}, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + properties: [], + }, + }; + }); + + 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)'); + }); + + it('should update inputs and outputs', () => { + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.inputs).toEqual([]); + expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]); + }); + + it('should remove the usableAsTool property', () => { + fullNodeWrapper.description.usableAsTool = true; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.usableAsTool).toBeUndefined(); + }); + + it("should add toolDescription property if it doesn't exist", () => { + const result = convertNodeToAiTool(fullNodeWrapper); + const toolDescriptionProp = result.description.properties.find( + (prop) => prop.name === 'toolDescription', + ); + expect(toolDescriptionProp).toBeDefined(); + expect(toolDescriptionProp?.type).toBe('string'); + 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({ + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + }); + }); + + it('should preserve existing properties', () => { + const existingProp: INodeProperties = { + displayName: 'Existing Prop', + name: 'existingProp', + type: 'string', + default: 'test', + }; + fullNodeWrapper.description.properties = [existingProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription + expect(result.description.properties).toContainEqual(existingProp); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e993b188c05a..8895ba097b4b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,6 +1017,9 @@ importers: packages/core: dependencies: + '@langchain/core': + specifier: 0.2.18 + version: 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 @@ -1077,6 +1080,9 @@ importers: xml2js: specifier: 'catalog:' version: 0.6.2 + zod: + specifier: 'catalog:' + version: 3.23.8 devDependencies: '@types/aws4': specifier: ^1.5.1 @@ -21616,7 +21622,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21641,7 +21647,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.2) eslint: 8.57.0 @@ -21661,7 +21667,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -22531,7 +22537,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25552,7 +25558,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -26438,7 +26444,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color