@juspay/neurolink 9.77.0 → 9.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,15 @@
16
16
  * Fail-open by design: missing query, <=1 routable server, validation
17
17
  * failure, empty/invalid pick, or any thrown error returns an EMPTY list
18
18
  * (exclude nothing -> all tools, identical to routing disabled). Never throws.
19
+ *
20
+ * L2 embedding fast-path (ITEM B): when `params.embedFn` is supplied and the
21
+ * catalog's total tool count exceeds `embeddingConfig.minToolsToActivate`, a
22
+ * hybrid cosine+BM25 retriever narrows the candidate set BEFORE the LLM router.
23
+ * Any embedding failure falls back to the standard LLM-router path.
24
+ *
25
+ * Tool granularity (ITEM D): when `params.granularity === "tool"`, individual
26
+ * unpicked tools are excluded rather than whole servers. Falls back to "server"
27
+ * if the embedding path is disabled or fails.
19
28
  */
20
29
  import type { ToolRoutingCatalogEntry, ToolRoutingResolutionParams, ToolRoutingServerDescriptor } from "../types/index.js";
21
30
  /**
@@ -16,13 +16,27 @@
16
16
  * Fail-open by design: missing query, <=1 routable server, validation
17
17
  * failure, empty/invalid pick, or any thrown error returns an EMPTY list
18
18
  * (exclude nothing -> all tools, identical to routing disabled). Never throws.
19
+ *
20
+ * L2 embedding fast-path (ITEM B): when `params.embedFn` is supplied and the
21
+ * catalog's total tool count exceeds `embeddingConfig.minToolsToActivate`, a
22
+ * hybrid cosine+BM25 retriever narrows the candidate set BEFORE the LLM router.
23
+ * Any embedding failure falls back to the standard LLM-router path.
24
+ *
25
+ * Tool granularity (ITEM D): when `params.granularity === "tool"`, individual
26
+ * unpicked tools are excluded rather than whole servers. Falls back to "server"
27
+ * if the embedding path is disabled or fails.
19
28
  */
20
29
  import { z } from "zod";
21
30
  import { logger } from "../utils/logger.js";
22
31
  import { withTimeout } from "../utils/async/index.js";
32
+ import { selectRelevantToolNames } from "./toolRoutingEmbedding.js";
23
33
  const routerOutputSchema = z.object({
24
34
  servers: z.array(z.string()),
25
35
  });
36
+ /** Default minimum catalog tool count that activates the embedding fast-path. */
37
+ const DEFAULT_EMBEDDING_MIN_TOOLS = 20;
38
+ /** Default number of top-ranked tool candidates the embedding retriever keeps. */
39
+ const DEFAULT_EMBEDDING_TOP_K = 20;
26
40
  /**
27
41
  * Upper bound on the user-query text interpolated into the router prompt.
28
42
  * The query is untrusted and can attempt prompt injection — the blast radius
@@ -169,12 +183,116 @@ function safeEmitDecision(emitDecision, decision) {
169
183
  // Intentionally swallowed — telemetry must never affect routing behaviour.
170
184
  }
171
185
  }
186
+ // ---------------------------------------------------------------------------
187
+ // L2 embedding fast-path helpers (ITEM B + D)
188
+ // ---------------------------------------------------------------------------
189
+ // ToolRoutingEmbeddingFastPathResult is imported from src/lib/types/toolRouting.ts
190
+ // (Critical Rule 2 — all type aliases live in src/lib/types/).
191
+ /**
192
+ * Runs the L2 embedding fast-path over `routableServers` for `userQuery`.
193
+ *
194
+ * Returns a result with `embeddingActivated: false` on any skip or error —
195
+ * the caller must treat this as a "fall back to LLM router" signal.
196
+ *
197
+ * This function is PURE with respect to provider access: the caller injects
198
+ * `embedFn` so no provider classes are imported here.
199
+ */
200
+ async function runEmbeddingFastPath(userQuery, routableServers, alwaysIncludeServerIds, embedFn, opts) {
201
+ const { granularity, vectorCache } = opts;
202
+ const noop = {
203
+ excludedToolNames: [],
204
+ embeddingActivated: false,
205
+ candidateToolCount: 0,
206
+ granularity,
207
+ };
208
+ // Count routable tools (always-include servers are already excluded from
209
+ // routableServers at call site, but be defensive).
210
+ const routableToolCount = routableServers.reduce((sum, server) => sum + server.toolNames.length, 0);
211
+ if (routableToolCount < opts.minToolsToActivate) {
212
+ logger.debug("[ToolRouting] L2 embedding skipped — catalog below threshold", {
213
+ toolCount: routableToolCount,
214
+ minToolsToActivate: opts.minToolsToActivate,
215
+ });
216
+ return noop;
217
+ }
218
+ // Build retrieval items: one per individual tool, with text =
219
+ // "<server description> — <tool name>" so both server context and the tool
220
+ // identifier itself inform the embedding.
221
+ const items = routableServers.flatMap((server) => server.toolNames.map((toolName) => ({
222
+ name: toolName,
223
+ text: `${server.description} — ${toolName}`,
224
+ })));
225
+ try {
226
+ const topK = Math.min(opts.topK, items.length);
227
+ const topToolNames = await selectRelevantToolNames(userQuery, items, {
228
+ topK,
229
+ weights: opts.weights,
230
+ embedFn,
231
+ vectorCache,
232
+ });
233
+ const topToolSet = new Set(topToolNames);
234
+ if (granularity === "tool") {
235
+ // Tool-granularity (ITEM D): exclude every catalog tool NOT in the top-K,
236
+ // regardless of server. Always-include server tools are not in
237
+ // routableServers so they are untouched.
238
+ const excludedToolNames = items
239
+ .map((item) => item.name)
240
+ .filter((name) => !topToolSet.has(name));
241
+ logger.debug("[ToolRouting] L2 embedding applied (tool granularity)", {
242
+ totalTools: items.length,
243
+ topK,
244
+ keptTools: topToolNames.length,
245
+ excludedTools: excludedToolNames.length,
246
+ });
247
+ return {
248
+ excludedToolNames,
249
+ embeddingActivated: true,
250
+ candidateToolCount: topToolNames.length,
251
+ granularity: "tool",
252
+ };
253
+ }
254
+ // Server-granularity (default): map surviving tools back to their servers.
255
+ // A server is "kept" if at least one of its tools survived the top-K cut.
256
+ // The tools of every server with ZERO surviving tools are excluded.
257
+ const keptServerIds = new Set(routableServers
258
+ .filter((server) => server.toolNames.some((toolName) => topToolSet.has(toolName)))
259
+ .map((server) => server.id));
260
+ // Never exclude always-include servers (defensive — they should not be in
261
+ // routableServers, but guard against caller mistakes).
262
+ const alwaysSet = new Set(alwaysIncludeServerIds);
263
+ const excludedServers = routableServers.filter((server) => !keptServerIds.has(server.id) && !alwaysSet.has(server.id));
264
+ const excludedToolNames = excludedServers.flatMap((server) => server.toolNames);
265
+ logger.debug("[ToolRouting] L2 embedding applied (server granularity)", {
266
+ totalTools: items.length,
267
+ topK,
268
+ keptServers: keptServerIds.size,
269
+ excludedServers: excludedServers.length,
270
+ excludedTools: excludedToolNames.length,
271
+ });
272
+ return {
273
+ excludedToolNames,
274
+ embeddingActivated: true,
275
+ candidateToolCount: topToolNames.length,
276
+ granularity: "server",
277
+ };
278
+ }
279
+ catch (embeddingError) {
280
+ logger.warn("[ToolRouting] L2 embedding fast-path failed, falling back to LLM router", {
281
+ error: embeddingError instanceof Error
282
+ ? embeddingError.message
283
+ : String(embeddingError),
284
+ });
285
+ return noop;
286
+ }
287
+ }
172
288
  /**
173
289
  * Resolves which registered tool names to EXCLUDE for a single stream() turn.
174
290
  * Returns an empty list on any skip/failure path — see module doc.
175
291
  */
176
292
  export async function resolveToolRoutingExclusions(params) {
177
- const { catalog, alwaysIncludeServerIds, userQuery, routerPromptPrefix, routerModel, timeoutMs, generateFn, emitDecision, } = params;
293
+ const { catalog, alwaysIncludeServerIds, userQuery, routerPromptPrefix, routerModel, timeoutMs, generateFn, emitDecision,
294
+ // L2 / ITEM D parameters (all optional — omitting reproduces today's behavior)
295
+ embedFn, embeddingConfig, granularity = "server", embeddingVectorCache, } = params;
178
296
  const routableServers = catalog.filter((server) => !alwaysIncludeServerIds.includes(server.id));
179
297
  const routingStartTime = Date.now();
180
298
  try {
@@ -198,6 +316,64 @@ export async function resolveToolRoutingExclusions(params) {
198
316
  });
199
317
  return [];
200
318
  }
319
+ // -------------------------------------------------------------------------
320
+ // L2 EMBEDDING FAST-PATH (ITEM B + D)
321
+ // -------------------------------------------------------------------------
322
+ // Attempt the embedding retriever when an embedFn is provided. On success,
323
+ // return the embedding-derived exclusion list immediately WITHOUT an LLM
324
+ // call. On any failure (embedFn throws, threshold not met, etc.), fall
325
+ // through to the existing LLM-router path below.
326
+ if (embedFn) {
327
+ const embResult = await runEmbeddingFastPath(userQuery, routableServers, alwaysIncludeServerIds, embedFn, {
328
+ topK: embeddingConfig?.topK ?? DEFAULT_EMBEDDING_TOP_K,
329
+ minToolsToActivate: embeddingConfig?.minToolsToActivate ?? DEFAULT_EMBEDDING_MIN_TOOLS,
330
+ weights: embeddingConfig?.weights,
331
+ granularity,
332
+ vectorCache: embeddingVectorCache,
333
+ });
334
+ if (embResult.embeddingActivated) {
335
+ logger.debug("[ToolRouting] L2 embedding fast-path succeeded", {
336
+ granularity: embResult.granularity,
337
+ candidateToolCount: embResult.candidateToolCount,
338
+ excludedToolCount: embResult.excludedToolNames.length,
339
+ routableServerCount: routableServers.length,
340
+ durationMs: Date.now() - routingStartTime,
341
+ });
342
+ // For server-granularity results, reconstruct selected/excluded server
343
+ // ids for the decision so telemetry fields are consistent.
344
+ let selectedServerIds = [];
345
+ let excludedServerIds = [];
346
+ if (embResult.granularity === "server") {
347
+ const excludedToolSet = new Set(embResult.excludedToolNames);
348
+ // A server is "excluded" if ALL its tools are excluded.
349
+ excludedServerIds = routableServers
350
+ .filter((server) => server.toolNames.every((name) => excludedToolSet.has(name)))
351
+ .map((server) => server.id);
352
+ selectedServerIds = routableServers
353
+ .filter((server) => !excludedServerIds.includes(server.id))
354
+ .map((server) => server.id);
355
+ }
356
+ safeEmitDecision(emitDecision, {
357
+ outcome: "applied",
358
+ selectedServerIds,
359
+ excludedServerIds,
360
+ hallucinatedIds: [],
361
+ excludedToolCount: embResult.excludedToolNames.length,
362
+ routableServerCount: routableServers.length,
363
+ cacheHit: false,
364
+ durationMs: Date.now() - routingStartTime,
365
+ embeddingActivated: true,
366
+ candidateToolCount: embResult.candidateToolCount,
367
+ granularity: embResult.granularity,
368
+ });
369
+ return embResult.excludedToolNames;
370
+ }
371
+ // embedFn was provided but threshold not met OR retriever failed open —
372
+ // fall through to the LLM-router path below.
373
+ }
374
+ // -------------------------------------------------------------------------
375
+ // EXISTING LLM ROUTER PATH (L3) — runs when embedding is off or fell back
376
+ // -------------------------------------------------------------------------
201
377
  const routerPrompt = buildRouterPrompt(userQuery, routableServers, routerPromptPrefix);
202
378
  // `timeout` lets the provider abort its own request (frees the socket);
203
379
  // withTimeout adds a hard wall-clock ceiling over the whole call so router
@@ -290,6 +466,7 @@ export async function resolveToolRoutingExclusions(params) {
290
466
  routableServerCount: routableServers.length,
291
467
  cacheHit: false,
292
468
  durationMs: Date.now() - routingStartTime,
469
+ granularity: "server",
293
470
  });
294
471
  return excludedToolNames;
295
472
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Embedding-retrieval fast-path for pre-call tool routing (ITEM B / L2).
3
+ *
4
+ * For large tool catalogs, ranking by semantic similarity is far cheaper than
5
+ * an LLM router call and benchmarks at sub-10 ms when embedding vectors are
6
+ * cached. This module is intentionally PURE with no provider imports: the
7
+ * caller injects an `embedFn` so the retriever stays testable and free of
8
+ * circular dependencies.
9
+ *
10
+ * Hybrid scoring formula:
11
+ * score = weights.cosine * cosineSimilarity(queryVec, toolVec)
12
+ * + weights.bm25 * bm25Score(query, toolText)
13
+ *
14
+ * Both components are independently normalized to [0, 1] before combination
15
+ * so neither scale dominates.
16
+ *
17
+ * Fail-safe contract:
18
+ * `ToolEmbeddingIndex.rank()` and `selectRelevantToolNames()` propagate any
19
+ * error thrown by the injected `embedFn`. The CALLER (tool-routing
20
+ * orchestration) is responsible for catching that error and degrading
21
+ * gracefully to the LLM-router / server-granularity path.
22
+ *
23
+ * Determinism:
24
+ * All public functions are deterministic given the same inputs and embedFn.
25
+ * No Math.random(), no argless Date, no global mutable state outside the
26
+ * instance-level vector cache (which is keyed by text content).
27
+ */
28
+ import type { ToolRetrievalItem, ToolRetrievalRankedResult, ToolRetrievalSelectOptions, ToolRetrievalWeights } from "../types/index.js";
29
+ /**
30
+ * Cosine similarity between two vectors.
31
+ *
32
+ * Returns 0 when either vector is zero-length, has zero magnitude, or the
33
+ * lengths differ — all of which indicate the comparison is meaningless.
34
+ */
35
+ export declare function cosineSimilarity(a: number[], b: number[]): number;
36
+ /**
37
+ * An in-process index that ranks tool catalog items by hybrid semantic +
38
+ * lexical relevance to a query.
39
+ *
40
+ * Embedding vectors for tool descriptions are computed lazily on the first
41
+ * `rank()` call and cached by description text, so subsequent turns that
42
+ * share the same catalog pay only the cost of embedding the query itself.
43
+ *
44
+ * ### Fail-safe
45
+ * Any error thrown by `embedFn` propagates out of `rank()`. The CALLER must
46
+ * catch it and degrade to the LLM-router path; `ToolEmbeddingIndex` itself
47
+ * never silently swallows embedFn errors.
48
+ *
49
+ * ### Thread-safety
50
+ * The internal cache is a plain `Map`. Node.js is single-threaded so there
51
+ * are no data races, but if the same index instance is used concurrently
52
+ * (e.g. two turns in parallel) both calls will race to populate the cache;
53
+ * the last writer wins (same content either way because `embedFn` is
54
+ * deterministic for a given text).
55
+ */
56
+ export declare class ToolEmbeddingIndex {
57
+ private readonly items;
58
+ private readonly embedFn;
59
+ private bm25CorpusCache?;
60
+ /**
61
+ * Cached embedding vectors keyed by the item's `text` field.
62
+ * Populated on first `rank()` call for texts not yet seen.
63
+ *
64
+ * Callers can share a single Map across multiple index instances (e.g.
65
+ * across turns in the same NeuroLink session) so tool vectors are computed
66
+ * once and reused. Pass the shared Map via the constructor's third argument.
67
+ */
68
+ private readonly vectorCache;
69
+ constructor(items: ToolRetrievalItem[], embedFn: (texts: string[]) => Promise<number[][]>,
70
+ /**
71
+ * Optional shared vector cache. When supplied, cached vectors from
72
+ * previous turns are reused and any newly-computed vectors are stored
73
+ * into this same Map, making it warm for the next call.
74
+ */
75
+ sharedVectorCache?: Map<string, number[]>);
76
+ /**
77
+ * Returns the top-K catalog items ranked by hybrid score descending.
78
+ *
79
+ * @throws If `embedFn` throws — propagated verbatim so the caller can fail
80
+ * open. No wrapping, no swallowing.
81
+ */
82
+ rank(query: string, opts: {
83
+ topK: number;
84
+ weights?: ToolRetrievalWeights;
85
+ timeoutMs?: number;
86
+ }): Promise<ToolRetrievalRankedResult[]>;
87
+ }
88
+ /**
89
+ * Selects the most relevant tool names from a catalog given a query.
90
+ *
91
+ * Creates a temporary `ToolEmbeddingIndex`, runs `rank()`, and returns just
92
+ * the tool names. Use `ToolEmbeddingIndex` directly when you want to reuse
93
+ * cached tool vectors across multiple queries (e.g. multiple turns with the
94
+ * same catalog).
95
+ *
96
+ * @throws If `opts.embedFn` throws — propagated so the caller can degrade to
97
+ * the LLM-router path.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const tools = await selectRelevantToolNames(
102
+ * "show me yesterday's sales",
103
+ * catalog.flatMap(server => server.toolNames.map(name => ({
104
+ * name,
105
+ * text: `${server.description} — ${name}`,
106
+ * }))),
107
+ * { topK: 5, embedFn: myEmbedFn },
108
+ * );
109
+ * // => ["analytics_getSales", "analytics_getRevenue", ...]
110
+ * ```
111
+ */
112
+ export declare function selectRelevantToolNames(query: string, items: ToolRetrievalItem[], opts: ToolRetrievalSelectOptions): Promise<string[]>;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Embedding-retrieval fast-path for pre-call tool routing (ITEM B / L2).
3
+ *
4
+ * For large tool catalogs, ranking by semantic similarity is far cheaper than
5
+ * an LLM router call and benchmarks at sub-10 ms when embedding vectors are
6
+ * cached. This module is intentionally PURE with no provider imports: the
7
+ * caller injects an `embedFn` so the retriever stays testable and free of
8
+ * circular dependencies.
9
+ *
10
+ * Hybrid scoring formula:
11
+ * score = weights.cosine * cosineSimilarity(queryVec, toolVec)
12
+ * + weights.bm25 * bm25Score(query, toolText)
13
+ *
14
+ * Both components are independently normalized to [0, 1] before combination
15
+ * so neither scale dominates.
16
+ *
17
+ * Fail-safe contract:
18
+ * `ToolEmbeddingIndex.rank()` and `selectRelevantToolNames()` propagate any
19
+ * error thrown by the injected `embedFn`. The CALLER (tool-routing
20
+ * orchestration) is responsible for catching that error and degrading
21
+ * gracefully to the LLM-router / server-granularity path.
22
+ *
23
+ * Determinism:
24
+ * All public functions are deterministic given the same inputs and embedFn.
25
+ * No Math.random(), no argless Date, no global mutable state outside the
26
+ * instance-level vector cache (which is keyed by text content).
27
+ */
28
+ import { withTimeout } from "../utils/async/index.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+ const DEFAULT_WEIGHTS = { cosine: 0.8, bm25: 0.2 };
33
+ /** BM25 free-parameter k1 — controls term-frequency saturation. */
34
+ const BM25_K1 = 1.5;
35
+ /** BM25 free-parameter b — controls document-length normalisation. */
36
+ const BM25_B = 0.75;
37
+ // ---------------------------------------------------------------------------
38
+ // Primitive helpers (pure, no side-effects)
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Tokenise text for BM25/lexical scoring: lowercase, strip punctuation, split
42
+ * on whitespace, drop empty tokens.
43
+ */
44
+ function tokenize(text) {
45
+ return text
46
+ .toLowerCase()
47
+ .replace(/[^\w\s]/g, " ")
48
+ .split(/\s+/)
49
+ .filter((t) => t.length > 0);
50
+ }
51
+ /**
52
+ * Cosine similarity between two vectors.
53
+ *
54
+ * Returns 0 when either vector is zero-length, has zero magnitude, or the
55
+ * lengths differ — all of which indicate the comparison is meaningless.
56
+ */
57
+ export function cosineSimilarity(a, b) {
58
+ if (a.length === 0 || b.length === 0 || a.length !== b.length) {
59
+ return 0;
60
+ }
61
+ let dot = 0;
62
+ let magA = 0;
63
+ let magB = 0;
64
+ for (let i = 0; i < a.length; i++) {
65
+ dot += a[i] * b[i];
66
+ magA += a[i] * a[i];
67
+ magB += b[i] * b[i];
68
+ }
69
+ if (magA === 0 || magB === 0) {
70
+ return 0;
71
+ }
72
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // BM25 scorer
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * A minimal, deterministic BM25 corpus over a fixed set of documents.
79
+ * Documents are indexed at construction time; only `score()` is exposed
80
+ * for query-time use.
81
+ *
82
+ * This is a TF-IDF/BM25 hybrid consistent with the InMemoryBM25Index in
83
+ * `src/lib/rag/retrieval/hybridSearch.ts`, adapted for a read-once static
84
+ * corpus (tool descriptions do not change within a turn).
85
+ *
86
+ * `ToolRoutingBm25Doc` is the per-document shape; it lives in
87
+ * `src/lib/types/toolRouting.ts` per Critical Rule 2.
88
+ */
89
+ class Bm25Corpus {
90
+ docs;
91
+ avgDocLength;
92
+ /** IDF per unique token across all documents (computed once). */
93
+ idf;
94
+ constructor(texts) {
95
+ this.docs = texts.map((text) => {
96
+ const tokens = tokenize(text);
97
+ const tf = new Map();
98
+ for (const t of tokens) {
99
+ tf.set(t, (tf.get(t) ?? 0) + 1);
100
+ }
101
+ return { tokens, tf };
102
+ });
103
+ const totalLength = this.docs.reduce((s, d) => s + d.tokens.length, 0);
104
+ this.avgDocLength =
105
+ this.docs.length > 0 ? totalLength / this.docs.length : 1;
106
+ // Precompute IDF for every token that appears in any document.
107
+ const N = this.docs.length;
108
+ const df = new Map();
109
+ for (const doc of this.docs) {
110
+ for (const token of doc.tf.keys()) {
111
+ df.set(token, (df.get(token) ?? 0) + 1);
112
+ }
113
+ }
114
+ this.idf = new Map();
115
+ for (const [token, docFreq] of df) {
116
+ // Robertson/Sparck-Jones IDF (safe against 0):
117
+ // log((N - df + 0.5) / (df + 0.5) + 1)
118
+ this.idf.set(token, Math.log((N - docFreq + 0.5) / (docFreq + 0.5) + 1));
119
+ }
120
+ }
121
+ /**
122
+ * Returns the BM25 score of `docIndex` against `query`.
123
+ * Score is always >= 0. Returns 0 for empty queries or out-of-range indices.
124
+ */
125
+ score(docIndex, query) {
126
+ if (docIndex < 0 || docIndex >= this.docs.length) {
127
+ return 0;
128
+ }
129
+ const queryTokens = tokenize(query);
130
+ if (queryTokens.length === 0) {
131
+ return 0;
132
+ }
133
+ const doc = this.docs[docIndex];
134
+ const docLength = doc.tokens.length;
135
+ let total = 0;
136
+ for (const qt of queryTokens) {
137
+ const tf = doc.tf.get(qt) ?? 0;
138
+ if (tf === 0) {
139
+ continue;
140
+ }
141
+ const idf = this.idf.get(qt) ?? 0;
142
+ // BM25 term contribution
143
+ const numerator = tf * (BM25_K1 + 1);
144
+ const denominator = tf + BM25_K1 * (1 - BM25_B + BM25_B * (docLength / this.avgDocLength));
145
+ total += idf * (numerator / denominator);
146
+ }
147
+ return total;
148
+ }
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Normalisation helper
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * Min-max normalises an array of non-negative scores to [0, 1].
155
+ * Returns an array of zeros when all scores are identical (range === 0).
156
+ */
157
+ function normalizeScores(scores) {
158
+ if (scores.length === 0) {
159
+ return [];
160
+ }
161
+ let min = scores[0] ?? 0;
162
+ let max = scores[0] ?? 0;
163
+ for (let i = 1; i < scores.length; i++) {
164
+ const s = scores[i] ?? 0;
165
+ if (s < min) {
166
+ min = s;
167
+ }
168
+ if (s > max) {
169
+ max = s;
170
+ }
171
+ }
172
+ const range = max - min;
173
+ if (range === 0) {
174
+ return scores.map(() => 0);
175
+ }
176
+ return scores.map((s) => (s - min) / range);
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // ToolEmbeddingIndex
180
+ // ---------------------------------------------------------------------------
181
+ /**
182
+ * An in-process index that ranks tool catalog items by hybrid semantic +
183
+ * lexical relevance to a query.
184
+ *
185
+ * Embedding vectors for tool descriptions are computed lazily on the first
186
+ * `rank()` call and cached by description text, so subsequent turns that
187
+ * share the same catalog pay only the cost of embedding the query itself.
188
+ *
189
+ * ### Fail-safe
190
+ * Any error thrown by `embedFn` propagates out of `rank()`. The CALLER must
191
+ * catch it and degrade to the LLM-router path; `ToolEmbeddingIndex` itself
192
+ * never silently swallows embedFn errors.
193
+ *
194
+ * ### Thread-safety
195
+ * The internal cache is a plain `Map`. Node.js is single-threaded so there
196
+ * are no data races, but if the same index instance is used concurrently
197
+ * (e.g. two turns in parallel) both calls will race to populate the cache;
198
+ * the last writer wins (same content either way because `embedFn` is
199
+ * deterministic for a given text).
200
+ */
201
+ export class ToolEmbeddingIndex {
202
+ items;
203
+ embedFn;
204
+ bm25CorpusCache;
205
+ /**
206
+ * Cached embedding vectors keyed by the item's `text` field.
207
+ * Populated on first `rank()` call for texts not yet seen.
208
+ *
209
+ * Callers can share a single Map across multiple index instances (e.g.
210
+ * across turns in the same NeuroLink session) so tool vectors are computed
211
+ * once and reused. Pass the shared Map via the constructor's third argument.
212
+ */
213
+ vectorCache;
214
+ constructor(items, embedFn,
215
+ /**
216
+ * Optional shared vector cache. When supplied, cached vectors from
217
+ * previous turns are reused and any newly-computed vectors are stored
218
+ * into this same Map, making it warm for the next call.
219
+ */
220
+ sharedVectorCache) {
221
+ this.items = items;
222
+ this.embedFn = embedFn;
223
+ this.vectorCache = sharedVectorCache ?? new Map();
224
+ }
225
+ /**
226
+ * Returns the top-K catalog items ranked by hybrid score descending.
227
+ *
228
+ * @throws If `embedFn` throws — propagated verbatim so the caller can fail
229
+ * open. No wrapping, no swallowing.
230
+ */
231
+ async rank(query, opts) {
232
+ if (this.items.length === 0) {
233
+ return [];
234
+ }
235
+ const weights = opts.weights ?? DEFAULT_WEIGHTS;
236
+ // Identify which item texts still need embedding vectors.
237
+ const uncachedTexts = [
238
+ ...new Set(this.items
239
+ .map((item) => item.text)
240
+ .filter((text) => !this.vectorCache.has(text))),
241
+ ];
242
+ if (uncachedTexts.length > 0) {
243
+ // embedFn errors propagate — caller is responsible for fail-open.
244
+ const vectors = await withTimeout(this.embedFn(uncachedTexts), opts.timeoutMs ?? 10000, "ToolEmbeddingIndex.embed");
245
+ for (let i = 0; i < uncachedTexts.length; i++) {
246
+ const vec = vectors[i];
247
+ if (vec !== undefined) {
248
+ this.vectorCache.set(uncachedTexts[i], vec);
249
+ }
250
+ }
251
+ }
252
+ // Embed the query (single call; the query is not cached since it changes
253
+ // every turn — caching query vectors is the caller's responsibility if
254
+ // desired, e.g. via ToolRoutingCache).
255
+ const [queryVector] = await withTimeout(this.embedFn([query]), opts.timeoutMs ?? 10000, "ToolEmbeddingIndex.queryEmbed");
256
+ // Build BM25 corpus lazily; rebuild only when the item texts change.
257
+ const corpusKey = this.items.map((item) => item.text).join("\x00");
258
+ if (!this.bm25CorpusCache || this.bm25CorpusCache.key !== corpusKey) {
259
+ this.bm25CorpusCache = {
260
+ key: corpusKey,
261
+ corpus: new Bm25Corpus(this.items.map((item) => item.text)),
262
+ };
263
+ }
264
+ const bm25Corpus = this.bm25CorpusCache.corpus;
265
+ // Compute raw cosine and BM25 scores for each item.
266
+ const rawCosine = [];
267
+ const rawBm25 = [];
268
+ for (let i = 0; i < this.items.length; i++) {
269
+ const toolVec = this.vectorCache.get(this.items[i].text) ?? [];
270
+ rawCosine.push(cosineSimilarity(queryVector ?? [], toolVec));
271
+ rawBm25.push(bm25Corpus.score(i, query));
272
+ }
273
+ // Normalize each component independently so scales don't interact.
274
+ const normCosine = normalizeScores(rawCosine);
275
+ const normBm25 = normalizeScores(rawBm25);
276
+ // Combine into hybrid score and zip with item names.
277
+ const ranked = this.items.map((item, i) => ({
278
+ name: item.name,
279
+ score: weights.cosine * (normCosine[i] ?? 0) +
280
+ weights.bm25 * (normBm25[i] ?? 0),
281
+ }));
282
+ // Sort descending by score, then by name for a deterministic tie-break.
283
+ ranked.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
284
+ return ranked.slice(0, opts.topK);
285
+ }
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Convenience wrapper
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * Selects the most relevant tool names from a catalog given a query.
292
+ *
293
+ * Creates a temporary `ToolEmbeddingIndex`, runs `rank()`, and returns just
294
+ * the tool names. Use `ToolEmbeddingIndex` directly when you want to reuse
295
+ * cached tool vectors across multiple queries (e.g. multiple turns with the
296
+ * same catalog).
297
+ *
298
+ * @throws If `opts.embedFn` throws — propagated so the caller can degrade to
299
+ * the LLM-router path.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * const tools = await selectRelevantToolNames(
304
+ * "show me yesterday's sales",
305
+ * catalog.flatMap(server => server.toolNames.map(name => ({
306
+ * name,
307
+ * text: `${server.description} — ${name}`,
308
+ * }))),
309
+ * { topK: 5, embedFn: myEmbedFn },
310
+ * );
311
+ * // => ["analytics_getSales", "analytics_getRevenue", ...]
312
+ * ```
313
+ */
314
+ export async function selectRelevantToolNames(query, items, opts) {
315
+ const index = new ToolEmbeddingIndex(items, opts.embedFn, opts.vectorCache);
316
+ const ranked = await index.rank(query, {
317
+ topK: opts.topK,
318
+ weights: opts.weights,
319
+ timeoutMs: opts.timeoutMs,
320
+ });
321
+ return ranked.map((r) => r.name);
322
+ }
package/dist/index.d.ts CHANGED
@@ -219,6 +219,7 @@ export { logger } from "./utils/logger.js";
219
219
  export { getPoolStats } from "./utils/redis.js";
220
220
  export { ToolRoutingCache } from "./core/toolRoutingCache.js";
221
221
  export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
222
+ export { cosineSimilarity, ToolEmbeddingIndex, selectRelevantToolNames, } from "./core/toolRoutingEmbedding.js";
222
223
  export declare function initializeTelemetry(): Promise<boolean>;
223
224
  export declare function getTelemetryStatus(): Promise<{
224
225
  enabled: boolean;