Skip to content

Commit 5b6b787

Browse files
burivuhsterOlegIvaniv
andauthoredMar 13, 2025··
fix(Structured Output Parser Node, Auto-fixing Output Parser Node, Tools Agent Node): Structured output improvements (#13908)
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
1 parent 3103748 commit 5b6b787

File tree

7 files changed

+138
-13
lines changed

7 files changed

+138
-13
lines changed
 

‎cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function createRunDataWithError(inputMessage: string) {
5353
input: inputMessage,
5454
system_message: 'You are a helpful assistant',
5555
formatting_instructions:
56-
'IMPORTANT: Always call `format_final_response` to format your final response!',
56+
'IMPORTANT: Always call `format_final_json_response` to format your final response!',
5757
},
5858
},
5959
},
@@ -68,7 +68,7 @@ function createRunDataWithError(inputMessage: string) {
6868
input: inputMessage,
6969
system_message: 'You are a helpful assistant',
7070
formatting_instructions:
71-
'IMPORTANT: Always call `format_final_response` to format your final response!',
71+
'IMPORTANT: Always call `format_final_json_response` to format your final response!',
7272
},
7373
},
7474
},

‎packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export function handleParsedStepOutput(
211211

212212
/**
213213
* Parses agent steps using the provided output parser.
214-
* If the agent used the 'format_final_response' tool, the output is parsed accordingly.
214+
* If the agent used the 'format_final_json_response' tool, the output is parsed accordingly.
215215
*
216216
* @param steps - The agent finish or action steps
217217
* @param outputParser - The output parser (if defined)
@@ -221,9 +221,9 @@ export function handleParsedStepOutput(
221221
export const getAgentStepsParser =
222222
(outputParser?: N8nOutputParser, memory?: BaseChatMemory) =>
223223
async (steps: AgentFinish | AgentAction[]): Promise<AgentFinish | AgentAction[]> => {
224-
// Check if the steps contain the 'format_final_response' tool invocation.
224+
// Check if the steps contain the 'format_final_json_response' tool invocation.
225225
if (Array.isArray(steps)) {
226-
const responseParserTool = steps.find((step) => step.tool === 'format_final_response');
226+
const responseParserTool = steps.find((step) => step.tool === 'format_final_json_response');
227227
if (responseParserTool && outputParser) {
228228
const toolInput = responseParserTool.toolInput;
229229
// Ensure the tool input is a string
@@ -318,9 +318,9 @@ export async function getTools(
318318
const schema = getOutputParserSchema(outputParser);
319319
const structuredOutputParserTool = new DynamicStructuredTool({
320320
schema,
321-
name: 'format_final_response',
321+
name: 'format_final_json_response',
322322
description:
323-
'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.',
323+
'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.',
324324
// We do not use a function here because we intercept the output with the parser.
325325
func: async () => '',
326326
});
@@ -454,7 +454,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
454454
input,
455455
system_message: options.systemMessage ?? SYSTEM_MESSAGE,
456456
formatting_instructions:
457-
'IMPORTANT: Always call `format_final_response` to format your final response!',
457+
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
458458
},
459459
{ signal: this.getExecutionCancelSignal() },
460460
);

‎packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ describe('getTools', () => {
217217
const tools = await getTools(ctx, fakeOutputParser);
218218
// Our fake getConnectedTools returns one tool; with outputParser, one extra is appended.
219219
expect(tools.length).toEqual(2);
220-
const dynamicTool = tools.find((t) => t.name === 'format_final_response');
220+
const dynamicTool = tools.find((t) => t.name === 'format_final_json_response');
221221
expect(dynamicTool).toBeDefined();
222222
});
223223
});

‎packages/@n8n/nodes-langchain/utils/helpers.ts

+19
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,22 @@ export const getConnectedTools = async (
221221

222222
return finalTools;
223223
};
224+
225+
/**
226+
* Sometimes model output is wrapped in an additional object property.
227+
* This function unwraps the output if it is in the format { output: { output: { ... } } }
228+
*/
229+
export function unwrapNestedOutput(output: Record<string, unknown>): Record<string, unknown> {
230+
if (
231+
'output' in output &&
232+
Object.keys(output).length === 1 &&
233+
typeof output.output === 'object' &&
234+
output.output !== null &&
235+
'output' in output.output &&
236+
Object.keys(output.output).length === 1
237+
) {
238+
return output.output as Record<string, unknown>;
239+
}
240+
241+
return output;
242+
}

‎packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputFixingParser.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ export class N8nOutputFixingParser extends BaseOutputParser {
3939

4040
try {
4141
// First attempt to parse the completion
42-
const response = await this.outputParser.parse(completion, callbacks, (e) => e);
42+
const response = await this.outputParser.parse(completion, callbacks, (e) => {
43+
if (e instanceof OutputParserException) {
44+
return e;
45+
}
46+
return new OutputParserException(e.message, completion);
47+
});
4348
logAiEvent(this.context, 'ai-output-parsed', { text: completion, response });
4449

4550
this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [

‎packages/@n8n/nodes-langchain/utils/output_parsers/N8nStructuredOutputParser.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ISupplyDataFunctions } from 'n8n-workflow';
55
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
66
import { z } from 'zod';
77

8-
import { logAiEvent } from '../helpers';
8+
import { logAiEvent, unwrapNestedOutput } from '../helpers';
99

1010
const STRUCTURED_OUTPUT_KEY = '__structured__output';
1111
const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object';
@@ -36,11 +36,14 @@ export class N8nStructuredOutputParser extends StructuredOutputParser<
3636
const json = JSON.parse(jsonString.trim());
3737
const parsed = await this.schema.parseAsync(json);
3838

39-
const result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ??
39+
let result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ??
4040
get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ??
4141
get(parsed, STRUCTURED_OUTPUT_KEY) ??
4242
parsed) as Record<string, unknown>;
4343

44+
// Unwrap any doubly-nested output structures (e.g., {output: {output: {...}}})
45+
result = unwrapNestedOutput(result);
46+
4447
logAiEvent(this.context, 'ai-output-parsed', { text, response: result });
4548

4649
this.context.addOutputData(NodeConnectionType.AiOutputParser, index, [

‎packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts

+99-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { NodeOperationError } from 'n8n-workflow';
44
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
55
import { z } from 'zod';
66

7-
import { escapeSingleCurlyBrackets, getConnectedTools } from '../helpers';
7+
import { escapeSingleCurlyBrackets, getConnectedTools, unwrapNestedOutput } from '../helpers';
88
import { N8nTool } from '../N8nTool';
99

1010
describe('escapeSingleCurlyBrackets', () => {
@@ -243,3 +243,101 @@ describe('getConnectedTools', () => {
243243
expect(tools[0]).toBe(mockN8nTool);
244244
});
245245
});
246+
247+
describe('unwrapNestedOutput', () => {
248+
it('should unwrap doubly nested output', () => {
249+
const input = {
250+
output: {
251+
output: {
252+
text: 'Hello world',
253+
confidence: 0.95,
254+
},
255+
},
256+
};
257+
258+
const expected = {
259+
output: {
260+
text: 'Hello world',
261+
confidence: 0.95,
262+
},
263+
};
264+
265+
expect(unwrapNestedOutput(input)).toEqual(expected);
266+
});
267+
268+
it('should not modify regular output object', () => {
269+
const input = {
270+
output: {
271+
text: 'Hello world',
272+
confidence: 0.95,
273+
},
274+
};
275+
276+
expect(unwrapNestedOutput(input)).toEqual(input);
277+
});
278+
279+
it('should not modify object without output property', () => {
280+
const input = {
281+
result: 'success',
282+
data: {
283+
text: 'Hello world',
284+
},
285+
};
286+
287+
expect(unwrapNestedOutput(input)).toEqual(input);
288+
});
289+
290+
it('should not modify when output is not an object', () => {
291+
const input = {
292+
output: 'Hello world',
293+
};
294+
295+
expect(unwrapNestedOutput(input)).toEqual(input);
296+
});
297+
298+
it('should not modify when object has multiple properties', () => {
299+
const input = {
300+
output: {
301+
output: {
302+
text: 'Hello world',
303+
},
304+
},
305+
meta: {
306+
timestamp: 123456789,
307+
},
308+
};
309+
310+
expect(unwrapNestedOutput(input)).toEqual(input);
311+
});
312+
313+
it('should not modify when inner output has multiple properties', () => {
314+
const input = {
315+
output: {
316+
output: {
317+
text: 'Hello world',
318+
},
319+
meta: {
320+
timestamp: 123456789,
321+
},
322+
},
323+
};
324+
325+
expect(unwrapNestedOutput(input)).toEqual(input);
326+
});
327+
328+
it('should handle null values properly', () => {
329+
const input = {
330+
output: null,
331+
};
332+
333+
expect(unwrapNestedOutput(input)).toEqual(input);
334+
});
335+
336+
it('should handle empty object values properly', () => {
337+
const input = {
338+
output: {},
339+
};
340+
341+
expect(unwrapNestedOutput(input)).toEqual(input);
342+
});
343+
});

0 commit comments

Comments
 (0)
Please sign in to comment.