mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-02 08:27:29 -08:00
fix(AI Agent Node): Escape curly brackets in tools description for non Tool agents (#11772)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
This commit is contained in:
parent
1987363f79
commit
83abdfaf02
|
@ -31,7 +31,7 @@ export async function conversationalAgentExecute(
|
||||||
| BaseChatMemory
|
| BaseChatMemory
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
|
||||||
const outputParsers = await getOptionalOutputParsers(this);
|
const outputParsers = await getOptionalOutputParsers(this);
|
||||||
|
|
||||||
await checkForStructuredTools(tools, this.getNode(), 'Conversational Agent');
|
await checkForStructuredTools(tools, this.getNode(), 'Conversational Agent');
|
||||||
|
|
|
@ -26,7 +26,7 @@ export async function planAndExecuteAgentExecute(
|
||||||
0,
|
0,
|
||||||
)) as BaseChatModel;
|
)) as BaseChatModel;
|
||||||
|
|
||||||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
|
||||||
|
|
||||||
await checkForStructuredTools(tools, this.getNode(), 'Plan & Execute Agent');
|
await checkForStructuredTools(tools, this.getNode(), 'Plan & Execute Agent');
|
||||||
const outputParsers = await getOptionalOutputParsers(this);
|
const outputParsers = await getOptionalOutputParsers(this);
|
||||||
|
|
|
@ -31,7 +31,7 @@ export async function reActAgentAgentExecute(
|
||||||
| BaseLanguageModel
|
| BaseLanguageModel
|
||||||
| BaseChatModel;
|
| BaseChatModel;
|
||||||
|
|
||||||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
|
||||||
|
|
||||||
await checkForStructuredTools(tools, this.getNode(), 'ReAct Agent');
|
await checkForStructuredTools(tools, this.getNode(), 'ReAct Agent');
|
||||||
|
|
||||||
|
|
|
@ -165,10 +165,29 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string {
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeSingleCurlyBrackets(text?: string): string | undefined {
|
||||||
|
if (text === undefined) return undefined;
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
result = result
|
||||||
|
// First handle triple brackets to avoid interference with double brackets
|
||||||
|
.replace(/(?<!{){{{(?!{)/g, '{{{{')
|
||||||
|
.replace(/(?<!})}}}(?!})/g, '}}}}')
|
||||||
|
// Then handle single brackets, but only if they're not part of double brackets
|
||||||
|
// Convert single { to {{ if it's not already part of {{ or {{{
|
||||||
|
.replace(/(?<!{){(?!{)/g, '{{')
|
||||||
|
// Convert single } to }} if it's not already part of }} or }}}
|
||||||
|
.replace(/(?<!})}(?!})/g, '}}');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const getConnectedTools = async (
|
export const getConnectedTools = async (
|
||||||
ctx: IExecuteFunctions,
|
ctx: IExecuteFunctions,
|
||||||
enforceUniqueNames: boolean,
|
enforceUniqueNames: boolean,
|
||||||
convertStructuredTool: boolean = true,
|
convertStructuredTool: boolean = true,
|
||||||
|
escapeCurlyBrackets: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
const connectedTools =
|
const connectedTools =
|
||||||
((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || [];
|
((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || [];
|
||||||
|
@ -189,6 +208,10 @@ export const getConnectedTools = async (
|
||||||
}
|
}
|
||||||
seenNames.add(name);
|
seenNames.add(name);
|
||||||
|
|
||||||
|
if (escapeCurlyBrackets) {
|
||||||
|
tool.description = escapeSingleCurlyBrackets(tool.description) ?? tool.description;
|
||||||
|
}
|
||||||
|
|
||||||
if (convertStructuredTool && tool instanceof N8nTool) {
|
if (convertStructuredTool && tool instanceof N8nTool) {
|
||||||
finalTools.push(tool.asDynamicTool());
|
finalTools.push(tool.asDynamicTool());
|
||||||
} else {
|
} else {
|
||||||
|
|
245
packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts
Normal file
245
packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
import { DynamicTool, type Tool } from '@langchain/core/tools';
|
||||||
|
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { escapeSingleCurlyBrackets, getConnectedTools } from '../helpers';
|
||||||
|
import { N8nTool } from '../N8nTool';
|
||||||
|
|
||||||
|
describe('escapeSingleCurlyBrackets', () => {
|
||||||
|
it('should return undefined when input is undefined', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape single curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello {world}')).toBe('Hello {{world}}');
|
||||||
|
expect(escapeSingleCurlyBrackets('Test {value} here')).toBe('Test {{value}} here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not escape already double curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello {{world}}')).toBe('Hello {{world}}');
|
||||||
|
expect(escapeSingleCurlyBrackets('Test {{value}} here')).toBe('Test {{value}} here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed single and double curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello {{world}} and {earth}')).toBe(
|
||||||
|
'Hello {{world}} and {{earth}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('')).toBe('');
|
||||||
|
});
|
||||||
|
it('should handle string with no curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello world')).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with only opening curly bracket', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello { world')).toBe('Hello {{ world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with only closing curly bracket', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello world }')).toBe('Hello world }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with multiple single curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('{Hello} {world}')).toBe('{{Hello}} {{world}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with alternating single and double curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('{a} {{b}} {c} {{d}}')).toBe('{{a}} {{b}} {{c}} {{d}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with curly brackets at the start and end', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('{start} middle {end}')).toBe('{{start}} middle {{end}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with special characters', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Special {!@#$%^&*} chars')).toBe(
|
||||||
|
'Special {{!@#$%^&*}} chars',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with numbers in curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Numbers {123} here')).toBe('Numbers {{123}} here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with whitespace in curly brackets', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Whitespace { } here')).toBe('Whitespace {{ }} here');
|
||||||
|
});
|
||||||
|
it('should handle multi-line input with single curly brackets', () => {
|
||||||
|
const input = `
|
||||||
|
Line 1 {test}
|
||||||
|
Line 2 {another test}
|
||||||
|
Line 3
|
||||||
|
`;
|
||||||
|
const expected = `
|
||||||
|
Line 1 {{test}}
|
||||||
|
Line 2 {{another test}}
|
||||||
|
Line 3
|
||||||
|
`;
|
||||||
|
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-line input with mixed single and double curly brackets', () => {
|
||||||
|
const input = `
|
||||||
|
{Line 1}
|
||||||
|
{{Line 2}}
|
||||||
|
Line {3} {{4}}
|
||||||
|
`;
|
||||||
|
const expected = `
|
||||||
|
{{Line 1}}
|
||||||
|
{{Line 2}}
|
||||||
|
Line {{3}} {{4}}
|
||||||
|
`;
|
||||||
|
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-line input with curly brackets at line starts and ends', () => {
|
||||||
|
const input = `
|
||||||
|
{Start of line 1
|
||||||
|
End of line 2}
|
||||||
|
{3} Line 3 {3}
|
||||||
|
`;
|
||||||
|
const expected = `
|
||||||
|
{{Start of line 1
|
||||||
|
End of line 2}}
|
||||||
|
{{3}} Line 3 {{3}}
|
||||||
|
`;
|
||||||
|
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-line input with nested curly brackets', () => {
|
||||||
|
const input = `
|
||||||
|
Outer {
|
||||||
|
Inner {nested}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const expected = `
|
||||||
|
Outer {{
|
||||||
|
Inner {{nested}}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
it('should handle string with triple uneven curly brackets - opening', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello {{{world}')).toBe('Hello {{{{world}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with triple uneven curly brackets - closing', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('Hello world}}}')).toBe('Hello world}}}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with triple uneven curly brackets - mixed opening and closing', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('{{{Hello}}} {world}}}')).toBe('{{{{Hello}}}} {{world}}}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string with triple uneven curly brackets - multiple occurrences', () => {
|
||||||
|
expect(escapeSingleCurlyBrackets('{{{a}}} {{b}}} {{{c}')).toBe('{{{{a}}}} {{b}}}} {{{{c}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-line input with triple uneven curly brackets', () => {
|
||||||
|
const input = `
|
||||||
|
{{{Line 1}
|
||||||
|
Line 2}}}
|
||||||
|
{{{3}}} Line 3 {{{4
|
||||||
|
`;
|
||||||
|
const expected = `
|
||||||
|
{{{{Line 1}}
|
||||||
|
Line 2}}}}
|
||||||
|
{{{{3}}}} Line 3 {{{{4
|
||||||
|
`;
|
||||||
|
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConnectedTools', () => {
|
||||||
|
let mockExecuteFunctions: IExecuteFunctions;
|
||||||
|
let mockNode: INode;
|
||||||
|
let mockN8nTool: N8nTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNode = {
|
||||||
|
id: 'test-node',
|
||||||
|
name: 'Test Node',
|
||||||
|
type: 'test',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
|
||||||
|
|
||||||
|
mockN8nTool = new N8nTool(mockExecuteFunctions, {
|
||||||
|
name: 'Dummy Tool',
|
||||||
|
description: 'A dummy tool for testing',
|
||||||
|
func: jest.fn(),
|
||||||
|
schema: z.object({
|
||||||
|
foo: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no tools are connected', async () => {
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, true);
|
||||||
|
expect(tools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tools without modification when enforceUniqueNames is false', async () => {
|
||||||
|
const mockTools = [
|
||||||
|
{ name: 'tool1', description: 'desc1' },
|
||||||
|
{ name: 'tool1', description: 'desc2' }, // Duplicate name
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||||
|
expect(tools).toEqual(mockTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when duplicate tool names exist and enforceUniqueNames is true', async () => {
|
||||||
|
const mockTools = [
|
||||||
|
{ name: 'tool1', description: 'desc1' },
|
||||||
|
{ name: 'tool1', description: 'desc2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||||
|
|
||||||
|
await expect(getConnectedTools(mockExecuteFunctions, true)).rejects.toThrow(NodeOperationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape curly brackets in tool descriptions when escapeCurlyBrackets is true', async () => {
|
||||||
|
const mockTools = [{ name: 'tool1', description: 'Test {value}' }] as Tool[];
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, true, false, true);
|
||||||
|
expect(tools[0].description).toBe('Test {{value}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert N8nTool to dynamic tool when convertStructuredTool is true', async () => {
|
||||||
|
const mockDynamicTool = new DynamicTool({
|
||||||
|
name: 'dynamicTool',
|
||||||
|
description: 'desc',
|
||||||
|
func: jest.fn(),
|
||||||
|
});
|
||||||
|
const asDynamicToolSpy = jest.fn().mockReturnValue(mockDynamicTool);
|
||||||
|
mockN8nTool.asDynamicTool = asDynamicToolSpy;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, true, true);
|
||||||
|
expect(asDynamicToolSpy).toHaveBeenCalled();
|
||||||
|
expect(tools[0]).toEqual(mockDynamicTool);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not convert N8nTool when convertStructuredTool is false', async () => {
|
||||||
|
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
|
||||||
|
expect(tools[0]).toBe(mockN8nTool);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue