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>
This commit is contained in:
Eugene
2025-03-13 18:20:24 +03:00
committed by GitHub
parent 31037484a5
commit 5b6b78709e
7 changed files with 138 additions and 13 deletions

View File

@@ -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!',
},
},
},

View File

@@ -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<AgentFinish | AgentAction[]> => {
// 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<INodeE
input,
system_message: options.systemMessage ?? SYSTEM_MESSAGE,
formatting_instructions:
'IMPORTANT: Always call `format_final_response` to format your final response!',
'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.',
},
{ signal: this.getExecutionCancelSignal() },
);

View File

@@ -217,7 +217,7 @@ describe('getTools', () => {
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();
});
});

View File

@@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
}
return output;
}

View File

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

View File

@@ -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<string, unknown>;
// 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, [

View File

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