From 8b467e3f569514787fc865789fd4bf2387051120 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Mon, 12 May 2025 12:31:17 +0200 Subject: [PATCH] feat(core): Implement partial execution for all tool nodes (#15168) --- .../nodes/ToolExecutor/ToolExecutor.node.json | 17 ++ .../nodes/ToolExecutor/ToolExecutor.node.ts | 92 +++++++++ .../test/ToolExecutor.node.test.ts | 158 +++++++++++++++ .../ToolExecutor/test/convertToSchema.test.ts | 119 ++++++++++++ .../ToolExecutor/utils/convertToSchema.ts | 39 ++++ .../nodes/ToolExecutor/utils/executeTool.ts | 20 ++ .../McpClientTool/McpClientTool.node.test.ts | 1 + .../nodes/mcp/McpClientTool/loadOptions.ts | 1 + packages/@n8n/nodes-langchain/package.json | 4 +- .../load-nodes-and-credentials.test.ts | 2 - .../cli/src/load-nodes-and-credentials.ts | 2 - packages/cli/src/manual-execution.service.ts | 10 +- .../workflow-execution.service.test.ts | 3 + .../workflows/workflow-execution.service.ts | 2 + .../cli/src/workflows/workflow.request.ts | 2 + .../__tests__/workflow-execute.test.ts | 22 ++- .../__tests__/is-tool.test.ts | 57 +++++- .../__tests__/rewire-graph.test.ts | 81 ++++++-- .../partial-execution-utils/is-tool.ts | 18 +- .../partial-execution-utils/rewire-graph.ts | 35 +++- .../src/execution-engine/workflow-execute.ts | 15 +- packages/core/test/helpers/constants.ts | 19 ++ packages/frontend/editor-ui/src/Interface.ts | 6 + .../components/FromAiParametersModal.test.ts | 143 ++++++++++---- .../src/components/FromAiParametersModal.vue | 180 ++++++++++++++---- .../src/components/NodeExecuteButton.vue | 4 +- .../editor-ui/src/components/NodeSettings.vue | 6 +- .../elements/nodes/CanvasNodeToolbar.vue | 6 +- .../src/composables/useRunWorkflow.test.ts | 47 +++-- .../src/composables/useRunWorkflow.ts | 22 ++- packages/frontend/editor-ui/src/constants.ts | 1 + ...des.test.ts => agentRequest.store.test.ts} | 105 +++++----- ...errides.store.ts => agentRequest.store.ts} | 98 +++++----- .../editor-ui/src/stores/nodeTypes.store.ts | 19 +- .../src/utils/nodes/nodeTransforms.ts | 20 +- .../frontend/editor-ui/src/views/NodeView.vue | 10 +- packages/workflow/src/Interfaces.ts | 10 + packages/workflow/src/NodeHelpers.ts | 4 +- packages/workflow/test/NodeHelpers.test.ts | 8 +- 39 files changed, 1129 insertions(+), 279 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.json create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/ToolExecutor.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/convertToSchema.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/convertToSchema.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/executeTool.ts rename packages/frontend/editor-ui/src/stores/{parameterOverrides.test.ts => agentRequest.store.test.ts} (60%) rename packages/frontend/editor-ui/src/stores/{parameterOverrides.store.ts => agentRequest.store.ts} (60%) diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.json b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.json new file mode 100644 index 0000000000..a547efe560 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.json @@ -0,0 +1,17 @@ +{ + "node": "n8n-nodes-base.toolExecutor", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "details": "Can execute tools by simulating an agent function call with a given query.", + "categories": ["Core Nodes"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.editimage/" + } + ] + }, + "subcategories": { + "Core Nodes": ["Helpers"] + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.ts b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.ts new file mode 100644 index 0000000000..192794ba3e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/ToolExecutor.node.ts @@ -0,0 +1,92 @@ +import { Tool, StructuredTool } from '@langchain/core/tools'; +import type { Toolkit } from 'langchain/agents'; +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; + +import { executeTool } from './utils/executeTool'; + +export class ToolExecutor implements INodeType { + description: INodeTypeDescription = { + displayName: 'Tool Executor', + name: 'toolExecutor', + version: 1, + defaults: { + name: 'Tool Executor', + }, + hidden: true, + inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiTool], + outputs: [NodeConnectionTypes.Main], + properties: [ + { + displayName: 'Query', + name: 'query', + type: 'json', + default: '{}', + description: 'Parameters to pass to the tool as JSON or string', + }, + { + displayName: 'Tool Name', + name: 'toolName', + type: 'string', + default: '', + description: 'Name of the tool to execute if the connected tool is a toolkit', + }, + ], + group: ['transform'], + description: 'Node to execute tools without an AI Agent', + }; + + async execute(this: IExecuteFunctions): Promise { + const query = this.getNodeParameter('query', 0, {}) as string | object; + const toolName = this.getNodeParameter('toolName', 0, '') as string; + + let parsedQuery: string | object; + + try { + parsedQuery = typeof query === 'string' ? JSON.parse(query) : query; + } catch (error) { + parsedQuery = query; + } + + const resultData: INodeExecutionData[] = []; + const toolInputs = await this.getInputConnectionData(NodeConnectionTypes.AiTool, 0); + + if (!toolInputs || !Array.isArray(toolInputs)) { + throw new NodeOperationError(this.getNode(), 'No tool inputs found'); + } + + try { + for (const tool of toolInputs) { + // Handle toolkits + if (tool && typeof (tool as Toolkit).getTools === 'function') { + const toolsInToolkit = (tool as Toolkit).getTools(); + for (const toolkitTool of toolsInToolkit) { + if (toolkitTool instanceof Tool || toolkitTool instanceof StructuredTool) { + if (toolName === toolkitTool.name) { + const result = await executeTool(toolkitTool, parsedQuery); + resultData.push(result); + } + } + } + } else { + // Handle single tool + if (!toolName || toolName === (tool as Tool).name) { + const result = await executeTool(tool as Tool, parsedQuery); + resultData.push(result); + } + } + } + } catch (error) { + throw new NodeOperationError( + this.getNode(), + `Error executing tool: ${(error as Error).message}`, + ); + } + return [resultData]; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/ToolExecutor.node.test.ts b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/ToolExecutor.node.test.ts new file mode 100644 index 0000000000..297ad2cd47 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/ToolExecutor.node.test.ts @@ -0,0 +1,158 @@ +import { DynamicTool, DynamicStructuredTool } from '@langchain/core/tools'; +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; +import { z } from 'zod'; + +import { ToolExecutor } from '../ToolExecutor.node'; + +describe('ToolExecutor Node', () => { + let node: ToolExecutor; + let mockExecuteFunction: jest.Mocked; + + beforeEach(() => { + node = new ToolExecutor(); + mockExecuteFunction = mock(); + + mockExecuteFunction.logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + mockExecuteFunction.getNode.mockReturnValue({ + name: 'Tool Executor', + typeVersion: 1, + parameters: {}, + } as INode); + + jest.clearAllMocks(); + }); + + describe('description', () => { + it('should have the expected properties', () => { + expect(node.description).toBeDefined(); + expect(node.description.name).toBe('toolExecutor'); + expect(node.description.displayName).toBe('Tool Executor'); + expect(node.description.version).toBe(1); + expect(node.description.properties).toBeDefined(); + expect(node.description.inputs).toEqual([ + NodeConnectionTypes.Main, + NodeConnectionTypes.AiTool, + ]); + expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]); + }); + }); + + describe('ToolExecutor', () => { + it('should throw error if no tool inputs found', async () => { + mockExecuteFunction.getInputConnectionData.mockResolvedValue(null); + + await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow( + new NodeOperationError(mockExecuteFunction.getNode(), 'No tool inputs found'), + ); + }); + + it('executes a basic tool with string input', async () => { + const mockInvoke = jest.fn().mockResolvedValue('test result'); + + const mockTool = new DynamicTool({ + name: 'test_tool', + description: 'A test tool', + func: jest.fn(), + }); + + mockTool.invoke = mockInvoke; + + mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]); + mockExecuteFunction.getNodeParameter.mockImplementation((param) => { + if (param === 'query') return 'test input'; + return ''; + }); + + const result = await node.execute.call(mockExecuteFunction); + + expect(mockInvoke).toHaveBeenCalledWith('test input'); + expect(result).toEqual([[{ json: 'test result' }]]); + }); + + it('executes a structured tool with schema validation', async () => { + const mockTool = new DynamicStructuredTool({ + name: 'test_structured_tool', + description: 'A test structured tool', + schema: z.object({ + number: z.number(), + boolean: z.boolean(), + }), + func: jest.fn(), + }); + + const mockInvoke = jest.fn().mockResolvedValue('test result'); + mockTool.invoke = mockInvoke; + + mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]); + mockExecuteFunction.getNodeParameter.mockImplementation((param) => { + if (param === 'query') return { number: '42', boolean: 'true' }; + return ''; + }); + + const result = await node.execute.call(mockExecuteFunction); + + expect(mockTool.invoke).toHaveBeenCalledWith({ number: 42, boolean: true }); + expect(result).toEqual([[{ json: 'test result' }]]); + }); + + it('executes a specific tool from a toolkit with several tools', async () => { + const mockTool = new DynamicTool({ + name: 'specific_tool', + description: 'A specific tool', + func: jest.fn().mockResolvedValue('specific result'), + }); + + const irrelevantTool = new DynamicTool({ + name: 'other_tool', + description: 'A specific irrelevant tool', + func: jest.fn().mockResolvedValue('specific result'), + }); + + mockTool.invoke = jest.fn().mockResolvedValue('specific result'); + + const toolkit = { + getTools: () => [mockTool, irrelevantTool], + }; + + mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]); + mockExecuteFunction.getNodeParameter.mockImplementation((param) => { + if (param === 'query') return 'test input'; + if (param === 'toolName') return 'specific_tool'; + return ''; + }); + + const result = await node.execute.call(mockExecuteFunction); + + expect(mockTool.invoke).toHaveBeenCalledWith('test input'); + expect(result).toEqual([[{ json: 'specific result' }]]); + }); + + it('handles JSON string query inputs', async () => { + const mockTool = new DynamicTool({ + name: 'json_tool', + description: 'A tool that handles JSON', + func: jest.fn(), + }); + mockTool.invoke = jest.fn().mockResolvedValue('json result'); + + mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]); + mockExecuteFunction.getNodeParameter.mockImplementation((param) => { + if (param === 'query') return '{"key": "value"}'; + return ''; + }); + + const result = await node.execute.call(mockExecuteFunction); + + expect(mockTool.invoke).toHaveBeenCalledWith({ key: 'value' }); + expect(result).toEqual([[{ json: 'json result' }]]); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/convertToSchema.test.ts b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/convertToSchema.test.ts new file mode 100644 index 0000000000..ed25b69eb3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/test/convertToSchema.test.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; + +import { convertValueBySchema, convertObjectBySchema } from '../utils/convertToSchema'; + +describe('convertToSchema', () => { + describe('convertValueBySchema', () => { + it('should convert string to number when schema is ZodNumber', () => { + const result = convertValueBySchema('42', z.number()); + expect(result).toBe(42); + }); + + it('should convert string to boolean when schema is ZodBoolean', () => { + expect(convertValueBySchema('true', z.boolean())).toBe(true); + expect(convertValueBySchema('false', z.boolean())).toBe(false); + expect(convertValueBySchema('TRUE', z.boolean())).toBe(true); + expect(convertValueBySchema('FALSE', z.boolean())).toBe(false); + }); + + it('should parse JSON string when schema is ZodObject', () => { + const result = convertValueBySchema( + '{"key": "value", "other_key": 1, "booleanValue": false }', + z.object({}), + ); + expect(result).toEqual({ key: 'value', other_key: 1, booleanValue: false }); + }); + + it('should return original value if JSON parsing fails', () => { + const result = convertValueBySchema('invalid json', z.object({})); + expect(result).toEqual('invalid json'); + }); + + it('should return original value for non-string inputs', () => { + const input = { key: 'value' }; + const result = convertValueBySchema(input, z.object({})); + expect(result).toEqual(input); + }); + }); + + describe('convertObjectBySchema', () => { + it('should convert object values according to schema', () => { + const schema = z.object({ + numberValue: z.number(), + booleanValue: z.boolean(), + object: z.object({}), + unchanged: z.string(), + }); + + const input = { + numberValue: '42', + booleanValue: 'true', + object: '{"nested": "value"}', + unchanged: 'string value', + }; + + const result = convertObjectBySchema(input, schema); + + expect(result).toEqual({ + numberValue: 42, + booleanValue: true, + object: { nested: 'value' }, + unchanged: 'string value', + }); + }); + + it('should return original object if schema has no shape', () => { + const input = { key: 'value' }; + const result = convertObjectBySchema(input, {}); + expect(result).toBe(input); + }); + + it('should return original object if input is null', () => { + const result = convertObjectBySchema(null, z.object({})); + expect(result).toBeNull(); + }); + + it('should handle nested objects', () => { + const schema = z.object({ + nested: z.object({ + numberValue: z.number(), + booleanValue: z.boolean(), + }), + }); + + const input = { + nested: { + numberValue: '42', + booleanValue: 'true', + }, + }; + + const result = convertObjectBySchema(input, schema); + + expect(result).toEqual({ + nested: { + numberValue: 42, + booleanValue: true, + }, + }); + }); + + it('should preserve fields not in schema', () => { + const schema = z.object({ + number: z.number(), + }); + + const input = { + number: '42', + extra: 'value', + }; + + const result = convertObjectBySchema(input, schema); + + expect(result).toEqual({ + number: 42, + extra: 'value', + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/convertToSchema.ts b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/convertToSchema.ts new file mode 100644 index 0000000000..b985b1d42e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/convertToSchema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const convertValueBySchema = (value: unknown, schema: any): unknown => { + if (!schema || !value) return value; + + if (typeof value === 'string') { + if (schema instanceof z.ZodNumber) { + return Number(value); + } else if (schema instanceof z.ZodBoolean) { + return value.toLowerCase() === 'true'; + } else if (schema instanceof z.ZodObject) { + try { + const parsed = JSON.parse(value); + return convertValueBySchema(parsed, schema); + } catch { + return value; + } + } + } + + if (schema instanceof z.ZodObject && typeof value === 'object' && value !== null) { + const result: any = {}; + for (const [key, val] of Object.entries(value)) { + const fieldSchema = schema.shape[key]; + if (fieldSchema) { + result[key] = convertValueBySchema(val, fieldSchema); + } else { + result[key] = val; + } + } + return result; + } + + return value; +}; + +export const convertObjectBySchema = (obj: any, schema: any): any => { + return convertValueBySchema(obj, schema); +}; diff --git a/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/executeTool.ts b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/executeTool.ts new file mode 100644 index 0000000000..c97231d65a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ToolExecutor/utils/executeTool.ts @@ -0,0 +1,20 @@ +import type { StructuredTool } from 'langchain/tools'; +import { type IDataObject, type INodeExecutionData } from 'n8n-workflow'; + +import { convertObjectBySchema } from './convertToSchema'; + +export async function executeTool( + tool: StructuredTool, + query: string | object, +): Promise { + let convertedQuery: string | object = query; + if ('schema' in tool && tool.schema) { + convertedQuery = convertObjectBySchema(query, tool.schema); + } + + const result = await tool.invoke(convertedQuery); + + return { + json: result as IDataObject, + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts index 9432aed5bd..846e1634d8 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.test.ts @@ -38,6 +38,7 @@ describe('McpClientTool', () => { description: 'MyTool does something', name: 'MyTool', value: 'MyTool', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, }, ]); }); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts index b4be13c18f..99a602b6a7 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/loadOptions.ts @@ -28,5 +28,6 @@ export async function getTools(this: ILoadOptionsFunctions): Promise { group: ['input'], inputs: [], outputs: ['ai_tool'], - usableAsTool: true, properties: [ { default: 'A test node', @@ -371,7 +370,6 @@ describe('LoadNodesAndCredentials', () => { inputs: [], outputs: ['ai_tool'], description: 'A test node', - usableAsTool: true, properties: [ { displayName: 'Description', diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 2c6dd429d0..36d216e1be 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -319,8 +319,6 @@ export class LoadNodesAndCredentials { } as INodeTypeBaseDescription) : deepCopy(usableNode); const wrapped = this.convertNodeToAiTool({ description }).description; - // TODO: Remove this when we support partial execution on all tool nodes - wrapped.usableAsTool = true; this.types.nodes.push(wrapped); this.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] }; diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 98b0b113a6..7bb2da37fa 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -127,9 +127,16 @@ export class ManualExecutionService { // Rewire graph to be able to execute the destination tool node if (isTool(destinationNode, workflow.nodeTypes)) { - workflow = rewireGraph(destinationNode, DirectedGraph.fromWorkflow(workflow)).toWorkflow({ + const graph = rewireGraph( + destinationNode, + DirectedGraph.fromWorkflow(workflow), + data.agentRequest, + ); + + workflow = graph.toWorkflow({ ...workflow, }); + data.destinationNode = graph.getDirectChildConnections(destinationNode).at(0)?.to?.name; } } @@ -150,6 +157,7 @@ export class ManualExecutionService { data.pinData, data.dirtyNodeNames, data.destinationNode, + data.agentRequest, ); } else { return workflowExecute.runPartialWorkflow( diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 551ab455e9..fb4b28fabf 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -92,6 +92,7 @@ describe('WorkflowExecutionService', () => { const runPayload = mock({ startNodes: [], destinationNode: undefined, + agentRequest: undefined, }); workflowRunner.run.mockResolvedValue(executionId); @@ -123,6 +124,7 @@ describe('WorkflowExecutionService', () => { workflowData: { nodes: [node] }, startNodes: [], destinationNode: node.name, + agentRequest: undefined, }); jest @@ -177,6 +179,7 @@ describe('WorkflowExecutionService', () => { nodes: [triggerNode], }, triggerToStartFrom: undefined, + agentRequest: undefined, }); workflowRunner.run.mockResolvedValue(executionId); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index fa8cc5c799..6f81c82204 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -107,6 +107,7 @@ export class WorkflowExecutionService { destinationNode, dirtyNodeNames, triggerToStartFrom, + agentRequest, }: WorkflowRequest.ManualRunPayload, user: User, pushRef?: string, @@ -181,6 +182,7 @@ export class WorkflowExecutionService { partialExecutionVersion, dirtyNodeNames, triggerToStartFrom, + agentRequest, }; const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 334c109ab8..ccdfa28548 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -6,6 +6,7 @@ import type { StartNodeData, ITaskData, IWorkflowBase, + AiAgentRequest, } from 'n8n-workflow'; import type { AuthenticatedRequest, ListQuery } from '@/requests'; @@ -35,6 +36,7 @@ export declare namespace WorkflowRequest { name: string; data?: ITaskData; }; + agentRequest?: AiAgentRequest; }; type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 7cd29a256e..9f0daa01a4 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -820,14 +820,32 @@ describe('WorkflowExecute', () => { .spyOn(workflowExecute, 'processRunExecutionData') .mockImplementationOnce(jest.fn()); + const expectedToolExecutor: INode = { + name: 'PartialExecutionToolExecutor', + disabled: false, + type: '@n8n/n8n-nodes-langchain.toolExecutor', + parameters: { + query: {}, + toolName: '', + }, + id: agentNode.id, + typeVersion: 0, + position: [0, 0], + }; + const expectedTool = { ...tool, rewireOutputLogTo: NodeConnectionTypes.AiTool, }; const expectedGraph = new DirectedGraph() - .addNodes(trigger, expectedTool) - .addConnections({ from: trigger, to: expectedTool }) + .addNodes(trigger, expectedToolExecutor, expectedTool) + .addConnections({ from: trigger, to: expectedToolExecutor }) + .addConnections({ + from: expectedTool, + to: expectedToolExecutor, + type: NodeConnectionTypes.AiTool, + }) .toWorkflow({ ...workflow }); // ACT diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/is-tool.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/is-tool.test.ts index e7b5341d02..70321acff6 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/is-tool.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/is-tool.test.ts @@ -3,10 +3,15 @@ import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow'; import { isTool } from '../is-tool'; -const mockNode = mock({ id: '1', type: 'n8n-nodes-base.openAi', typeVersion: 1 }); -const mockNodeTypes = mock(); - describe('isTool', () => { + const mockNode = mock({ + id: '1', + type: 'n8n-nodes-base.openAi', + typeVersion: 1, + parameters: {}, + }); + const mockNodeTypes = mock(); + it('should return true for a node with AiTool output', () => { mockNodeTypes.getByNameAndVersion.mockReturnValue({ description: { @@ -28,7 +33,53 @@ describe('isTool', () => { expect(result).toBe(true); }); + it('should return true for a node with AiTool output in NodeOutputConfiguration', () => { + mockNodeTypes.getByNameAndVersion.mockReturnValue({ + description: { + outputs: [{ type: NodeConnectionTypes.AiTool }, { type: NodeConnectionTypes.Main }], + version: 0, + defaults: { + name: '', + color: '', + }, + inputs: [NodeConnectionTypes.Main], + properties: [], + displayName: '', + name: '', + group: [], + description: '', + }, + }); + const result = isTool(mockNode, mockNodeTypes); + expect(result).toBe(true); + }); + + it('returns true for a vectore store node in retrieve-as-tool mode', () => { + mockNode.type = 'n8n-nodes-base.vectorStore'; + mockNode.parameters = { mode: 'retrieve-as-tool' }; + mockNodeTypes.getByNameAndVersion.mockReturnValue({ + description: { + outputs: [NodeConnectionTypes.Main], + version: 0, + defaults: { + name: '', + color: '', + }, + inputs: [NodeConnectionTypes.Main], + properties: [], + displayName: '', + name: '', + group: [], + description: '', + }, + }); + const result = isTool(mockNode, mockNodeTypes); + expect(result).toBe(true); + }); + it('returns false for node with no AiTool output', () => { + mockNode.type = 'n8n-nodes-base.someTool'; + mockNode.parameters = {}; mockNodeTypes.getByNameAndVersion.mockReturnValue({ description: { outputs: [NodeConnectionTypes.Main], diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/rewire-graph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/rewire-graph.test.ts index 77a939b212..ef831138e8 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/rewire-graph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/rewire-graph.test.ts @@ -1,4 +1,4 @@ -import { NodeConnectionTypes } from 'n8n-workflow'; +import { type INode, NodeConnectionTypes } from 'n8n-workflow'; import { createNodeData } from './helpers'; import { DirectedGraph } from '../directed-graph'; @@ -19,15 +19,27 @@ describe('rewireGraph()', () => { const rewiredGraph = rewireGraph(tool, graph); - const toolConnections = rewiredGraph.getDirectParentConnections(tool); - expect(toolConnections).toHaveLength(1); - expect(toolConnections[0].from.name).toBe('trigger'); - expect(toolConnections[0].type).toBe(NodeConnectionTypes.Main); + const executorNode = rewiredGraph + .getNodesByNames(['PartialExecutionToolExecutor']) + .values() + .next().value as INode; + + const toolConnections = rewiredGraph.getDirectParentConnections(executorNode); + + expect(toolConnections).toHaveLength(2); + // Executor should be connected to tool + expect(toolConnections[0].from).toBe(tool); + expect(toolConnections[0].to).toBe(executorNode); + expect(toolConnections[0].type).toBe(NodeConnectionTypes.AiTool); + // Executor should be connected to trigger + expect(toolConnections[1].from).toBe(trigger); + expect(toolConnections[1].to).toBe(executorNode); + expect(toolConnections[1].type).toBe(NodeConnectionTypes.Main); expect(rewiredGraph.hasNode(root.name)).toBe(false); }); - it('rewires all incoming connections of the root node to the tool', () => { + it('rewires all incoming connections of the root node to the executorNode', () => { const tool = createNodeData({ name: 'tool', type: 'n8n-nodes-base.ai-tool' }); const root = createNodeData({ name: 'root' }); const trigger = createNodeData({ name: 'trigger' }); @@ -46,10 +58,16 @@ describe('rewireGraph()', () => { const rewiredGraph = rewireGraph(tool, graph); - const toolConnections = rewiredGraph.getDirectParentConnections(tool); - expect(toolConnections).toHaveLength(2); - expect(toolConnections.map((cn) => cn.from.name).sort()).toEqual( - ['secondNode', 'thirdNode'].sort(), + const executorNode = rewiredGraph + .getNodesByNames(['PartialExecutionToolExecutor']) + .values() + .next().value as INode; + + const executorConnections = rewiredGraph.getDirectParentConnections(executorNode); + + expect(executorConnections).toHaveLength(3); + expect(executorConnections.map((cn) => cn.from.name).sort()).toEqual( + ['tool', 'secondNode', 'thirdNode'].sort(), ); }); @@ -71,9 +89,17 @@ describe('rewireGraph()', () => { const rewiredGraph = rewireGraph(tool, graph); - const toolConnections = rewiredGraph.getDirectParentConnections(tool); - expect(toolConnections).toHaveLength(1); - expect(toolConnections[0].type).toBe(NodeConnectionTypes.Main); + const executorNode = rewiredGraph + .getNodesByNames(['PartialExecutionToolExecutor']) + .values() + .next().value as INode; + + const executorConnections = rewiredGraph.getDirectParentConnections(executorNode); + + expect(executorConnections).toHaveLength(2); + + expect(executorConnections[0].type).toBe(NodeConnectionTypes.AiTool); + expect(executorConnections[1].type).toBe(NodeConnectionTypes.Main); }); it('sets rewireOutputLogTo to AiTool on the tool node', () => { @@ -116,4 +142,33 @@ describe('rewireGraph()', () => { expect(rewiredGraph.hasNode(root.name)).toBe(false); }); + + it('sets parameters.query and toolName on the executor node', () => { + const tool = createNodeData({ name: 'tool', type: 'n8n-nodes-base.ai-tool' }); + const trigger = createNodeData({ name: 'trigger' }); + const root = createNodeData({ name: 'root' }); + const agentRequest = { + query: { some: 'query' }, + tool: { + name: 'toolName', + }, + }; + + const graph = new DirectedGraph(); + graph.addNodes(trigger, root, tool); + graph.addConnections( + { from: trigger, to: root, type: NodeConnectionTypes.Main }, + { from: tool, to: root, type: NodeConnectionTypes.AiTool }, + ); + + const rewiredGraph = rewireGraph(tool, graph, agentRequest); + + const executorNode = rewiredGraph + .getNodesByNames(['PartialExecutionToolExecutor']) + .values() + .next().value as INode; + + expect(executorNode.parameters.query).toEqual(agentRequest.query); + expect(executorNode.parameters.toolName).toEqual(agentRequest.tool.name); + }); }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/is-tool.ts b/packages/core/src/execution-engine/partial-execution-utils/is-tool.ts index 2ce3ceb914..0dd06a7c91 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/is-tool.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/is-tool.ts @@ -2,5 +2,21 @@ import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow'; export function isTool(node: INode, nodeTypes: INodeTypes) { const type = nodeTypes.getByNameAndVersion(node.type, node.typeVersion); - return type.description.outputs.includes(NodeConnectionTypes.AiTool); + + // Check if node is a vector store in retrieve-as-tool mode + if (node.type.includes('vectorStore')) { + const mode = node.parameters?.mode; + return mode === 'retrieve-as-tool'; + } + + // Check for other tool nodes + for (const output of type.description.outputs) { + if (typeof output === 'string') { + return output === NodeConnectionTypes.AiTool; + } else if (output?.type && output.type === NodeConnectionTypes.AiTool) { + return true; + } + } + + return false; } diff --git a/packages/core/src/execution-engine/partial-execution-utils/rewire-graph.ts b/packages/core/src/execution-engine/partial-execution-utils/rewire-graph.ts index bda9d3830c..98b1c533b8 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/rewire-graph.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/rewire-graph.ts @@ -1,9 +1,15 @@ import * as a from 'assert/strict'; -import { type INode, NodeConnectionTypes } from 'n8n-workflow'; +import { type AiAgentRequest, type INode, NodeConnectionTypes } from 'n8n-workflow'; import { type DirectedGraph } from './directed-graph'; -export function rewireGraph(tool: INode, graph: DirectedGraph): DirectedGraph { +export const TOOL_EXECUTOR_NODE_NAME = 'PartialExecutionToolExecutor'; + +export function rewireGraph( + tool: INode, + graph: DirectedGraph, + agentRequest?: AiAgentRequest, +): DirectedGraph { const modifiedGraph = graph.clone(); const children = modifiedGraph.getChildren(tool); @@ -19,12 +25,33 @@ export function rewireGraph(tool: INode, graph: DirectedGraph): DirectedGraph { .getDirectParentConnections(rootNode) .filter((cn) => cn.type === NodeConnectionTypes.Main); - tool.rewireOutputLogTo = NodeConnectionTypes.AiTool; + // Create virtual agent node + const toolExecutor: INode = { + name: TOOL_EXECUTOR_NODE_NAME, + disabled: false, + type: '@n8n/n8n-nodes-langchain.toolExecutor', + parameters: { + query: agentRequest?.query ?? {}, + toolName: agentRequest?.tool?.name ?? '', + }, + id: rootNode.id, + typeVersion: 0, + position: [0, 0], + }; + // Add virtual agent to graph + modifiedGraph.addNode(toolExecutor); + + // Rewire tool output to virtual agent + tool.rewireOutputLogTo = NodeConnectionTypes.AiTool; + modifiedGraph.addConnection({ from: tool, to: toolExecutor, type: NodeConnectionTypes.AiTool }); + + // Rewire all incoming connections to virtual agent for (const cn of allIncomingConnection) { - modifiedGraph.addConnection({ from: cn.from, to: tool }); + modifiedGraph.addConnection({ from: cn.from, to: toolExecutor, type: cn.type }); } + // Remove original agent node modifiedGraph.removeNode(rootNode); return modifiedGraph; diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index 254e202a33..de7f8ecfa0 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -40,6 +40,7 @@ import type { INodeIssues, INodeType, ITaskStartedData, + AiAgentRequest, } from 'n8n-workflow'; import { LoggerProxy as Logger, @@ -51,6 +52,7 @@ import { Node, UnexpectedError, UserError, + OperationalError, } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; @@ -72,6 +74,7 @@ import { isTool, getNextExecutionIndex, } from './partial-execution-utils'; +import { TOOL_EXECUTOR_NODE_NAME } from './partial-execution-utils/rewire-graph'; import { RoutingNode } from './routing-node'; import { TriggersAndPollers } from './triggers-and-pollers'; @@ -346,6 +349,7 @@ export class WorkflowExecute { pinData: IPinData = {}, dirtyNodeNames: string[] = [], destinationNodeName?: string, + agentRequest?: AiAgentRequest, ): PCancelable { // TODO: Refactor the call-site to make `destinationNodeName` a required // after removing the old partial execution flow. @@ -354,7 +358,7 @@ export class WorkflowExecute { 'a destinationNodeName is required for the new partial execution flow', ); - const destination = workflow.getNode(destinationNodeName); + let destination = workflow.getNode(destinationNodeName); assert.ok( destination, `Could not find a node with the name ${destinationNodeName} in the workflow.`, @@ -364,8 +368,15 @@ export class WorkflowExecute { // Partial execution of nodes as tools if (isTool(destination, workflow.nodeTypes)) { - graph = rewireGraph(destination, graph); + graph = rewireGraph(destination, graph, agentRequest); workflow = graph.toWorkflow({ ...workflow }); + // Rewire destination node to the virtual agent + const toolExecutorNode = workflow.getNode(TOOL_EXECUTOR_NODE_NAME); + if (!toolExecutorNode) { + throw new OperationalError('ToolExecutor can not be found'); + } + destination = toolExecutorNode; + destinationNodeName = toolExecutorNode.name; } else { // Edge Case 1: // Support executing a single node that is not connected to a trigger diff --git a/packages/core/test/helpers/constants.ts b/packages/core/test/helpers/constants.ts index 2a54b7d5c9..3a0bf9e560 100644 --- a/packages/core/test/helpers/constants.ts +++ b/packages/core/test/helpers/constants.ts @@ -43,6 +43,25 @@ export const predefinedNodesTypes: INodeTypeData = { type: new SplitInBatches(), sourcePath: '', }, + '@n8n/n8n-nodes-langchain.toolExecutor': { + sourcePath: '', + type: { + description: { + displayName: 'Test tool executor', + name: 'toolTestExecutor', + group: ['transform'], + version: 1, + description: 'Test tool executor', + defaults: { + name: 'Test Tool Executor', + color: '#0000FF', + }, + inputs: [NodeConnectionTypes.AiTool, NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + }, + }, + }, 'n8n-nodes-base.toolTest': { sourcePath: '', type: { diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 7a87066cb1..5ec5106d38 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -217,6 +217,12 @@ export interface IStartRunData { name: string; data?: ITaskData; }; + agentRequest?: { + query: NodeParameterValueType; + tool: { + name: NodeParameterValueType; + }; + }; } export interface ITableData { diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts index 2098f5ead2..cf6e268e7f 100644 --- a/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts +++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts @@ -1,12 +1,14 @@ import { createTestingPinia } from '@pinia/testing'; import { createComponentRenderer } from '@/__tests__/render'; import FromAiParametersModal from '@/components/FromAiParametersModal.vue'; -import { FROM_AI_PARAMETERS_MODAL_KEY, STORES } from '@/constants'; +import { FROM_AI_PARAMETERS_MODAL_KEY, STORES, AI_MCP_TOOL_NODE_TYPE } from '@/constants'; import userEvent from '@testing-library/user-event'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { useParameterOverridesStore } from '@/stores/parameterOverrides.store'; +import { useAgentRequestStore } from '@/stores/agentRequest.store'; import { useRouter } from 'vue-router'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { nextTick } from 'vue'; const ModalStub = { template: ` @@ -26,12 +28,20 @@ vi.mocked(useRouter); const mockNode = { id: 'id1', name: 'Test Node', + type: 'n8n-nodes-base.ai-tool', parameters: { testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}", testParam: "={{ $fromAi('testParam', ``, 'string') }}", }, }; +const mockMcpNode = { + id: 'id1', + name: 'Test MCP Node', + type: AI_MCP_TOOL_NODE_TYPE, + parameters: {}, +}; + const mockParentNode = { name: 'Parent Node', }; @@ -43,7 +53,7 @@ const mockRunData = { ['Test Node']: [ { inputOverride: { - [NodeConnectionTypes.AiTool]: [[{ json: { testParam: 'override' } }]], + [NodeConnectionTypes.AiTool]: [[{ json: { query: { testParam: 'override' } } }]], }, }, ], @@ -57,10 +67,27 @@ const mockWorkflow = { getChildNodes: () => ['Parent Node'], }; +const mockTools = [ + { + name: 'Test Tool', + value: 'test-tool', + inputSchema: { + properties: { + query: { + type: 'string', + description: 'Test query', + }, + }, + }, + }, +]; + const renderModal = createComponentRenderer(FromAiParametersModal); let pinia: ReturnType; let workflowsStore: ReturnType; -let parameterOverridesStore: ReturnType; +let agentRequestStore: ReturnType; +let nodeTypesStore: ReturnType; + describe('FromAiParametersModal', () => { beforeEach(() => { pinia = createTestingPinia({ @@ -83,14 +110,23 @@ describe('FromAiParametersModal', () => { }, }); workflowsStore = useWorkflowsStore(); - workflowsStore.getNodeByName = vi - .fn() - .mockImplementation((name: string) => (name === 'Test Node' ? mockNode : mockParentNode)); + workflowsStore.getNodeByName = vi.fn().mockImplementation((name: string) => { + switch (name) { + case 'Test Node': + return mockNode; + case 'Test MCP Node': + return mockMcpNode; + default: + return mockParentNode; + } + }); workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow); - parameterOverridesStore = useParameterOverridesStore(); - parameterOverridesStore.clearParameterOverrides = vi.fn(); - parameterOverridesStore.addParameterOverrides = vi.fn(); - parameterOverridesStore.substituteParameters = vi.fn(); + agentRequestStore = useAgentRequestStore(); + agentRequestStore.clearAgentRequests = vi.fn(); + agentRequestStore.addAgentRequests = vi.fn(); + agentRequestStore.generateAgentRequest = vi.fn(); + nodeTypesStore = useNodeTypesStore(); + nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools); }); it('renders correctly with node data', () => { @@ -112,6 +148,53 @@ describe('FromAiParametersModal', () => { expect(getByTitle('Test Test Node')).toBeTruthy(); }); + it('shows tool selection for AI tool nodes', async () => { + const { findByRole } = renderModal({ + props: { + modalName: FROM_AI_PARAMETERS_MODAL_KEY, + data: { + nodeName: 'Test MCP Node', + }, + }, + global: { + stubs: { + Modal: ModalStub, + }, + }, + pinia, + }); + + const toolSelect = await findByRole('combobox'); + expect(toolSelect).toBeTruthy(); + }); + + it('shows tool parameters after tool selection', async () => { + const { getByTestId, findByRole, findByText } = renderModal({ + props: { + modalName: FROM_AI_PARAMETERS_MODAL_KEY, + data: { + nodeName: 'Test MCP Node', + }, + }, + global: { + stubs: { + Modal: ModalStub, + }, + }, + pinia, + }); + + const toolSelect = await findByRole('combobox'); + await userEvent.click(toolSelect); + + const toolOption = await findByText('Test Tool'); + await userEvent.click(toolOption); + await nextTick(); + const inputs = getByTestId('from-ai-parameters-modal-inputs'); + const inputByName = inputs.querySelector('input[name="query.query"]'); + expect(inputByName).toBeTruthy(); + }); + it('uses run data when available as initial values', async () => { const { getByTestId } = renderModal({ props: { @@ -130,14 +213,10 @@ describe('FromAiParametersModal', () => { await userEvent.click(getByTestId('execute-workflow-button')); - expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith( - 'test-workflow', - 'id1', - { - testBoolean: true, - testParam: 'override', - }, - ); + expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', { + 'query.testBoolean': true, + 'query.testParam': 'override', + }); }); it('clears parameter overrides when modal is executed', async () => { @@ -158,13 +237,10 @@ describe('FromAiParametersModal', () => { await userEvent.click(getByTestId('execute-workflow-button')); - expect(parameterOverridesStore.clearParameterOverrides).toHaveBeenCalledWith( - 'test-workflow', - 'id1', - ); + expect(agentRequestStore.clearAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1'); }); - it('adds substitutes for parameters when executed', async () => { + it('adds agent request with given parameters when executed', async () => { const { getByTestId } = renderModal({ props: { modalName: FROM_AI_PARAMETERS_MODAL_KEY, @@ -182,17 +258,16 @@ describe('FromAiParametersModal', () => { const inputs = getByTestId('from-ai-parameters-modal-inputs'); await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element); - await userEvent.clear(inputs.querySelector('input[name="testParam"]') as Element); - await userEvent.type(inputs.querySelector('input[name="testParam"]') as Element, 'given value'); + await userEvent.clear(inputs.querySelector('input[name="query.testParam"]') as Element); + await userEvent.type( + inputs.querySelector('input[name="query.testParam"]') as Element, + 'given value', + ); await userEvent.click(getByTestId('execute-workflow-button')); - expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith( - 'test-workflow', - 'id1', - { - testBoolean: false, - testParam: 'given value', - }, - ); + expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', { + 'query.testBoolean': false, + 'query.testParam': 'given value', + }); }); }); diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue index 98647895e5..d45e678d33 100644 --- a/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue +++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue @@ -1,20 +1,23 @@