From 139b803daefca44fd66a92156867d77ccdffcc66 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Thu, 30 Apr 2026 09:26:36 +0200 Subject: [PATCH] fix: Use explicit node references for AI memory session keys (#29473) --- .../evaluations/binaryChecks/checks/index.ts | 2 + .../memory-session-key-expression.test.ts | 84 +++++++++ .../checks/memory-session-key-expression.ts | 61 ++++++ .../telegram-chatbot-memory-session.json | 13 ++ .../build-workflow-agent.prompt.ts | 26 ++- .../src/ast-interpreter/interpreter.test.ts | 12 ++ .../src/ast-interpreter/validators.ts | 1 + .../src/codegen/parse-workflow-code.test.ts | 27 +++ .../src/codegen/parse-workflow-code.ts | 3 +- .../@n8n/workflow-sdk/src/expression.test.ts | 28 ++- packages/@n8n/workflow-sdk/src/expression.ts | 1 + .../@n8n/workflow-sdk/src/expression/index.ts | 62 ++++++- packages/@n8n/workflow-sdk/src/index.ts | 1 + .../prompts/best-practices/guides/chatbot.ts | 11 ++ .../parameter-guides/predecessor-output.ts | 7 + .../sdk-reference/additional-functions.ts | 3 + .../src/prompts/sdk-reference/expressions.ts | 15 +- .../workflow-builder/plugins/defaults.test.ts | 1 + .../src/workflow-builder/plugins/defaults.ts | 2 + .../workflow-builder/plugins/exports.test.ts | 15 +- .../plugins/validators/index.ts | 1 + .../memory-session-key-validator.test.ts | 173 ++++++++++++++++++ .../memory-session-key-validator.ts | 83 +++++++++ 23 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.test.ts create mode 100644 packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.ts create mode 100644 packages/@n8n/instance-ai/evaluations/data/workflows/telegram-chatbot-memory-session.json create mode 100644 packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.test.ts create mode 100644 packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.ts diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts index 05c843cf792..9ae4f9d1928 100644 --- a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/index.ts @@ -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, diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.test.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.test.ts new file mode 100644 index 00000000000..843f5805782 --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.test.ts @@ -0,0 +1,84 @@ +import { memorySessionKeyExpression } from './memory-session-key-expression'; +import type { WorkflowResponse } from '../../clients/n8n-client'; + +function createWorkflow(memoryParameters: Record): 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 }); + }); +}); diff --git a/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.ts b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.ts new file mode 100644 index 00000000000..4658d0c443b --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/binaryChecks/checks/memory-session-key-expression.ts @@ -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): 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('; ')}`, + } + : {}), + }; + }, +}; diff --git a/packages/@n8n/instance-ai/evaluations/data/workflows/telegram-chatbot-memory-session.json b/packages/@n8n/instance-ai/evaluations/data/workflows/telegram-chatbot-memory-session.json new file mode 100644 index 00000000000..38b1e9bc944 --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/data/workflows/telegram-chatbot-memory-session.json @@ -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." + } + ] +} diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts index 8dfbaf53701..a26ad8c2017 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts @@ -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 diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts index f2529153942..8284e18f7b6 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts @@ -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'); diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts index 1ce0b0e9080..c45692c3e84 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts @@ -40,6 +40,7 @@ export const ALLOWED_SDK_FUNCTIONS = new Set([ // Utility 'fromAi', // NEW: replaces ($) => $.fromAi() pattern + 'nodeJson', ]); /** diff --git a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts index 09f2d1aff38..074800c7441 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts @@ -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)', () => { diff --git a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.ts b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.ts index 4f624a07c67..7da50455ff7 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.ts @@ -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, }; /** diff --git a/packages/@n8n/workflow-sdk/src/expression.test.ts b/packages/@n8n/workflow-sdk/src/expression.test.ts index 7544430f193..dd4017276a8 100644 --- a/packages/@n8n/workflow-sdk/src/expression.test.ts +++ b/packages/@n8n/workflow-sdk/src/expression.test.ts @@ -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'); diff --git a/packages/@n8n/workflow-sdk/src/expression.ts b/packages/@n8n/workflow-sdk/src/expression.ts index 0bb8e9cab65..3c52b03bcb7 100644 --- a/packages/@n8n/workflow-sdk/src/expression.ts +++ b/packages/@n8n/workflow-sdk/src/expression.ts @@ -3,5 +3,6 @@ export { parseExpression, isExpression, expr, + nodeJson, createFromAIExpression, } from './expression/index'; diff --git a/packages/@n8n/workflow-sdk/src/expression/index.ts b/packages/@n8n/workflow-sdk/src/expression/index.ts index c4950095025..ef6d49911f4 100644 --- a/packages/@n8n/workflow-sdk/src/expression/index.ts +++ b/packages/@n8n/workflow-sdk/src/expression/index.ts @@ -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; + +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 // ============================================================================= diff --git a/packages/@n8n/workflow-sdk/src/index.ts b/packages/@n8n/workflow-sdk/src/index.ts index 01a36555605..b55aa9da10d 100644 --- a/packages/@n8n/workflow-sdk/src/index.ts +++ b/packages/@n8n/workflow-sdk/src/index.ts @@ -138,6 +138,7 @@ export { parseExpression, isExpression, expr, + nodeJson, createFromAIExpression, } from './expression'; diff --git a/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/chatbot.ts b/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/chatbot.ts index 9c0ebbc861d..2226f86b5e3 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/chatbot.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/best-practices/guides/chatbot.ts @@ -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. diff --git a/packages/@n8n/workflow-sdk/src/prompts/node-guidance/parameter-guides/predecessor-output.ts b/packages/@n8n/workflow-sdk/src/prompts/node-guidance/parameter-guides/predecessor-output.ts index 4e8001b9dcb..e7d3d1f3d21 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/node-guidance/parameter-guides/predecessor-output.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/node-guidance/parameter-guides/predecessor-output.ts @@ -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\` diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/additional-functions.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/additional-functions.ts index bf7bd5c3d77..b9cdbcfc7ac 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/additional-functions.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/additional-functions.ts @@ -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()\``; diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/expressions.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/expressions.ts index a09876422bb..eb83eefa69b 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/expressions.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/expressions.ts @@ -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')\``; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts index 67f661ae16c..b06c5c9ed38 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.test.ts @@ -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', () => { diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts index 5088a0384aa..1fd6a23c4c0 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/defaults.ts @@ -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, diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/exports.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/exports.test.ts index d0530f1d938..71ee8739a68 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/exports.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/exports.test.ts @@ -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(); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts index 2f3e872862a..bb1b9f674af 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/index.ts @@ -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'; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.test.ts new file mode 100644 index 00000000000..5547cc67be2 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.test.ts @@ -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 } = {}, + subnodeType?: string, +): NodeInstance { + return { + type, + name, + version: '1', + config: { + parameters: config.parameters ?? {}, + }, + ...(subnodeType ? { _subnodeType: subnodeType } : {}), + } as NodeInstance; +} + +function createGraphNode(node: NodeInstance): 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); + }); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.ts new file mode 100644 index 00000000000..0608639fe1b --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/validators/memory-session-key-validator.ts @@ -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, +): node is NodeInstance & { readonly _subnodeType: string } { + return '_subnodeType' in node && typeof node._subnodeType === 'string'; +} + +function isMemorySubnode(node: NodeInstance): boolean { + return hasSubnodeType(node) && node._subnodeType === MEMORY_SUBNODE_TYPE; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function usesFromInputSessionId(parameters: Record): 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, + _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)] + : [], + ); + }, +};