From 1291399b88697dfd3e430a85be2e68343b9a4ed2 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:47:27 +0200 Subject: [PATCH] feat: Add execute function to tools (no-changelog) (#19997) --- .../ToolCalculator.node.test.ts | 105 ++++++ .../ToolCalculator/ToolCalculator.node.ts | 30 +- .../tools/ToolCode/ToolCode.node.test.ts | 128 +++++++- .../nodes/tools/ToolCode/ToolCode.node.ts | 310 ++++++++++-------- .../ToolSearXng/ToolSearXng.node.test.ts | 150 +++++++++ .../tools/ToolSearXng/ToolSearXng.node.ts | 47 ++- .../ToolSerpApi/ToolSerpApi.node.test.ts | 148 +++++++++ .../tools/ToolSerpApi/ToolSerpApi.node.ts | 33 +- .../nodes/tools/ToolThink/ToolThink.node.ts | 55 +++- .../ToolThink/test/ToolThink.node.test.ts | 138 +++++++- .../ToolVectorStore.node.test.ts | 139 +++++++- .../ToolVectorStore/ToolVectorStore.node.ts | 84 +++-- .../ToolWikipedia/ToolWikipedia.node.test.ts | 154 +++++++++ .../tools/ToolWikipedia/ToolWikipedia.node.ts | 38 ++- .../ToolWolframAlpha.node.test.ts | 141 ++++++++ .../ToolWolframAlpha/ToolWolframAlpha.node.ts | 23 ++ .../ToolWorkflow/v2/ToolWorkflowV2.node.ts | 60 +++- .../v2/utils/WorkflowToolService.ts | 61 ++-- 18 files changed, 1594 insertions(+), 250 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.test.ts new file mode 100644 index 00000000000..5067fc0d81b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.test.ts @@ -0,0 +1,105 @@ +import { Calculator } from '@langchain/community/tools/calculator'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { ToolCalculator } from './ToolCalculator.node'; + +describe('ToolCalculator', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return Calculator tool instance', async () => { + const node = new ToolCalculator(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ name: 'test calculator' })), + }), + ); + + expect(supplyDataResult.response).toBeInstanceOf(Calculator); + }); + }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute calculator and return result', async () => { + const node = new ToolCalculator(); + const inputData: INodeExecutionData[] = [ + { + json: { input: '2 + 2' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test calculator' })), + }); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: '4', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + + it('should handle multiple input items', async () => { + const node = new ToolCalculator(); + const inputData: INodeExecutionData[] = [ + { + json: { input: '2 + 2' }, + }, + { + json: { input: '5 * 3' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test calculator' })), + }); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: '4', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: '15', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts index c4a23076b4d..5d0291a74db 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCalculator/ToolCalculator.node.ts @@ -1,5 +1,7 @@ import { Calculator } from '@langchain/community/tools/calculator'; import { + type IExecuteFunctions, + type INodeExecutionData, NodeConnectionTypes, type INodeType, type INodeTypeDescription, @@ -10,6 +12,12 @@ import { import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): Calculator { + const calculator = new Calculator(); + calculator.name = ctx.getNode().name; + return calculator; +} + export class ToolCalculator implements INodeType { description: INodeTypeDescription = { displayName: 'Calculator', @@ -46,7 +54,27 @@ export class ToolCalculator implements INodeType { async supplyData(this: ISupplyDataFunctions): Promise { return { - response: logWrapper(new Calculator(), this), + response: logWrapper(getTool(this), this), }; } + + async execute(this: IExecuteFunctions): Promise { + const calculator = getTool(this); + const input = this.getInputData(); + const response: INodeExecutionData[] = []; + for (let i = 0; i < input.length; i++) { + const inputItem = input[i]; + const result = await calculator.invoke(inputItem.json); + response.push({ + json: { + response: result, + }, + pairedItem: { + item: i, + }, + }); + } + + return [response]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts index 93c09e9a14f..748dbbdc3af 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.test.ts @@ -1,6 +1,11 @@ import { mock } from 'jest-mock-extended'; import { DynamicTool } from 'langchain/tools'; -import { type INode, type ISupplyDataFunctions } from 'n8n-workflow'; +import { + type IExecuteFunctions, + type INode, + type INodeExecutionData, + type ISupplyDataFunctions, +} from 'n8n-workflow'; import { ToolCode } from './ToolCode.node'; @@ -78,4 +83,125 @@ describe('ToolCode', () => { expect(tool.func).toBeInstanceOf(Function); }); }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute code tool and return result', async () => { + const node = new ToolCode(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test query' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'wrong_field'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return "test result";'; + default: + return; + } + }), + getMode: jest.fn(() => 'manual'), + }); + + // Mock the DynamicTool.invoke method + const mockResult = 'test result'; + DynamicTool.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(DynamicTool.prototype.invoke).toHaveBeenCalledWith({ query: 'test query' }); + }); + + it('should handle multiple input items', async () => { + const node = new ToolCode(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'first query' }, + }, + { + json: { query: 'second query' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'description text'; + case 'name': + return 'wrong_field'; + case 'specifyInputSchema': + return false; + case 'language': + return 'javaScript'; + case 'jsCode': + return 'return "result for " + query;'; + default: + return; + } + }), + getMode: jest.fn(() => 'manual'), + }); + + // Mock the DynamicTool.invoke method + DynamicTool.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('result for first query') + .mockResolvedValueOnce('result for second query'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'result for first query', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'result for second query', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(DynamicTool.prototype.invoke).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index c8461778cd6..a33f83e92b0 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -10,18 +10,20 @@ import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import type { ExecutionError, IDataObject, + IExecuteFunctions, + INodeExecutionData, INodeType, INodeTypeDescription, ISupplyDataFunctions, SupplyData, } from 'n8n-workflow'; - import { jsonParse, NodeConnectionTypes, nodeNameToToolName, NodeOperationError, } from 'n8n-workflow'; + import { buildInputSchemaField, buildJsonSchemaExampleField, @@ -46,6 +48,154 @@ const jsonSchemaExampleNotice = buildJsonSchemaExampleNotice({ const jsonSchemaField = buildInputSchemaField({ showExtraProps: { specifyInputSchema: [true] } }); +function getTool( + ctx: ISupplyDataFunctions | IExecuteFunctions, + itemIndex: number, + log: boolean = true, +) { + const node = ctx.getNode(); + const workflowMode = ctx.getMode(); + + const runnersConfig = Container.get(TaskRunnersConfig); + const isRunnerEnabled = runnersConfig.enabled; + + const { typeVersion } = node; + const name = + typeVersion <= 1.1 + ? (ctx.getNodeParameter('name', itemIndex) as string) + : nodeNameToToolName(node); + + const description = ctx.getNodeParameter('description', itemIndex) as string; + + const useSchema = ctx.getNodeParameter('specifyInputSchema', itemIndex) as boolean; + + const language = ctx.getNodeParameter('language', itemIndex) as string; + let code = ''; + if (language === 'javaScript') { + code = ctx.getNodeParameter('jsCode', itemIndex) as string; + } else { + code = ctx.getNodeParameter('pythonCode', itemIndex) as string; + } + + // @deprecated - TODO: Remove this after a new python runner is implemented + const getSandbox = (query: string | IDataObject, index = 0) => { + const context = getSandboxContext.call(ctx, index); + context.query = query; + + let sandbox: Sandbox; + if (language === 'javaScript') { + sandbox = new JavaScriptSandbox(context, code, ctx.helpers); + } else { + sandbox = new PythonSandbox(context, code, ctx.helpers); + } + + sandbox.on( + 'output', + workflowMode === 'manual' + ? ctx.sendMessageToUI.bind(ctx) + : (...args: unknown[]) => + console.log(`[Workflow "${ctx.getWorkflow().id}"][Node "${node.name}"]`, ...args), + ); + return sandbox; + }; + + const runFunction = async (query: string | IDataObject): Promise => { + if (language === 'javaScript' && isRunnerEnabled) { + const sandbox = new JsTaskRunnerSandbox( + code, + 'runOnceForAllItems', + workflowMode, + ctx, + undefined, + { + query, + }, + ); + const executionData = await sandbox.runCodeForTool(); + return executionData; + } else { + // use old vm2-based sandbox for python or when without runner enabled + const sandbox = getSandbox(query, itemIndex); + return await sandbox.runCode(); + } + }; + + const toolHandler = async (query: string | IDataObject): Promise => { + const { index } = log + ? ctx.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]) + : { index: 0 }; + + let response: any = ''; + let executionError: ExecutionError | undefined; + try { + response = await runFunction(query); + } catch (error: unknown) { + executionError = new NodeOperationError(ctx.getNode(), error as ExecutionError); + response = `There was an error: "${executionError.message}"`; + } + + if (typeof response === 'number') { + response = (response as number).toString(); + } + + if (typeof response !== 'string') { + // TODO: Do some more testing. Issues here should actually fail the workflow + executionError = new NodeOperationError(ctx.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + response = `There was an error: "${executionError.message}"`; + } + + if (executionError && log) { + void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError); + } else if (log) { + void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); + } + + return response; + }; + + const commonToolOptions = { + name, + description, + func: toolHandler, + }; + + let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; + + if (useSchema) { + try { + // We initialize these even though one of them will always be empty + // it makes it easier to navigate the ternary operator + const jsonExample = ctx.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + const inputSchema = ctx.getNodeParameter('inputSchema', itemIndex, '') as string; + + const schemaType = ctx.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + + const jsonSchema = + schemaType === 'fromJson' + ? generateSchemaFromExample(jsonExample, ctx.getNode().typeVersion >= 1.3) + : jsonParse(inputSchema); + + const zodSchema = convertJsonSchemaToZod(jsonSchema); + + tool = new DynamicStructuredTool({ + schema: zodSchema, + ...commonToolOptions, + }); + } catch (error) { + throw new NodeOperationError( + ctx.getNode(), + 'Error during parsing of JSON Schema. \n ' + error, + ); + } + } else { + tool = new DynamicTool(commonToolOptions); + } + + return tool; +} + export class ToolCode implements INodeType { description: INodeTypeDescription = { displayName: 'Code Tool', @@ -200,146 +350,26 @@ export class ToolCode implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const node = this.getNode(); - const workflowMode = this.getMode(); - - const runnersConfig = Container.get(TaskRunnersConfig); - const isRunnerEnabled = runnersConfig.enabled; - - const { typeVersion } = node; - const name = - typeVersion <= 1.1 - ? (this.getNodeParameter('name', itemIndex) as string) - : nodeNameToToolName(node); - - const description = this.getNodeParameter('description', itemIndex) as string; - - const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; - - const language = this.getNodeParameter('language', itemIndex) as string; - let code = ''; - if (language === 'javaScript') { - code = this.getNodeParameter('jsCode', itemIndex) as string; - } else { - code = this.getNodeParameter('pythonCode', itemIndex) as string; - } - - // @deprecated - TODO: Remove this after a new python runner is implemented - const getSandbox = (query: string | IDataObject, index = 0) => { - const context = getSandboxContext.call(this, index); - context.query = query; - - let sandbox: Sandbox; - if (language === 'javaScript') { - sandbox = new JavaScriptSandbox(context, code, this.helpers); - } else { - sandbox = new PythonSandbox(context, code, this.helpers); - } - - sandbox.on( - 'output', - workflowMode === 'manual' - ? this.sendMessageToUI.bind(this) - : (...args: unknown[]) => - console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args), - ); - return sandbox; - }; - - const runFunction = async (query: string | IDataObject): Promise => { - if (language === 'javaScript' && isRunnerEnabled) { - const sandbox = new JsTaskRunnerSandbox( - code, - 'runOnceForAllItems', - workflowMode, - this, - undefined, - { - query, - }, - ); - const executionData = await sandbox.runCodeForTool(); - return executionData; - } else { - // use old vm2-based sandbox for python or when without runner enabled - const sandbox = getSandbox(query, itemIndex); - return await sandbox.runCode(); - } - }; - - const toolHandler = async (query: string | IDataObject): Promise => { - const { index } = this.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); - - let response: any = ''; - let executionError: ExecutionError | undefined; - try { - response = await runFunction(query); - } catch (error: unknown) { - executionError = new NodeOperationError(this.getNode(), error as ExecutionError); - response = `There was an error: "${executionError.message}"`; - } - - if (typeof response === 'number') { - response = (response as number).toString(); - } - - if (typeof response !== 'string') { - // TODO: Do some more testing. Issues here should actually fail the workflow - executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { - description: `The response property should be a string, but it is an ${typeof response}`, - }); - response = `There was an error: "${executionError.message}"`; - } - - if (executionError) { - void this.addOutputData(NodeConnectionTypes.AiTool, index, executionError); - } else { - void this.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); - } - - return response; - }; - - const commonToolOptions = { - name, - description, - func: toolHandler, - }; - - let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; - - if (useSchema) { - try { - // We initialize these even though one of them will always be empty - // 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; - - const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; - - const jsonSchema = - schemaType === 'fromJson' - ? generateSchemaFromExample(jsonExample, this.getNode().typeVersion >= 1.3) - : jsonParse(inputSchema); - - const zodSchema = convertJsonSchemaToZod(jsonSchema); - - tool = new DynamicStructuredTool({ - schema: zodSchema, - ...commonToolOptions, - }); - } catch (error) { - throw new NodeOperationError( - this.getNode(), - 'Error during parsing of JSON Schema. \n ' + error, - ); - } - } else { - tool = new DynamicTool(commonToolOptions); - } - return { - response: tool, + response: getTool(this, itemIndex), }; } + async execute(this: IExecuteFunctions): Promise { + const result: INodeExecutionData[] = []; + const input = this.getInputData(); + for (let i = 0; i < input.length; i++) { + const item = input[i]; + const tool = getTool(this, i, false); + result.push({ + json: { + response: await tool.invoke(item.json), + }, + pairedItem: { + item: i, + }, + }); + } + + return [result]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.test.ts new file mode 100644 index 00000000000..a89b7ab42cc --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.test.ts @@ -0,0 +1,150 @@ +import { SearxngSearch } from '@langchain/community/tools/searxng_search'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { ToolSearXng } from './ToolSearXng.node'; + +describe('ToolSearXng', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return SearXNG tool instance', async () => { + const node = new ToolSearXng(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ name: 'test searxng' })), + getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(SearxngSearch); + }); + }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute SearXNG search and return result', async () => { + const node = new ToolSearXng(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'artificial intelligence' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test searxng' })), + getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }); + + // Mock the SearxngSearch.invoke method + const mockResult = 'Search results for artificial intelligence...'; + SearxngSearch.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(SearxngSearch.prototype.invoke).toHaveBeenCalledWith({ + query: 'artificial intelligence', + }); + }); + + it('should handle multiple input items', async () => { + const node = new ToolSearXng(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'machine learning' }, + }, + { + json: { query: 'deep learning' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test searxng' })), + getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }); + + // Mock the SearxngSearch.invoke method + SearxngSearch.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('Machine learning search results') + .mockResolvedValueOnce('Deep learning search results'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'Machine learning search results', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'Deep learning search results', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(SearxngSearch.prototype.invoke).toHaveBeenCalledTimes(2); + }); + + it('should handle credentials and options correctly', async () => { + const node = new ToolSearXng(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test query' }, + }, + ]; + + const testOptions = { engines: ['google'], safesearch: 1 }; + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test searxng' })), + getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.test.com' }), + getNodeParameter: jest.fn().mockReturnValue(testOptions), + }); + + SearxngSearch.prototype.invoke = jest.fn().mockResolvedValue('test result'); + + await node.execute.call(mockExecute); + + expect(mockExecute.getCredentials).toHaveBeenCalledWith('searXngApi'); + expect(mockExecute.getNodeParameter).toHaveBeenCalledWith('options', 0); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.ts index 7c941afce26..7bdc1372ee6 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSearXng/ToolSearXng.node.ts @@ -1,6 +1,8 @@ import { SearxngSearch } from '@langchain/community/tools/searxng_search'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { + IExecuteFunctions, + INodeExecutionData, INodeType, INodeTypeDescription, ISupplyDataFunctions, @@ -17,6 +19,19 @@ type Options = { safesearch: 0 | 1 | 2; }; +async function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number) { + const credentials = await ctx.getCredentials<{ apiUrl: string }>('searXngApi'); + const options = ctx.getNodeParameter('options', itemIndex) as Options; + + return new SearxngSearch({ + apiBase: credentials.apiUrl, + headers: { + Accept: 'application/json', + }, + params: options, + }); +} + export class ToolSearXng implements INodeType { description: INodeTypeDescription = { displayName: 'SearXNG', @@ -107,19 +122,27 @@ export class ToolSearXng implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials<{ apiUrl: string }>('searXngApi'); - const options = this.getNodeParameter('options', itemIndex) as Options; - - const tool = new SearxngSearch({ - apiBase: credentials.apiUrl, - headers: { - Accept: 'application/json', - }, - params: options, - }); - return { - response: logWrapper(tool, this), + response: logWrapper(await getTool(this, itemIndex), this), }; } + + async execute(this: IExecuteFunctions): Promise { + const result: INodeExecutionData[] = []; + const input = this.getInputData(); + for (let i = 0; i < input.length; i++) { + const item = input[i]; + const tool = await getTool(this, i); + result.push({ + json: { + response: await tool.invoke(item.json), + }, + pairedItem: { + item: i, + }, + }); + } + + return [result]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.test.ts new file mode 100644 index 00000000000..43e0acc1ee0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.test.ts @@ -0,0 +1,148 @@ +import { SerpAPI } from '@langchain/community/tools/serpapi'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { ToolSerpApi } from './ToolSerpApi.node'; + +describe('ToolSerpApi', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return SerpAPI tool instance', async () => { + const node = new ToolSerpApi(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ name: 'test serpapi' })), + getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }), + 0, + ); + + expect(supplyDataResult.response).toBeInstanceOf(SerpAPI); + }); + }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute SerpAPI search and return result', async () => { + const node = new ToolSerpApi(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'artificial intelligence news' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test serpapi' })), + getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }); + + // Mock the SerpAPI.invoke method + const mockResult = 'Latest news about artificial intelligence...'; + SerpAPI.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(SerpAPI.prototype.invoke).toHaveBeenCalledWith(inputData[0]); + }); + + it('should handle multiple input items', async () => { + const node = new ToolSerpApi(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'machine learning' }, + }, + { + json: { query: 'deep learning' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test serpapi' })), + getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }), + getNodeParameter: jest.fn().mockReturnValue({}), + }); + + // Mock the SerpAPI.invoke method + SerpAPI.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('Machine learning search results') + .mockResolvedValueOnce('Deep learning search results'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'Machine learning search results', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'Deep learning search results', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(SerpAPI.prototype.invoke).toHaveBeenCalledTimes(2); + }); + + it('should handle credentials and options correctly', async () => { + const node = new ToolSerpApi(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test query' }, + }, + ]; + + const testOptions = { engine: 'google', location: 'US' }; + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test serpapi' })), + getCredentials: jest.fn().mockResolvedValue({ apiKey: 'secret-api-key' }), + getNodeParameter: jest.fn().mockReturnValue(testOptions), + }); + + SerpAPI.prototype.invoke = jest.fn().mockResolvedValue('test result'); + + await node.execute.call(mockExecute); + + expect(mockExecute.getCredentials).toHaveBeenCalledWith('serpApi'); + expect(mockExecute.getNodeParameter).toHaveBeenCalledWith('options', 0); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts index 84235b16819..6b21ca41389 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts @@ -1,14 +1,23 @@ import { SerpAPI } from '@langchain/community/tools/serpapi'; import { + type IExecuteFunctions, NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, + type INodeExecutionData, } from 'n8n-workflow'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +async function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number) { + const credentials = await ctx.getCredentials('serpApi'); + + const options = ctx.getNodeParameter('options', itemIndex) as object; + + return new SerpAPI(credentials.apiKey as string, options); +} export class ToolSerpApi implements INodeType { description: INodeTypeDescription = { @@ -114,12 +123,26 @@ export class ToolSerpApi implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials('serpApi'); - - const options = this.getNodeParameter('options', itemIndex) as object; - return { - response: logWrapper(new SerpAPI(credentials.apiKey as string, options), this), + response: logWrapper(await getTool(this, itemIndex), this), }; } + + async execute(this: IExecuteFunctions): Promise { + const inputData = this.getInputData(); + const returnData: INodeExecutionData[] = []; + for (let itemIndex = 0; itemIndex < inputData.length; itemIndex++) { + const tool = await getTool(this, itemIndex); + const query = inputData[itemIndex]; + const result = await tool.invoke(query); + returnData.push({ + json: { + response: result, + }, + pairedItem: { item: itemIndex }, + }); + } + + return [returnData]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/ToolThink.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/ToolThink.node.ts index dd590c81799..618a245f419 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/ToolThink.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/ToolThink.node.ts @@ -1,16 +1,37 @@ import { DynamicTool } from 'langchain/tools'; import { + type IExecuteFunctions, NodeConnectionTypes, nodeNameToToolName, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, + type INodeExecutionData, } from 'n8n-workflow'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +async function getTool( + ctx: ISupplyDataFunctions | IExecuteFunctions, + itemIndex: number, +): Promise { + const node = ctx.getNode(); + const { typeVersion } = node; + + const name = typeVersion === 1 ? 'thinking_tool' : nodeNameToToolName(node); + const description = ctx.getNodeParameter('description', itemIndex) as string; + + return new DynamicTool({ + name, + description, + func: async (subject: string) => { + return subject; + }, + }); +} + // A thinking tool, see https://www.anthropic.com/engineering/claude-think-tool const defaultToolDescription = @@ -63,22 +84,30 @@ export class ToolThink implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const node = this.getNode(); - const { typeVersion } = node; - - const name = typeVersion === 1 ? 'thinking_tool' : nodeNameToToolName(node); - const description = this.getNodeParameter('description', itemIndex) as string; - - const tool = new DynamicTool({ - name, - description, - func: async (subject: string) => { - return subject; - }, - }); + const tool = await getTool(this, itemIndex); return { response: logWrapper(tool, this), }; } + + async execute(this: IExecuteFunctions): Promise { + const input = this.getInputData(); + const response: INodeExecutionData[] = []; + for (let i = 0; i < input.length; i++) { + const inputItem = input[i]; + const tool = await getTool(this, i); + const result = await tool.invoke(inputItem.json); + response.push({ + json: { + response: result, + }, + pairedItem: { + item: i, + }, + }); + } + + return [response]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/test/ToolThink.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/test/ToolThink.node.test.ts index 49a069cdc8c..6e329ed6df8 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/test/ToolThink.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolThink/test/ToolThink.node.test.ts @@ -1,6 +1,11 @@ import { mock } from 'jest-mock-extended'; import { DynamicTool } from 'langchain/tools'; -import type { ISupplyDataFunctions, INode } from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + ISupplyDataFunctions, + INode, +} from 'n8n-workflow'; import { ToolThink } from '../ToolThink.node'; @@ -59,4 +64,135 @@ describe('ToolThink', () => { expect(response.name).toEqual('My_Thinking_Tool'); }); }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute think tool and return input as result', async () => { + const node = new ToolThink(); + const inputData: INodeExecutionData[] = [ + { + json: { input: 'thinking about this problem' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.1, name: 'test think tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'Tool for thinking'; + default: + return; + } + }), + }); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'thinking about this problem', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + + it('should handle multiple input items', async () => { + const node = new ToolThink(); + const inputData: INodeExecutionData[] = [ + { + json: { input: 'first thought' }, + }, + { + json: { input: 'second thought' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.1, name: 'test think tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'Tool for thinking'; + default: + return; + } + }), + }); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'first thought', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'second thought', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + }); + + it('should use hardcoded name for version 1', async () => { + const node = new ToolThink(); + const inputData: INodeExecutionData[] = [ + { + json: { input: 'test' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1, name: 'My Thinking Tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'Tool for thinking'; + default: + return; + } + }), + }); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'test', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + // The tool should be created with the hardcoded name for version 1 + // This is tested indirectly through the getTool function usage + expect(mockExecute.getNode).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts index cefd873b3f0..41c12587ee2 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.test.ts @@ -1,6 +1,12 @@ import { mock } from 'jest-mock-extended'; import { VectorStoreQATool } from 'langchain/tools'; -import { NodeConnectionTypes, type INode, type ISupplyDataFunctions } from 'n8n-workflow'; +import { + NodeConnectionTypes, + type IExecuteFunctions, + type INode, + type INodeExecutionData, + type ISupplyDataFunctions, +} from 'n8n-workflow'; import { ToolVectorStore } from './ToolVectorStore.node'; @@ -88,4 +94,135 @@ describe('ToolVectorStore', () => { expect(tool.description).toContain('test_tool'); }); }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute vector store tool and return result', async () => { + const node = new ToolVectorStore(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test question' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'test description'; + case 'topK': + return 4; + default: + return; + } + }), + getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => { + switch (inputName) { + case NodeConnectionTypes.AiVectorStore: + return jest.fn(); + case NodeConnectionTypes.AiLanguageModel: + return { + _modelType: jest.fn(), + }; + default: + return; + } + }), + }); + + // Mock the VectorStoreQATool.invoke method + const mockResult = 'This is the answer from vector store'; + VectorStoreQATool.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(VectorStoreQATool.prototype.invoke).toHaveBeenCalledWith(inputData[0].json); + }); + + it('should handle multiple input items', async () => { + const node = new ToolVectorStore(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'first question' }, + }, + { + json: { query: 'second question' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ typeVersion: 1.2, name: 'test tool' })), + getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => { + switch (paramName) { + case 'description': + return 'test description'; + case 'topK': + return 4; + default: + return; + } + }), + getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => { + switch (inputName) { + case NodeConnectionTypes.AiVectorStore: + return jest.fn(); + case NodeConnectionTypes.AiLanguageModel: + return { + _modelType: jest.fn(), + }; + default: + return; + } + }), + }); + + // Mock the VectorStoreQATool.invoke method + VectorStoreQATool.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('Answer to first question') + .mockResolvedValueOnce('Answer to second question'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'Answer to first question', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'Answer to second question', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(VectorStoreQATool.prototype.invoke).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts index 0b9b3479d3c..bdd77f4f759 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -3,6 +3,8 @@ import type { VectorStore } from '@langchain/core/vectorstores'; import { VectorDBQAChain } from 'langchain/chains'; import { VectorStoreQATool } from 'langchain/tools'; import type { + IExecuteFunctions, + INodeExecutionData, INodeType, INodeTypeDescription, ISupplyDataFunctions, @@ -13,6 +15,40 @@ import { NodeConnectionTypes, nodeNameToToolName } from 'n8n-workflow'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +async function getTool( + ctx: ISupplyDataFunctions | IExecuteFunctions, + itemIndex: number, +): Promise { + const node = ctx.getNode(); + const { typeVersion } = node; + const name = + typeVersion <= 1 + ? (ctx.getNodeParameter('name', itemIndex) as string) + : nodeNameToToolName(node); + const toolDescription = ctx.getNodeParameter('description', itemIndex) as string; + const topK = ctx.getNodeParameter('topK', itemIndex, 4) as number; + const description = VectorStoreQATool.getDescription(name, toolDescription); + const vectorStore = (await ctx.getInputConnectionData( + NodeConnectionTypes.AiVectorStore, + itemIndex, + )) as VectorStore; + const llm = (await ctx.getInputConnectionData( + NodeConnectionTypes.AiLanguageModel, + itemIndex, + )) as BaseLanguageModel; + + const vectorStoreTool = new VectorStoreQATool(name, description, { + llm, + vectorStore, + }); + + vectorStoreTool.chain = VectorDBQAChain.fromLLM(llm, vectorStore, { + k: topK, + }); + + return vectorStoreTool; +} + export class ToolVectorStore implements INodeType { description: INodeTypeDescription = { displayName: 'Vector Store Question Answer Tool', @@ -97,37 +133,29 @@ export class ToolVectorStore implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const node = this.getNode(); - const { typeVersion } = node; - const name = - typeVersion <= 1 - ? (this.getNodeParameter('name', itemIndex) as string) - : nodeNameToToolName(node); - const toolDescription = this.getNodeParameter('description', itemIndex) as string; - const topK = this.getNodeParameter('topK', itemIndex, 4) as number; - - const vectorStore = (await this.getInputConnectionData( - NodeConnectionTypes.AiVectorStore, - itemIndex, - )) as VectorStore; - - const llm = (await this.getInputConnectionData( - NodeConnectionTypes.AiLanguageModel, - 0, - )) as BaseLanguageModel; - - const description = VectorStoreQATool.getDescription(name, toolDescription); - const vectorStoreTool = new VectorStoreQATool(name, description, { - llm, - vectorStore, - }); - - vectorStoreTool.chain = VectorDBQAChain.fromLLM(llm, vectorStore, { - k: topK, - }); + const vectorStoreTool = await getTool(this, itemIndex); return { response: logWrapper(vectorStoreTool, this), }; } + + async execute(this: IExecuteFunctions): Promise { + const inputData = this.getInputData(); + const result: INodeExecutionData[] = []; + for (let itemIndex = 0; itemIndex < inputData.length; itemIndex++) { + const tool = await getTool(this, itemIndex); + const outputData = await tool.invoke(inputData[itemIndex].json); + result.push({ + json: { + response: outputData, + }, + pairedItem: { + item: itemIndex, + }, + }); + } + + return [result]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.test.ts new file mode 100644 index 00000000000..239a59aafe3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.test.ts @@ -0,0 +1,154 @@ +import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { ToolWikipedia } from './ToolWikipedia.node'; + +describe('ToolWikipedia', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return Wikipedia tool instance', async () => { + const node = new ToolWikipedia(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ name: 'test wikipedia' })), + }), + ); + + expect(supplyDataResult.response).toBeInstanceOf(WikipediaQueryRun); + }); + }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute wikipedia search and return result', async () => { + const node = new ToolWikipedia(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'artificial intelligence' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wikipedia' })), + }); + + // Mock the WikipediaQueryRun.invoke method + const mockResult = 'Artificial intelligence (AI) is intelligence demonstrated by machines...'; + WikipediaQueryRun.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledWith({ + query: 'artificial intelligence', + }); + }); + + it('should handle multiple input items', async () => { + const node = new ToolWikipedia(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'machine learning' }, + }, + { + json: { query: 'deep learning' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wikipedia' })), + }); + + // Mock the WikipediaQueryRun.invoke method + WikipediaQueryRun.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('Machine learning (ML) is a field of artificial intelligence...') + .mockResolvedValueOnce('Deep learning (also known as deep structured learning...'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'Machine learning (ML) is a field of artificial intelligence...', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: 'Deep learning (also known as deep structured learning...', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledTimes(2); + }); + + it('should skip undefined items', async () => { + const node = new ToolWikipedia(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test' }, + }, + ]; + // Simulate undefined item by mocking getInputData to return array with undefined + inputData.push(undefined as any); + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wikipedia' })), + }); + + // Mock the WikipediaQueryRun.invoke method + WikipediaQueryRun.prototype.invoke = jest.fn().mockResolvedValue('test result'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: 'test result', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts index 8c6265e802d..1f63eb23d72 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts @@ -1,15 +1,25 @@ import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run'; import { + type IExecuteFunctions, NodeConnectionTypes, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, + type INodeExecutionData, } from 'n8n-workflow'; import { logWrapper } from '@utils/logWrapper'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; +function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): WikipediaQueryRun { + const WikiTool = new WikipediaQueryRun(); + WikiTool.name = ctx.getNode().name; + WikiTool.description = + 'A tool for interacting with and fetching data from the Wikipedia API. The input should always be a string query.'; + return WikiTool; +} + export class ToolWikipedia implements INodeType { description: INodeTypeDescription = { displayName: 'Wikipedia', @@ -44,13 +54,29 @@ export class ToolWikipedia implements INodeType { }; async supplyData(this: ISupplyDataFunctions): Promise { - const WikiTool = new WikipediaQueryRun(); - - WikiTool.description = - 'A tool for interacting with and fetching data from the Wikipedia API. The input should always be a string query.'; - return { - response: logWrapper(WikiTool, this), + response: logWrapper(getTool(this), this), }; } + + async execute(this: IExecuteFunctions): Promise { + const WikiTool = getTool(this); + + const items = this.getInputData(); + + const response: INodeExecutionData[] = []; + for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) { + const item = items[itemIndex]; + if (item === undefined) { + continue; + } + const result = await WikiTool.invoke(item.json); + response.push({ + json: { response: result }, + pairedItem: { item: itemIndex }, + }); + } + + return [response]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.test.ts new file mode 100644 index 00000000000..8433e4c1554 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.test.ts @@ -0,0 +1,141 @@ +import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { ToolWolframAlpha } from './ToolWolframAlpha.node'; + +describe('ToolWolframAlpha', () => { + describe('supplyData', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return WolframAlpha tool instance', async () => { + const node = new ToolWolframAlpha(); + + const supplyDataResult = await node.supplyData.call( + mock({ + getNode: jest.fn(() => mock({ name: 'test wolfram' })), + getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }), + }), + ); + + expect(supplyDataResult.response).toBeInstanceOf(WolframAlphaTool); + }); + }); + + describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should execute WolframAlpha query and return result', async () => { + const node = new ToolWolframAlpha(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'what is 2+2?' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wolfram' })), + getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }), + }); + + // Mock the WolframAlphaTool.invoke method + const mockResult = '4'; + WolframAlphaTool.prototype.invoke = jest.fn().mockResolvedValue(mockResult); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: mockResult, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + expect(WolframAlphaTool.prototype.invoke).toHaveBeenCalledWith(inputData[0].json); + }); + + it('should handle multiple input items', async () => { + const node = new ToolWolframAlpha(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'what is 5*3?' }, + }, + { + json: { query: 'what is the square root of 16?' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wolfram' })), + getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }), + }); + + // Mock the WolframAlphaTool.invoke method + WolframAlphaTool.prototype.invoke = jest + .fn() + .mockResolvedValueOnce('15') + .mockResolvedValueOnce('4'); + + const result = await node.execute.call(mockExecute); + + expect(result).toEqual([ + [ + { + json: { + response: '15', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + response: '4', + }, + pairedItem: { + item: 1, + }, + }, + ], + ]); + expect(WolframAlphaTool.prototype.invoke).toHaveBeenCalledTimes(2); + }); + + it('should handle credentials correctly', async () => { + const node = new ToolWolframAlpha(); + const inputData: INodeExecutionData[] = [ + { + json: { query: 'test query' }, + }, + ]; + + const mockExecute = mock({ + getInputData: jest.fn(() => inputData), + getNode: jest.fn(() => mock({ name: 'test wolfram' })), + getCredentials: jest.fn().mockResolvedValue({ appId: 'secret-app-id' }), + }); + + WolframAlphaTool.prototype.invoke = jest.fn().mockResolvedValue('test result'); + + await node.execute.call(mockExecute); + + expect(mockExecute.getCredentials).toHaveBeenCalledWith('wolframAlphaApi'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts index 12f849e8c69..edfb29635bb 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWolframAlpha/ToolWolframAlpha.node.ts @@ -1,6 +1,8 @@ import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha'; import { NodeConnectionTypes, + type IExecuteFunctions, + type INodeExecutionData, type INodeType, type INodeTypeDescription, type ISupplyDataFunctions, @@ -56,4 +58,25 @@ export class ToolWolframAlpha implements INodeType { response: logWrapper(new WolframAlphaTool({ appid: credentials.appId as string }), this), }; } + + async execute(this: IExecuteFunctions): Promise { + const credentials = await this.getCredentials('wolframAlphaApi'); + const input = this.getInputData(); + const result: INodeExecutionData[] = []; + + for (let i = 0; i < input.length; i++) { + const item = input[i]; + const tool = new WolframAlphaTool({ appid: credentials.appId as string }); + result.push({ + json: { + response: await tool.invoke(item.json), + }, + pairedItem: { + item: i, + }, + }); + } + + return [result]; + } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts index b198ce4427f..ca3dc03c6bf 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -1,17 +1,43 @@ +import type { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; + import type { INodeTypeBaseDescription, ISupplyDataFunctions, SupplyData, INodeType, INodeTypeDescription, + IExecuteFunctions, + INodeExecutionData, } from 'n8n-workflow'; - import { nodeNameToToolName } from 'n8n-workflow'; import { localResourceMapping } from './methods'; import { WorkflowToolService } from './utils/WorkflowToolService'; import { versionDescription } from './versionDescription'; +async function getTool( + ctx: ISupplyDataFunctions | IExecuteFunctions, + enableLogging: boolean, + itemIndex: number, +): Promise { + const node = ctx.getNode(); + const { typeVersion } = node; + const returnAllItems = typeVersion > 2; + + const workflowToolService = new WorkflowToolService(ctx, { returnAllItems }); + const name = + typeVersion <= 2.1 ? (ctx.getNodeParameter('name', 0) as string) : nodeNameToToolName(node); + const description = ctx.getNodeParameter('description', 0) as string; + + return await workflowToolService.createTool({ + ctx, + name, + description, + itemIndex, + manualLogging: enableLogging, + }); +} + export class ToolWorkflowV2 implements INodeType { description: INodeTypeDescription; @@ -27,24 +53,24 @@ export class ToolWorkflowV2 implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const node = this.getNode(); - const { typeVersion } = node; - const returnAllItems = typeVersion > 2; + return { response: await getTool(this, true, itemIndex) }; + } - const workflowToolService = new WorkflowToolService(this, { returnAllItems }); - const name = - typeVersion <= 2.1 - ? (this.getNodeParameter('name', itemIndex) as string) - : nodeNameToToolName(node); - const description = this.getNodeParameter('description', itemIndex) as string; + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); - const tool = await workflowToolService.createTool({ - ctx: this, - name, - description, - itemIndex, - }); + const response: INodeExecutionData[] = []; + for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) { + const item = items[itemIndex]; + const tool = await getTool(this, false, itemIndex); - return { response: tool }; + if (item === undefined) { + continue; + } + const result = await tool.invoke(item.json); + response.push(result); + } + + return [response]; } } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index e68997ee4e3..e5c181599ae 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -10,6 +10,7 @@ import type { ExecutionError, FromAIArgument, IDataObject, + IExecuteFunctions, IExecuteWorkflowInfo, INodeExecutionData, INodeParameterResourceLocator, @@ -51,7 +52,7 @@ export class WorkflowToolService { private returnAllItems: boolean = false; constructor( - private baseContext: ISupplyDataFunctions, + private baseContext: ISupplyDataFunctions | IExecuteFunctions, options?: { returnAllItems: boolean }, ) { const subWorkflowInputs = this.baseContext.getNode().parameters @@ -66,11 +67,13 @@ export class WorkflowToolService { name, description, itemIndex, + manualLogging = true, }: { - ctx: ISupplyDataFunctions; + ctx: ISupplyDataFunctions | IExecuteFunctions; name: string; description: string; itemIndex: number; + manualLogging?: boolean; }): Promise { // Handler for the tool execution, will be called when the tool is executed // This function will execute the sub-workflow and return the response @@ -78,7 +81,7 @@ export class WorkflowToolService { // of the same tool when the tool is used in a loop or in a parallel execution. const node = ctx.getNode(); - let runIndex: number = ctx.getNextRunIndex(); + let runIndex: number = 'getNextRunIndex' in ctx ? ctx.getNextRunIndex() : 0; const toolHandler = async ( query: string | IDataObject, runManager?: CallbackManagerForToolRun, @@ -97,13 +100,17 @@ export class WorkflowToolService { for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) { const localRunIndex = runIndex++; + + let context = this.baseContext; // We need to clone the context here to handle runIndex correctly // Otherwise the runIndex will be shared between different executions // Causing incorrect data to be passed to the sub-workflow and via $fromAI - const context = this.baseContext.cloneWith({ - runIndex: localRunIndex, - inputData: [[{ json: { query } }]], - }); + if ('cloneWith' in this.baseContext) { + context = this.baseContext.cloneWith({ + runIndex: localRunIndex, + inputData: [[{ json: { query } }]], + }); + } // Get abort signal from context for cancellation support const abortSignal = context.getExecutionCancelSignal?.(); @@ -153,12 +160,14 @@ export class WorkflowToolService { }; } - void context.addOutputData( - NodeConnectionTypes.AiTool, - localRunIndex, - [responseData], - metadata, - ); + if (manualLogging) { + void context.addOutputData( + NodeConnectionTypes.AiTool, + localRunIndex, + [responseData], + metadata, + ); + } return processedResponse; } catch (error) { @@ -171,13 +180,15 @@ export class WorkflowToolService { lastError = executionError; const errorResponse = `There was an error: "${executionError.message}"`; - const metadata = parseErrorMetadata(error); - void context.addOutputData( - NodeConnectionTypes.AiTool, - localRunIndex, - executionError, - metadata, - ); + if (manualLogging) { + const metadata = parseErrorMetadata(error); + void context.addOutputData( + NodeConnectionTypes.AiTool, + localRunIndex, + executionError, + metadata, + ); + } if (tryIndex === maxTries - 1) { return errorResponse; @@ -224,7 +235,7 @@ export class WorkflowToolService { * Executes specified sub-workflow with provided inputs */ private async executeSubWorkflow( - context: ISupplyDataFunctions, + context: ISupplyDataFunctions | IExecuteFunctions, workflowInfo: IExecuteWorkflowInfo, items: INodeExecutionData[], workflowProxy: IWorkflowDataProxyData, @@ -265,7 +276,7 @@ export class WorkflowToolService { * This function will be called as part of the tool execution (from the toolHandler) */ private async runFunction( - context: ISupplyDataFunctions, + context: ISupplyDataFunctions | IExecuteFunctions, query: string | IDataObject, itemIndex: number, runManager?: CallbackManagerForToolRun, @@ -298,7 +309,7 @@ export class WorkflowToolService { * Gets the sub-workflow info based on the source (database or parameter) */ private async getSubWorkflowInfo( - context: ISupplyDataFunctions, + context: ISupplyDataFunctions | IExecuteFunctions, source: string, itemIndex: number, workflowProxy: IWorkflowDataProxyData, @@ -336,7 +347,7 @@ export class WorkflowToolService { } private prepareRawData( - context: ISupplyDataFunctions, + context: ISupplyDataFunctions | IExecuteFunctions, query: string | IDataObject, itemIndex: number, ): IDataObject { @@ -359,7 +370,7 @@ export class WorkflowToolService { * Prepares the sub-workflow items for execution */ private async prepareWorkflowItems( - context: ISupplyDataFunctions, + context: ISupplyDataFunctions | IExecuteFunctions, query: string | IDataObject, itemIndex: number, rawData: IDataObject,