fix(core): Escape curly braces in LangChain prompt templates to prevent parsing errors (#25821)

Co-authored-by: aalises <aalises@users.noreply.github.com>
This commit is contained in:
Albert Alises
2026-02-16 11:43:28 +00:00
committed by GitHub
co-authored by aalises
parent 7506ecec82
commit f669db4ebf
5 changed files with 88 additions and 5 deletions
@@ -26,8 +26,13 @@ export async function processItem(
systemPromptTemplate?: string;
};
const escapedTemplate = (options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE).replace(
/[{}]/g,
(match) => match + match,
);
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
`${options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE}
`${escapedTemplate}
{format_instructions}`,
);
@@ -116,6 +116,28 @@ describe('processItem', () => {
expect(result).toEqual({ name: 'John', age: 30 });
});
it('should handle curly braces in custom system prompt template', async () => {
const customTemplate = 'Extract JSON like this: {"name": "value"} from the text.';
const mockExecuteFunctions = {
getNodeParameter: (param: string) => {
if (param === 'text') return 'John is 30 years old';
if (param === 'options') return { systemPromptTemplate: customTemplate };
return undefined;
},
getNode: () => ({ typeVersion: 1.1 }),
};
const llm = new FakeLLM({ response: formatFakeLlmResponse({ name: 'John', age: 30 }) });
const parser = OutputFixingParser.fromLLM(
llm,
StructuredOutputParser.fromZodSchema(makeZodSchemaFromAttributes(mockPersonAttributes)),
);
const result = await processItem(mockExecuteFunctions as any, 0, llm, parser);
expect(result).toEqual({ name: 'John', age: 30 });
});
it('should handle retries when LLM returns invalid data', async () => {
const mockExecuteFunctions = {
getNodeParameter: (param: string) => {
@@ -217,8 +217,12 @@ export class SentimentAnalysis implements INodeType {
? OutputFixingParser.fromLLM(llm, structuredParser)
: structuredParser;
const escapedTemplate = (options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE)
.replace(/[{}]/g, (match) => match + match)
.replaceAll('{{categories}}', '{categories}');
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
`${options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE}
`${escapedTemplate}
{format_instructions}`,
);
@@ -357,8 +361,12 @@ export class SentimentAnalysis implements INodeType {
? OutputFixingParser.fromLLM(llm, structuredParser)
: structuredParser;
const escapedTemplate = (options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE)
.replace(/[{}]/g, (match) => match + match)
.replaceAll('{{categories}}', '{categories}');
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
`${options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE}
`${escapedTemplate}
{format_instructions}`,
);
@@ -36,8 +36,12 @@ export async function processItem(
itemIndex,
SYSTEM_PROMPT_TEMPLATE,
) as string;
const escapedTemplate = (systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE)
.replace(/[{}]/g, (match) => match + match)
.replaceAll('{{categories}}', '{categories}');
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
`${systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE}
`${escapedTemplate}
{format_instructions}
${multiClassPrompt}
${fallbackPrompt}`,
@@ -1,4 +1,4 @@
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts';
import { FakeChatModel } from '@langchain/core/utils/testing';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
@@ -102,6 +102,50 @@ describe('processItem', () => {
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
});
it('should escape curly braces in custom system prompt template', async () => {
const templateWithBraces = 'Classify using format {"category": "value"}: {categories}';
mockContext.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
if (param === 'inputText') return 'Test input';
if (param === 'options.systemPromptTemplate') return templateWithBraces;
return defaultValue;
});
const mockParser = {
getFormatInstructions: () => '[format instructions]',
};
const mockChain = {
invoke: jest.fn().mockResolvedValue({ category: 'test' }),
};
const mockPipe = jest.fn().mockReturnValue({
pipe: jest.fn().mockReturnValue({
withConfig: jest.fn().mockReturnValue(mockChain),
}),
});
jest.mocked(ChatPromptTemplate.fromMessages).mockReturnValue({ pipe: mockPipe } as any);
await processItem(
mockContext,
0,
{ json: {} },
fakeLLM,
mockParser as any,
[{ category: 'test', description: 'test category' }],
'multi class prompt',
undefined,
);
expect(SystemMessagePromptTemplate.fromTemplate).toHaveBeenCalledWith(
expect.stringContaining('{{"category": "value"}}'),
);
expect(SystemMessagePromptTemplate.fromTemplate).toHaveBeenCalledWith(
expect.stringContaining('{categories}'),
);
});
it('should handle fallback prompt', async () => {
const mockParser = {
getFormatInstructions: () => '[format instructions]',