diff --git a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts b/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.host-shape.test.ts similarity index 88% rename from packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts rename to packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.host-shape.test.ts index 40e4886b31f..40e9cdbacca 100644 --- a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.host-shape.test.ts @@ -1,6 +1,15 @@ -import { NodeSearchEngine, SCORE_WEIGHTS } from '../node-search-engine'; -import type { SearchableNodeType } from '../node-search-engine.types'; -import { AI_CONNECTION_TYPES } from '../node-search-engine.types'; +/** + * Engine behaviour over a host-supplied `SearchableNodeType` rather than a full + * `INodeTypeDescription`: result caching, n8n Connect metadata, and builder + * hints. Moved here from @n8n/instance-ai when its forked search engine was + * consolidated into this package. + */ + +import type { NodeConnectionType } from 'n8n-workflow'; + +import { NodeSearchEngine, SCORE_WEIGHTS } from '../search-engine'; +import type { SearchableNodeType } from '../types'; +import { AI_CONNECTION_TYPES } from '../types'; // --------------------------------------------------------------------------- // Test fixtures @@ -37,26 +46,34 @@ const slackNode = makeNode({ description: 'Send messages to Slack', }); +/** + * A host's `builderHint` carries fields the engine does not declare, such as + * `extraTypeDefContent`. Built outside the `makeNode` literal so it reaches the + * engine the same way a real host's wider object does, rather than tripping the + * excess-property check that only applies to fresh literals. + */ +const agentBuilderHint = { + searchHint: 'Use an AI Agent for autonomous task execution', + inputs: { + ai_languageModel: { required: true }, + ai_memory: { required: false }, + ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } }, + }, + extraTypeDefContent: [ + { + content: + '\n\nconst agent = node({ ... })\n\n', + }, + ], +}; + const agentNode = makeNode({ name: '@n8n/n8n-nodes-langchain.agent', displayName: 'AI Agent', description: 'An AI agent that uses tools', inputs: ['main', 'ai_languageModel', 'ai_memory', 'ai_tool'], outputs: ['main'], - builderHint: { - message: 'Use an AI Agent for autonomous task execution', - inputs: { - ai_languageModel: { required: true }, - ai_memory: { required: false }, - ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } }, - }, - extraTypeDefContent: [ - { - content: - '\n\nconst agent = node({ ... })\n\n', - }, - ], - }, + builderHint: agentBuilderHint, }); const openAiNode = makeNode({ @@ -206,7 +223,7 @@ describe('NodeSearchEngine', () => { expect(results.length).toBeLessThanOrEqual(2); }); - it('should include builder hint message when present', () => { + it('should include the builder search hint alongside subnode requirements', () => { const results = engine.searchByName('AI Agent'); const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent'); expect(agentResult).toBeDefined(); @@ -319,7 +336,7 @@ describe('NodeSearchEngine', () => { }); it('should return empty for unknown connection type', () => { - const results = engine.searchByConnectionType('ai_nonexistent'); + const results = engine.searchByConnectionType('ai_nonexistent' as NodeConnectionType); expect(results).toEqual([]); }); @@ -536,6 +553,40 @@ describe('NodeSearchEngine', () => { }); }); + // ----------------------------------------------------------------------- + // Input handling + // ----------------------------------------------------------------------- + + describe('host shapes carrying extra fields', () => { + it('reads a host node that also carries a properties array, leaving it untouched', () => { + // `properties` is the field a full INodeTypeDescription is recognised by, + // and a host shape is free to carry one too. Its presence must not make the + // engine reshape the node. + const hostNodeWithProperties = { + ...makeNode({ + name: 'n8n-nodes-base.hostish', + displayName: 'Hostish Node', + description: 'A host shape that also exposes properties', + outputs: ['ai_tool'], + builderHint: { searchHint: 'host hint' }, + }), + properties: [{ name: 'resource', type: 'options' }], + }; + + const engine = new NodeSearchEngine([hostNodeWithProperties]); + + // Same object back, so nothing converted or copied it on the way in. + expect(engine.getNodeType('n8n-nodes-base.hostish')).toBe(hostNodeWithProperties); + + const [byName] = engine.searchByName('Hostish'); + expect(byName.displayName).toBe('Hostish Node'); + expect(byName.builderHintMessage).toBe('host hint'); + + const [byConnection] = engine.searchByConnectionType('ai_tool' as NodeConnectionType); + expect(byConnection.name).toBe('n8n-nodes-base.hostish'); + }); + }); + // ----------------------------------------------------------------------- // Static methods // ----------------------------------------------------------------------- diff --git a/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.test.ts b/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.test.ts index d5532095059..047423b3f98 100644 --- a/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.test.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/__tests__/search-engine.test.ts @@ -11,11 +11,11 @@ import { type NodeConnectionType, } from 'n8n-workflow'; -import { CodeBuilderNodeSearchEngine, SCORE_WEIGHTS } from '../search-engine'; +import { NodeSearchEngine, SCORE_WEIGHTS } from '../search-engine'; import { createNodeType } from './helpers'; -describe('CodeBuilderNodeSearchEngine', () => { - let searchEngine: CodeBuilderNodeSearchEngine; +describe('NodeSearchEngine', () => { + let searchEngine: NodeSearchEngine; let nodeTypes: INodeTypeDescription[]; beforeEach(() => { @@ -76,7 +76,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }), ]; - searchEngine = new CodeBuilderNodeSearchEngine(nodeTypes); + searchEngine = new NodeSearchEngine(nodeTypes); }); describe('searchByName', () => { @@ -150,7 +150,7 @@ describe('CodeBuilderNodeSearchEngine', () => { inputs: [], outputs: ['ai_tool'], }); - const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, httpToolNode]); + const engine = new NodeSearchEngine([...nodeTypes, httpToolNode]); const results = engine.searchByName('http', 1, (nodeId) => nodeId.endsWith('Tool')); @@ -172,7 +172,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'Test Node', description: undefined, }); - const engine = new CodeBuilderNodeSearchEngine([nodeWithoutDesc]); + const engine = new NodeSearchEngine([nodeWithoutDesc]); const results = engine.searchByName('test'); @@ -187,7 +187,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: undefined as unknown as string, } as INodeTypeDescription; - const engineWithMalformed = new CodeBuilderNodeSearchEngine([...nodeTypes, malformedNode]); + const engineWithMalformed = new NodeSearchEngine([...nodeTypes, malformedNode]); // Should not throw and should return valid results expect(() => engineWithMalformed.searchByName('http')).not.toThrow(); @@ -216,12 +216,7 @@ describe('CodeBuilderNodeSearchEngine', () => { description: 'Work with datasets', group: ['transform'], }); - const engine = new CodeBuilderNodeSearchEngine([ - ...nodeTypes, - setNode, - settingsNode, - datasetNode, - ]); + const engine = new NodeSearchEngine([...nodeTypes, setNode, settingsNode, datasetNode]); const results = engine.searchByName('set', 5); @@ -245,7 +240,7 @@ describe('CodeBuilderNodeSearchEngine', () => { group: ['transform'], }), ); - const engine = new CodeBuilderNodeSearchEngine([...competitors, setNode]); + const engine = new NodeSearchEngine([...competitors, setNode]); const results = engine.searchByName('set', 3); @@ -315,10 +310,10 @@ describe('CodeBuilderNodeSearchEngine', () => { }), ]; - let engine: CodeBuilderNodeSearchEngine; + let engine: NodeSearchEngine; beforeEach(() => { - engine = new CodeBuilderNodeSearchEngine(multiWordNodes); + engine = new NodeSearchEngine(multiWordNodes); }); it.each([ @@ -377,7 +372,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'Code Tool', outputs: ['ai_tool'], }); - const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, anotherTool]); + const engine = new NodeSearchEngine([...nodeTypes, anotherTool]); const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool); @@ -413,7 +408,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'Expression Node', outputs: '={{ $parameter.mode === "tool" ? "ai_tool" : "main" }}', }); - const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, expressionNode]); + const engine = new NodeSearchEngine([...nodeTypes, expressionNode]); const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool); @@ -435,7 +430,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'Something Else', outputs: '={{ "ai_tool" }}', }); - const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, exactMatch, expressionMatch]); + const engine = new NodeSearchEngine([...nodeTypes, exactMatch, expressionMatch]); const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 20, 'calculator'); @@ -472,7 +467,7 @@ describe('CodeBuilderNodeSearchEngine', () => { outputs: ['ai_tool'], }), ); - const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, ...manyNodes]); + const engine = new NodeSearchEngine([...nodeTypes, ...manyNodes]); const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 5); @@ -559,14 +554,14 @@ describe('CodeBuilderNodeSearchEngine', () => { describe('static methods', () => { it('should identify AI connection types', () => { - expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_tool')).toBe(true); - expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_languageModel')).toBe(true); - expect(CodeBuilderNodeSearchEngine.isAiConnectionType('main')).toBe(false); - expect(CodeBuilderNodeSearchEngine.isAiConnectionType('Main')).toBe(false); + expect(NodeSearchEngine.isAiConnectionType('ai_tool')).toBe(true); + expect(NodeSearchEngine.isAiConnectionType('ai_languageModel')).toBe(true); + expect(NodeSearchEngine.isAiConnectionType('main')).toBe(false); + expect(NodeSearchEngine.isAiConnectionType('Main')).toBe(false); }); it('should get all AI connection types', () => { - const aiTypes = CodeBuilderNodeSearchEngine.getAiConnectionTypes(); + const aiTypes = NodeSearchEngine.getAiConnectionTypes(); expect(aiTypes).toContain(NodeConnectionTypes.AiTool); expect(aiTypes).toContain(NodeConnectionTypes.AiLanguageModel); @@ -582,7 +577,7 @@ describe('CodeBuilderNodeSearchEngine', () => { describe('edge cases and error handling', () => { it('should handle empty node types array', () => { - const emptyEngine = new CodeBuilderNodeSearchEngine([]); + const emptyEngine = new NodeSearchEngine([]); expect(emptyEngine.searchByName('test')).toEqual([]); expect(emptyEngine.searchByConnectionType(NodeConnectionTypes.AiTool)).toEqual([]); @@ -597,7 +592,7 @@ describe('CodeBuilderNodeSearchEngine', () => { alias: null as unknown as string[], }, }); - const engine = new CodeBuilderNodeSearchEngine([nodeWithNulls]); + const engine = new NodeSearchEngine([nodeWithNulls]); expect(() => engine.searchByName('null')).not.toThrow(); const results = engine.searchByName('null'); @@ -640,7 +635,7 @@ describe('CodeBuilderNodeSearchEngine', () => { version: 3, }); - const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2, nodeV3]); + const engine = new NodeSearchEngine([nodeV1, nodeV2, nodeV3]); const results = engine.searchByName('http'); // Should only return one node (the latest version) @@ -661,7 +656,7 @@ describe('CodeBuilderNodeSearchEngine', () => { version: [2, 3], }); - const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2V3]); + const engine = new NodeSearchEngine([nodeV1, nodeV2V3]); const results = engine.searchByName('code'); // Should return the node with array version [2, 3] as latest @@ -682,7 +677,7 @@ describe('CodeBuilderNodeSearchEngine', () => { version: 3, }); - const engine = new CodeBuilderNodeSearchEngine([nodeV1V2, nodeV3]); + const engine = new NodeSearchEngine([nodeV1V2, nodeV3]); const results = engine.searchByName('webhook'); expect(results).toHaveLength(1); @@ -738,7 +733,7 @@ describe('CodeBuilderNodeSearchEngine', () => { searchHint: 'Use with output parser for structured output', }, }); - const engine = new CodeBuilderNodeSearchEngine([agentNode]); + const engine = new NodeSearchEngine([agentNode]); const results = engine.searchByName('agent'); @@ -784,7 +779,7 @@ describe('CodeBuilderNodeSearchEngine', () => { } as unknown as NonNullable['inputs'], }, }); - const engine = new CodeBuilderNodeSearchEngine([agentNode]); + const engine = new NodeSearchEngine([agentNode]); const results = engine.searchByName('agent'); @@ -799,7 +794,7 @@ describe('CodeBuilderNodeSearchEngine', () => { name: 'n8n-nodes-base.httpRequest', displayName: 'HTTP Request', }); - const engine = new CodeBuilderNodeSearchEngine([basicNode]); + const engine = new NodeSearchEngine([basicNode]); const results = engine.searchByName('http'); @@ -818,7 +813,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }, }, }); - const engine = new CodeBuilderNodeSearchEngine([openAiModel]); + const engine = new NodeSearchEngine([openAiModel]); const results = engine.searchByConnectionType(NodeConnectionTypes.AiLanguageModel); @@ -843,7 +838,7 @@ describe('CodeBuilderNodeSearchEngine', () => { searchHint: 'Test hint message', }, }); - const engine = new CodeBuilderNodeSearchEngine([agentNode]); + const engine = new NodeSearchEngine([agentNode]); const results = engine.searchByName('agent'); const formatted = engine.formatResult(results[0]); @@ -867,7 +862,7 @@ describe('CodeBuilderNodeSearchEngine', () => { name: 'n8n-nodes-base.code', displayName: 'Code', }); - const engine = new CodeBuilderNodeSearchEngine([basicNode]); + const engine = new NodeSearchEngine([basicNode]); const results = engine.searchByName('code'); const formatted = engine.formatResult(results[0]); @@ -879,7 +874,7 @@ describe('CodeBuilderNodeSearchEngine', () => { describe('getSubnodesForConnectionType', () => { it('should return default subnodes for ai_languageModel', () => { - const engine = new CodeBuilderNodeSearchEngine([]); + const engine = new NodeSearchEngine([]); const subnodes = engine.getSubnodesForConnectionType('ai_languageModel'); @@ -887,7 +882,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }); it('should return default subnodes for ai_memory', () => { - const engine = new CodeBuilderNodeSearchEngine([]); + const engine = new NodeSearchEngine([]); const subnodes = engine.getSubnodesForConnectionType('ai_memory'); @@ -895,7 +890,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }); it('should return default subnodes for ai_embedding', () => { - const engine = new CodeBuilderNodeSearchEngine([]); + const engine = new NodeSearchEngine([]); const subnodes = engine.getSubnodesForConnectionType('ai_embedding'); @@ -903,7 +898,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }); it('should return empty array for ai_tool (varies by use case)', () => { - const engine = new CodeBuilderNodeSearchEngine([]); + const engine = new NodeSearchEngine([]); const subnodes = engine.getSubnodesForConnectionType('ai_tool'); @@ -911,7 +906,7 @@ describe('CodeBuilderNodeSearchEngine', () => { }); it('should return empty array for unknown connection type', () => { - const engine = new CodeBuilderNodeSearchEngine([]); + const engine = new NodeSearchEngine([]); const subnodes = engine.getSubnodesForConnectionType('unknown_type'); @@ -941,7 +936,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'Window Buffer Memory', outputs: ['ai_memory'], }); - const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel, bufferMemory]); + const engine = new NodeSearchEngine([agentNode, openAiModel, bufferMemory]); const relatedIds = engine.getRelatedSubnodeIds(['@n8n/n8n-nodes-langchain.agent'], new Set()); @@ -977,11 +972,7 @@ describe('CodeBuilderNodeSearchEngine', () => { outputs: ['ai_embedding'], }); - const engine = new CodeBuilderNodeSearchEngine([ - vectorMemory, - inMemoryVectorStore, - openAiEmbeddings, - ]); + const engine = new NodeSearchEngine([vectorMemory, inMemoryVectorStore, openAiEmbeddings]); const relatedIds = engine.getRelatedSubnodeIds( ['@n8n/n8n-nodes-langchain.memoryVectorStore'], @@ -1009,7 +1000,7 @@ describe('CodeBuilderNodeSearchEngine', () => { displayName: 'OpenAI Chat Model', outputs: ['ai_languageModel'], }); - const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel]); + const engine = new NodeSearchEngine([agentNode, openAiModel]); // Exclude OpenAI model from results const relatedIds = engine.getRelatedSubnodeIds( @@ -1025,7 +1016,7 @@ describe('CodeBuilderNodeSearchEngine', () => { name: 'n8n-nodes-base.httpRequest', displayName: 'HTTP Request', }); - const engine = new CodeBuilderNodeSearchEngine([basicNode]); + const engine = new NodeSearchEngine([basicNode]); const relatedIds = engine.getRelatedSubnodeIds(['n8n-nodes-base.httpRequest'], new Set()); diff --git a/packages/@n8n/ai-utilities/src/node-catalog/index.ts b/packages/@n8n/ai-utilities/src/node-catalog/index.ts index 175791a638c..3374ae8689c 100644 --- a/packages/@n8n/ai-utilities/src/node-catalog/index.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/index.ts @@ -11,7 +11,7 @@ export { NodeTypeParser } from './node-type-parser'; export type { ParsedNodeType } from './node-type-parser'; -export { CodeBuilderNodeSearchEngine, SCORE_WEIGHTS } from './search-engine'; +export { NodeSearchEngine, SCORE_WEIGHTS } from './search-engine'; export { searchCodeBuilderNodes, formatNodeResult } from './search'; export type { @@ -80,4 +80,10 @@ export type { RequiredNodeCredentialSlot, } from './dynamic-node-parameters'; -export type { SubnodeRequirement, CodeBuilderNodeSearchResult } from './types'; +export { AI_CONNECTION_TYPES } from './types'; +export type { + AiGatewayNodeMeta, + NodeSearchResult, + SearchableNodeType, + SubnodeRequirement, +} from './types'; diff --git a/packages/@n8n/ai-utilities/src/node-catalog/node-type-parser.ts b/packages/@n8n/ai-utilities/src/node-catalog/node-type-parser.ts index 3ec03d5b3ea..d39174d96b3 100644 --- a/packages/@n8n/ai-utilities/src/node-catalog/node-type-parser.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/node-type-parser.ts @@ -17,7 +17,7 @@ import { type LeanNodeTypeDescription, type VersionDiscriminators, } from './lean-node-type'; -import { CodeBuilderNodeSearchEngine } from './search-engine'; +import { NodeSearchEngine } from './search-engine'; export interface ParsedNodeType { id: string; @@ -34,11 +34,11 @@ export interface ParsedNodeType { export class NodeTypeParser { private nodeTypes: LeanNodeTypeDescription[]; private nodeTypeIndex: Map; - private searchEngine: CodeBuilderNodeSearchEngine; + private searchEngine: NodeSearchEngine; constructor(nodeTypes: INodeTypeDescription[]) { this.nodeTypes = nodeTypes.map(toLeanNodeType); - this.searchEngine = new CodeBuilderNodeSearchEngine(this.nodeTypes); + this.searchEngine = new NodeSearchEngine(this.nodeTypes); this.nodeTypeIndex = this.buildIndex(); } diff --git a/packages/@n8n/ai-utilities/src/node-catalog/search-engine.ts b/packages/@n8n/ai-utilities/src/node-catalog/search-engine.ts index 4d433602eb2..8f25ad7bcd2 100644 --- a/packages/@n8n/ai-utilities/src/node-catalog/search-engine.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/search-engine.ts @@ -1,32 +1,41 @@ /** - * Code Builder Node Search Engine + * Node Search Engine * - * This is a fork of tools/engines/node-search-engine.ts for the code-builder agent. - * It includes additional features for subnode requirements, related subnodes, and - * builder hints that are specific to the code-builder workflow generation approach. + * Shared by the MCP server tool surface (via `NodeCatalogService`) and Instance + * AI, which each carried a near-identical fork until they drifted in their + * connection-type lists. A third, older fork still lives in + * `ai-workflow-builder.ee` and is tracked separately. * - * The original node-search-engine.ts is used by the multi-agent system and has - * different requirements. + * Callers hand over whatever node shape they already hold: a full + * `INodeTypeDescription`, a pre-digested `LeanNodeTypeDescription`, or the + * leaner `SearchableNodeType` an embedding host assembles. See + * {@link SearchableNodeType}. */ import { sublimeSearch } from '@n8n/utils/search/sublime-search'; -import type { BuilderHintInputs, INodeTypeDescription, NodeConnectionType } from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import type { INodeTypeDescription, NodeConnectionType } from 'n8n-workflow'; -import { toLeanNodeType, type LeanNodeTypeDescription } from './lean-node-type'; -import type { CodeBuilderNodeSearchResult, SubnodeRequirement } from './types'; +import type { LeanNodeTypeDescription } from './lean-node-type'; +import { + AI_CONNECTION_TYPES, + type AiConnectionType, + type NodeSearchResult, + type SearchableBuilderHintInputs, + type SearchableNodeType, + type SubnodeRequirement, +} from './types'; -/** Detect already-lean inputs by their marker field so we can skip re-conversion. */ -function isLeanNodeType( - node: INodeTypeDescription | LeanNodeTypeDescription, -): node is LeanNodeTypeDescription { - return 'discriminatorsByVersion' in node; -} - -/** Type guard for NodeConnectionType */ -function isNodeConnectionType(value: string): value is NodeConnectionType { - return Object.values(NodeConnectionTypes).includes(value as NodeConnectionType); -} +/** + * Any node shape the engine accepts. + * + * All three are read directly, with no conversion step: the engine only reads + * the fields in {@link SearchableNodeType}, which every one of them already + * carries. It never reads a lean-only field such as `discriminatorsByVersion`, + * so there is nothing to gain from narrowing an input to one specific shape + * (and no way to do it reliably, since a host shape is free to carry extra + * fields like `properties`). + */ +export type SearchEngineInput = INodeTypeDescription | LeanNodeTypeDescription | SearchableNodeType; /** * Default subnodes for each connection type @@ -95,7 +104,7 @@ function getLatestVersion(version: number | number[]): number { return Array.isArray(version) ? Math.max(...version) : version; } -function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirement[] { +function extractSubnodeRequirements(inputs?: SearchableBuilderHintInputs): SubnodeRequirement[] { if (!inputs) return []; return Object.entries(inputs) @@ -110,8 +119,26 @@ function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirem })); } -function dedupeNodes(nodes: LeanNodeTypeDescription[]): LeanNodeTypeDescription[] { - const dedupeCache: Record = {}; +function toNodeSearchResult(node: SearchableNodeType, score: number): NodeSearchResult { + const subnodeRequirements = extractSubnodeRequirements(node.builderHint?.inputs); + const builderHintMessage = node.builderHint?.searchHint; + + return { + name: node.name, + displayName: node.displayName, + description: node.description ?? 'No description available', + version: getLatestVersion(node.version), + inputs: node.inputs, + outputs: node.outputs, + score, + ...(builderHintMessage && { builderHintMessage }), + ...(subnodeRequirements.length > 0 && { subnodeRequirements }), + ...(node.aiGateway && { aiGateway: node.aiGateway }), + }; +} + +function dedupeNodes(nodes: SearchEngineInput[]): SearchableNodeType[] { + const dedupeCache: Record = {}; nodes.forEach((node) => { const cachedNodeType = dedupeCache[node.name]; if (!cachedNodeType) { @@ -130,20 +157,65 @@ function dedupeNodes(nodes: LeanNodeTypeDescription[]): LeanNodeTypeDescription[ return Object.values(dedupeCache); } +/** + * Cap on cached queries per engine instance. An engine can outlive a great many + * queries (`NodeCatalogService` holds one for the life of the process) and the + * keys are model-authored query strings, so an uncapped map would grow without + * bound. Evicting in insertion order is enough: the loser is recomputed on its + * next use, which costs one search. + */ +const MAX_CACHED_QUERIES = 256; + +function setCapped(cache: Map, key: string, value: NodeSearchResult[]) { + if (cache.size >= MAX_CACHED_QUERIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) cache.delete(oldestKey); + } + cache.set(key, value); +} + +/** + * Copy a cached result before handing it out, so that a caller adding fields to + * what it got back cannot corrupt the cache for the next query. Callers do + * exactly that: the Instance AI nodes tool attaches `discriminators` per result + * and `suggestedNode` per requirement. + * + * The copy is deliberately one level deep per object: the result itself and its + * requirement objects. Nested values (`inputs`, `outputs`, a requirement's + * `displayOptions`) stay shared with the cached entry, because nothing on either + * side writes to them, and deep-copying them would cost on every cache hit to + * guard against a mutation no caller performs. + */ +function cloneSearchResult(result: NodeSearchResult): NodeSearchResult { + return { + ...result, + ...(result.subnodeRequirements + ? { + subnodeRequirements: result.subnodeRequirements.map((requirement) => ({ + ...requirement, + })), + } + : {}), + }; +} + +function cloneSearchResults(results: NodeSearchResult[]): NodeSearchResult[] { + return results.map(cloneSearchResult); +} + /** * Pure business logic for searching nodes * Separated from tool infrastructure for better testability. - * - * Accepts either full `INodeTypeDescription[]` or already-converted - * `LeanNodeTypeDescription[]` — production callers pass lean to avoid the - * conversion cost, tests typically pass full descriptions and the engine - * converts on the way in. */ -export class CodeBuilderNodeSearchEngine { - private readonly nodeTypes: LeanNodeTypeDescription[]; - constructor(nodeTypes: Array) { - const lean = nodeTypes.map((n) => (isLeanNodeType(n) ? n : toLeanNodeType(n))); - this.nodeTypes = dedupeNodes(lean); +export class NodeSearchEngine { + private readonly nodeTypes: SearchableNodeType[]; + + private readonly nameSearchCache = new Map(); + + private readonly connectionSearchCache = new Map(); + + constructor(nodeTypes: SearchEngineInput[]) { + this.nodeTypes = dedupeNodes(nodeTypes); } /** @@ -152,31 +224,28 @@ export class CodeBuilderNodeSearchEngine { * description keyword matching and direct type-name / display-name matching * for nodes the fuzzy search missed. * - * Ported from the Instance AI `NodeSearchEngine` so code-builder / MCP node - * search resolves natural-language multi-word queries (e.g. "telegram send - * message", "ai agent langchain") instead of returning no results — these - * previously failed because sublimeSearch matched the whole query string as a - * single ordered character subsequence against one field. + * The per-term splitting exists because sublimeSearch matches the whole query + * as a single ordered character subsequence against one field, which fails + * natural-language queries like "telegram send message" or "ai agent + * langchain". */ private fuzzySearchNodes( query: string, - candidates: LeanNodeTypeDescription[], + candidates: SearchableNodeType[], limit?: number, - ): Array<{ item: LeanNodeTypeDescription; score: number }> { + ): Array<{ item: SearchableNodeType; score: number }> { const queryLower = query.toLowerCase().trim(); const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 1); const isMultiWord = queryTerms.length > 1; - type ScoredNode = { item: LeanNodeTypeDescription; score: number }; + type ScoredNode = { item: SearchableNodeType; score: number }; let searchResults: ScoredNode[]; // For multi-word queries, search each term individually then merge. - // sublimeSearch breaks down on multi-word queries because it tries to - // fuzzy-match the entire string as one character sequence. if (isMultiWord) { const scoreMap = new Map(); for (const term of queryTerms) { - const termResults = sublimeSearch( + const termResults = sublimeSearch( term, candidates, NODE_SEARCH_KEYS, @@ -190,7 +259,7 @@ export class CodeBuilderNodeSearchEngine { } searchResults = [...scoreMap.values()]; } else { - searchResults = sublimeSearch(query, candidates, NODE_SEARCH_KEYS); + searchResults = sublimeSearch(query, candidates, NODE_SEARCH_KEYS); } const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name)); @@ -264,34 +333,28 @@ export class CodeBuilderNodeSearchEngine { query: string, limit: number = 20, nodeFilter?: (nodeId: string) => boolean, - ): CodeBuilderNodeSearchResult[] { - const nodeTypes = nodeFilter - ? this.nodeTypes.filter((node) => nodeFilter(node.name)) - : this.nodeTypes; + ): NodeSearchResult[] { + // A predicate can't be part of a cache key, so filtered searches skip the + // cache rather than risk serving another filter's results. + if (nodeFilter) { + return this.fuzzySearchNodes( + query, + this.nodeTypes.filter((node) => nodeFilter(node.name)), + limit, + ) + .slice(0, limit) + .map(({ item, score }) => toNodeSearchResult(item, score)); + } - return this.fuzzySearchNodes(query, nodeTypes, limit) + const cacheKey = `${query}\0${limit}`; + const cached = this.nameSearchCache.get(cacheKey); + if (cached) return cloneSearchResults(cached); + + const results = this.fuzzySearchNodes(query, this.nodeTypes, limit) .slice(0, limit) - .map( - ({ - item, - score, - }: { item: LeanNodeTypeDescription; score: number }): CodeBuilderNodeSearchResult => { - const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs); - return { - name: item.name, - displayName: item.displayName, - description: item.description ?? 'No description available', - version: getLatestVersion(item.version), - inputs: item.inputs, - outputs: item.outputs, - score, - ...(item.builderHint?.searchHint && { - builderHintMessage: item.builderHint.searchHint, - }), - ...(subnodeRequirements.length > 0 && { subnodeRequirements }), - }; - }, - ); + .map(({ item, score }) => toNodeSearchResult(item, score)); + setCapped(this.nameSearchCache, cacheKey, results); + return cloneSearchResults(results); } /** @@ -306,38 +369,29 @@ export class CodeBuilderNodeSearchEngine { connectionType: NodeConnectionType, limit: number = 20, nameFilter?: string, - ): CodeBuilderNodeSearchResult[] { + ): NodeSearchResult[] { + const cacheKey = `${connectionType}\0${limit}\0${nameFilter ?? ''}`; + const cached = this.connectionSearchCache.get(cacheKey); + if (cached) return cloneSearchResults(cached); + // First, filter by connection type const nodesWithConnectionType = this.nodeTypes .map((nodeType) => { const connectionScore = this.getConnectionScore(nodeType, connectionType); return connectionScore > 0 ? { nodeType, connectionScore } : null; }) - .filter((result): result is { nodeType: LeanNodeTypeDescription; connectionScore: number } => + .filter((result): result is { nodeType: SearchableNodeType; connectionScore: number } => Boolean(result), ); // If no name filter, return connection matches sorted by score if (!nameFilter) { - return nodesWithConnectionType + const results = nodesWithConnectionType .sort((a, b) => b.connectionScore - a.connectionScore) .slice(0, limit) - .map(({ nodeType, connectionScore }) => { - const subnodeRequirements = extractSubnodeRequirements(nodeType.builderHint?.inputs); - return { - name: nodeType.name, - displayName: nodeType.displayName, - version: getLatestVersion(nodeType.version), - description: nodeType.description ?? 'No description available', - inputs: nodeType.inputs, - outputs: nodeType.outputs, - score: connectionScore, - ...(nodeType.builderHint?.searchHint && { - builderHintMessage: nodeType.builderHint.searchHint, - }), - ...(subnodeRequirements.length > 0 && { subnodeRequirements }), - }; - }); + .map(({ nodeType, connectionScore }) => toNodeSearchResult(nodeType, connectionScore)); + setCapped(this.connectionSearchCache, cacheKey, results); + return cloneSearchResults(results); } // Apply name filter using the same multi-word-aware fuzzy search as searchByName @@ -345,29 +399,15 @@ export class CodeBuilderNodeSearchEngine { const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly, limit); // Combine connection score with name score - return nameFilteredResults - .slice(0, limit) - .map(({ item, score: nameScore }: { item: LeanNodeTypeDescription; score: number }) => { - const connectionResult = nodesWithConnectionType.find( - (result) => result.nodeType.name === item.name, - ); - const connectionScore = connectionResult?.connectionScore ?? 0; - const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs); - - return { - name: item.name, - version: getLatestVersion(item.version), - displayName: item.displayName, - description: item.description ?? 'No description available', - inputs: item.inputs, - outputs: item.outputs, - score: connectionScore + nameScore, - ...(item.builderHint?.searchHint && { - builderHintMessage: item.builderHint.searchHint, - }), - ...(subnodeRequirements.length > 0 && { subnodeRequirements }), - }; - }); + const results = nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => { + const connectionResult = nodesWithConnectionType.find( + (result) => result.nodeType.name === item.name, + ); + const connectionScore = connectionResult?.connectionScore ?? 0; + return toNodeSearchResult(item, connectionScore + nameScore); + }); + setCapped(this.connectionSearchCache, cacheKey, results); + return cloneSearchResults(results); } /** @@ -375,7 +415,7 @@ export class CodeBuilderNodeSearchEngine { * @param result - Single search result * @returns XML-formatted string */ - formatResult(result: CodeBuilderNodeSearchResult): string { + formatResult(result: NodeSearchResult): string { const parts = [ ' ', ` ${result.name}`, @@ -385,6 +425,16 @@ export class CodeBuilderNodeSearchEngine { ` ${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}`, ]; + // Flag n8n Connect coverage so the model can prefer it over comparable + // alternatives when the user has not named a specific tool. + if (result.aiGateway) { + const minVersion = + result.aiGateway.minVersion !== undefined + ? ` min_version="${result.aiGateway.minVersion}"` + : ''; + parts.push(` `); + } + // Add builder hint message if present if (result.builderHintMessage) { parts.push(` ${result.builderHintMessage}`); @@ -477,7 +527,7 @@ export class CodeBuilderNodeSearchEngine { * @param nodeId - The node ID to look up * @returns The node type description or undefined */ - getNodeType(nodeId: string): LeanNodeTypeDescription | undefined { + getNodeType(nodeId: string): SearchableNodeType | undefined { return this.nodeTypes.find((n) => n.name === nodeId); } @@ -488,7 +538,7 @@ export class CodeBuilderNodeSearchEngine { * @returns Score indicating match quality */ private getConnectionScore( - nodeType: LeanNodeTypeDescription, + nodeType: SearchableNodeType, connectionType: NodeConnectionType, ): number { const outputs = nodeType.outputs; @@ -521,10 +571,7 @@ export class CodeBuilderNodeSearchEngine { * Get all available AI connection types * @returns Array of AI connection types */ - static getAiConnectionTypes(): NodeConnectionType[] { - return Object.values(NodeConnectionTypes).filter( - (type): type is NodeConnectionType => - isNodeConnectionType(type) && CodeBuilderNodeSearchEngine.isAiConnectionType(type), - ); + static getAiConnectionTypes(): readonly AiConnectionType[] { + return AI_CONNECTION_TYPES; } } diff --git a/packages/@n8n/ai-utilities/src/node-catalog/types.ts b/packages/@n8n/ai-utilities/src/node-catalog/types.ts index 1c7b77e8cdb..563ffea68a7 100644 --- a/packages/@n8n/ai-utilities/src/node-catalog/types.ts +++ b/packages/@n8n/ai-utilities/src/node-catalog/types.ts @@ -5,7 +5,117 @@ * `code-builder/types.ts` (the parts not tied to the agent runtime). */ -import type { IDisplayOptions, INodeTypeDescription } from 'n8n-workflow'; +import type { AINodeConnectionType, IDisplayOptions } from 'n8n-workflow'; + +/** + * Every AI connection type, as a literal tuple so callers can build a zod enum + * from it. Spelled out rather than derived because `z.enum` needs literals. + * + * Two compile-time guards keep it in step with n8n-workflow, which owns the + * canonical list. `satisfies` rejects a member that is not a real connection + * type, and {@link AiConnectionTypesAreExhaustive} rejects a real one that is + * missing. Both directions matter: this list drifted in each of them before. + */ +export const AI_CONNECTION_TYPES = [ + 'ai_agent', + 'ai_chain', + 'ai_document', + 'ai_embedding', + 'ai_languageModel', + 'ai_memory', + 'ai_outputParser', + 'ai_reranker', + 'ai_retriever', + 'ai_textSplitter', + 'ai_tool', + 'ai_vectorStore', +] as const satisfies readonly AINodeConnectionType[]; + +export type AiConnectionType = (typeof AI_CONNECTION_TYPES)[number]; + +type AssertNever = T; + +/** + * Compile-time proof that {@link AI_CONNECTION_TYPES} lists every `ai_*` type. + * If n8n-workflow gains one that is missing above, `Exclude` stops resolving to + * `never` and this fails its constraint. Type-only, so it costs no runtime code. + */ +export type AiConnectionTypesAreExhaustive = AssertNever< + Exclude +>; + +/** + * Metadata about how a node is reachable via the AI Gateway (n8n Connect). + * Attached to node results only when the instance is licensed for the gateway + * AND the node is listed in the gateway config. Absence means either + * "not licensed" or "not supported"; consumers treat both the same. + * + * Search results carry it so a model can prefer a covered node when the user + * has not named a specific integration. + * + * `operations` mirrors the gateway config's `supportedActions[nodeName]`: a map + * from resource name to allowed operations. Nodes without a resource dimension + * use the marker key `'__operation_only__'`. + */ +export interface AiGatewayNodeMeta { + supported: true; + operations?: Record; + minVersion?: number; + hiddenProperties?: string[]; +} + +/** + * Node connection lists as the search engine sees them: either a list of + * connection types / configuration objects, or an expression string. + * + * Deliberately loose. The engine only tests membership and serializes these, so + * this accepts both `INodeTypeDescription['inputs']` and the plain `string[]` + * shape hosts hand over without either side casting. + */ +export type SearchableConnections = ReadonlyArray | string; + +/** Availability and requirement config for one AI input in `builderHint.inputs`. */ +export interface SearchableBuilderHintInputConfig { + required: boolean; + displayOptions?: IDisplayOptions | Record; +} + +/** + * `builderHint.inputs`, keyed by connection type. + * + * String-keyed rather than keyed by `AINodeConnectionType` so that hosts + * supplying a plain record satisfy it alongside n8n-workflow's literal-keyed + * `BuilderHintInputs`. The engine only reads `Object.entries`, so the narrower + * key type buys it nothing. + */ +export type SearchableBuilderHintInputs = Record< + string, + SearchableBuilderHintInputConfig | undefined +>; + +/** + * The node type shape the search engine reads. + * + * Only the fields the engine touches are required. The rest are optional so + * that both a full `INodeTypeDescription`, the pre-digested + * `LeanNodeTypeDescription`, and the leaner shape an embedding host supplies all + * satisfy it without conversion. + */ +export interface SearchableNodeType { + name: string; + displayName: string; + description: string; + version: number | number[]; + inputs: SearchableConnections; + outputs: SearchableConnections; + /** Only the `alias` array is consumed, by sublimeSearch. */ + codex?: { alias?: string[] }; + aiGateway?: AiGatewayNodeMeta; + builderHint?: { + searchHint?: string; + inputs?: SearchableBuilderHintInputs; + }; +} /** * Represents a subnode requirement for AI nodes. @@ -17,25 +127,33 @@ export interface SubnodeRequirement { /** Whether this subnode is required */ required: boolean; /** Conditions under which this subnode is required (e.g., when hasOutputParser is true) */ - displayOptions?: IDisplayOptions; + displayOptions?: IDisplayOptions | Record; + /** + * Preferred node to satisfy this requirement, matching a provider the user + * already has a credential for. Set for ai_languageModel when an LLM + * credential exists; use it instead of the generic default. + */ + suggestedNode?: string; } /** * Node search result with scoring and subnode requirements. * - * This is an extended version of the base NodeSearchResult that includes - * builder hints and subnode requirements for the code-builder agent. + * Extends the plain node fields with a relevance score, builder hints, and + * subnode requirements. */ -export interface CodeBuilderNodeSearchResult { +export interface NodeSearchResult { name: string; displayName: string; description: string; version: number; score: number; - inputs: INodeTypeDescription['inputs']; - outputs: INodeTypeDescription['outputs']; - /** General hint message for workflow builders (from node-level builderHint.searchHint) */ + inputs: SearchableConnections; + outputs: SearchableConnections; + /** General hint message for workflow builders (from `builderHint.searchHint`) */ builderHintMessage?: string; /** Subnode requirements extracted from builderHint.inputs */ subnodeRequirements?: SubnodeRequirement[]; + /** Present when the node is reachable via n8n Connect on this instance. */ + aiGateway?: AiGatewayNodeMeta; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/types.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/types.ts index 3e055a5d01c..ef4195df5a8 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/types.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/types.ts @@ -8,48 +8,7 @@ import type { Callbacks } from '@langchain/core/callbacks/manager'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { Logger } from '@n8n/backend-common'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; -import type { - IDisplayOptions, - INodeTypeDescription, - ITelemetryTrackProperties, -} from 'n8n-workflow'; - -// ============================================================================ -// Forked types from types/nodes.ts for code-builder specific features -// ============================================================================ - -/** - * Represents a subnode requirement for AI nodes - * Extracted from builderHint.inputs on node type descriptions - */ -export interface SubnodeRequirement { - /** The connection type (e.g., 'ai_languageModel', 'ai_memory') */ - connectionType: string; - /** Whether this subnode is required */ - required: boolean; - /** Conditions under which this subnode is required (e.g., when hasOutputParser is true) */ - displayOptions?: IDisplayOptions; -} - -/** - * Node search result with scoring and subnode requirements - * - * This is an extended version of the base NodeSearchResult that includes - * builder hints and subnode requirements for the code-builder agent. - */ -export interface CodeBuilderNodeSearchResult { - name: string; - displayName: string; - description: string; - version: number; - score: number; - inputs: INodeTypeDescription['inputs']; - outputs: INodeTypeDescription['outputs']; - /** General hint message for workflow builders (from node-level builderHint.searchHint) */ - builderHintMessage?: string; - /** Subnode requirements extracted from builderHint.inputs */ - subnodeRequirements?: SubnodeRequirement[]; -} +import type { INodeTypeDescription, ITelemetryTrackProperties } from 'n8n-workflow'; // ============================================================================ // Code Builder Agent Types diff --git a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts index b332f08956b..bcd6f8362df 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts @@ -495,7 +495,7 @@ function coerceCodex(value: unknown): SearchableNodeDescription['codex'] | undef function coerceBuilderHint(value: unknown): SearchableNodeDescription['builderHint'] | undefined { if (!isRecord(value)) return undefined; const hint: NonNullable = {}; - if (typeof value.message === 'string') hint.message = value.message; + if (typeof value.searchHint === 'string') hint.searchHint = value.searchHint; const inputs = coerceHintPortMap(value.inputs); if (inputs) hint.inputs = inputs; const outputs = coerceHintPortMap(value.outputs); diff --git a/packages/@n8n/instance-ai/src/tools/nodes.tool.ts b/packages/@n8n/instance-ai/src/tools/nodes.tool.ts index 372995517c3..9b7434e074e 100644 --- a/packages/@n8n/instance-ai/src/tools/nodes.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/nodes.tool.ts @@ -2,13 +2,17 @@ * Consolidated nodes tool — list, search, describe, type-definition, suggested, explore-resources. */ import { Tool } from '@n8n/agents'; -import { categoryList, suggestedNodesData } from '@n8n/ai-utilities/node-catalog'; +import { + AI_CONNECTION_TYPES, + NodeSearchEngine, + categoryList, + suggestedNodesData, + type SearchableNodeType, +} from '@n8n/ai-utilities/node-catalog'; import { z } from 'zod'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import type { InstanceAiContext } from '../types'; -import { NodeSearchEngine } from './nodes/node-search-engine'; -import { AI_CONNECTION_TYPES, type SearchableNodeType } from './nodes/node-search-engine.types'; import { pickPreferredChatModelNode } from './nodes/preferred-chat-model'; import { buildCredentialMap } from './workflows/resolve-credentials'; diff --git a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.ts b/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.ts deleted file mode 100644 index 4a915b9d698..00000000000 --- a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * Node Search Engine - * - * Ported from ai-workflow-builder.ee/code-builder/engines/code-builder-node-search-engine.ts - * into the instance-ai package. All n8n-workflow dependencies have been - * replaced with local types so the package stays decoupled. - */ - -import { sublimeSearch } from '@n8n/utils/search/sublime-search'; - -import type { - BuilderHintInputs, - NodeSearchResult, - SearchableNodeType, - SubnodeRequirement, -} from './node-search-engine.types'; -import { AI_CONNECTION_TYPES } from './node-search-engine.types'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Default subnodes for each connection type. - * These are sensible defaults shown in search results. - */ -const DEFAULT_SUBNODES: Record = { - ai_languageModel: ['@n8n/n8n-nodes-langchain.lmChatOpenAi'], - ai_memory: ['@n8n/n8n-nodes-langchain.memoryBufferWindow'], - ai_embedding: ['@n8n/n8n-nodes-langchain.embeddingsOpenAi'], - ai_vectorStore: ['@n8n/n8n-nodes-langchain.vectorStoreInMemory'], - // ai_tool is intentionally excluded - varies by use case -}; - -/** - * Search keys configuration for sublimeSearch. - * Keys are ordered by importance with corresponding weights. - */ -const NODE_SEARCH_KEYS = [ - { key: 'displayName', weight: 1.5 }, - { key: 'name', weight: 1.3 }, - { key: 'codex.alias', weight: 1.0 }, -]; - -/** - * Extract the short type name from a full node name. - * e.g., "n8n-nodes-base.set" -> "set" - */ -function getTypeName(nodeName: string): string { - if (!nodeName) return ''; - const lastDotIndex = nodeName.lastIndexOf('.'); - return lastDotIndex >= 0 ? nodeName.substring(lastDotIndex + 1) : nodeName; -} - -/** Scoring weights for connection type matching. */ -export const SCORE_WEIGHTS = { - CONNECTION_EXACT: 100, - CONNECTION_IN_EXPRESSION: 50, -} as const; - -function getLatestVersion(version: number | number[]): number { - return Array.isArray(version) ? Math.max(...version) : version; -} - -/** Extract subnode requirements from builderHint.inputs. */ -function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirement[] { - if (!inputs) return []; - - return Object.entries(inputs) - .filter((entry): entry is [string, NonNullable<(typeof entry)[1]>] => entry[1] !== null) - .map(([connectionType, config]) => ({ - connectionType, - required: config.required, - ...(config.displayOptions && { displayOptions: config.displayOptions }), - })); -} - -function getBuilderHintMessage( - builderHint?: SearchableNodeType['builderHint'], -): string | undefined { - return builderHint?.message ?? builderHint?.searchHint; -} - -function toNodeSearchResult(node: SearchableNodeType, score: number): NodeSearchResult { - const subnodeRequirements = extractSubnodeRequirements(node.builderHint?.inputs); - const builderHintMessage = getBuilderHintMessage(node.builderHint); - - return { - name: node.name, - displayName: node.displayName, - description: node.description ?? 'No description available', - version: getLatestVersion(node.version), - inputs: node.inputs, - outputs: node.outputs, - score, - ...(builderHintMessage && { builderHintMessage }), - ...(subnodeRequirements.length > 0 && { subnodeRequirements }), - ...(node.aiGateway && { aiGateway: node.aiGateway }), - }; -} - -function dedupeNodes(nodes: SearchableNodeType[]): SearchableNodeType[] { - const dedupeCache: Record = {}; - nodes.forEach((node) => { - const cachedNodeType = dedupeCache[node.name]; - if (!cachedNodeType) { - dedupeCache[node.name] = node; - return; - } - - const cachedVersion = getLatestVersion(cachedNodeType.version); - const nextVersion = getLatestVersion(node.version); - - if (nextVersion > cachedVersion) { - dedupeCache[node.name] = node; - } - }); - - return Object.values(dedupeCache); -} - -function cloneSearchResult(result: NodeSearchResult): NodeSearchResult { - return { - ...result, - ...(result.subnodeRequirements - ? { - subnodeRequirements: result.subnodeRequirements.map((requirement) => ({ - ...requirement, - })), - } - : {}), - }; -} - -function cloneSearchResults(results: NodeSearchResult[]): NodeSearchResult[] { - return results.map(cloneSearchResult); -} - -function descriptionMatchScore( - description: string | undefined, - queryLower: string, - queryTerms: string[], -): number { - if (!description) return 0; - const descriptionLower = description.toLowerCase(); - if (queryTerms.length === 0) return descriptionLower.includes(queryLower) ? 1 : 0; - let matches = 0; - for (const term of queryTerms) { - if (descriptionLower.includes(term)) matches += 1; - } - return matches; -} - -// --------------------------------------------------------------------------- -// Engine -// --------------------------------------------------------------------------- - -/** - * Pure business logic for searching nodes. - * Separated from tool infrastructure for better testability. - */ -export class NodeSearchEngine { - private readonly nodeTypes: SearchableNodeType[]; - - private readonly nameSearchCache = new Map(); - - private readonly connectionSearchCache = new Map(); - - constructor(nodeTypes: SearchableNodeType[]) { - this.nodeTypes = dedupeNodes(nodeTypes); - } - - /** - * Fuzzy-search a list of nodes by query, handling multi-word queries by - * splitting into individual terms and merging results. Falls back to - * direct type-name / display-name matching for nodes the fuzzy search missed. - */ - private fuzzySearchNodes( - query: string, - candidates: SearchableNodeType[], - limit?: number, - ): Array<{ item: SearchableNodeType; score: number }> { - const queryLower = query.toLowerCase().trim(); - const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 1); - const isMultiWord = queryTerms.length > 1; - - // For multi-word queries, search each term individually then merge. - // sublimeSearch breaks down on multi-word queries because it tries to - // fuzzy-match the entire string as one character sequence. - type ScoredNode = { item: SearchableNodeType; score: number }; - let searchResults: ScoredNode[]; - - if (isMultiWord) { - const scoreMap = new Map(); - for (const term of queryTerms) { - const termResults = sublimeSearch( - term, - candidates, - NODE_SEARCH_KEYS, - ).slice(0, limit); - for (const r of termResults) { - const existing = scoreMap.get(r.item.name); - if (!existing || r.score > existing.score) { - scoreMap.set(r.item.name, { item: r.item, score: r.score }); - } - } - } - searchResults = [...scoreMap.values()]; - } else { - searchResults = sublimeSearch(query, candidates, NODE_SEARCH_KEYS); - } - - const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name)); - const remainingDescriptionMatches = - limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, limit - searchResults.length); - if (remainingDescriptionMatches > 0) { - const descriptionResults: ScoredNode[] = []; - for (const item of candidates) { - if (fuzzyResultNames.has(item.name)) continue; - const score = descriptionMatchScore(item.description, queryLower, queryTerms); - if (score === 0) continue; - descriptionResults.push({ item, score }); - fuzzyResultNames.add(item.name); - if (descriptionResults.length >= remainingDescriptionMatches) break; - } - searchResults = [...searchResults, ...descriptionResults]; - } - - // Direct type name / display name match — catches nodes fuzzy search missed - const typeNameMatches = candidates - .filter((node) => { - if (fuzzyResultNames.has(node.name)) return false; - const typeName = getTypeName(node.name).toLowerCase(); - const displayName = node.displayName.toLowerCase(); - if (typeName === queryLower || displayName === queryLower) return true; - return queryTerms.some( - (term) => - typeName === term || - typeName.includes(term) || - displayName === term || - displayName.includes(term), - ); - }) - .map((item) => ({ item, score: 0 })); - - // Merge and sort: exact type/display name matches first, then by fuzzy score - const allResults = [...searchResults, ...typeNameMatches]; - allResults.sort((a, b) => { - const typeNameA = getTypeName(a.item.name).toLowerCase(); - const displayNameA = a.item.displayName.toLowerCase(); - const typeNameB = getTypeName(b.item.name).toLowerCase(); - const displayNameB = b.item.displayName.toLowerCase(); - const exactA = - typeNameA === queryLower || - displayNameA === queryLower || - queryTerms.some((t) => typeNameA === t || displayNameA === t); - const exactB = - typeNameB === queryLower || - displayNameB === queryLower || - queryTerms.some((t) => typeNameB === t || displayNameB === t); - if (exactA && !exactB) return -1; - if (!exactA && exactB) return 1; - return b.score - a.score; - }); - - return allResults; - } - - /** - * Search nodes by name, display name, or description. - * Always returns the latest version of a node. - * @param query - The search query string - * @param limit - Maximum number of results to return - * @returns Array of matching nodes sorted by relevance - */ - searchByName(query: string, limit: number = 20): NodeSearchResult[] { - const cacheKey = `${query}\0${limit}`; - const cached = this.nameSearchCache.get(cacheKey); - if (cached) return cloneSearchResults(cached); - - const results = this.fuzzySearchNodes(query, this.nodeTypes, limit) - .slice(0, limit) - .map(({ item, score }) => toNodeSearchResult(item, score)); - this.nameSearchCache.set(cacheKey, results); - return cloneSearchResults(results); - } - - /** - * Search for sub-nodes that output a specific connection type. - * Always returns the latest version of a node. - * @param connectionType - The connection type to search for - * @param limit - Maximum number of results - * @param nameFilter - Optional name filter - * @returns Array of matching sub-nodes - */ - searchByConnectionType( - connectionType: string, - limit: number = 20, - nameFilter?: string, - ): NodeSearchResult[] { - const cacheKey = `${connectionType}\0${limit}\0${nameFilter ?? ''}`; - const cached = this.connectionSearchCache.get(cacheKey); - if (cached) return cloneSearchResults(cached); - - // First, filter by connection type - const nodesWithConnectionType = this.nodeTypes - .map((nodeType) => { - const connectionScore = this.getConnectionScore(nodeType, connectionType); - return connectionScore > 0 ? { nodeType, connectionScore } : null; - }) - .filter((result): result is { nodeType: SearchableNodeType; connectionScore: number } => - Boolean(result), - ); - - // If no name filter, return connection matches sorted by score - if (!nameFilter) { - const results = nodesWithConnectionType - .sort((a, b) => b.connectionScore - a.connectionScore) - .slice(0, limit) - .map(({ nodeType, connectionScore }) => toNodeSearchResult(nodeType, connectionScore)); - this.connectionSearchCache.set(cacheKey, results); - return cloneSearchResults(results); - } - - // Apply name filter using the same multi-word-aware fuzzy search as searchByName - const nodeTypesOnly = nodesWithConnectionType.map((result) => result.nodeType); - const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly, limit); - - // Combine connection score with name score - const results = nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => { - const connectionResult = nodesWithConnectionType.find( - (result) => result.nodeType.name === item.name, - ); - const connectionScore = connectionResult?.connectionScore ?? 0; - return toNodeSearchResult(item, connectionScore + nameScore); - }); - this.connectionSearchCache.set(cacheKey, results); - return cloneSearchResults(results); - } - - /** - * Format search results for tool output. - * @param result - Single search result - * @returns XML-formatted string - */ - formatResult(result: NodeSearchResult): string { - const parts = [ - ' ', - ` ${result.name}`, - ` ${result.version}`, - ` ${result.description}`, - ` ${typeof result.inputs === 'object' ? JSON.stringify(result.inputs) : result.inputs}`, - ` ${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}`, - ]; - - // Flag n8n Connect coverage so the model can prefer it over comparable - // alternatives when the user has not named a specific tool. - if (result.aiGateway) { - const minVersion = - result.aiGateway.minVersion !== undefined - ? ` min_version="${result.aiGateway.minVersion}"` - : ''; - parts.push(` `); - } - - // Add builder hint message if present - if (result.builderHintMessage) { - parts.push(` ${result.builderHintMessage}`); - } - - // Add subnode requirements if present - if (result.subnodeRequirements && result.subnodeRequirements.length > 0) { - parts.push(' '); - for (const req of result.subnodeRequirements) { - const requiredStr = req.required ? 'required' : 'optional'; - if (req.displayOptions) { - parts.push( - ` ${JSON.stringify(req.displayOptions)}`, - ); - } else { - parts.push( - ' ', - ); - } - } - parts.push(' '); - } - - parts.push(' '); - - return '\n' + parts.join('\n'); - } - - /** - * Get subnodes that output a specific connection type. - * Uses default subnodes for common connection types. - * @param connectionType - The connection type to find subnodes for - * @returns Array of node IDs that can satisfy this connection type - */ - getSubnodesForConnectionType(connectionType: string): string[] { - // Return defaults for this connection type if available - // ai_tool is excluded - it varies by use case - return DEFAULT_SUBNODES[connectionType] ?? []; - } - - /** - * Recursively collect related subnode IDs for nodes with builderHint.inputs. - * For each connection type requirement, finds default subnodes and their - * transitive dependencies. - * @param nodeIds - Initial node IDs to get related subnodes for - * @param excludeNodeIds - Node IDs to exclude (already shown in results) - * @returns Set of related node IDs - */ - getRelatedSubnodeIds(nodeIds: string[], excludeNodeIds: Set): Set { - const allRelated = new Set(); - const visited = new Set(excludeNodeIds); - - // Mark initial nodes as visited - for (const nodeId of nodeIds) { - visited.add(nodeId); - } - - // Process queue of nodes to check for subnode requirements - const queue = [...nodeIds]; - - while (queue.length > 0) { - const currentNodeId = queue.shift()!; - const nodeType = this.nodeTypes.find((n) => n.name === currentNodeId); - if (!nodeType?.builderHint?.inputs) continue; - - // For each connection type in builderHint.inputs, get default subnodes - for (const connectionType of Object.keys(nodeType.builderHint.inputs)) { - const subnodeIds = this.getSubnodesForConnectionType(connectionType); - - for (const subnodeId of subnodeIds) { - if (visited.has(subnodeId)) continue; - - visited.add(subnodeId); - allRelated.add(subnodeId); - - // Add to queue for recursive processing - queue.push(subnodeId); - } - } - } - - return allRelated; - } - - /** - * Get a node type by ID. - * @param nodeId - The node ID to look up - * @returns The node type description or undefined - */ - getNodeType(nodeId: string): SearchableNodeType | undefined { - return this.nodeTypes.find((n) => n.name === nodeId); - } - - /** - * Check if a node has a specific connection type in outputs. - * @param nodeType - Node type to check - * @param connectionType - Connection type to look for - * @returns Score indicating match quality - */ - private getConnectionScore(nodeType: SearchableNodeType, connectionType: string): number { - const outputs = nodeType.outputs; - - if (Array.isArray(outputs)) { - // Direct array match - if (outputs.includes(connectionType)) { - return SCORE_WEIGHTS.CONNECTION_EXACT; - } - } else if (typeof outputs === 'string') { - // Expression string - check if it contains the connection type - if (outputs.includes(connectionType)) { - return SCORE_WEIGHTS.CONNECTION_IN_EXPRESSION; - } - } - - return 0; - } - - /** - * Validate if a connection type is an AI connection type. - * @param connectionType - Connection type to validate - * @returns True if it's an AI connection type - */ - static isAiConnectionType(connectionType: string): boolean { - return connectionType.startsWith('ai_'); - } - - /** - * Get all available AI connection types. - * @returns Array of AI connection type strings - */ - static getAiConnectionTypes(): readonly string[] { - return AI_CONNECTION_TYPES; - } -} diff --git a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts b/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts deleted file mode 100644 index de7d7b8134e..00000000000 --- a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Local types for the NodeSearchEngine. - * - * These are lightweight copies of the n8n-workflow types used by the search - * engine. The instance-ai package intentionally does NOT depend on - * n8n-workflow, so we keep a minimal, self-contained subset here. - */ - -import type { AiGatewayNodeMeta } from '../../types'; - -// --------------------------------------------------------------------------- -// AI connection types -// --------------------------------------------------------------------------- - -/** All AI connection type values (mirrors NodeConnectionTypes from n8n-workflow). */ -export const AI_CONNECTION_TYPES = [ - 'ai_agent', - 'ai_chain', - 'ai_document', - 'ai_embedding', - 'ai_languageModel', - 'ai_memory', - 'ai_outputParser', - 'ai_retriever', - 'ai_textSplitter', - 'ai_tool', - 'ai_vectorRetriever', - 'ai_vectorStore', -] as const; - -export type AiConnectionType = (typeof AI_CONNECTION_TYPES)[number]; - -// --------------------------------------------------------------------------- -// Builder hint types -// --------------------------------------------------------------------------- - -/** Configuration for a single AI subnode input in builderHint.inputs. */ -export interface BuilderHintInputConfig { - /** Whether this AI input is required. */ - required: boolean; - /** Conditions under which this input should be available / required. */ - displayOptions?: Record; -} - -/** Maps AI input types to their configuration. */ -export type BuilderHintInputs = Partial>; - -// --------------------------------------------------------------------------- -// Searchable node type -// --------------------------------------------------------------------------- - -/** - * Subset of INodeTypeDescription used by the search engine. - * - * Only the fields that the engine actually reads are included so that - * callers can satisfy this interface without pulling in the full - * INodeTypeDescription from n8n-workflow. - */ -export interface SearchableNodeType { - name: string; - displayName: string; - description: string; - version: number | number[]; - inputs: string[] | string; - outputs: string[] | string; - /** Present when the node is reachable via n8n Connect on this instance. */ - aiGateway?: AiGatewayNodeMeta; - codex?: { - alias?: string[]; - }; - builderHint?: { - message?: string; - searchHint?: string; - inputs?: BuilderHintInputs; - /** - * Multi-line content variations emitted into generated `.d.ts` only; - * intentionally ignored by the search engine to keep results lightweight. - */ - extraTypeDefContent?: Array<{ - content: string; - displayOptions?: { - show?: Record; - hide?: Record; - }; - }>; - }; -} - -// --------------------------------------------------------------------------- -// Search result types -// --------------------------------------------------------------------------- - -/** Subnode requirement extracted from builderHint.inputs. */ -export interface SubnodeRequirement { - /** The connection type (e.g., 'ai_languageModel', 'ai_memory'). */ - connectionType: string; - /** Whether this subnode is required. */ - required: boolean; - /** Conditions under which this subnode is required. */ - displayOptions?: Record; - /** - * Preferred node to satisfy this requirement, matching a provider the user - * already has a credential for. Set for ai_languageModel when an LLM - * credential exists; use it instead of the generic default. - */ - suggestedNode?: string; -} - -/** Node search result with scoring and subnode requirements. */ -export interface NodeSearchResult { - name: string; - displayName: string; - description: string; - version: number; - score: number; - inputs: string[] | string; - outputs: string[] | string; - /** General hint message for workflow builders (from builderHint.message/searchHint). */ - builderHintMessage?: string; - /** Subnode requirements extracted from builderHint.inputs. */ - subnodeRequirements?: SubnodeRequirement[]; - /** Present when the node is reachable via n8n Connect on this instance. */ - aiGateway?: AiGatewayNodeMeta; -} diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index fb17771d1f2..6d00fc2f65c 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -12,6 +12,7 @@ import type { Telemetry, Workspace, } from '@n8n/agents'; +import type { AiGatewayNodeMeta } from '@n8n/ai-utilities/node-catalog'; import type { AgentJsonConfig, AgentSkill, @@ -233,21 +234,11 @@ export interface NodeSummary { } /** - * Metadata about how a node is reachable via the AI Gateway (n8n Connect). - * Attached to node results only when the instance is licensed for the - * gateway AND the node is listed in the gateway config. Absence means either - * "not licensed" or "not supported" — consumers treat both the same. - * - * `operations` mirrors the gateway config's `supportedActions[nodeName]`: - * a map from resource name to allowed operations. Nodes without a resource - * dimension use the marker key `'__operation_only__'`. + * Re-exported from `@n8n/ai-utilities/node-catalog`, which owns the definition + * because the shared node search engine reads it. Kept exported here so this + * package's consumers have one import site for the Instance AI types. */ -export interface AiGatewayNodeMeta { - supported: true; - operations?: Record; - minVersion?: number; - hiddenProperties?: string[]; -} +export type { AiGatewayNodeMeta }; export interface NodeDescription extends NodeSummary { properties: Array<{ @@ -623,7 +614,7 @@ export interface SearchableNodeDescription { outputs: string[] | string; codex?: { alias?: string[] }; builderHint?: { - message?: string; + searchHint?: string; inputs?: Record }>; outputs?: Record }>; }; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index ee0143e2d26..0ac474f9ea8 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -2537,7 +2537,7 @@ export class InstanceAiAdapterService { if (n.builderHint) { result.builderHint = {}; if (n.builderHint.searchHint) { - result.builderHint.message = n.builderHint.searchHint; + result.builderHint.searchHint = n.builderHint.searchHint; } if (n.builderHint.inputs) { const inputs: Record<