mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix: Use explicit node references for AI memory session keys (#29473)
This commit is contained in:
parent
4fd68bfc99
commit
139b803dae
|
|
@ -15,6 +15,7 @@ import { hasNodes } from './has-nodes';
|
|||
import { hasStartNode } from './has-start-node';
|
||||
import { hasTrigger } from './has-trigger';
|
||||
import { memoryProperlyConnected } from './memory-properly-connected';
|
||||
import { memorySessionKeyExpression } from './memory-session-key-expression';
|
||||
import { noDisabledNodes } from './no-disabled-nodes';
|
||||
import { noEmptySetNodes } from './no-empty-set-nodes';
|
||||
import { noHardcodedCredentials } from './no-hardcoded-credentials';
|
||||
|
|
@ -40,6 +41,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
|
|||
agentHasDynamicPrompt,
|
||||
agentHasLanguageModel,
|
||||
memoryProperlyConnected,
|
||||
memorySessionKeyExpression,
|
||||
vectorStoreHasEmbeddings,
|
||||
noHardcodedCredentials,
|
||||
noUnnecessaryCodeNodes,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { memorySessionKeyExpression } from './memory-session-key-expression';
|
||||
import type { WorkflowResponse } from '../../clients/n8n-client';
|
||||
|
||||
function createWorkflow(memoryParameters: Record<string, unknown>): WorkflowResponse {
|
||||
return {
|
||||
id: 'workflow-1',
|
||||
name: 'Memory expression test',
|
||||
active: false,
|
||||
nodes: [
|
||||
{
|
||||
name: 'Telegram Trigger',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'Conversation Memory',
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
parameters: memoryParameters,
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Telegram Trigger': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Conversation Memory': {
|
||||
ai_memory: [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('memorySessionKeyExpression', () => {
|
||||
it('fails when a connected memory node uses $json in a custom sessionKey', async () => {
|
||||
const workflow = createWorkflow({
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: '={{ $json.chatId }}',
|
||||
});
|
||||
|
||||
const result = await memorySessionKeyExpression.run(workflow, { prompt: '' });
|
||||
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Conversation Memory');
|
||||
expect(result.comment).toContain('sessionKey');
|
||||
});
|
||||
|
||||
it('fails when a connected legacy memory node uses $json in sessionId', async () => {
|
||||
const workflow = createWorkflow({
|
||||
sessionIdType: 'customKey',
|
||||
sessionId: '={{ $json.chatId }}',
|
||||
});
|
||||
|
||||
const result = await memorySessionKeyExpression.run(workflow, { prompt: '' });
|
||||
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('sessionId');
|
||||
});
|
||||
|
||||
it('passes when a connected memory node references the trigger explicitly', async () => {
|
||||
const workflow = createWorkflow({
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: "={{ $('Telegram Trigger').item.json.message.chat.id }}",
|
||||
});
|
||||
|
||||
const result = await memorySessionKeyExpression.run(workflow, { prompt: '' });
|
||||
|
||||
expect(result).toEqual({ pass: true });
|
||||
});
|
||||
|
||||
it('passes for the Chat Trigger fromInput session ID mode', async () => {
|
||||
const workflow = createWorkflow({
|
||||
sessionIdType: 'fromInput',
|
||||
sessionKey: '={{ $json.sessionId }}',
|
||||
});
|
||||
|
||||
const result = await memorySessionKeyExpression.run(workflow, { prompt: '' });
|
||||
|
||||
expect(result).toEqual({ pass: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { WorkflowNodeResponse } from '../../clients/n8n-client';
|
||||
import type { BinaryCheck } from '../types';
|
||||
import { collectSourcesByConnectionType } from '../utils';
|
||||
|
||||
const SESSION_KEY_PARAMETERS = ['sessionKey', 'sessionId'];
|
||||
|
||||
function isMemoryNode(type: string): boolean {
|
||||
const shortName = type.split('.').pop() ?? '';
|
||||
return shortName.toLowerCase().includes('memory');
|
||||
}
|
||||
|
||||
function isExpressionUsingJson(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.includes('$json') &&
|
||||
(value.startsWith('=') || value.includes('{{'))
|
||||
);
|
||||
}
|
||||
|
||||
function usesFromInputSessionId(parameters: Record<string, unknown>): boolean {
|
||||
return parameters.sessionIdType === 'fromInput';
|
||||
}
|
||||
|
||||
function getUnsafeSessionKeyParameters(node: WorkflowNodeResponse): string[] {
|
||||
const parameters = node.parameters;
|
||||
if (!parameters || usesFromInputSessionId(parameters)) return [];
|
||||
|
||||
return SESSION_KEY_PARAMETERS.filter((parameterName) =>
|
||||
isExpressionUsingJson(parameters[parameterName]),
|
||||
);
|
||||
}
|
||||
|
||||
export const memorySessionKeyExpression: BinaryCheck = {
|
||||
name: 'memory_session_key_expression',
|
||||
description: 'AI memory custom session keys use explicit source node references',
|
||||
kind: 'deterministic',
|
||||
run(workflow) {
|
||||
const connectedMemoryNodeNames = collectSourcesByConnectionType(
|
||||
workflow.connections ?? {},
|
||||
'ai_memory',
|
||||
);
|
||||
const memoryNodes = (workflow.nodes ?? []).filter(
|
||||
(node) => connectedMemoryNodeNames.has(node.name) && isMemoryNode(node.type),
|
||||
);
|
||||
|
||||
const issues = memoryNodes.flatMap((node) =>
|
||||
getUnsafeSessionKeyParameters(node).map(
|
||||
(parameterName) => `"${node.name}" uses $json in ${parameterName}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
pass: issues.length === 0,
|
||||
...(issues.length > 0
|
||||
? {
|
||||
comment: `Memory session keys should reference the trigger/source node explicitly: ${issues.join('; ')}`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"prompt": "Build a Telegram chatbot workflow for a family assistant. It should receive Telegram messages, answer with an AI Agent using an OpenAI chat model, keep short-term conversation memory scoped separately for each Telegram chat, and send the AI Agent's answer back to the same Telegram chat. Configure all nodes as completely as possible and don't ask me for credentials, I'll set them up later.",
|
||||
"complexity": "medium",
|
||||
"tags": ["build", "telegram", "chatbot", "ai-agent", "memory", "expressions"],
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "distinct-telegram-chat",
|
||||
"description": "A Telegram message from one chat is answered with memory scoped to that chat id",
|
||||
"dataSetup": "The Telegram Trigger receives a text message from chat id 123456 with text 'What is on the family calendar today?' from user 'Alex'. The AI Agent returns a short helpful answer. The Telegram sendMessage call returns a success response.",
|
||||
"successCriteria": "The workflow executes without errors. It contains a Telegram Trigger, an AI Agent, a chat model, and a memory node connected to the agent. The memory node scopes conversation history by Telegram chat id using an explicit source-node reference to the Telegram Trigger chat id, not $json. The final Telegram response is sent back to chat id 123456 and contains the AI Agent answer."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -86,6 +86,16 @@ After writing any workflow with IF, Switch, or Filter nodes, verify:
|
|||
|
||||
### AI Agent with Subnodes — use factory functions in subnodes config
|
||||
\`\`\`javascript
|
||||
const chatTrigger = trigger({
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Chat Trigger',
|
||||
parameters: { public: false },
|
||||
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
|
||||
}
|
||||
});
|
||||
|
||||
const model = languageModel({
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
|
|
@ -108,6 +118,19 @@ const parser = outputParser({
|
|||
}
|
||||
});
|
||||
|
||||
const memoryNode = memory({
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Conversation Memory',
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: nodeJson(chatTrigger, 'sessionId'),
|
||||
contextWindowLength: 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const agent = node({
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
|
|
@ -119,11 +142,12 @@ const agent = node({
|
|||
hasOutputParser: true,
|
||||
options: { systemMessage: 'You are an expert...' }
|
||||
},
|
||||
subnodes: { model: model, outputParser: parser }
|
||||
subnodes: { model: model, memory: memoryNode, outputParser: parser }
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
WRONG: \`.to(agent, { connectionType: 'ai_languageModel' })\` — subnodes MUST be in the config object.
|
||||
For values inside AI subnodes, use explicit references such as \`nodeJson(triggerNode, 'sessionId')\` instead of \`$json.sessionId\`. For Chat Trigger memory specifically, \`sessionIdType: 'fromInput'\` is also valid.
|
||||
|
||||
### Code Node
|
||||
\`\`\`javascript
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ const createMockSDKFunctions = (): SDKFunctions => ({
|
|||
fromAi: jest.fn(
|
||||
(key: string, desc?: string) => `={{ $fromAI('${key}'${desc ? `, '${desc}'` : ''}) }}`,
|
||||
),
|
||||
nodeJson: jest.fn((node: { name: string } | string, path: string) => {
|
||||
const name = typeof node === 'string' ? node : node.name;
|
||||
return `={{ $('${name}').item.json.${path} }}`;
|
||||
}),
|
||||
});
|
||||
|
||||
describe('AST Interpreter', () => {
|
||||
|
|
@ -224,6 +228,14 @@ describe('AST Interpreter', () => {
|
|||
expect(result).toContain('$fromAI');
|
||||
});
|
||||
|
||||
it('should call nodeJson function', () => {
|
||||
const code = "export default nodeJson('Telegram Trigger', 'message.chat.id');";
|
||||
const result = interpretSDKCode(code, sdkFunctions);
|
||||
|
||||
expect(sdkFunctions.nodeJson).toHaveBeenCalledWith('Telegram Trigger', 'message.chat.id');
|
||||
expect(result).toBe("={{ $('Telegram Trigger').item.json.message.chat.id }}");
|
||||
});
|
||||
|
||||
it('should chain method calls', () => {
|
||||
const code = `
|
||||
const wf = workflow('id', 'name');
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const ALLOWED_SDK_FUNCTIONS = new Set([
|
|||
|
||||
// Utility
|
||||
'fromAi', // NEW: replaces ($) => $.fromAi() pattern
|
||||
'nodeJson',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,33 @@ describe('parseWorkflowCodeToBuilder', () => {
|
|||
expect(json.name).toBe('My Workflow');
|
||||
expect(json.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should parse SDK code using nodeJson()', () => {
|
||||
const code = `
|
||||
const telegramTrigger = trigger({
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
version: 1,
|
||||
config: { name: 'Telegram Trigger', parameters: {} }
|
||||
});
|
||||
const setChat = node({
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {
|
||||
name: 'Set Chat',
|
||||
parameters: { chatId: nodeJson(telegramTrigger, 'message.chat.id') }
|
||||
}
|
||||
});
|
||||
export default workflow('test-id', 'My Workflow').add(telegramTrigger).to(setChat);
|
||||
`;
|
||||
|
||||
const builder = parseWorkflowCodeToBuilder(code);
|
||||
const json = builder.toJSON();
|
||||
const setNode = json.nodes.find((node) => node.name === 'Set Chat');
|
||||
|
||||
expect(setNode?.parameters?.chatId).toBe(
|
||||
"={{ $('Telegram Trigger').item.json.message.chat.id }}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain object code (WorkflowJSON)', () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import { interpretSDKCode, InterpreterError, SecurityError } from '../ast-interpreter';
|
||||
import type { SDKFunctions } from '../ast-interpreter';
|
||||
import { expr as exprFn } from '../expression';
|
||||
import { expr as exprFn, nodeJson as nodeJsonFn } from '../expression';
|
||||
import { isWorkflowBuilder, isWorkflowJSON } from '../typeguards';
|
||||
import type { WorkflowJSON, WorkflowBuilder } from '../types/base';
|
||||
import { workflow as workflowFn } from '../workflow-builder';
|
||||
|
|
@ -553,6 +553,7 @@ const sdkFunctions: SDKFunctions = {
|
|||
reranker: rerankerFn,
|
||||
fromAi: fromAiFn,
|
||||
expr: exprFn,
|
||||
nodeJson: nodeJsonFn,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseExpression, expr, createFromAIExpression } from './expression';
|
||||
import { parseExpression, expr, nodeJson, createFromAIExpression } from './expression';
|
||||
|
||||
describe('Expression System', () => {
|
||||
describe('expr() helper for expressions', () => {
|
||||
|
|
@ -51,6 +51,32 @@ describe('Expression System', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('nodeJson() helper for explicit node references', () => {
|
||||
it('should build expression from a node instance and dot path', () => {
|
||||
const node = { name: 'Telegram Trigger' };
|
||||
|
||||
const result = nodeJson(node as never, 'message.chat.id');
|
||||
|
||||
expect(result).toBe("={{ $('Telegram Trigger').item.json.message.chat.id }}");
|
||||
});
|
||||
|
||||
it('should build expression from a node name and array path', () => {
|
||||
const result = nodeJson('Set User', ['profile', 'user-id']);
|
||||
|
||||
expect(result).toBe('={{ $(\'Set User\').item.json.profile["user-id"] }}');
|
||||
});
|
||||
|
||||
it('should escape node names', () => {
|
||||
const result = nodeJson("Bob's Trigger", 'message.text');
|
||||
|
||||
expect(result).toBe("={{ $('Bob\\'s Trigger').item.json.message.text }}");
|
||||
});
|
||||
|
||||
it('should throw for an empty path', () => {
|
||||
expect(() => nodeJson('Set', '')).toThrow('nodeJson() requires a non-empty JSON path.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAIExpression() key sanitization', () => {
|
||||
it('should sanitize keys with spaces', () => {
|
||||
const result = createFromAIExpression('user email');
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ export {
|
|||
parseExpression,
|
||||
isExpression,
|
||||
expr,
|
||||
nodeJson,
|
||||
createFromAIExpression,
|
||||
} from './expression/index';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow';
|
||||
|
||||
import type { FromAIArgumentType } from '../types/base';
|
||||
import type { FromAIArgumentType, NodeInstance } from '../types/base';
|
||||
|
||||
/**
|
||||
* Parse n8n expression string to extract the inner expression
|
||||
|
|
@ -81,6 +81,66 @@ export function expr(expression: string): string {
|
|||
return '=' + normalized;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Explicit Node JSON Reference Generator
|
||||
// =============================================================================
|
||||
|
||||
type NodeJsonReference = NodeInstance<string, string, unknown> | string;
|
||||
|
||||
const IDENTIFIER_PATH_SEGMENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
function resolveNodeName(node: NodeJsonReference): string {
|
||||
return typeof node === 'string' ? node : node.name;
|
||||
}
|
||||
|
||||
function escapeNodeName(nodeName: string): string {
|
||||
return nodeName.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function normalizePath(path: string | readonly string[]): string[] {
|
||||
const segments = typeof path === 'string' ? path.split('.') : [...path];
|
||||
const normalized = segments.map((segment) => segment.trim());
|
||||
|
||||
if (normalized.length === 0 || normalized.some((segment) => segment.length === 0)) {
|
||||
throw new Error('nodeJson() requires a non-empty JSON path.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatPathSegment(segment: string): string {
|
||||
if (IDENTIFIER_PATH_SEGMENT.test(segment)) {
|
||||
return `.${segment}`;
|
||||
}
|
||||
|
||||
return `[${JSON.stringify(segment)}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an expression that references JSON data from a specific node by name.
|
||||
*
|
||||
* Prefer this over `$json` when a value comes from an AI subnode, a fan-in
|
||||
* branch, or any node other than the immediate main-flow predecessor.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* nodeJson(telegramTrigger, 'message.chat.id')
|
||||
* // "={{ $('Telegram Trigger').item.json.message.chat.id }}"
|
||||
*
|
||||
* nodeJson('Set User', ['profile', 'user-id'])
|
||||
* // "={{ $('Set User').item.json.profile[\"user-id\"] }}"
|
||||
* ```
|
||||
*/
|
||||
export function nodeJson(node: NodeJsonReference, path: string | readonly string[]): string {
|
||||
const nodeName = resolveNodeName(node);
|
||||
if (!nodeName) {
|
||||
throw new Error('nodeJson() requires a node or node name.');
|
||||
}
|
||||
|
||||
const pathExpression = normalizePath(path).map(formatPathSegment).join('');
|
||||
return `={{ $('${escapeNodeName(nodeName)}').item.json${pathExpression} }}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// $fromAI Expression Generator
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export {
|
|||
parseExpression,
|
||||
isExpression,
|
||||
expr,
|
||||
nodeJson,
|
||||
createFromAIExpression,
|
||||
} from './expression';
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ Include information with the user prompt such as timestamp, user ID, or session
|
|||
If there are other agents involved in the workflow you should share memory between the chatbot and those other agents where it makes sense.
|
||||
Connect the same memory node to multiple agents to enable data sharing and context continuity.
|
||||
|
||||
### Memory Session Keys
|
||||
|
||||
Memory session keys must uniquely identify the user or chat. In AI Agent memory subnodes, do not use $json for a custom session key because the memory subnode does not have the same immediate predecessor context as a main-flow node.
|
||||
|
||||
Use nodeJson(triggerNode, 'field.path') for external chat platforms:
|
||||
- Telegram: sessionIdType = customKey, sessionKey = nodeJson(telegramTrigger, 'message.chat.id')
|
||||
- Slack: sessionIdType = customKey, sessionKey = nodeJson(slackTrigger, 'event.channel')
|
||||
- WhatsApp: sessionIdType = customKey, sessionKey = nodeJson(whatsAppTrigger, 'messages.0.from')
|
||||
|
||||
For the built-in n8n Chat Trigger, prefer memory parameters sessionIdType = fromInput and omit a custom sessionKey, because the Chat Trigger provides the session ID directly to the AI Agent.
|
||||
|
||||
## Context Engineering & AI Agent Output
|
||||
|
||||
It can be beneficial to respond to the user as a tool of the chatbot agent rather than using the agent output - this allows the agent to loop/carry out multiple responses if necessary.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ AI Agent nodes (n8n-nodes-langchain.agent) wrap their response in an "output" ob
|
|||
- Use \`$('AI Agent').item.json.output.fieldName\` when referencing a node, instead of \`$('AI Agent').item.json.fieldName\`
|
||||
- WRONG: \`$json.summary\` → CORRECT: \`$json.output.summary\`
|
||||
|
||||
#### AI Agent Subnode Input Context
|
||||
AI Agent subnodes (memory, language models, tools, parsers, retrievers, and vector stores) are connected through AI connections, not the normal main data path.
|
||||
- For memory custom session keys, do NOT use \`$json.chatId\` or \`$json.sessionId\`; reference the trigger/source node explicitly.
|
||||
- Use \`nodeJson(triggerNode, 'message.chat.id')\` or \`$('Trigger Node').item.json.message.chat.id\`.
|
||||
- For tool parameters controlled by the agent, use \`$fromAI(...)\` instead of upstream JSON.
|
||||
- The built-in Chat Trigger memory shortcut is \`sessionIdType: 'fromInput'\`, where no custom session key expression is needed.
|
||||
|
||||
#### Webhook Node Output Structure
|
||||
When referencing data from a Webhook node (n8n-nodes-base.webhook), the incoming request is structured under \`$json\`:
|
||||
- \`$json.headers\` - HTTP headers, example: \`$json.headers.authorization\`
|
||||
|
|
|
|||
|
|
@ -20,5 +20,8 @@ export const ADDITIONAL_FUNCTIONS = `Additional SDK functions:
|
|||
- \`.onError(handler)\` — connects a node's error output to a handler node. Requires \`onError: 'continueErrorOutput'\` in the node config.
|
||||
Example: \`httpNode.onError(errorHandler)\` (with \`config: { onError: 'continueErrorOutput' }\`)
|
||||
|
||||
- \`nodeJson(node, 'field.path')\` — creates an explicit expression reference to JSON data from a specific node. Use this instead of \`$json\` in AI Agent subnodes, fan-in nodes, or when reading further upstream data.
|
||||
Example: \`sessionKey: nodeJson(telegramTrigger, 'message.chat.id')\`
|
||||
|
||||
- Additional subnode factories (all follow the same pattern as \`languageModel()\` and \`tool()\`):
|
||||
\`memory()\`, \`outputParser()\`, \`embeddings()\`, \`vectorStore()\`, \`retriever()\`, \`documentLoader()\`, \`textSplitter()\``;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const EXPRESSION_REFERENCE = `Available variables inside \`expr('{{ ... }
|
|||
|
||||
- \`$json\` — current item's JSON data from the immediate predecessor node
|
||||
- \`$('NodeName').item.json\` — access any node's output by name
|
||||
- \`nodeJson(node, 'field.path')\` — SDK helper that creates \`={{ $('NodeName').item.json.field.path }}\`
|
||||
- \`$input.first()\` — first item from immediate predecessor
|
||||
- \`$input.all()\` — all items from immediate predecessor
|
||||
- \`$input.item\` — current item being processed
|
||||
|
|
@ -35,4 +36,16 @@ Dynamic data from other nodes — \`$()\` MUST always be inside \`{{ }}\`, never
|
|||
|
||||
- WRONG: \`expr('{{ ' + JSON.stringify($('Source').all().map(i => i.json.name)) + ' }}')\` — $() outside {{ }}
|
||||
- CORRECT: \`expr('{{ $("Source").all().map(i => ({ option: i.json.name })) }}')\` — $() inside {{ }}
|
||||
- CORRECT: \`expr('{{ { "fields": [{ "values": $("Fetch Projects").all().map(i => ({ option: i.json.name })) }] } }}')\` — complex JSON inside {{ }}`;
|
||||
- CORRECT: \`expr('{{ { "fields": [{ "values": $("Fetch Projects").all().map(i => ({ option: i.json.name })) }] } }}')\` — complex JSON inside {{ }}
|
||||
|
||||
When \`$json\` is unsafe - use \`nodeJson(node, 'path')\` or \`$('NodeName').item.json.path\` instead:
|
||||
|
||||
- AI Agent subnodes: memory, language model, parser, retriever, vector store, and tool subnodes do not have the same immediate predecessor context as a main-flow node.
|
||||
WRONG: \`sessionKey: expr('{{ $json.chatId }}')\`
|
||||
CORRECT: \`sessionKey: nodeJson(telegramTrigger, 'message.chat.id')\`
|
||||
- Multi-branch fan-in: if a node receives data after IF/Switch/Merge-style branching, \`$json\` only means the current incoming item and may not contain the source field you need.
|
||||
WRONG: \`expr('{{ $json.userId }}')\`
|
||||
CORRECT: \`nodeJson(userLookup, 'user.id')\`
|
||||
- Further-upstream data: if the value comes from any node other than the immediate main predecessor, reference that node explicitly.
|
||||
WRONG: \`expr('{{ $json.email }}')\`
|
||||
CORRECT: \`nodeJson(formTrigger, 'body.email')\``;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ describe('Default Plugins', () => {
|
|||
expect(validatorIds).toContain('core:disconnected-node');
|
||||
expect(validatorIds).toContain('core:agent');
|
||||
expect(validatorIds).toContain('core:http-request');
|
||||
expect(validatorIds).toContain('core:memory-session-key');
|
||||
});
|
||||
|
||||
it('registerDefaultPlugins registers core composite handlers', () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
fromAiValidator,
|
||||
httpRequestValidator,
|
||||
maxNodesValidator,
|
||||
memorySessionKeyValidator,
|
||||
mergeNodeValidator,
|
||||
missingTriggerValidator,
|
||||
noNodesValidator,
|
||||
|
|
@ -51,6 +52,7 @@ const coreValidators: ValidatorPlugin[] = [
|
|||
httpRequestValidator,
|
||||
toolNodeValidator,
|
||||
fromAiValidator,
|
||||
memorySessionKeyValidator,
|
||||
|
||||
// Node-type validators (medium priority)
|
||||
setNodeValidator,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
/**
|
||||
* Test that the public API exports are accessible
|
||||
*/
|
||||
import { PluginRegistry, pluginRegistry, registerDefaultPlugins, workflow } from '../../index';
|
||||
import {
|
||||
PluginRegistry,
|
||||
nodeJson,
|
||||
pluginRegistry,
|
||||
registerDefaultPlugins,
|
||||
workflow,
|
||||
} from '../../index';
|
||||
import type {
|
||||
ValidationIssue,
|
||||
PluginContext,
|
||||
|
|
@ -13,6 +19,13 @@ import type {
|
|||
} from '../../index';
|
||||
|
||||
describe('Public API exports', () => {
|
||||
describe('Expression helper exports', () => {
|
||||
it('exports nodeJson function', () => {
|
||||
expect(nodeJson).toBeDefined();
|
||||
expect(typeof nodeJson).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registry exports', () => {
|
||||
it('exports PluginRegistry class', () => {
|
||||
expect(PluginRegistry).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { filterNodeValidator } from './filter-node-validator';
|
|||
export { fromAiValidator } from './from-ai-validator';
|
||||
export { httpRequestValidator } from './http-request-validator';
|
||||
export { maxNodesValidator } from './max-nodes-validator';
|
||||
export { memorySessionKeyValidator } from './memory-session-key-validator';
|
||||
export { mergeNodeValidator } from './merge-node-validator';
|
||||
export { missingTriggerValidator } from './missing-trigger-validator';
|
||||
export { noNodesValidator } from './no-nodes-validator';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { memorySessionKeyValidator } from './memory-session-key-validator';
|
||||
import type { GraphNode, NodeInstance } from '../../../types/base';
|
||||
import type { PluginContext } from '../types';
|
||||
|
||||
function createMockNode(
|
||||
type: string,
|
||||
name: string,
|
||||
config: { parameters?: Record<string, unknown> } = {},
|
||||
subnodeType?: string,
|
||||
): NodeInstance<string, string, unknown> {
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
version: '1',
|
||||
config: {
|
||||
parameters: config.parameters ?? {},
|
||||
},
|
||||
...(subnodeType ? { _subnodeType: subnodeType } : {}),
|
||||
} as NodeInstance<string, string, unknown>;
|
||||
}
|
||||
|
||||
function createGraphNode(node: NodeInstance<string, string, unknown>): GraphNode {
|
||||
return {
|
||||
instance: node,
|
||||
connections: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPluginContext(): PluginContext {
|
||||
return {
|
||||
nodes: new Map(),
|
||||
workflowId: 'test-workflow',
|
||||
workflowName: 'Test Workflow',
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('memorySessionKeyValidator', () => {
|
||||
describe('metadata', () => {
|
||||
it('has correct id', () => {
|
||||
expect(memorySessionKeyValidator.id).toBe('core:memory-session-key');
|
||||
});
|
||||
|
||||
it('has correct name', () => {
|
||||
expect(memorySessionKeyValidator.name).toBe('Memory Session Key Validator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNode', () => {
|
||||
it('returns warning for an AI memory subnode custom session key using $json', () => {
|
||||
const node = createMockNode(
|
||||
'@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
'Conversation Memory',
|
||||
{
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: '={{ $json.chatId }}',
|
||||
},
|
||||
},
|
||||
'ai_memory',
|
||||
);
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'UNSAFE_MEMORY_SESSION_KEY_EXPRESSION',
|
||||
severity: 'error',
|
||||
violationLevel: 'major',
|
||||
nodeName: 'Conversation Memory',
|
||||
parameterPath: 'sessionKey',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns warning for legacy memory sessionId parameters using $json', () => {
|
||||
const node = createMockNode(
|
||||
'@n8n/n8n-nodes-langchain.memoryMotorhead',
|
||||
'Conversation Memory',
|
||||
{
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionId: '={{ $json.chatId }}',
|
||||
},
|
||||
},
|
||||
'ai_memory',
|
||||
);
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'UNSAFE_MEMORY_SESSION_KEY_EXPRESSION',
|
||||
parameterPath: 'sessionId',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns no warning for explicit node references', () => {
|
||||
const node = createMockNode(
|
||||
'@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
'Conversation Memory',
|
||||
{
|
||||
parameters: {
|
||||
sessionIdType: 'customKey',
|
||||
sessionKey: "={{ $('Telegram Trigger').item.json.message.chat.id }}",
|
||||
},
|
||||
},
|
||||
'ai_memory',
|
||||
);
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns no warning for Chat Trigger fromInput memory mode', () => {
|
||||
const node = createMockNode(
|
||||
'@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
'Conversation Memory',
|
||||
{
|
||||
parameters: {
|
||||
sessionIdType: 'fromInput',
|
||||
sessionKey: '={{ $json.sessionId }}',
|
||||
},
|
||||
},
|
||||
'ai_memory',
|
||||
);
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns no warning for non-memory subnodes using $json', () => {
|
||||
const node = createMockNode(
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
'OpenAI Chat Model',
|
||||
{
|
||||
parameters: {
|
||||
model: 'gpt-5.4',
|
||||
options: {
|
||||
baseURL: '={{ $json.baseUrl }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
'ai_languageModel',
|
||||
);
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns no warning for a regular agent text parameter using $json', () => {
|
||||
const node = createMockNode('@n8n/n8n-nodes-langchain.agent', 'AI Agent', {
|
||||
parameters: {
|
||||
text: '={{ $json.chatInput }}',
|
||||
},
|
||||
});
|
||||
const ctx = createMockPluginContext();
|
||||
|
||||
const issues = memorySessionKeyValidator.validateNode(node, createGraphNode(node), ctx);
|
||||
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Memory Session Key Validator Plugin
|
||||
*
|
||||
* Validates that AI memory subnodes do not use ambiguous `$json` references
|
||||
* for manually configured session keys.
|
||||
*/
|
||||
|
||||
import type { GraphNode, NodeInstance } from '../../../types/base';
|
||||
import type { ValidatorPlugin, ValidationIssue, PluginContext } from '../types';
|
||||
|
||||
const MEMORY_SUBNODE_TYPE = 'ai_memory';
|
||||
const SESSION_KEY_PARAMETERS = ['sessionKey', 'sessionId'];
|
||||
|
||||
function hasSubnodeType(
|
||||
node: NodeInstance<string, string, unknown>,
|
||||
): node is NodeInstance<string, string, unknown> & { readonly _subnodeType: string } {
|
||||
return '_subnodeType' in node && typeof node._subnodeType === 'string';
|
||||
}
|
||||
|
||||
function isMemorySubnode(node: NodeInstance<string, string, unknown>): boolean {
|
||||
return hasSubnodeType(node) && node._subnodeType === MEMORY_SUBNODE_TYPE;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function usesFromInputSessionId(parameters: Record<string, unknown>): boolean {
|
||||
return parameters.sessionIdType === 'fromInput';
|
||||
}
|
||||
|
||||
function isUnsafeSessionExpression(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.includes('$json') &&
|
||||
(value.startsWith('=') || value.includes('{{'))
|
||||
);
|
||||
}
|
||||
|
||||
function createIssue(nodeName: string, parameterPath: string): ValidationIssue {
|
||||
return {
|
||||
code: 'UNSAFE_MEMORY_SESSION_KEY_EXPRESSION',
|
||||
message: `'${nodeName}' parameter '${parameterPath}' uses $json in an AI memory subnode session key. Use an explicit node reference such as nodeJson(trigger, 'message.chat.id') or $('Trigger').item.json.message.chat.id.`,
|
||||
severity: 'error',
|
||||
violationLevel: 'major',
|
||||
nodeName,
|
||||
parameterPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator for AI memory subnode session key expressions.
|
||||
*
|
||||
* Checks for:
|
||||
* - `$json` in manually configured memory session keys
|
||||
* - Does not flag Chat Trigger's `fromInput` memory mode
|
||||
*/
|
||||
export const memorySessionKeyValidator: ValidatorPlugin = {
|
||||
id: 'core:memory-session-key',
|
||||
name: 'Memory Session Key Validator',
|
||||
priority: 50,
|
||||
|
||||
validateNode(
|
||||
node: NodeInstance<string, string, unknown>,
|
||||
_graphNode: GraphNode,
|
||||
_ctx: PluginContext,
|
||||
): ValidationIssue[] {
|
||||
if (!isMemorySubnode(node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parameters = node.config?.parameters;
|
||||
if (!isRecord(parameters) || usesFromInputSessionId(parameters)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return SESSION_KEY_PARAMETERS.flatMap((parameterName) =>
|
||||
isUnsafeSessionExpression(parameters[parameterName])
|
||||
? [createIssue(node.name, parameterName)]
|
||||
: [],
|
||||
);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user