diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/evaluators/connections.test.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/evaluators/connections.test.ts index c56a572938ce7..a33986b33a8cb 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/evaluators/connections.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/evaluators/connections.test.ts @@ -143,6 +143,15 @@ describe('evaluateConnections', () => { inputs: `={{(() => { return [{ type: "${NodeConnectionTypes.Main}" }, { type: "${NodeConnectionTypes.AiTool}", displayName: "Tools" }]; })()}}`, outputs: [NodeConnectionTypes.Main], }, + { + name: 'n8n-nodes-test.agent', + displayName: 'AI Agent', + inputs: [ + { type: NodeConnectionTypes.AiTool, required: true, maxConnections: -1 }, + { type: NodeConnectionTypes.Main }, + ], + outputs: [NodeConnectionTypes.Main], + }, { name: 'n8n-nodes-test.merge', displayName: 'Merge', @@ -168,9 +177,33 @@ describe('evaluateConnections', () => { { name: 'n8n-nodes-test.vectorStore', displayName: 'Vector Store', - inputs: `={{ (() => { const mode = $parameter.mode; if (mode === "retrieve") { return [{ type: "${NodeConnectionTypes.AiEmbedding}", required: true }]; } return [{ type: "${NodeConnectionTypes.Main}" }, { type: "${NodeConnectionTypes.AiDocument}" }]; })() }}`, + inputs: `={{ + ((parameters) => { + const mode = parameters?.mode; + const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}] + + if (mode === 'retrieve-as-tool') { + return inputs; + } + + if (['insert', 'load', 'update'].includes(mode)) { + inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"}) + } + + if (['insert'].includes(mode)) { + inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1}) + } + return inputs + })($parameter) + }}`, outputs: `={{ (() => { const mode = $parameter.mode; if (mode === "retrieve-as-tool") { return [{ type: "${NodeConnectionTypes.AiTool}" }]; } return [{ type: "${NodeConnectionTypes.AiVectorStore}" }]; })() }}`, }, + { + name: 'n8n-nodes-test.embeddingsOpenAi', + displayName: 'OpenAI Embeddings', + inputs: [], + outputs: [NodeConnectionTypes.AiEmbedding], + }, ]); describe('basic workflow connections validation', () => { @@ -332,6 +365,111 @@ describe('evaluateConnections', () => { }), ); }); + + it('should detect AI sub-node not connected to a root node', () => { + const workflow = mock({ + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Chat Model', + type: 'n8n-nodes-test.chatOpenAi', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + connections: {}, + }); + + const { violations } = evaluateConnections(workflow, mockNodeTypes); + expect(violations).toContainEqual( + expect.objectContaining({ + description: expect.stringContaining( + 'Sub-node Chat Model (n8n-nodes-test.chatOpenAi) provides ai_languageModel but is not connected to a root node.', + ), + }), + ); + }); + + it('should not report issues for nested sub-nodes properly connected to a root node', () => { + const workflow = mock({ + nodes: [ + { + id: '0', + name: 'Manual Trigger', + type: 'n8n-nodes-test.manualTrigger', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + { + id: '1', + parameters: { + mode: 'retrieve-as-tool', + }, + name: 'Vector Store Retrieval', + type: 'n8n-nodes-test.vectorStore', + typeVersion: 1.3, + position: [0, 0], + }, + { + id: '2', + parameters: {}, + name: 'AI Agent', + type: 'n8n-nodes-test.agent', + typeVersion: 3, + position: [0, 0], + }, + { + id: '3', + parameters: {}, + name: 'OpenAI Embeddings', + type: 'n8n-nodes-test.embeddingsOpenAi', + typeVersion: 1.2, + position: [0, 0], + }, + ], + connections: { + 'Manual Trigger': { + main: [ + [ + { + node: 'AI Agent', + type: 'main', + index: 0, + }, + ], + ], + }, + 'Vector Store Retrieval': { + ai_tool: [ + [ + { + node: 'AI Agent', + type: 'ai_tool', + index: 0, + }, + ], + ], + }, + 'OpenAI Embeddings': { + ai_embedding: [ + [ + { + node: 'Vector Store Retrieval', + type: 'ai_embedding', + index: 0, + }, + ], + ], + }, + }, + }); + + const { violations } = evaluateConnections(workflow, mockNodeTypes); + expect(violations).toEqual([]); + }); }); describe('dynamic input/output resolution', () => { @@ -431,16 +569,19 @@ describe('evaluateConnections', () => { typeVersion: 1, position: [400, 0], }, + { + id: '4', + name: 'Embeddings', + type: 'n8n-nodes-test.embeddingsOpenAi', + parameters: {}, + typeVersion: 1, + position: [600, 0], + }, ], connections: { 'Manual Trigger': { main: [ [ - { - node: 'Vector Store', - type: 'main', - index: 0, - }, { node: 'OpenAI', type: 'main', @@ -460,6 +601,17 @@ describe('evaluateConnections', () => { ], ], }, + Embeddings: { + ai_embedding: [ + [ + { + node: 'Vector Store', + type: 'ai_embedding', + index: 0, + }, + ], + ], + }, }, }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts index 88bc41867c953..28494bf8737be 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts @@ -2,6 +2,7 @@ import type { INodeConnections, INodeTypeDescription, NodeConnectionType } from import { mapConnectionsByDestination } from 'n8n-workflow'; import type { SimpleWorkflow } from '@/types'; +import { isSubNode } from '@/utils/node-helpers'; import { resolveNodeInputs, resolveNodeOutputs } from '@/validation/utils/resolve-connections'; import type { @@ -130,6 +131,50 @@ function checkMergeNodeConnections( return issues; } +function checkSubNodeRootConnections( + workflow: SimpleWorkflow, + nodeInfo: NodeResolvedConnectionTypesInfo, + nodesByName: Map, +): ProgrammaticViolation[] { + const issues: ProgrammaticViolation[] = []; + + const { node, nodeType, resolvedOutputs } = nodeInfo; + + if (!resolvedOutputs || resolvedOutputs.size === 0) { + return issues; + } + + if (!isSubNode(nodeType, node)) { + return issues; + } + + const aiOutputs = Array.from(resolvedOutputs).filter((output) => output.startsWith('ai_')); + + if (aiOutputs.length === 0) { + return issues; + } + + const nodeConnections = workflow.connections?.[node.name]; + + for (const outputType of aiOutputs) { + const connectionsForType = nodeConnections?.[outputType]; + + const hasRootConnection = connectionsForType?.some((connectionGroup) => + connectionGroup?.some((connection) => connection?.node && nodesByName.has(connection.node)), + ); + + if (!hasRootConnection) { + issues.push({ + type: 'critical', + description: `Sub-node ${node.name} (${node.type}) provides ${outputType} but is not connected to a root node.`, + pointsDeducted: 50, + }); + } + } + + return issues; +} + export function validateConnections( workflow: SimpleWorkflow, nodeTypes: INodeTypeDescription[], @@ -141,9 +186,11 @@ export function validateConnections( } const connectionsByDestination = mapConnectionsByDestination(workflow.connections); + const nodesByName = new Map(workflow.nodes.map((node) => [node.name, node])); + const nodeTypeMap = new Map(nodeTypes.map((type) => [type.name, type])); for (const node of workflow.nodes) { - const nodeType = nodeTypes.find((type) => type.name === node.type); + const nodeType = nodeTypeMap.get(node.type); if (!nodeType) { violations.push({ type: 'critical', @@ -178,6 +225,8 @@ export function validateConnections( violations.push(...checkUnsupportedConnections(nodeInfo, providedInputTypes)); violations.push(...checkMergeNodeConnections(nodeInfo, nodeConnections)); + + violations.push(...checkSubNodeRootConnections(workflow, nodeInfo, nodesByName)); } return violations;