mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
refactor(Structured Output Parser Node): Support schema via expression (#16671)
This commit is contained in:
@@ -36,8 +36,6 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
const outputParser = await getOptionalOutputParser(this);
|
||||
const tools = await getTools(this, outputParser);
|
||||
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 1) as number;
|
||||
const delayBetweenBatches = this.getNodeParameter(
|
||||
'options.batching.delayBetweenBatches',
|
||||
@@ -61,7 +59,8 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
||||
if (input === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'The “text” parameter is empty.');
|
||||
}
|
||||
|
||||
const outputParser = await getOptionalOutputParser(this, itemIndex);
|
||||
const tools = await getTools(this, outputParser);
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
systemMessage?: string;
|
||||
maxIterations?: number;
|
||||
@@ -112,7 +111,9 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
// This is only used to check if the output parser is connected
|
||||
// so we can parse the output if needed. Actual output parsing is done in the loop above
|
||||
const outputParser = await getOptionalOutputParser(this, 0);
|
||||
batchResults.forEach((result, index) => {
|
||||
const itemIndex = i + index;
|
||||
if (result.status === 'rejected') {
|
||||
|
||||
@@ -5,12 +5,21 @@ import type { Tool } from 'langchain/tools';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as helpers from '../../../../../utils/helpers';
|
||||
import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';
|
||||
|
||||
jest.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
N8nStructuredOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('toolsAgentExecute', () => {
|
||||
beforeEach(() => {
|
||||
@@ -58,8 +67,8 @@ describe('toolsAgentExecute', () => {
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
|
||||
.mockResolvedValueOnce({ output: { text: 'success 1' } })
|
||||
.mockResolvedValueOnce({ output: { text: 'success 2' } }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
@@ -108,10 +117,10 @@ describe('toolsAgentExecute', () => {
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }),
|
||||
.mockResolvedValueOnce({ output: { text: 'success 1' } })
|
||||
.mockResolvedValueOnce({ output: { text: 'success 2' } })
|
||||
.mockResolvedValueOnce({ output: { text: 'success 3' } })
|
||||
.mockResolvedValueOnce({ output: { text: 'success 4' } }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
@@ -163,7 +172,7 @@ describe('toolsAgentExecute', () => {
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: '{ "text": "success" }' })
|
||||
.mockResolvedValueOnce({ output: { text: 'success' } })
|
||||
.mockRejectedValueOnce(new Error('Test error')),
|
||||
};
|
||||
|
||||
@@ -220,4 +229,188 @@ describe('toolsAgentExecute', () => {
|
||||
|
||||
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('should fetch output parser with correct item index', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 2;
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
mockContext.getInputData.mockReturnValue([
|
||||
{ json: { text: 'test input 1' } },
|
||||
{ json: { text: 'test input 2' } },
|
||||
{ json: { text: 'test input 3' } },
|
||||
]);
|
||||
|
||||
const mockModel = mock<BaseChatModel>();
|
||||
mockModel.bindTools = jest.fn();
|
||||
mockModel.lc_namespace = ['chat_models'];
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||
|
||||
const mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||
const mockParser3 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||
|
||||
const getOptionalOutputParserSpy = jest
|
||||
.spyOn(outputParserModule, 'getOptionalOutputParser')
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2)
|
||||
.mockResolvedValueOnce(mockParser3)
|
||||
.mockResolvedValueOnce(undefined); // For the check call
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'options.batching.batchSize') return defaultValue;
|
||||
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
|
||||
if (param === 'options')
|
||||
return {
|
||||
systemMessage: 'You are a helpful assistant',
|
||||
maxIterations: 10,
|
||||
returnIntermediateSteps: false,
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
// Verify getOptionalOutputParser was called with correct indices
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 0);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 1);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 0);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 2);
|
||||
});
|
||||
|
||||
it('should pass different output parsers to getTools for each item', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 2;
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
mockContext.getInputData.mockReturnValue([
|
||||
{ json: { text: 'test input 1' } },
|
||||
{ json: { text: 'test input 2' } },
|
||||
]);
|
||||
|
||||
const mockModel = mock<BaseChatModel>();
|
||||
mockModel.bindTools = jest.fn();
|
||||
mockModel.lc_namespace = ['chat_models'];
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||
|
||||
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||
|
||||
jest
|
||||
.spyOn(outputParserModule, 'getOptionalOutputParser')
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2);
|
||||
|
||||
const getToolsSpy = jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'options')
|
||||
return {
|
||||
systemMessage: 'You are a helpful assistant',
|
||||
maxIterations: 10,
|
||||
returnIntermediateSteps: false,
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
// Verify getTools was called with different parsers
|
||||
expect(getToolsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getToolsSpy).toHaveBeenNthCalledWith(1, mockContext, true, false);
|
||||
expect(getToolsSpy).toHaveBeenNthCalledWith(2, mockContext, true, false);
|
||||
});
|
||||
|
||||
it('should maintain correct parser-item mapping in batch processing', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 2;
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
mockContext.getInputData.mockReturnValue([
|
||||
{ json: { text: 'test input 1' } },
|
||||
{ json: { text: 'test input 2' } },
|
||||
{ json: { text: 'test input 3' } },
|
||||
{ json: { text: 'test input 4' } },
|
||||
]);
|
||||
|
||||
const mockModel = mock<BaseChatModel>();
|
||||
mockModel.bindTools = jest.fn();
|
||||
mockModel.lc_namespace = ['chat_models'];
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||
|
||||
const mockParsers = [
|
||||
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||
];
|
||||
|
||||
const getOptionalOutputParserSpy = jest
|
||||
.spyOn(outputParserModule, 'getOptionalOutputParser')
|
||||
.mockImplementation(async (_ctx, index) => mockParsers[index || 0]);
|
||||
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'options.batching.batchSize') return 2;
|
||||
if (param === 'options.batching.delayBetweenBatches') return 0;
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'options')
|
||||
return {
|
||||
systemMessage: 'You are a helpful assistant',
|
||||
maxIterations: 10,
|
||||
returnIntermediateSteps: false,
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) })
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
// Verify each item got its corresponding parser based on index
|
||||
// It's called once per item + once to check if output parser is connected
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 1);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 0);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 2);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 3);
|
||||
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(6, mockContext, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user