refactor(core): Consolidate the node search engine into @n8n/ai-utilities (no-changelog) (#35546)

This commit is contained in:
Ricardo Espinoza 2026-08-13 09:28:06 +00:00 committed by GitHub
parent fb430aab05
commit b9496b2fa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 432 additions and 892 deletions

View File

@ -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:
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
},
],
};
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:
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
},
],
},
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
// -----------------------------------------------------------------------

View File

@ -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<INodeTypeDescription['builderHint']>['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());

View File

@ -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';

View File

@ -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<string, LeanNodeTypeDescription[]>;
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();
}

View File

@ -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<string, LeanNodeTypeDescription> = {};
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<string, SearchableNodeType> = {};
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<string, NodeSearchResult[]>, 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<INodeTypeDescription | LeanNodeTypeDescription>) {
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<string, NodeSearchResult[]>();
private readonly connectionSearchCache = new Map<string, NodeSearchResult[]>();
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<string, ScoredNode>();
for (const term of queryTerms) {
const termResults = sublimeSearch<LeanNodeTypeDescription>(
const termResults = sublimeSearch<SearchableNodeType>(
term,
candidates,
NODE_SEARCH_KEYS,
@ -190,7 +259,7 @@ export class CodeBuilderNodeSearchEngine {
}
searchResults = [...scoreMap.values()];
} else {
searchResults = sublimeSearch<LeanNodeTypeDescription>(query, candidates, NODE_SEARCH_KEYS);
searchResults = sublimeSearch<SearchableNodeType>(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 = [
' <node>',
` <node_name>${result.name}</node_name>`,
@ -385,6 +425,16 @@ export class CodeBuilderNodeSearchEngine {
` <node_outputs>${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}</node_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(` <n8n_credits supported="true"${minVersion} />`);
}
// Add builder hint message if present
if (result.builderHintMessage) {
parts.push(` <builder_hint>${result.builderHintMessage}</builder_hint>`);
@ -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;
}
}

View File

@ -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 extends never> = 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<AINodeConnectionType, AiConnectionType>
>;
/**
* 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<string, string[]>;
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 | object> | string;
/** Availability and requirement config for one AI input in `builderHint.inputs`. */
export interface SearchableBuilderHintInputConfig {
required: boolean;
displayOptions?: IDisplayOptions | Record<string, unknown>;
}
/**
* `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<string, unknown>;
/**
* 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;
}

View File

@ -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

View File

@ -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<SearchableNodeDescription['builderHint']> = {};
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);

View File

@ -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';

View File

@ -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<string, string[]> = {
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<string, SearchableNodeType> = {};
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<string, NodeSearchResult[]>();
private readonly connectionSearchCache = new Map<string, NodeSearchResult[]>();
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<string, ScoredNode>();
for (const term of queryTerms) {
const termResults = sublimeSearch<SearchableNodeType>(
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<SearchableNodeType>(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 = [
' <node>',
` <node_name>${result.name}</node_name>`,
` <node_version>${result.version}</node_version>`,
` <node_description>${result.description}</node_description>`,
` <node_inputs>${typeof result.inputs === 'object' ? JSON.stringify(result.inputs) : result.inputs}</node_inputs>`,
` <node_outputs>${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}</node_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(` <n8n_credits supported="true"${minVersion} />`);
}
// Add builder hint message if present
if (result.builderHintMessage) {
parts.push(` <builder_hint>${result.builderHintMessage}</builder_hint>`);
}
// Add subnode requirements if present
if (result.subnodeRequirements && result.subnodeRequirements.length > 0) {
parts.push(' <subnode_requirements>');
for (const req of result.subnodeRequirements) {
const requiredStr = req.required ? 'required' : 'optional';
if (req.displayOptions) {
parts.push(
` <requirement type="${req.connectionType}" status="${requiredStr}"><display_options>${JSON.stringify(req.displayOptions)}</display_options></requirement>`,
);
} else {
parts.push(
' <requirement type="' +
req.connectionType +
'" status="' +
requiredStr +
'" />',
);
}
}
parts.push(' </subnode_requirements>');
}
parts.push(' </node>');
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<string>): Set<string> {
const allRelated = new Set<string>();
const visited = new Set<string>(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;
}
}

View File

@ -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<string, unknown>;
}
/** Maps AI input types to their configuration. */
export type BuilderHintInputs = Partial<Record<string, BuilderHintInputConfig>>;
// ---------------------------------------------------------------------------
// 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<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}>;
};
}
// ---------------------------------------------------------------------------
// 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<string, unknown>;
/**
* 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;
}

View File

@ -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<string, string[]>;
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<string, { required: boolean; displayOptions?: Record<string, unknown> }>;
outputs?: Record<string, { required?: boolean; displayOptions?: Record<string, unknown> }>;
};

View File

@ -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<