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<