mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Execute tools in the order the root requested them (#36009)
This commit is contained in:
parent
a3fefb2572
commit
acd4fdacc9
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user