From 1fa6a720e72c3ce06bc803b97edcdd598170669f Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 30 Jul 2024 15:06:44 +0200 Subject: [PATCH 01/10] wip: use HTTP tool as DynamicStructuredTool wherever possible --- .../agents/OpenAiFunctionsAgent/execute.ts | 2 +- .../agents/Agent/agents/ToolsAgent/execute.ts | 2 +- .../OpenAiAssistant/OpenAiAssistant.node.ts | 2 +- .../ToolHttpRequest/ToolHttpRequest.node.ts | 11 +-- .../nodes/tools/ToolHttpRequest/utils.ts | 67 +++++++++++++++---- .../tools/ToolWorkflow/ToolWorkflow.node.ts | 2 +- .../actions/assistant/message.operation.ts | 2 +- .../OpenAi/actions/text/message.operation.ts | 2 +- .../@n8n/nodes-langchain/utils/N8nTool.ts | 55 +++++++++++++++ .../@n8n/nodes-langchain/utils/helpers.ts | 65 ++++++++++++++++-- 10 files changed, 183 insertions(+), 27 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/utils/N8nTool.ts diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index a027829e3a7d8..3c4ff28f06a10 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -38,7 +38,7 @@ export async function openAiFunctionsAgentExecute( const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as | BaseChatMemory | undefined; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, false); const outputParsers = await getOptionalOutputParsers(this); const options = this.getNodeParameter('options', 0, {}) as { systemMessage?: string; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 81ac6bad5db43..e0da7f1e315f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise; + const tools = (await getConnectedTools(this, true, false)) as Array; const outputParser = (await getOptionalOutputParsers(this))?.[0]; let structuredOutputParserTool: DynamicStructuredTool | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index f56ce7c5c4484..8f05faccb0274 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -313,7 +313,7 @@ export class OpenAiAssistant implements INodeType { async execute(this: IExecuteFunctions): Promise { const nodeVersion = this.getNode().typeVersion; - const tools = await getConnectedTools(this, nodeVersion > 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); const credentials = await this.getCredentials('openAiApi'); const items = this.getInputData(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 1d391f313ef36..46dc411fedc42 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -9,16 +9,15 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } from 'n8n-workflow'; -import { DynamicTool } from '@langchain/core/tools'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { configureHttpRequestFunction, configureResponseOptimizer, extractParametersFromText, - prepareToolDescription, configureToolFunction, updateParametersAndOptions, + makeToolInputSchema, } from './utils'; import { @@ -31,6 +30,7 @@ import { } from './descriptions'; import type { PlaceholderDefinition, ToolParameter } from './interfaces'; +import { N8nTool } from '../../../utils/N8nTool'; export class ToolHttpRequest implements INodeType { description: INodeTypeDescription = { @@ -394,9 +394,12 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const description = prepareToolDescription(toolDescription, toolParameters); + // const description = prepareToolDescription(toolDescription, toolParameters); + // const tool = new DynamicTool({ name, description, func }); - const tool = new DynamicTool({ name, description, func }); + const schema = makeToolInputSchema(toolParameters); + + const tool = new N8nTool(this, { name, description: toolDescription, func, schema }); return { response: tool, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 28015b588a101..1582e74d70f6a 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -27,6 +27,8 @@ import type { SendIn, ToolParameter, } from './interfaces'; +import type { DynamicZodObject } from '../../../types/zod.types'; +import { z } from 'zod'; const genericCredentialRequest = async (ctx: IExecuteFunctions, itemIndex: number) => { const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string; @@ -566,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: string): Promise => { + return async (query: string | IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; @@ -581,18 +583,22 @@ export const configureToolFunction = ( if (query) { let dataFromModel; - try { - dataFromModel = jsonParse(query); - } catch (error) { - if (toolParameters.length === 1) { - dataFromModel = { [toolParameters[0].name]: query }; - } else { - throw new NodeOperationError( - ctx.getNode(), - `Input is not a valid JSON: ${error.message}`, - { itemIndex }, - ); + if (typeof query === 'string') { + try { + dataFromModel = jsonParse(query); + } catch (error) { + if (toolParameters.length === 1) { + dataFromModel = { [toolParameters[0].name]: query }; + } else { + throw new NodeOperationError( + ctx.getNode(), + `Input is not a valid JSON: ${error.message}`, + { itemIndex }, + ); + } } + } else { + dataFromModel = query; } for (const parameter of toolParameters) { @@ -727,6 +733,8 @@ export const configureToolFunction = ( } } } catch (error) { + console.error(error); + const errorMessage = 'Input provided by model is not valid'; if (error instanceof NodeOperationError) { @@ -765,3 +773,38 @@ export const configureToolFunction = ( return response; }; }; + +function makeParameterZodSchema(parameter: ToolParameter) { + let schema: z.ZodTypeAny; + + if (parameter.type === 'string') { + schema = z.string(); + } else if (parameter.type === 'number') { + schema = z.number(); + } else if (parameter.type === 'boolean') { + schema = z.boolean(); + } else if (parameter.type === 'json') { + schema = z.string(); + } else { + schema = z.unknown(); + } + + if (!parameter.required) { + schema = schema.optional(); + } + + if (parameter.description) { + schema = schema.describe(parameter.description); + } + + return schema; +} + +export function makeToolInputSchema(parameters: ToolParameter[]): DynamicZodObject { + const schemaEntries = parameters.map((parameter) => [ + parameter.name, + makeParameterZodSchema(parameter), + ]); + + return z.object(Object.fromEntries(schemaEntries)); +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 54b8318f5b8b8..5ed96cbd60123 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -493,7 +493,7 @@ export class ToolWorkflow implements INodeType { if (useSchema) { try { // We initialize these even though one of them will always be empty - // it makes it easer to navigate the ternary operator + // it makes it easier to navigate the ternary operator const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts index da2770d05f2eb..134e8a1167924 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts @@ -163,7 +163,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); let assistantTools; if (tools.length) { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts index 4cf72e9f5f48c..d37be5a065e6d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts @@ -219,7 +219,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1; - externalTools = await getConnectedTools(this, enforceUniqueNames); + externalTools = await getConnectedTools(this, enforceUniqueNames, false); } if (externalTools.length) { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts new file mode 100644 index 0000000000000..3afd4e98e2e1e --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -0,0 +1,55 @@ +import type { DynamicStructuredToolInput } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; +import { StructuredOutputParser } from 'langchain/output_parsers'; + +export class N8nTool extends DynamicStructuredTool { + private context: IExecuteFunctions; + + constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { + super(fields); + + this.context = context; + } + + asDynamicTool(): DynamicTool { + const { name, func, description, schema, context } = this; + + const parser = new StructuredOutputParser(schema); + const formattingInstructions = parser.getFormatInstructions(); + + console.log({ formattingInstructions }); + + const wrappedFunc = async function (query: string) { + console.log('TOOL CALLED'); + + try { + const parsedQuery = await parser.parse(query); + + console.log({ parsedQuery }); + + const result = await func(parsedQuery); + console.log({ result }); + + return result; + } catch (e) { + console.log('ERROR', e); + + const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + void context.addOutputData(NodeConnectionType.AiTool, index, e); + + return e.toString(); + } + }; + + console.log('CONVERTED to DynamicTool'); + + return new DynamicTool({ + name, + description: + description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), + func: wrappedFunc, + }); + } +} diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index c6d27ee2f69f3..18bf2579d1ddd 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,17 +1,18 @@ -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'; -import { DynamicTool, type Tool } from '@langchain/core/tools'; +import type { Tool } from '@langchain/core/tools'; import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseChatMemory } from 'langchain/memory'; import type { BaseChatMessageHistory } from '@langchain/core/chat_history'; +import { N8nTool } from './N8nTool'; function hasMethods(obj: unknown, ...methodNames: Array): obj is T { return methodNames.every( @@ -178,7 +179,51 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } -export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNames: boolean) => { +// function convertToSimpleTool(ctx: IExecuteFunctions, tool: DynamicStructuredTool): DynamicTool { +// const name = tool.name; +// const description = tool.description; +// const func = tool.func; +// +// const parser = new StructuredOutputParser(tool.schema); +// +// const formattingInstructions = parser.getFormatInstructions(); +// +// const wrappedFunc = async function (query: string) { +// console.log('TOOL CALLED'); +// +// try { +// const parsedQuery = await parser.parse(query); +// +// console.log({ parsedQuery }); +// +// const result = await func(parsedQuery); +// +// console.log(result); +// return result; +// } catch (e) { +// console.log(query); +// // const result = await func(JSON.parse(query)); +// +// console.log(result); +// console.log(e); +// +// return e.toString(); +// } +// }; +// +// return new DynamicTool({ +// name, +// description: +// description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), +// func: wrappedFunc, +// }); +// } + +export const getConnectedTools = async ( + ctx: IExecuteFunctions, + enforceUniqueNames: boolean, + convertStructuredTool: boolean = true, +) => { const connectedTools = ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; @@ -186,8 +231,12 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam const seenNames = new Set(); + const finalTools = []; + for (const tool of connectedTools) { - if (!(tool instanceof DynamicTool)) continue; + // console.log({ tool }); + + // if (!(tool instanceof DynamicTool)) continue; const { name } = tool; if (seenNames.has(name)) { @@ -197,7 +246,13 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam ); } seenNames.add(name); + + if (convertStructuredTool && tool instanceof N8nTool) { + finalTools.push(tool.asDynamicTool()); + } else { + finalTools.push(tool); + } } - return connectedTools; + return finalTools; }; From 26c3728f11a2230adfc175f9d3f715eef052950c Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 30 Jul 2024 15:06:44 +0200 Subject: [PATCH 02/10] wip: use HTTP tool as DynamicStructuredTool wherever possible --- .../agents/OpenAiFunctionsAgent/execute.ts | 2 +- .../agents/Agent/agents/ToolsAgent/execute.ts | 2 +- .../OpenAiAssistant/OpenAiAssistant.node.ts | 2 +- .../ToolHttpRequest/ToolHttpRequest.node.ts | 11 +-- .../nodes/tools/ToolHttpRequest/utils.ts | 67 +++++++++++++++---- .../tools/ToolWorkflow/ToolWorkflow.node.ts | 2 +- .../actions/assistant/message.operation.ts | 2 +- .../OpenAi/actions/text/message.operation.ts | 2 +- .../@n8n/nodes-langchain/utils/N8nTool.ts | 55 +++++++++++++++ .../@n8n/nodes-langchain/utils/helpers.ts | 65 ++++++++++++++++-- 10 files changed, 183 insertions(+), 27 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/utils/N8nTool.ts diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index a027829e3a7d8..3c4ff28f06a10 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -38,7 +38,7 @@ export async function openAiFunctionsAgentExecute( const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as | BaseChatMemory | undefined; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, false); const outputParsers = await getOptionalOutputParsers(this); const options = this.getNodeParameter('options', 0, {}) as { systemMessage?: string; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 81ac6bad5db43..e0da7f1e315f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise; + const tools = (await getConnectedTools(this, true, false)) as Array; const outputParser = (await getOptionalOutputParsers(this))?.[0]; let structuredOutputParserTool: DynamicStructuredTool | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index f56ce7c5c4484..8f05faccb0274 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -313,7 +313,7 @@ export class OpenAiAssistant implements INodeType { async execute(this: IExecuteFunctions): Promise { const nodeVersion = this.getNode().typeVersion; - const tools = await getConnectedTools(this, nodeVersion > 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); const credentials = await this.getCredentials('openAiApi'); const items = this.getInputData(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 1d391f313ef36..46dc411fedc42 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -9,16 +9,15 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } from 'n8n-workflow'; -import { DynamicTool } from '@langchain/core/tools'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { configureHttpRequestFunction, configureResponseOptimizer, extractParametersFromText, - prepareToolDescription, configureToolFunction, updateParametersAndOptions, + makeToolInputSchema, } from './utils'; import { @@ -31,6 +30,7 @@ import { } from './descriptions'; import type { PlaceholderDefinition, ToolParameter } from './interfaces'; +import { N8nTool } from '../../../utils/N8nTool'; export class ToolHttpRequest implements INodeType { description: INodeTypeDescription = { @@ -394,9 +394,12 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const description = prepareToolDescription(toolDescription, toolParameters); + // const description = prepareToolDescription(toolDescription, toolParameters); + // const tool = new DynamicTool({ name, description, func }); - const tool = new DynamicTool({ name, description, func }); + const schema = makeToolInputSchema(toolParameters); + + const tool = new N8nTool(this, { name, description: toolDescription, func, schema }); return { response: tool, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 28015b588a101..1582e74d70f6a 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -27,6 +27,8 @@ import type { SendIn, ToolParameter, } from './interfaces'; +import type { DynamicZodObject } from '../../../types/zod.types'; +import { z } from 'zod'; const genericCredentialRequest = async (ctx: IExecuteFunctions, itemIndex: number) => { const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string; @@ -566,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: string): Promise => { + return async (query: string | IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; @@ -581,18 +583,22 @@ export const configureToolFunction = ( if (query) { let dataFromModel; - try { - dataFromModel = jsonParse(query); - } catch (error) { - if (toolParameters.length === 1) { - dataFromModel = { [toolParameters[0].name]: query }; - } else { - throw new NodeOperationError( - ctx.getNode(), - `Input is not a valid JSON: ${error.message}`, - { itemIndex }, - ); + if (typeof query === 'string') { + try { + dataFromModel = jsonParse(query); + } catch (error) { + if (toolParameters.length === 1) { + dataFromModel = { [toolParameters[0].name]: query }; + } else { + throw new NodeOperationError( + ctx.getNode(), + `Input is not a valid JSON: ${error.message}`, + { itemIndex }, + ); + } } + } else { + dataFromModel = query; } for (const parameter of toolParameters) { @@ -727,6 +733,8 @@ export const configureToolFunction = ( } } } catch (error) { + console.error(error); + const errorMessage = 'Input provided by model is not valid'; if (error instanceof NodeOperationError) { @@ -765,3 +773,38 @@ export const configureToolFunction = ( return response; }; }; + +function makeParameterZodSchema(parameter: ToolParameter) { + let schema: z.ZodTypeAny; + + if (parameter.type === 'string') { + schema = z.string(); + } else if (parameter.type === 'number') { + schema = z.number(); + } else if (parameter.type === 'boolean') { + schema = z.boolean(); + } else if (parameter.type === 'json') { + schema = z.string(); + } else { + schema = z.unknown(); + } + + if (!parameter.required) { + schema = schema.optional(); + } + + if (parameter.description) { + schema = schema.describe(parameter.description); + } + + return schema; +} + +export function makeToolInputSchema(parameters: ToolParameter[]): DynamicZodObject { + const schemaEntries = parameters.map((parameter) => [ + parameter.name, + makeParameterZodSchema(parameter), + ]); + + return z.object(Object.fromEntries(schemaEntries)); +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 54b8318f5b8b8..5ed96cbd60123 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -493,7 +493,7 @@ export class ToolWorkflow implements INodeType { if (useSchema) { try { // We initialize these even though one of them will always be empty - // it makes it easer to navigate the ternary operator + // it makes it easier to navigate the ternary operator const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts index da2770d05f2eb..134e8a1167924 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts @@ -163,7 +163,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); let assistantTools; if (tools.length) { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts index 4cf72e9f5f48c..d37be5a065e6d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts @@ -219,7 +219,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1; - externalTools = await getConnectedTools(this, enforceUniqueNames); + externalTools = await getConnectedTools(this, enforceUniqueNames, false); } if (externalTools.length) { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts new file mode 100644 index 0000000000000..3afd4e98e2e1e --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -0,0 +1,55 @@ +import type { DynamicStructuredToolInput } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; +import { StructuredOutputParser } from 'langchain/output_parsers'; + +export class N8nTool extends DynamicStructuredTool { + private context: IExecuteFunctions; + + constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { + super(fields); + + this.context = context; + } + + asDynamicTool(): DynamicTool { + const { name, func, description, schema, context } = this; + + const parser = new StructuredOutputParser(schema); + const formattingInstructions = parser.getFormatInstructions(); + + console.log({ formattingInstructions }); + + const wrappedFunc = async function (query: string) { + console.log('TOOL CALLED'); + + try { + const parsedQuery = await parser.parse(query); + + console.log({ parsedQuery }); + + const result = await func(parsedQuery); + console.log({ result }); + + return result; + } catch (e) { + console.log('ERROR', e); + + const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + void context.addOutputData(NodeConnectionType.AiTool, index, e); + + return e.toString(); + } + }; + + console.log('CONVERTED to DynamicTool'); + + return new DynamicTool({ + name, + description: + description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), + func: wrappedFunc, + }); + } +} diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index c6d27ee2f69f3..18bf2579d1ddd 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,17 +1,18 @@ -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'; -import { DynamicTool, type Tool } from '@langchain/core/tools'; +import type { Tool } from '@langchain/core/tools'; import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseChatMemory } from 'langchain/memory'; import type { BaseChatMessageHistory } from '@langchain/core/chat_history'; +import { N8nTool } from './N8nTool'; function hasMethods(obj: unknown, ...methodNames: Array): obj is T { return methodNames.every( @@ -178,7 +179,51 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } -export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNames: boolean) => { +// function convertToSimpleTool(ctx: IExecuteFunctions, tool: DynamicStructuredTool): DynamicTool { +// const name = tool.name; +// const description = tool.description; +// const func = tool.func; +// +// const parser = new StructuredOutputParser(tool.schema); +// +// const formattingInstructions = parser.getFormatInstructions(); +// +// const wrappedFunc = async function (query: string) { +// console.log('TOOL CALLED'); +// +// try { +// const parsedQuery = await parser.parse(query); +// +// console.log({ parsedQuery }); +// +// const result = await func(parsedQuery); +// +// console.log(result); +// return result; +// } catch (e) { +// console.log(query); +// // const result = await func(JSON.parse(query)); +// +// console.log(result); +// console.log(e); +// +// return e.toString(); +// } +// }; +// +// return new DynamicTool({ +// name, +// description: +// description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), +// func: wrappedFunc, +// }); +// } + +export const getConnectedTools = async ( + ctx: IExecuteFunctions, + enforceUniqueNames: boolean, + convertStructuredTool: boolean = true, +) => { const connectedTools = ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; @@ -186,8 +231,12 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam const seenNames = new Set(); + const finalTools = []; + for (const tool of connectedTools) { - if (!(tool instanceof DynamicTool)) continue; + // console.log({ tool }); + + // if (!(tool instanceof DynamicTool)) continue; const { name } = tool; if (seenNames.has(name)) { @@ -197,7 +246,13 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam ); } seenNames.add(name); + + if (convertStructuredTool && tool instanceof N8nTool) { + finalTools.push(tool.asDynamicTool()); + } else { + finalTools.push(tool); + } } - return connectedTools; + return finalTools; }; From 18867645bc558d429d80f98a5788d3b389fc9603 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 30 Jul 2024 16:44:54 +0200 Subject: [PATCH 03/10] wip: Fix zod schema for a JSON parameter --- .../@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 1582e74d70f6a..dc17982da97f4 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -784,7 +784,7 @@ function makeParameterZodSchema(parameter: ToolParameter) { } else if (parameter.type === 'boolean') { schema = z.boolean(); } else if (parameter.type === 'json') { - schema = z.string(); + schema = z.record(z.any()); } else { schema = z.unknown(); } From 73053866dc4b919914f6344f3c2121980d913f72 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Thu, 1 Aug 2024 10:49:05 +0200 Subject: [PATCH 04/10] wip: using fallback tool description for older models --- .../ToolHttpRequest/ToolHttpRequest.node.ts | 11 +++++++++-- packages/@n8n/nodes-langchain/utils/N8nTool.ts | 18 ++++++++++++------ packages/@n8n/nodes-langchain/utils/helpers.ts | 5 ++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 46dc411fedc42..b311ce4257887 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -18,6 +18,7 @@ import { configureToolFunction, updateParametersAndOptions, makeToolInputSchema, + prepareToolDescription, } from './utils'; import { @@ -394,12 +395,18 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - // const description = prepareToolDescription(toolDescription, toolParameters); + const fallbackDescription = prepareToolDescription(toolDescription, toolParameters); // const tool = new DynamicTool({ name, description, func }); const schema = makeToolInputSchema(toolParameters); - const tool = new N8nTool(this, { name, description: toolDescription, func, schema }); + const tool = new N8nTool(this, { + name, + description: toolDescription, + fallbackDescription, + func, + schema, + }); return { response: tool, diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index 3afd4e98e2e1e..4f56b5df41714 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -7,19 +7,25 @@ import { StructuredOutputParser } from 'langchain/output_parsers'; export class N8nTool extends DynamicStructuredTool { private context: IExecuteFunctions; - constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { + private fallbackDescription: string; + + constructor( + context: IExecuteFunctions, + fields: DynamicStructuredToolInput & { fallbackDescription?: string }, + ) { super(fields); this.context = context; + this.fallbackDescription = fields.fallbackDescription ?? ''; } asDynamicTool(): DynamicTool { - const { name, func, description, schema, context } = this; + const { name, func, schema, context } = this; const parser = new StructuredOutputParser(schema); - const formattingInstructions = parser.getFormatInstructions(); + // const formattingInstructions = parser.getFormatInstructions(); - console.log({ formattingInstructions }); + // console.log({ formattingInstructions }); const wrappedFunc = async function (query: string) { console.log('TOOL CALLED'); @@ -47,8 +53,8 @@ export class N8nTool extends DynamicStructuredTool { return new DynamicTool({ name, - description: - description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), + description: this.fallbackDescription, + // description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), func: wrappedFunc, }); } diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 18bf2579d1ddd..6e6a937184d33 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -13,6 +13,7 @@ import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseChatMemory } from 'langchain/memory'; import type { BaseChatMessageHistory } from '@langchain/core/chat_history'; import { N8nTool } from './N8nTool'; +import { DynamicTool } from '@langchain/core/tools'; function hasMethods(obj: unknown, ...methodNames: Array): obj is T { return methodNames.every( @@ -234,9 +235,7 @@ export const getConnectedTools = async ( const finalTools = []; for (const tool of connectedTools) { - // console.log({ tool }); - - // if (!(tool instanceof DynamicTool)) continue; + if (!(tool instanceof DynamicTool) && !(tool instanceof N8nTool)) continue; const { name } = tool; if (seenNames.has(name)) { From 377897ef40f2fd316657810e757da83589298445 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Mon, 5 Aug 2024 18:12:18 +0200 Subject: [PATCH 05/10] wip: wrap HTTP Tool into N8nTool, add tests --- .../ToolHttpRequest/ToolHttpRequest.node.ts | 4 - .../nodes/tools/ToolHttpRequest/utils.ts | 4 +- .../nodes-langchain/utils/N8nTool.spec.ts | 176 ++++++++++++++++++ .../@n8n/nodes-langchain/utils/N8nTool.ts | 100 +++++++--- .../@n8n/nodes-langchain/utils/helpers.ts | 40 ---- 5 files changed, 254 insertions(+), 70 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index b311ce4257887..7d3077e8a3ace 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -395,15 +395,11 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const fallbackDescription = prepareToolDescription(toolDescription, toolParameters); - // const tool = new DynamicTool({ name, description, func }); - const schema = makeToolInputSchema(toolParameters); const tool = new N8nTool(this, { name, description: toolDescription, - fallbackDescription, func, schema, }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index dc17982da97f4..eb04283b811f5 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -568,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: string | IDataObject): Promise => { + return async (query: IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; @@ -786,7 +786,7 @@ function makeParameterZodSchema(parameter: ToolParameter) { } else if (parameter.type === 'json') { schema = z.record(z.any()); } else { - schema = z.unknown(); + schema = z.string(); } if (!parameter.required) { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts new file mode 100644 index 0000000000000..6f4e5d69d6a00 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts @@ -0,0 +1,176 @@ +import { N8nTool } from './N8nTool'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import { z } from 'zod'; +import type { INode } from 'n8n-workflow'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; + +const mockNode: INode = { + id: '1', + name: 'Mock node', + typeVersion: 2, + type: 'n8n-nodes-base.mock', + position: [60, 760], + parameters: { + operation: 'test', + }, +}; + +describe('Test N8nTool wrapper as DynamicStructuredTool', () => { + it('should wrap a tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + expect(tool).toBeInstanceOf(DynamicStructuredTool); + }); +}); + +describe('Test N8nTool wrapper - DynamicTool fallback', () => { + it('should convert the tool to a dynamic tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool).toBeInstanceOf(DynamicTool); + }); + + it('should format fallback description correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string(), + bar: z.number().optional(), + qwe: z.boolean().describe('Boolean description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toContain('foo: (description: , type: string, required: true)'); + expect(dynamicTool.description).toContain( + 'bar: (description: , type: number, required: false)', + ); + + expect(dynamicTool.description).toContain( + 'qwe: (description: Boolean description, type: boolean, required: true)', + ); + }); + + it('should handle empty parameter list correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({}), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toEqual('A dummy tool for testing'); + }); + + it('should parse correct parameters', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + bar: z.number().optional(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameters = { foo: 'some value' }; + + await dynamicTool.func(JSON.stringify(testParameters)); + + expect(func).toHaveBeenCalledWith(testParameters); + }); + + it('should recover when 1 parameter is passed directly', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameter = 'some value'; + + await dynamicTool.func(testParameter); + + expect(func).toHaveBeenCalledWith({ foo: testParameter }); + }); + + it('should recover when JS object is passed instead of JSON', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + fallbackDescription: 'A fallback description of the dummy tool', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + await dynamicTool.func('{ foo: "some value" }'); + + expect(func).toHaveBeenCalledWith({ foo: 'some value' }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index 4f56b5df41714..4358ef9feca08 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -1,47 +1,102 @@ import type { DynamicStructuredToolInput } from '@langchain/core/tools'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import type { IExecuteFunctions } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import type { IExecuteFunctions, type IDataObject } from 'n8n-workflow'; +import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow'; import { StructuredOutputParser } from 'langchain/output_parsers'; +import type { ZodTypeAny } from 'zod'; +import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod'; + +const getSimplifiedType = (schema: ZodTypeAny) => { + if (schema instanceof ZodObject) { + return 'object'; + } else if (schema instanceof ZodNumber) { + return 'number'; + } else if (schema instanceof ZodBoolean) { + return 'boolean'; + } else if (schema instanceof ZodNullable || schema instanceof ZodOptional) { + return getSimplifiedType(schema.unwrap()); + } + + return 'string'; +}; + +const getParametersDescription = (parameters: Array<[string, ZodTypeAny]>) => + parameters + .map( + ([name, schema]) => + `${name}: (description: ${schema.description ?? ''}, type: ${getSimplifiedType(schema)}, required: ${!schema.isOptional()})`, + ) + .join(',\n '); + +export const prepareFallbackToolDescription = (toolDescription: string, schema: ZodObject) => { + let description = `${toolDescription}`; + + const toolParameters = Object.entries(schema.shape); + + if (toolParameters.length) { + description += ` +Tool expects valid stringified JSON object with ${toolParameters.length} properties. +Property names with description, type and required status: +${getParametersDescription(toolParameters)} +ALL parameters marked as required must be provided`; + } + + return description; +}; export class N8nTool extends DynamicStructuredTool { private context: IExecuteFunctions; - private fallbackDescription: string; - - constructor( - context: IExecuteFunctions, - fields: DynamicStructuredToolInput & { fallbackDescription?: string }, - ) { + constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { super(fields); this.context = context; - this.fallbackDescription = fields.fallbackDescription ?? ''; } asDynamicTool(): DynamicTool { - const { name, func, schema, context } = this; + const { name, func, schema, context, description } = this; const parser = new StructuredOutputParser(schema); - // const formattingInstructions = parser.getFormatInstructions(); - - // console.log({ formattingInstructions }); const wrappedFunc = async function (query: string) { - console.log('TOOL CALLED'); + let parsedQuery: object; + // First we try to parse the query using the structured parser (Zod schema) try { - const parsedQuery = await parser.parse(query); - - console.log({ parsedQuery }); + parsedQuery = await parser.parse(query); + } catch (e) { + // If we were unable to parse the query using the schema, we try to gracefully handle it + let dataFromModel; + + try { + // First we try to parse a JSON with more relaxed rules + dataFromModel = jsonParse(query, { acceptJSObject: true }); + } catch (error) { + // In case of error, + // If model supplied a simple string instead of an object AND only one parameter expected, we try to recover the object structure + if (Object.keys(schema.shape).length === 1) { + const parameterName = Object.keys(schema.shape)[0]; + dataFromModel = { [parameterName]: query }; + } else { + // Finally throw an error if we were unable to parse the query + throw new NodeOperationError( + context.getNode(), + `Input is not a valid JSON: ${error.message}`, + ); + } + } + + // If we were able to parse the query with a fallback, we try to validate it using the schema + // Here we will throw an error if the data still does not match the schema + parsedQuery = schema.parse(dataFromModel); + } + try { + // Call tool function with parsed query const result = await func(parsedQuery); - console.log({ result }); return result; } catch (e) { - console.log('ERROR', e); - const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); void context.addOutputData(NodeConnectionType.AiTool, index, e); @@ -49,12 +104,9 @@ export class N8nTool extends DynamicStructuredTool { } }; - console.log('CONVERTED to DynamicTool'); - return new DynamicTool({ name, - description: this.fallbackDescription, - // description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), + description: prepareFallbackToolDescription(description, schema), func: wrappedFunc, }); } diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 6e6a937184d33..673fa0402c483 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -180,46 +180,6 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } -// function convertToSimpleTool(ctx: IExecuteFunctions, tool: DynamicStructuredTool): DynamicTool { -// const name = tool.name; -// const description = tool.description; -// const func = tool.func; -// -// const parser = new StructuredOutputParser(tool.schema); -// -// const formattingInstructions = parser.getFormatInstructions(); -// -// const wrappedFunc = async function (query: string) { -// console.log('TOOL CALLED'); -// -// try { -// const parsedQuery = await parser.parse(query); -// -// console.log({ parsedQuery }); -// -// const result = await func(parsedQuery); -// -// console.log(result); -// return result; -// } catch (e) { -// console.log(query); -// // const result = await func(JSON.parse(query)); -// -// console.log(result); -// console.log(e); -// -// return e.toString(); -// } -// }; -// -// return new DynamicTool({ -// name, -// description: -// description + '\n\n' + formattingInstructions.replace(/\{/g, '{{').replace(/\}/g, '}}'), -// func: wrappedFunc, -// }); -// } - export const getConnectedTools = async ( ctx: IExecuteFunctions, enforceUniqueNames: boolean, From ca4dd3f9826312bb86f9a7352396967f63abc1b5 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Mon, 5 Aug 2024 18:25:24 +0200 Subject: [PATCH 06/10] wip: rename test file for consistency --- .../nodes-langchain/utils/{N8nTool.spec.ts => N8nTool.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/@n8n/nodes-langchain/utils/{N8nTool.spec.ts => N8nTool.test.ts} (100%) diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/utils/N8nTool.spec.ts rename to packages/@n8n/nodes-langchain/utils/N8nTool.test.ts From f67b7e94b0a51402dcb1f0fefae62f9086023b47 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Mon, 5 Aug 2024 18:30:47 +0200 Subject: [PATCH 07/10] wip: fix type errors --- .../@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts | 2 +- packages/@n8n/nodes-langchain/utils/N8nTool.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index eb04283b811f5..96ae0d8492a33 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -568,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: IDataObject): Promise => { + return async (query: string | IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index 4358ef9feca08..bb8bab08bd4fb 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -1,6 +1,6 @@ import type { DynamicStructuredToolInput } from '@langchain/core/tools'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import type { IExecuteFunctions, type IDataObject } from 'n8n-workflow'; +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow'; import { StructuredOutputParser } from 'langchain/output_parsers'; import type { ZodTypeAny } from 'zod'; @@ -31,7 +31,7 @@ const getParametersDescription = (parameters: Array<[string, ZodTypeAny]>) => export const prepareFallbackToolDescription = (toolDescription: string, schema: ZodObject) => { let description = `${toolDescription}`; - const toolParameters = Object.entries(schema.shape); + const toolParameters = Object.entries(schema.shape); if (toolParameters.length) { description += ` From aac272616d57ad828c5fa4642f62a52221d57fa4 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 6 Aug 2024 09:41:32 +0200 Subject: [PATCH 08/10] wip: fix type errors --- .../nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 7d3077e8a3ace..54b205e771d89 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -11,6 +11,7 @@ import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { N8nTool } from '../../../utils/N8nTool'; import { configureHttpRequestFunction, configureResponseOptimizer, @@ -18,7 +19,6 @@ import { configureToolFunction, updateParametersAndOptions, makeToolInputSchema, - prepareToolDescription, } from './utils'; import { @@ -31,7 +31,6 @@ import { } from './descriptions'; import type { PlaceholderDefinition, ToolParameter } from './interfaces'; -import { N8nTool } from '../../../utils/N8nTool'; export class ToolHttpRequest implements INodeType { description: INodeTypeDescription = { From 06d5536e3f5a49136243b8202ac2401b1c87a12f Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 6 Aug 2024 10:42:42 +0200 Subject: [PATCH 09/10] wip: fix type issues --- packages/@n8n/nodes-langchain/utils/N8nTool.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts index 6f4e5d69d6a00..6f12b18079551 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts @@ -24,7 +24,6 @@ describe('Test N8nTool wrapper as DynamicStructuredTool', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string(), @@ -44,7 +43,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string(), @@ -64,7 +62,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string(), @@ -93,7 +90,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({}), }); @@ -111,7 +107,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string().describe('Foo description'), @@ -136,7 +131,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string().describe('Foo description'), @@ -160,7 +154,6 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => { const tool = new N8nTool(ctx, { name: 'Dummy Tool', description: 'A dummy tool for testing', - fallbackDescription: 'A fallback description of the dummy tool', func, schema: z.object({ foo: z.string().describe('Foo description'), From 6e3c52c5d7c9cff31d679c39837b8d5ba7518f90 Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Tue, 6 Aug 2024 12:40:15 +0200 Subject: [PATCH 10/10] wip: add minor node version --- .../ToolHttpRequest/ToolHttpRequest.node.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 54b205e771d89..421e85e1b5b8c 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -9,6 +9,7 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } from 'n8n-workflow'; +import { DynamicTool } from '@langchain/core/tools'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { N8nTool } from '../../../utils/N8nTool'; @@ -16,6 +17,7 @@ import { configureHttpRequestFunction, configureResponseOptimizer, extractParametersFromText, + prepareToolDescription, configureToolFunction, updateParametersAndOptions, makeToolInputSchema, @@ -38,7 +40,7 @@ export class ToolHttpRequest implements INodeType { name: 'toolHttpRequest', icon: { light: 'file:httprequest.svg', dark: 'file:httprequest.dark.svg' }, group: ['output'], - version: 1, + version: [1, 1.1], description: 'Makes an HTTP request and returns the response data', subtitle: '={{ $parameter.toolDescription }}', defaults: { @@ -394,14 +396,24 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const schema = makeToolInputSchema(toolParameters); + let tool: DynamicTool | N8nTool; - const tool = new N8nTool(this, { - name, - description: toolDescription, - func, - schema, - }); + // If the node version is 1.1 or higher, we use the N8nTool wrapper: + // it allows to use tool as a DynamicStructuredTool and have a fallback to DynamicTool + if (this.getNode().typeVersion >= 1.1) { + const schema = makeToolInputSchema(toolParameters); + + tool = new N8nTool(this, { + name, + description: toolDescription, + func, + schema, + }); + } else { + // Keep the old behavior for nodes with version 1.0 + const description = prepareToolDescription(toolDescription, toolParameters); + tool = new DynamicTool({ name, description, func }); + } return { response: tool,