fix(core): Execute tools in the order the root requested them (#36009)

This commit is contained in:
Benjamin Schroth 2026-08-11 11:37:02 +02:00 committed by GitHub
parent a3fefb2572
commit acd4fdacc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 191 additions and 1 deletions

View File

@ -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 });

View File

@ -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<INodeExecutionData[][]> {
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

View File

@ -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,