first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,417 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { MessageContent, BaseMessage } from '@langchain/core/messages';
import { AIMessage, SystemMessage, HumanMessage } from '@langchain/core/messages';
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
type MessageRole = 'ai' | 'system' | 'user';
interface MessageRecord {
type: MessageRole;
message: string;
hideFromUI: boolean;
}
export function simplifyMessages(messages: BaseMessage[]): Array<Record<string, MessageContent>> {
if (messages.length === 0) return [];
const result: Array<Record<string, MessageContent>> = [];
let index = 0;
while (index < messages.length) {
const currentGroup: Record<string, MessageContent> = {};
do {
const message = messages[index];
const messageType = message.getType();
if (messageType in currentGroup) {
break;
}
currentGroup[messageType] = message.content;
index++;
} while (index < messages.length);
result.push(currentGroup);
}
return result;
}
const prepareOutputSetup = (ctx: IExecuteFunctions, version: number, memory: BaseChatMemory) => {
if (version === 1) {
//legacy behavior of insert and delete for version 1
return async (i: number) => {
const messages = await memory.chatHistory.getMessages();
const serializedMessages = messages?.map((message) => message.toJSON()) ?? [];
const executionData = ctx.helpers.constructExecutionMetaData(
ctx.helpers.returnJsonArray(serializedMessages as unknown as IDataObject[]),
{ itemData: { item: i } },
);
return executionData;
};
}
return async (i: number) => {
return [
{
json: { success: true },
pairedItem: { item: i },
},
];
};
};
export class MemoryManager implements INodeType {
description: INodeTypeDescription = {
displayName: 'Chat Memory Manager',
name: 'memoryManager',
icon: 'fa:database',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1],
description: 'Manage chat messages memory and use it in the workflow',
defaults: {
name: 'Chat Memory Manager',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Miscellaneous', 'Root Nodes'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.memorymanager/',
},
],
},
},
inputs: [
{
displayName: '',
type: NodeConnectionTypes.Main,
},
{
displayName: 'Memory',
type: NodeConnectionTypes.AiMemory,
required: true,
maxConnections: 1,
},
],
outputs: [
{
displayName: '',
type: NodeConnectionTypes.Main,
},
],
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
default: 'load',
options: [
{
name: 'Get Many Messages',
description: 'Retrieve chat messages from connected memory',
value: 'load',
},
{
name: 'Insert Messages',
description: 'Insert chat messages into connected memory',
value: 'insert',
},
{
name: 'Delete Messages',
description: 'Delete chat messages from connected memory',
value: 'delete',
},
],
},
{
displayName: 'Insert Mode',
name: 'insertMode',
type: 'options',
description: 'Choose how new messages are inserted into the memory',
noDataExpression: true,
default: 'insert',
options: [
{
name: 'Insert Messages',
value: 'insert',
description: 'Add messages alongside existing ones',
},
{
name: 'Override All Messages',
value: 'override',
description: 'Replace the current memory with new messages',
},
],
displayOptions: {
show: {
mode: ['insert'],
},
},
},
{
displayName: 'Delete Mode',
name: 'deleteMode',
type: 'options',
description: 'How messages are deleted from memory',
noDataExpression: true,
default: 'lastN',
options: [
{
name: 'Last N',
value: 'lastN',
description: 'Delete the last N messages',
},
{
name: 'All Messages',
value: 'all',
description: 'Clear all messages from memory',
},
],
displayOptions: {
show: {
mode: ['delete'],
},
},
},
{
displayName: 'Chat Messages',
name: 'messages',
description: 'Chat messages to insert into memory',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
placeholder: 'Add message',
options: [
{
name: 'messageValues',
displayName: 'Message',
values: [
{
displayName: 'Type Name or ID',
name: 'type',
type: 'options',
options: [
{
name: 'AI',
value: 'ai',
},
{
name: 'System',
value: 'system',
},
{
name: 'User',
value: 'user',
},
],
default: 'system',
},
{
displayName: 'Message',
name: 'message',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Hide Message in Chat',
name: 'hideFromUI',
type: 'boolean',
required: true,
default: false,
description: 'Whether to hide the message from the chat UI',
},
],
},
],
displayOptions: {
show: {
mode: ['insert'],
},
},
},
{
displayName: 'Messages Count',
name: 'lastMessagesCount',
type: 'number',
description: 'The amount of last messages to delete',
default: 2,
displayOptions: {
show: {
mode: ['delete'],
deleteMode: ['lastN'],
},
},
},
{
displayName: 'Simplify Output',
name: 'simplifyOutput',
type: 'boolean',
description: 'Whether to simplify the output to only include the sender and the text',
default: true,
displayOptions: {
show: {
mode: ['load'],
},
},
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Group Messages',
name: 'groupMessages',
type: 'boolean',
default: true,
description:
'Whether to group messages into a single item or return each message as a separate item',
},
],
displayOptions: {
show: {
mode: ['load'],
},
},
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const nodeVersion = this.getNode().typeVersion;
const items = this.getInputData();
const mode = this.getNodeParameter('mode', 0, 'load') as 'load' | 'insert' | 'delete';
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const memory = (await this.getInputConnectionData(
NodeConnectionTypes.AiMemory,
i,
)) as BaseChatMemory;
const prepareOutput = prepareOutputSetup(this, nodeVersion, memory);
const messages = await memory.chatHistory.getMessages();
if (mode === 'delete') {
const deleteMode = this.getNodeParameter('deleteMode', i) as 'lastN' | 'all';
if (deleteMode === 'lastN') {
const lastMessagesCount = this.getNodeParameter('lastMessagesCount', i) as number;
if (messages.length >= lastMessagesCount) {
const newMessages = messages.slice(0, messages.length - lastMessagesCount);
await memory.chatHistory.clear();
for (const message of newMessages) {
await memory.chatHistory.addMessage(message);
}
}
} else {
await memory.chatHistory.clear();
}
returnData.push(...(await prepareOutput(i)));
}
if (mode === 'insert') {
const insertMode = this.getNodeParameter('insertMode', i) as 'insert' | 'override';
const messagesToInsert = this.getNodeParameter(
'messages.messageValues',
i,
[],
) as MessageRecord[];
const templateMapper = {
ai: AIMessage,
system: SystemMessage,
user: HumanMessage,
};
if (insertMode === 'override') {
await memory.chatHistory.clear();
}
for (const message of messagesToInsert) {
const MessageClass = new templateMapper[message.type](message.message);
if (message.hideFromUI) {
MessageClass.additional_kwargs.hideFromUI = true;
}
await memory.chatHistory.addMessage(MessageClass);
}
returnData.push(...(await prepareOutput(i)));
}
if (mode === 'load') {
const simplifyOutput = this.getNodeParameter('simplifyOutput', i, false) as boolean;
const options = this.getNodeParameter('options', i);
//Load mode, legacy behavior for version 1, buggy - outputs only for single input item
if (simplifyOutput && messages.length && nodeVersion === 1) {
const groupMessages = options.groupMessages as boolean;
const output = simplifyMessages(messages);
return [
this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(
groupMessages ? [{ messages: output, messagesCount: output.length }] : output,
),
{ itemData: { item: i } },
),
];
}
let groupMessages = true;
//disable grouping if explicitly set to false
if (options.groupMessages === false) {
groupMessages = false;
}
//disable grouping if not set and node version is 1 (legacy behavior)
if (options.groupMessages === undefined && nodeVersion === 1) {
groupMessages = false;
}
let output: IDataObject[] =
(simplifyOutput
? simplifyMessages(messages)
: (messages?.map((message) => message.toJSON()) as unknown as IDataObject[])) ?? [];
if (groupMessages) {
output = [{ messages: output, messagesCount: output.length }];
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(output),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
}
return [returnData];
}
}
@@ -0,0 +1,273 @@
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/unbound-method, @typescript-eslint/no-explicit-any */
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseMessage } from '@langchain/core/messages';
import { SystemMessage } from '@langchain/core/messages';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { MemoryManager } from '../MemoryManager.node';
/**
* These tests verify that the Memory Manager resolves sub-node expressions
* (e.g. Session ID) per input item, not just once for item 0.
*
* See: https://github.com/n8n-io/n8n/issues/23890
*/
interface MockMemory {
memory: BaseChatMemory;
getMessages: jest.Mock;
addMessage: jest.Mock;
clear: jest.Mock;
}
function createMockMemory(messages: BaseMessage[] = []): MockMemory {
const getMessages = jest.fn().mockResolvedValue([...messages]);
const addMessage = jest.fn().mockResolvedValue(undefined);
const clear = jest.fn().mockResolvedValue(undefined);
const memory = { chatHistory: { getMessages, addMessage, clear } } as unknown as BaseChatMemory;
return { memory, getMessages, addMessage, clear };
}
function createMockContext() {
const mockHelpers = mock<IExecuteFunctions['helpers']>();
(mockHelpers.constructExecutionMetaData as any).mockImplementation(
(items: INodeExecutionData[], meta: { itemData: any }) =>
items.map((item) => ({ ...item, pairedItem: meta.itemData })),
);
mockHelpers.returnJsonArray.mockImplementation(
(data) =>
(Array.isArray(data) ? data : [data]).map((d) => ({
json: d,
})) as INodeExecutionData[],
);
const ctx = mock<IExecuteFunctions>({ helpers: mockHelpers });
ctx.getNode.mockReturnValue({
name: 'Chat Memory Manager',
typeVersion: 1.1,
parameters: {},
} as INode);
return ctx;
}
describe('MemoryManager.execute - multi-item session context', () => {
let node: MemoryManager;
beforeEach(() => {
node = new MemoryManager();
jest.clearAllMocks();
});
describe('insert mode', () => {
it('should request memory with correct itemIndex for each input item', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([
{ json: { name: 'First item', code: 1 } },
{ json: { name: 'Second item', code: 2 } },
{ json: { name: 'Third item', code: 3 } },
]);
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'mode') return 'insert';
if (param === 'insertMode') return 'insert';
if (param === 'messages.messageValues')
return [{ type: 'system', message: 'test', hideFromUI: false }];
return defaultValue;
});
const memories = [createMockMemory(), createMockMemory(), createMockMemory()];
ctx.getInputConnectionData
.mockResolvedValueOnce(memories[0].memory)
.mockResolvedValueOnce(memories[1].memory)
.mockResolvedValueOnce(memories[2].memory);
await node.execute.call(ctx);
expect(ctx.getInputConnectionData).toHaveBeenCalledTimes(3);
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
1,
NodeConnectionTypes.AiMemory,
0,
);
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
2,
NodeConnectionTypes.AiMemory,
1,
);
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
3,
NodeConnectionTypes.AiMemory,
2,
);
});
it('should insert messages into separate sessions for each item', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([
{ json: { name: 'First item', code: 1 } },
{ json: { name: 'Second item', code: 2 } },
]);
ctx.getNodeParameter.mockImplementation((param, i, defaultValue) => {
if (param === 'mode') return 'insert';
if (param === 'insertMode') return 'insert';
if (param === 'messages.messageValues')
return [{ type: 'system', message: `Message for item ${i}`, hideFromUI: false }];
return defaultValue;
});
const mock1 = createMockMemory();
const mock2 = createMockMemory();
ctx.getInputConnectionData
.mockResolvedValueOnce(mock1.memory)
.mockResolvedValueOnce(mock2.memory);
await node.execute.call(ctx);
expect(mock1.addMessage).toHaveBeenCalledTimes(1);
expect(mock2.addMessage).toHaveBeenCalledTimes(1);
});
it('should not cross-contaminate sessions in override mode with multiple items', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([
{ json: { name: 'First item', code: 1 } },
{ json: { name: 'Second item', code: 2 } },
]);
ctx.getNodeParameter.mockImplementation((param, i, defaultValue) => {
if (param === 'mode') return 'insert';
if (param === 'insertMode') return 'override';
if (param === 'messages.messageValues') {
const names = ['First item', 'Second item'];
return [{ type: 'system', message: names[i], hideFromUI: false }];
}
return defaultValue;
});
const mock1 = createMockMemory();
const mock2 = createMockMemory();
ctx.getInputConnectionData
.mockResolvedValueOnce(mock1.memory)
.mockResolvedValueOnce(mock2.memory);
await node.execute.call(ctx);
// Each memory should be cleared and written to exactly once
expect(mock1.clear).toHaveBeenCalledTimes(1);
expect(mock2.clear).toHaveBeenCalledTimes(1);
expect(mock1.addMessage).toHaveBeenCalledTimes(1);
expect(mock2.addMessage).toHaveBeenCalledTimes(1);
});
});
describe('load mode', () => {
it('should load messages from the correct session for each item', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([
{ json: { sessionId: 'session-A' } },
{ json: { sessionId: 'session-B' } },
]);
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'mode') return 'load';
if (param === 'simplifyOutput') return true;
if (param === 'options') return { groupMessages: false };
return defaultValue;
});
const mockA = createMockMemory([new SystemMessage('Message from session A')]);
const mockB = createMockMemory([new SystemMessage('Message from session B')]);
ctx.getInputConnectionData
.mockResolvedValueOnce(mockA.memory)
.mockResolvedValueOnce(mockB.memory);
const result = await node.execute.call(ctx);
expect(result[0]).toHaveLength(2);
const item0Message = result[0][0].json;
const item1Message = result[0][1].json;
expect(item0Message).toHaveProperty('system', 'Message from session A');
expect(item1Message).toHaveProperty('system', 'Message from session B');
});
});
describe('delete mode', () => {
it('should delete messages from the correct session for each item', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([{ json: { code: 1 } }, { json: { code: 2 } }]);
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'mode') return 'delete';
if (param === 'deleteMode') return 'all';
return defaultValue;
});
const mock1 = createMockMemory([new SystemMessage('Session 1 msg')]);
const mock2 = createMockMemory([new SystemMessage('Session 2 msg')]);
ctx.getInputConnectionData
.mockResolvedValueOnce(mock1.memory)
.mockResolvedValueOnce(mock2.memory);
await node.execute.call(ctx);
expect(mock1.clear).toHaveBeenCalledTimes(1);
expect(mock2.clear).toHaveBeenCalledTimes(1);
});
it('should delete last N messages from the correct session for each item', async () => {
const ctx = createMockContext();
ctx.getInputData.mockReturnValue([{ json: { code: 1 } }, { json: { code: 2 } }]);
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'mode') return 'delete';
if (param === 'deleteMode') return 'lastN';
if (param === 'lastMessagesCount') return 1;
return defaultValue;
});
const mock1 = createMockMemory([
new SystemMessage('Session 1 keep'),
new SystemMessage('Session 1 delete'),
]);
const mock2 = createMockMemory([
new SystemMessage('Session 2 keep'),
new SystemMessage('Session 2 delete'),
]);
ctx.getInputConnectionData
.mockResolvedValueOnce(mock1.memory)
.mockResolvedValueOnce(mock2.memory);
await node.execute.call(ctx);
expect(mock1.clear).toHaveBeenCalledTimes(1);
expect(mock2.clear).toHaveBeenCalledTimes(1);
// After deleting last 1 message, each memory should have its "keep" message re-added
expect(mock1.addMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Session 1 keep' }),
);
expect(mock2.addMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Session 2 keep' }),
);
});
});
});
@@ -0,0 +1,102 @@
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
import { simplifyMessages } from '../MemoryManager.node';
describe('simplifyMessages', () => {
it('should handle single message', () => {
const messages = [new HumanMessage('Hello')];
const result = simplifyMessages(messages);
expect(result).toEqual([{ human: 'Hello' }]);
});
it('should group different message types together', () => {
const messages = [
new HumanMessage('Hello, how are you?'),
new AIMessage("I'm doing well, thank you for asking! How about you?"),
];
const result = simplifyMessages(messages);
expect(result).toEqual([
{
human: 'Hello, how are you?',
ai: "I'm doing well, thank you for asking! How about you?",
},
]);
});
it('should separate consecutive messages of same type into different groups', () => {
const messages = [
new HumanMessage('First human message'),
new HumanMessage('Second human message'),
new AIMessage('AI response'),
];
const result = simplifyMessages(messages);
expect(result).toEqual([
{ human: 'First human message' },
{ human: 'Second human message', ai: 'AI response' },
]);
});
it('should handle three consecutive messages of same type', () => {
const messages = [new HumanMessage('1'), new HumanMessage('2'), new HumanMessage('3')];
const result = simplifyMessages(messages);
expect(result).toEqual([{ human: '1' }, { human: '2' }, { human: '3' }]);
});
it('should handle mixed message types with grouping', () => {
const messages = [
new SystemMessage('System message'),
new HumanMessage('Hello'),
new AIMessage('Hi there'),
new HumanMessage('Another human message'),
new AIMessage('Another AI message'),
];
const result = simplifyMessages(messages);
expect(result).toEqual([
{
system: 'System message',
human: 'Hello',
ai: 'Hi there',
},
{
human: 'Another human message',
ai: 'Another AI message',
},
]);
});
it('should handle system messages correctly', () => {
const messages = [
new SystemMessage('System instruction'),
new HumanMessage('User question'),
new AIMessage('AI response'),
];
const result = simplifyMessages(messages);
expect(result).toEqual([
{
system: 'System instruction',
human: 'User question',
ai: 'AI response',
},
]);
});
it('should handle alternating same types correctly', () => {
const messages = [
new HumanMessage('Human 1'),
new AIMessage('AI 1'),
new HumanMessage('Human 2'),
new AIMessage('AI 2'),
];
const result = simplifyMessages(messages);
expect(result).toEqual([
{
human: 'Human 1',
ai: 'AI 1',
},
{
human: 'Human 2',
ai: 'AI 2',
},
]);
});
});