diff --git a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts index 9aa30bb58c..73ecf81679 100644 --- a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts +++ b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts @@ -53,7 +53,7 @@ function createRunDataWithError(inputMessage: string) { input: inputMessage, system_message: 'You are a helpful assistant', formatting_instructions: - 'IMPORTANT: Always call `format_final_response` to format your final response!', + 'IMPORTANT: Always call `format_final_json_response` to format your final response!', }, }, }, @@ -68,7 +68,7 @@ function createRunDataWithError(inputMessage: string) { input: inputMessage, system_message: 'You are a helpful assistant', formatting_instructions: - 'IMPORTANT: Always call `format_final_response` to format your final response!', + 'IMPORTANT: Always call `format_final_json_response` to format your final response!', }, }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 543b6da49a..8bc37d60c7 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -211,7 +211,7 @@ export function handleParsedStepOutput( /** * Parses agent steps using the provided output parser. - * If the agent used the 'format_final_response' tool, the output is parsed accordingly. + * If the agent used the 'format_final_json_response' tool, the output is parsed accordingly. * * @param steps - The agent finish or action steps * @param outputParser - The output parser (if defined) @@ -221,9 +221,9 @@ export function handleParsedStepOutput( export const getAgentStepsParser = (outputParser?: N8nOutputParser, memory?: BaseChatMemory) => async (steps: AgentFinish | AgentAction[]): Promise => { - // Check if the steps contain the 'format_final_response' tool invocation. + // Check if the steps contain the 'format_final_json_response' tool invocation. if (Array.isArray(steps)) { - const responseParserTool = steps.find((step) => step.tool === 'format_final_response'); + const responseParserTool = steps.find((step) => step.tool === 'format_final_json_response'); if (responseParserTool && outputParser) { const toolInput = responseParserTool.toolInput; // Ensure the tool input is a string @@ -318,9 +318,9 @@ export async function getTools( const schema = getOutputParserSchema(outputParser); const structuredOutputParserTool = new DynamicStructuredTool({ schema, - name: 'format_final_response', + name: 'format_final_json_response', description: - 'Always use this tool for the final output to the user. It validates the output so only use it when you are sure the output is final.', + 'Use this tool to format your final response to the user in a structured JSON format. This tool validates your output against a schema to ensure it meets the required format. ONLY use this tool when you have completed all necessary reasoning and are ready to provide your final answer. Do not use this tool for intermediate steps or for asking questions. The output from this tool will be directly returned to the user.', // We do not use a function here because we intercept the output with the parser. func: async () => '', }); @@ -454,7 +454,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise { const tools = await getTools(ctx, fakeOutputParser); // Our fake getConnectedTools returns one tool; with outputParser, one extra is appended. expect(tools.length).toEqual(2); - const dynamicTool = tools.find((t) => t.name === 'format_final_response'); + const dynamicTool = tools.find((t) => t.name === 'format_final_json_response'); expect(dynamicTool).toBeDefined(); }); }); diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 6b5816e7b8..ba9551ed6a 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -221,3 +221,22 @@ export const getConnectedTools = async ( return finalTools; }; + +/** + * Sometimes model output is wrapped in an additional object property. + * This function unwraps the output if it is in the format { output: { output: { ... } } } + */ +export function unwrapNestedOutput(output: Record): Record { + if ( + 'output' in output && + Object.keys(output).length === 1 && + typeof output.output === 'object' && + output.output !== null && + 'output' in output.output && + Object.keys(output.output).length === 1 + ) { + return output.output as Record; + } + + return output; +} diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts index de07bfc7cf..2fb91bab47 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts @@ -39,7 +39,12 @@ export class N8nOutputFixingParser extends BaseOutputParser { try { // First attempt to parse the completion - const response = await this.outputParser.parse(completion, callbacks, (e) => e); + const response = await this.outputParser.parse(completion, callbacks, (e) => { + if (e instanceof OutputParserException) { + return e; + } + return new OutputParserException(e.message, completion); + }); logAiEvent(this.context, 'ai-output-parsed', { text: completion, response }); this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [ diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts index 3b8410df74..08ba1735a2 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts @@ -5,7 +5,7 @@ import type { ISupplyDataFunctions } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { z } from 'zod'; -import { logAiEvent } from '../helpers'; +import { logAiEvent, unwrapNestedOutput } from '../helpers'; const STRUCTURED_OUTPUT_KEY = '__structured__output'; const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object'; @@ -36,11 +36,14 @@ export class N8nStructuredOutputParser extends StructuredOutputParser< const json = JSON.parse(jsonString.trim()); const parsed = await this.schema.parseAsync(json); - const result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ?? + let result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ?? get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ?? get(parsed, STRUCTURED_OUTPUT_KEY) ?? parsed) as Record; + // Unwrap any doubly-nested output structures (e.g., {output: {output: {...}}}) + result = unwrapNestedOutput(result); + logAiEvent(this.context, 'ai-output-parsed', { text, response: result }); this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [ diff --git a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts index 37b7488f42..b86a18a76e 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts +++ b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts @@ -4,7 +4,7 @@ import { NodeOperationError } from 'n8n-workflow'; import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow'; import { z } from 'zod'; -import { escapeSingleCurlyBrackets, getConnectedTools } from '../helpers'; +import { escapeSingleCurlyBrackets, getConnectedTools, unwrapNestedOutput } from '../helpers'; import { N8nTool } from '../N8nTool'; describe('escapeSingleCurlyBrackets', () => { @@ -243,3 +243,101 @@ describe('getConnectedTools', () => { expect(tools[0]).toBe(mockN8nTool); }); }); + +describe('unwrapNestedOutput', () => { + it('should unwrap doubly nested output', () => { + const input = { + output: { + output: { + text: 'Hello world', + confidence: 0.95, + }, + }, + }; + + const expected = { + output: { + text: 'Hello world', + confidence: 0.95, + }, + }; + + expect(unwrapNestedOutput(input)).toEqual(expected); + }); + + it('should not modify regular output object', () => { + const input = { + output: { + text: 'Hello world', + confidence: 0.95, + }, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should not modify object without output property', () => { + const input = { + result: 'success', + data: { + text: 'Hello world', + }, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should not modify when output is not an object', () => { + const input = { + output: 'Hello world', + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should not modify when object has multiple properties', () => { + const input = { + output: { + output: { + text: 'Hello world', + }, + }, + meta: { + timestamp: 123456789, + }, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should not modify when inner output has multiple properties', () => { + const input = { + output: { + output: { + text: 'Hello world', + }, + meta: { + timestamp: 123456789, + }, + }, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should handle null values properly', () => { + const input = { + output: null, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); + + it('should handle empty object values properly', () => { + const input = { + output: {}, + }; + + expect(unwrapNestedOutput(input)).toEqual(input); + }); +});