From acd4fdacc9daa2acbcc5864405c0a6d005f6a6c6 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:37:02 +0200 Subject: [PATCH] fix(core): Execute tools in the order the root requested them (#36009) --- .../__tests__/requests-response.test.ts | 96 +++++++++++++++++++ ...process-process-run-execution-data.test.ts | 89 +++++++++++++++++ .../src/execution-engine/requests-response.ts | 7 +- 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/packages/core/src/execution-engine/__tests__/requests-response.test.ts b/packages/core/src/execution-engine/__tests__/requests-response.test.ts index cf0a7d72672..05626748917 100644 --- a/packages/core/src/execution-engine/__tests__/requests-response.test.ts +++ b/packages/core/src/execution-engine/__tests__/requests-response.test.ts @@ -496,6 +496,102 @@ describe('handleRequests', () => { expect(resumingNode.metadata).toHaveProperty('preservedSourceOverwrite', preservedSource); }); + test('enqueues actions in reverse so the LIFO stack runs them in request order', () => { + // ARRANGE + const toolA = createNodeData({ name: 'Tool A', type: types.passThrough }); + const toolB = createNodeData({ name: 'Tool B', type: types.passThrough }); + const toolC = createNodeData({ name: 'Tool C', type: types.passThrough }); + const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough }); + + const workflow = new DirectedGraph() + .addNodes(toolA, toolB, toolC, agentNode) + .toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } }); + + const executionData: IExecuteData = { + data: { main: [[{ json: {} }]] }, + source: { main: [{ previousNode: 'Trigger', previousNodeOutput: 0, previousNodeRun: 0 }] }, + node: agentNode, + }; + + const request: EngineRequest = { + actions: ['Tool A', 'Tool B', 'Tool C'].map((nodeName, index) => ({ + actionType: 'ExecutionNodeAction', + nodeName, + input: {}, + type: 'ai_tool', + id: `tool_call_${index}`, + metadata: { itemIndex: 0 }, + })), + metadata: {}, + }; + + // ACT + const result = handleRequest({ + workflow, + currentNode: agentNode, + request, + runIndex: 0, + executionData, + runData: {}, + }); + + // ASSERT + // The requesting node is enqueued first so it ends up at the bottom of the stack, + // then the tools in reverse — unshifting these in turn yields [A, B, C, AI Agent]. + expect(result.nodesToBeExecuted.map((n) => n.inputConnectionData.node)).toEqual([ + 'AI Agent', + 'Tool C', + 'Tool B', + 'Tool A', + ]); + }); + + test('keeps request order for the legacy execution order, which enqueues with push', () => { + // ARRANGE + const toolA = createNodeData({ name: 'Tool A', type: types.passThrough }); + const toolB = createNodeData({ name: 'Tool B', type: types.passThrough }); + const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough }); + + const workflow = new DirectedGraph() + .addNodes(toolA, toolB, agentNode) + .toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v0' } }); + + const executionData: IExecuteData = { + data: { main: [[{ json: {} }]] }, + source: { main: [{ previousNode: 'Trigger', previousNodeOutput: 0, previousNodeRun: 0 }] }, + node: agentNode, + }; + + const request: EngineRequest = { + actions: ['Tool A', 'Tool B'].map((nodeName, index) => ({ + actionType: 'ExecutionNodeAction', + nodeName, + input: {}, + type: 'ai_tool', + id: `tool_call_${index}`, + metadata: { itemIndex: 0 }, + })), + metadata: {}, + }; + + // ACT + const result = handleRequest({ + workflow, + currentNode: agentNode, + request, + runIndex: 0, + executionData, + runData: {}, + }); + + // ASSERT + expect(result.nodesToBeExecuted.map((n) => n.inputConnectionData.node)).toEqual([ + 'AI Agent', + 'Tool A', + 'Tool B', + ]); + }); + test('does not add preserveSourceOverwrite metadata when not present', () => { const agentNode = createNodeData({ name: 'AI Agent', type: types.passThrough }); diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts index be4df709ef5..61ae68ca828 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts @@ -651,6 +651,95 @@ describe('processRunExecutionData', () => { }); }); + test('executes requested tools in the order the actions were requested', async () => { + // ARRANGE + const executionOrder: string[] = []; + let response: EngineResponse | undefined; + + const recordingTool = (name: string): INodeType => ({ + ...passThroughNode, + async execute(this: IExecuteFunctions): Promise { + executionOrder.push(name); + return [this.getInputData()]; + }, + }); + + const toolNames = ['tool1', 'tool2', 'tool3']; + const toolNodes = toolNames.map((name) => createNodeData({ name, type: `${name}Type` })); + + const nodeTypeWithRequests = modifyNode(passThroughNode) + .return({ + actions: toolNames.map((nodeName, index) => ({ + actionType: 'ExecutionNodeAction', + nodeName, + input: { query: nodeName }, + type: 'ai_tool', + id: `action_${index}`, + metadata: { itemIndex: 0 }, + })), + metadata: {}, + }) + .return((r) => { + response = r; + return [[{ json: { done: true } }]]; + }) + .done(); + + const nodeWithRequests = createNodeData({ + name: 'nodeWithRequests', + type: 'nodeWithRequests', + }); + + const nodeTypes = NodeTypes({ + ...nodeTypeArguments, + nodeWithRequests: { type: nodeTypeWithRequests, sourcePath: '' }, + ...Object.fromEntries( + toolNames.map((name) => [`${name}Type`, { type: recordingTool(name), sourcePath: '' }]), + ), + }); + + const graph = new DirectedGraph().addNodes(nodeWithRequests, ...toolNodes); + for (const toolNode of toolNodes) { + graph.addConnections({ from: toolNode, to: nodeWithRequests, type: 'ai_tool' }); + } + const workflow = graph.toWorkflow({ + name: '', + active: false, + nodeTypes, + settings: { executionOrder: 'v1' }, + }); + + const executionData = createRunExecutionData({ + startData: { startNodes: [{ name: nodeWithRequests.name, sourceData: null }] }, + executionData: { + nodeExecutionStack: [ + { + data: { main: [[{ json: { prompt: 'test prompt' } }]] }, + node: nodeWithRequests, + source: { main: [{ previousNode: 'Start' }] }, + }, + ], + }, + }); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData); + + // ACT + const result = await workflowExecute.processRunExecutionData(workflow); + + // ASSERT + // Tools run in request order, not reversed by the LIFO execution stack + expect(executionOrder).toEqual(toolNames); + + // The responses handed back to the requesting node stay aligned with that order + expect((response?.actionResponses ?? []).map((r) => r.action.nodeName)).toEqual(toolNames); + + // ...and so does what the logs panel sorts on + const runData = result.data.resultData.runData; + const executionIndexes = toolNames.map((name) => runData[name][0].executionIndex); + expect(executionIndexes).toEqual([...executionIndexes].sort((a, b) => a - b)); + }); + test('skips waiting tools processing when parent node cannot be found', async () => { // ARRANGE // This test simulates the scenario where executionData.source.main[0].previousNode diff --git a/packages/core/src/execution-engine/requests-response.ts b/packages/core/src/execution-engine/requests-response.ts index e502cab3968..ff473833cd3 100644 --- a/packages/core/src/execution-engine/requests-response.ts +++ b/packages/core/src/execution-engine/requests-response.ts @@ -268,7 +268,12 @@ export function handleRequest({ return { nodesToBeExecuted: [] }; } - // 3. add current node back to the bottom of the stack + // 3. under executionOrder v1 reverse the actions to run the requests in the order the root requested them to run + if (workflow.settings.executionOrder === 'v1') { + nodesToBeExecuted.reverse(); + } + + // 4. add current node back to the bottom of the stack nodesToBeExecuted.unshift({ inputConnectionData: result.connectionData, parentOutputIndex: 0,