@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.
@@ -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;
package/dist/lib/index.js CHANGED
@@ -362,6 +362,8 @@ export { getPoolStats } from "./utils/redis.js";
362
362
  export { ToolRoutingCache } from "./core/toolRoutingCache.js";
363
363
  // Pre-call tool routing resolver — exported for host integrations and testing
364
364
  export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
365
+ // Pre-call tool routing — embedding fast-path (ITEM B: L2 semantic retrieval)
366
+ export { cosineSimilarity, ToolEmbeddingIndex, selectRelevantToolNames, } from "./core/toolRoutingEmbedding.js";
365
367
  // ============================================================================
366
368
  // REAL-TIME SERVICES & TELEMETRY - Enterprise Platform Features
367
369
  // ============================================================================
@@ -102,6 +102,7 @@ export declare class NeuroLink {
102
102
  private conversationMemoryConfig?;
103
103
  private toolRoutingConfig?;
104
104
  private toolRoutingCacheInstance?;
105
+ private toolRoutingVectorCache?;
105
106
  private toolDedupConfig?;
106
107
  private enableOrchestration;
107
108
  private authProvider?;
@@ -340,7 +340,7 @@ export class NeuroLink {
340
340
  * @param result - The result of the tool execution (optional)
341
341
  * @param error - The error if execution failed (optional)
342
342
  */
343
- emitToolEndEvent(toolName, startTime, success, result, error) {
343
+ emitToolEndEvent(toolName, startTime, success, result, error, executionId) {
344
344
  // Emit tool end event (NeuroLink format - enhanced with result/error)
345
345
  // Serialize error to string for consumer compatibility (event listeners
346
346
  // commonly check `typeof event.error === "string"`).
@@ -350,6 +350,7 @@ export class NeuroLink {
350
350
  timestamp: Date.now(),
351
351
  result,
352
352
  error: error ? error.message : undefined,
353
+ executionId,
353
354
  }));
354
355
  }
355
356
  // Conversation memory support
@@ -363,6 +364,11 @@ export class NeuroLink {
363
364
  // Lazy-initialized routing decision cache (ITEM C). Created on first use so
364
365
  // instances that don't use routing pay no overhead.
365
366
  toolRoutingCacheInstance;
367
+ // Persisted vector cache for the L2 embedding fast-path (ITEM B). Populated
368
+ // on the first turn and reused across subsequent turns so tool embedding
369
+ // vectors are computed once. Cleared when the catalog changes via
370
+ // setToolRoutingServers() so stale vectors are never reused.
371
+ toolRoutingVectorCache;
366
372
  // Opt-in tool-signature deduplication config.
367
373
  toolDedupConfig;
368
374
  // Add orchestration property
@@ -5934,7 +5940,11 @@ Current user's request: ${currentInput}`;
5934
5940
  // Require a non-empty sessionId to avoid anonymous sessions sharing a
5935
5941
  // ":query" namespace (cross-session cache leak).
5936
5942
  const cacheKey = cacheEnabled && sessionId ? `${sessionId}:${routingQuery}` : undefined;
5943
+ const routableServerCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
5937
5944
  // --- ITEM E: build the emitDecision callback ---
5945
+ // Constructed BEFORE the cache-hit check so it is available for all
5946
+ // outcome paths, including cache-hit turns (Finding 4).
5947
+ const routingStartTime = Date.now();
5938
5948
  const emitDecision = (decision) => {
5939
5949
  try {
5940
5950
  const activeSpan = trace.getActiveSpan();
@@ -5949,100 +5959,176 @@ Current user's request: ${currentInput}`;
5949
5959
  activeSpan.setAttribute("tool_routing.excluded_tool_count", decision.excludedToolCount);
5950
5960
  activeSpan.setAttribute("tool_routing.cache_hit", decision.cacheHit);
5951
5961
  activeSpan.setAttribute("tool_routing.duration_ms", decision.durationMs);
5962
+ // Optional L2 / tool-granularity fields (present only when embedding ran).
5963
+ if (decision.embeddingActivated !== undefined) {
5964
+ activeSpan.setAttribute("tool_routing.embedding_activated", decision.embeddingActivated);
5965
+ }
5966
+ if (decision.candidateToolCount !== undefined) {
5967
+ activeSpan.setAttribute("tool_routing.candidate_tool_count", decision.candidateToolCount);
5968
+ }
5969
+ if (decision.granularity !== undefined) {
5970
+ activeSpan.setAttribute("tool_routing.granularity", decision.granularity);
5971
+ }
5952
5972
  }
5953
5973
  catch {
5954
5974
  // Telemetry must never affect routing behaviour.
5955
5975
  }
5956
5976
  };
5957
- // Unified hit/miss block: always produces raw (pre-stickiness) exclusions
5958
- // so stickiness can be applied identically on both paths below.
5977
+ // Check the cache first.
5978
+ if (cacheEnabled && cache && cacheKey !== undefined) {
5979
+ const cached = cache.get(cacheKey);
5980
+ if (cached) {
5981
+ // Decrement stickiness turn counter even on a cache hit so the window
5982
+ // advances correctly regardless of whether the LLM router ran
5983
+ // (Finding 3). Re-apply stickiness to the cached exclusion list so
5984
+ // the live stickiness window, not the one at write-time, is honoured
5985
+ // (Finding 2 complement: we stored the pre-stickiness list, so
5986
+ // re-applying here gives the correct per-turn view).
5987
+ let cachedExcluded = cached.excludedToolNames;
5988
+ if (stickinessEnabled && sessionId) {
5989
+ try {
5990
+ const stickyIds = cache.getStickyServerIds(sessionId);
5991
+ if (stickyIds.length > 0) {
5992
+ cachedExcluded = cachedExcluded.filter((toolName) => !stickyIds.some((id) => toolName.startsWith(`${id}_`)));
5993
+ }
5994
+ }
5995
+ catch {
5996
+ // Stickiness failure is non-fatal.
5997
+ }
5998
+ }
5999
+ // Notify the emitDecision callback so custom telemetry sinks observe
6000
+ // every routing outcome, not just non-cached turns (Finding 4).
6001
+ const cachedSelectedSet = new Set(cached.selectedServerIds);
6002
+ const cachedExcludedServerIds = catalog
6003
+ .map((e) => e.id)
6004
+ .filter((id) => !cachedSelectedSet.has(id));
6005
+ emitDecision({
6006
+ outcome: "cache-hit",
6007
+ selectedServerIds: cached.selectedServerIds,
6008
+ excludedServerIds: cachedExcludedServerIds,
6009
+ hallucinatedIds: [],
6010
+ excludedToolCount: cachedExcluded.length,
6011
+ routableServerCount,
6012
+ cacheHit: true,
6013
+ durationMs: Date.now() - routingStartTime,
6014
+ });
6015
+ logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
6016
+ hasSessionId: !!sessionId,
6017
+ routingQueryLength: routingQuery.length,
6018
+ });
6019
+ if (cachedExcluded.length > 0) {
6020
+ options.excludeTools = [
6021
+ ...(options.excludeTools ?? []),
6022
+ ...cachedExcluded,
6023
+ ];
6024
+ }
6025
+ return;
6026
+ }
6027
+ }
6028
+ // The router call below re-enters the public generate(), whose finally
6029
+ // block resets _disableToolCacheForCurrentRequest to false. That flag is
6030
+ // turn-scoped (set at the top of this turn) and read by the main tool
6031
+ // execution path that runs after routing, so save it before the router
6032
+ // call and restore it afterward to keep the turn's cache setting intact.
6033
+ const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5959
6034
  let routedExcludeTools;
5960
- let selectedServerIds = [];
5961
6035
  let resolvedDecision;
5962
- let fromCache = false;
5963
- const cached = cacheEnabled && cache && cacheKey !== undefined
5964
- ? cache.get(cacheKey)
5965
- : undefined;
5966
- if (cached) {
5967
- // Cache HIT — use stored raw exclusions (pre-stickiness).
5968
- fromCache = true;
5969
- routedExcludeTools = [...cached.excludedToolNames];
5970
- selectedServerIds = [...cached.selectedServerIds];
5971
- logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
5972
- cacheKey,
5973
- });
5974
- // Emit cache-hit telemetry with full parity to the miss path.
5975
- try {
5976
- const activeSpan = trace.getActiveSpan();
5977
- if (activeSpan) {
5978
- const routableCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
5979
- activeSpan.setAttribute("tool_routing.outcome", "cache-hit");
5980
- activeSpan.setAttribute("tool_routing.routable_server_count", routableCount);
5981
- activeSpan.setAttribute("tool_routing.cache_hit", true);
5982
- activeSpan.setAttribute("tool_routing.selected_server_ids", spanJsonAttribute(selectedServerIds));
5983
- activeSpan.setAttribute("tool_routing.excluded_tool_count", routedExcludeTools.length);
6036
+ try {
6037
+ // Intercept the decision so we can store it in the cache.
6038
+ const captureDecision = (decision) => {
6039
+ resolvedDecision = decision;
6040
+ emitDecision(decision);
6041
+ };
6042
+ // --- ITEM B: build the embedFn for the L2 embedding fast-path ---
6043
+ // The vector cache is persisted at the NeuroLink instance level so
6044
+ // tool embedding vectors are computed once and reused across turns
6045
+ // (Finding 1 fix). It is cleared by setToolRoutingServers() when the
6046
+ // catalog changes so stale vectors are never used after an update.
6047
+ let routingEmbedFn;
6048
+ const embeddingCfg = routingConfig.embedding;
6049
+ if (embeddingCfg?.enabled === true) {
6050
+ try {
6051
+ // Resolve the embedding provider: use the explicitly configured one
6052
+ // if present, otherwise fall back to the stream/generate call's
6053
+ // provider. The factory call is wrapped in try/catch so a provider
6054
+ // that doesn't support embedMany (it throws at call time, not
6055
+ // construction time) fails open when routingEmbedFn is invoked.
6056
+ const embProviderName = embeddingCfg.provider ??
6057
+ (options.provider && options.provider !== "auto"
6058
+ ? options.provider
6059
+ : undefined) ??
6060
+ routingConfig.routerModel?.provider;
6061
+ if (embProviderName) {
6062
+ const embProvider = await AIProviderFactory.createProvider(embProviderName, embeddingCfg.model, true, this, undefined, this.resolveCredentials(options.credentials));
6063
+ // Bind embedMany with the configured model (may be undefined —
6064
+ // the provider uses its default embedding model in that case).
6065
+ routingEmbedFn = (texts) => withTimeout(embProvider.embedMany(texts, embeddingCfg.model), embeddingCfg.timeoutMs ?? 10000);
6066
+ // Lazy-init the persistent vector cache for this instance.
6067
+ // Subsequent turns reuse the same Map so text→vector lookups
6068
+ // already populated from earlier turns are served from memory.
6069
+ if (!this.toolRoutingVectorCache) {
6070
+ this.toolRoutingVectorCache = new Map();
6071
+ }
6072
+ }
6073
+ }
6074
+ catch (embSetupError) {
6075
+ logger.debug("[ToolRouting] Embedding provider setup failed, L2 path disabled for this turn", {
6076
+ error: embSetupError instanceof Error
6077
+ ? embSetupError.message
6078
+ : String(embSetupError),
6079
+ });
6080
+ // routingEmbedFn remains undefined — fast-path is skipped.
5984
6081
  }
5985
6082
  }
5986
- catch {
5987
- // Telemetry must never affect routing behaviour.
5988
- }
6083
+ routedExcludeTools = await resolveToolRoutingExclusions({
6084
+ catalog,
6085
+ alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
6086
+ userQuery: routingQuery,
6087
+ routerPromptPrefix: routingConfig.routerPromptPrefix,
6088
+ routerModel: {
6089
+ provider: routingConfig.routerModel?.provider ??
6090
+ options.provider,
6091
+ model: routingConfig.routerModel?.model ?? options.model,
6092
+ region: routingConfig.routerModel?.region ?? options.region,
6093
+ temperature: routingConfig.routerModel?.temperature,
6094
+ },
6095
+ timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
6096
+ // Forward the abort signal so a cancelled turn aborts the router
6097
+ // call promptly instead of waiting out the routing timeout.
6098
+ generateFn: (generateOptions) => this.generate({
6099
+ ...generateOptions,
6100
+ abortSignal: options.abortSignal,
6101
+ }),
6102
+ emitDecision: captureDecision,
6103
+ // L2 / ITEM D — only populated when embedding is configured.
6104
+ embedFn: routingEmbedFn,
6105
+ embeddingConfig: embeddingCfg,
6106
+ granularity: routingConfig.granularity ?? "server",
6107
+ // Pass the persistent vector cache so tool embeddings are reused
6108
+ // across turns (Finding 1).
6109
+ embeddingVectorCache: routingEmbedFn !== undefined
6110
+ ? this.toolRoutingVectorCache
6111
+ : undefined,
6112
+ });
5989
6113
  }
5990
- else {
5991
- // Cache MISS — run the router LLM.
5992
- // The router call re-enters the public generate(), whose finally block
5993
- // resets _disableToolCacheForCurrentRequest to false. That flag is
5994
- // turn-scoped and read by the main tool execution path after routing, so
5995
- // save it before the router call and restore it afterward.
5996
- const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5997
- try {
5998
- // Intercept the decision so we can store it in the cache.
5999
- const captureDecision = (decision) => {
6000
- resolvedDecision = decision;
6001
- emitDecision(decision);
6002
- };
6003
- routedExcludeTools = await resolveToolRoutingExclusions({
6004
- catalog,
6005
- alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
6006
- userQuery: routingQuery,
6007
- routerPromptPrefix: routingConfig.routerPromptPrefix,
6008
- routerModel: {
6009
- provider: routingConfig.routerModel?.provider ??
6010
- options.provider,
6011
- model: routingConfig.routerModel?.model ?? options.model,
6012
- region: routingConfig.routerModel?.region ?? options.region,
6013
- temperature: routingConfig.routerModel?.temperature,
6014
- },
6015
- timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
6016
- // Forward the abort signal so a cancelled turn aborts the router
6017
- // call promptly instead of waiting out the routing timeout.
6018
- generateFn: (generateOptions) => this.generate({
6019
- ...generateOptions,
6020
- abortSignal: options.abortSignal,
6021
- }),
6022
- emitDecision: captureDecision,
6023
- });
6024
- }
6025
- finally {
6026
- this._disableToolCacheForCurrentRequest =
6027
- cacheDisabledForCurrentRequest;
6028
- }
6029
- if (resolvedDecision?.outcome === "applied") {
6030
- selectedServerIds = resolvedDecision.selectedServerIds;
6031
- }
6114
+ finally {
6115
+ this._disableToolCacheForCurrentRequest =
6116
+ cacheDisabledForCurrentRequest;
6032
6117
  }
6033
6118
  // Aborted during the router call — skip applying now-stale exclusions;
6034
6119
  // the main generation path enforces the abort itself.
6035
6120
  if (options.abortSignal?.aborted) {
6036
6121
  return;
6037
6122
  }
6038
- // Capture raw (pre-stickiness) exclusions for cache storage on MISS path.
6039
- const rawExclusions = [...routedExcludeTools];
6040
- // Apply stickiness on BOTH hit and miss paths: the sticky ids were
6041
- // recorded on a prior turn and represent servers that should stay warm for
6042
- // the current turn. Consuming (decrementing) them before recordSelection
6043
- // ensures the window covers the correct set of future turns rather than
6044
- // burning one count on the same turn the selection is recorded
6045
- // (off-by-one fix).
6123
+ // Snapshot the raw (pre-stickiness) exclusion list before applying
6124
+ // stickiness overrides. The cache stores this snapshot so future cache
6125
+ // hits can re-apply the then-current stickiness state (Finding 2).
6126
+ const preStickinessExcludeTools = routedExcludeTools;
6127
+ // Apply stickiness FIRST: the sticky ids were recorded on a prior turn and
6128
+ // represent servers that should stay warm for the current turn. Consuming
6129
+ // (decrementing) them before recordSelection ensures the window covers the
6130
+ // correct set of future turns rather than burning one count on the same
6131
+ // turn the selection is recorded (off-by-one fix).
6046
6132
  if (stickinessEnabled && cache && sessionId) {
6047
6133
  try {
6048
6134
  const stickyIds = cache.getStickyServerIds(sessionId);
@@ -6057,15 +6143,16 @@ Current user's request: ${currentInput}`;
6057
6143
  // Stickiness failure is non-fatal.
6058
6144
  }
6059
6145
  }
6060
- // --- ITEM C: store result for future turns (MISS path only) ---
6061
- // Cache stores RAW (pre-stickiness) exclusions so the cached value is
6062
- // correct regardless of which sessions hit it on subsequent turns.
6063
- if (!fromCache && resolvedDecision?.outcome === "applied" && cache) {
6146
+ // --- ITEM C: store result for future turns ---
6147
+ if (resolvedDecision?.outcome === "applied" && cache) {
6148
+ // Store the PRE-stickiness exclusion list in the cache so future hits
6149
+ // re-apply the live stickiness window rather than the one from this
6150
+ // turn (Finding 2).
6064
6151
  if (cacheEnabled && cacheKey !== undefined) {
6065
6152
  try {
6066
6153
  cache.set(cacheKey, {
6067
- excludedToolNames: rawExclusions,
6068
- selectedServerIds,
6154
+ excludedToolNames: preStickinessExcludeTools,
6155
+ selectedServerIds: resolvedDecision.selectedServerIds,
6069
6156
  });
6070
6157
  }
6071
6158
  catch {
@@ -6077,7 +6164,7 @@ Current user's request: ${currentInput}`;
6077
6164
  // the same turn it is set (the off-by-one fix above).
6078
6165
  if (stickinessEnabled && sessionId) {
6079
6166
  try {
6080
- cache.recordSelection(sessionId, selectedServerIds);
6167
+ cache.recordSelection(sessionId, resolvedDecision.selectedServerIds);
6081
6168
  }
6082
6169
  catch {
6083
6170
  // Stickiness failure is non-fatal.
@@ -6174,6 +6261,12 @@ Current user's request: ${currentInput}`;
6174
6261
  return;
6175
6262
  }
6176
6263
  this.toolRoutingConfig.servers = servers;
6264
+ // Clear the persisted vector cache so tool vectors are recomputed against
6265
+ // the new catalog on the next turn (stale vectors must never be reused).
6266
+ this.toolRoutingVectorCache = undefined;
6267
+ // Cached routing decisions are catalog-dependent too; force the next turn
6268
+ // to recompute exclusions against the new server/tool set.
6269
+ this.toolRoutingCacheInstance = undefined;
6177
6270
  }
6178
6271
  async validateStreamRequestOptions(options, startTime) {
6179
6272
  await this.validateStreamInput(options);
@@ -8484,9 +8577,16 @@ Current user's request: ${currentInput}`;
8484
8577
  : params
8485
8578
  ? JSON.stringify(params)
8486
8579
  : "";
8580
+ const executionStartTime = Date.now();
8581
+ // Per-invocation id so consumers can correlate a tool:start with its matching
8582
+ // tool:end even when the same tool runs multiple times concurrently.
8583
+ const executionId = `${toolName}-${executionStartTime}-${Math.random()
8584
+ .toString(36)
8585
+ .slice(2, 11)}`;
8487
8586
  return {
8488
8587
  functionTag: "NeuroLink.executeTool",
8489
- executionStartTime: Date.now(),
8588
+ executionStartTime,
8589
+ executionId,
8490
8590
  externalTool,
8491
8591
  toolType,
8492
8592
  inputSize: inputStr.length,
@@ -8544,6 +8644,7 @@ Current user's request: ${currentInput}`;
8544
8644
  this.emitter.emit("tool:start", createToolEventPayload(toolName, {
8545
8645
  timestamp: executionContext.executionStartTime,
8546
8646
  input: params,
8647
+ executionId: executionContext.executionId,
8547
8648
  }));
8548
8649
  const toolInfo = this.toolRegistry.getToolInfo(toolName);
8549
8650
  const finalOptions = {
@@ -8709,7 +8810,7 @@ Current user's request: ${currentInput}`;
8709
8810
  prepared.metrics.errorCategories[mappedCategory] =
8710
8811
  (prepared.metrics.errorCategories[mappedCategory] || 0) + 1;
8711
8812
  }
8712
- this.emitToolEndEvent(toolName, executionContext.executionStartTime, !isToolError, result, isToolError && errorText ? new Error(errorText) : undefined);
8813
+ this.emitToolEndEvent(toolName, executionContext.executionStartTime, !isToolError, result, isToolError && errorText ? new Error(errorText) : undefined, executionContext.executionId);
8713
8814
  toolSpan.setAttribute("tool.result.status", isToolError ? "error" : "success");
8714
8815
  toolSpan.setAttribute("tool.duration_ms", executionTime);
8715
8816
  return result;
@@ -8728,7 +8829,7 @@ Current user's request: ${currentInput}`;
8728
8829
  });
8729
8830
  prepared.metrics.errorCategories[ErrorCategory.EXECUTION] =
8730
8831
  (prepared.metrics.errorCategories[ErrorCategory.EXECUTION] || 0) + 1;
8731
- this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, new Error(`Circuit breaker open for ${toolName} (state=${error.breakerState}, failures=${error.failureCount})`));
8832
+ this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, new Error(`Circuit breaker open for ${toolName} (state=${error.breakerState}, failures=${error.failureCount})`), executionContext.executionId);
8732
8833
  toolSpan.setAttribute("tool.result.status", "circuit_breaker_open");
8733
8834
  toolSpan.setAttribute("tool.duration_ms", executionTime);
8734
8835
  toolSpan.setAttribute("tool.circuit_breaker.state", error.breakerState);
@@ -8783,7 +8884,7 @@ Current user's request: ${currentInput}`;
8783
8884
  const category = structuredError.category || ErrorCategory.EXECUTION;
8784
8885
  prepared.metrics.errorCategories[category] =
8785
8886
  (prepared.metrics.errorCategories[category] || 0) + 1;
8786
- this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, structuredError);
8887
+ this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, structuredError, executionContext.executionId);
8787
8888
  // Gate on listenerCount: Node EventEmitter rethrows the original error
8788
8889
  // from emit("error", e) when no listener is registered, which would
8789
8890
  // short-circuit the surrounding flow and surface as an unhandled
@@ -220,7 +220,7 @@ export async function executeAutoresearchTick(task, neurolink, emitter) {
220
220
  ...(phasePolicy.forcedTool
221
221
  ? {
222
222
  prepareStep: ({ stepNumber }) => {
223
- if (stepNumber === 0) {
223
+ if (stepNumber === 0 && phasePolicy.forcedTool) {
224
224
  return {
225
225
  toolChoice: {
226
226
  type: "tool",
@@ -38,6 +38,71 @@ export type ToolRoutingModelConfig = {
38
38
  /** Router sampling temperature. Default: 0. */
39
39
  temperature?: number;
40
40
  };
41
+ /**
42
+ * Weights for the hybrid scoring formula used by `ToolEmbeddingIndex.rank()`.
43
+ * Scores are computed as: `cosine * cosine + bm25 * bm25Score` then
44
+ * normalized before sorting.
45
+ * Default: `{ cosine: 0.8, bm25: 0.2 }`.
46
+ */
47
+ export type ToolRetrievalWeights = {
48
+ /** Weight applied to the cosine-similarity (dense) component. */
49
+ cosine: number;
50
+ /** Weight applied to the BM25 (sparse/lexical) component. */
51
+ bm25: number;
52
+ };
53
+ /**
54
+ * Configuration for the L2 embedding fast-path (ITEM B).
55
+ *
56
+ * When enabled and the catalog's total tool count reaches `minToolsToActivate`,
57
+ * a hybrid cosine + BM25 retriever ranks all tools by relevance to the query
58
+ * and takes the top-`topK` candidates. This is far cheaper than an LLM call
59
+ * (sub-10 ms warm) and fires BEFORE or INSTEAD of the LLM router.
60
+ *
61
+ * Fail-open: any embedding error (missing provider, network failure, wrong
62
+ * model) silently falls back to the existing LLM-router / server-granularity
63
+ * path — the turn is never broken.
64
+ */
65
+ export type ToolRoutingEmbeddingConfig = {
66
+ /**
67
+ * Activate the embedding fast-path. Default: false (backward-compatible).
68
+ * Setting this to true without supplying `provider`/`model` causes the SDK
69
+ * to try the stream call's configured provider; if that provider does not
70
+ * support embeddings the layer fails open.
71
+ */
72
+ enabled?: boolean;
73
+ /**
74
+ * Maximum number of top-ranked tool candidates passed to the post-embedding
75
+ * decision stage. Default: 20.
76
+ */
77
+ topK?: number;
78
+ /**
79
+ * Minimum total tool count in the catalog before the embedding path
80
+ * activates. Below this threshold the catalog is small enough that the LLM
81
+ * router alone is cheap and fast. Default: 20.
82
+ */
83
+ minToolsToActivate?: number;
84
+ /**
85
+ * Weights for the hybrid scoring formula:
86
+ * score = cosine * cosineSim + bm25 * bm25Score (both normalized to [0,1])
87
+ * Default: `{ cosine: 0.8, bm25: 0.2 }`.
88
+ */
89
+ weights?: ToolRetrievalWeights;
90
+ /**
91
+ * Provider name to use for the embedding call (e.g. "openai", "vertex").
92
+ * Defaults to the stream/generate call's configured provider. The provider
93
+ * must support `embedMany()`.
94
+ */
95
+ provider?: string;
96
+ /**
97
+ * Embedding model name (provider-specific). When omitted the provider's
98
+ * default embedding model is used (e.g. text-embedding-3-small for OpenAI).
99
+ */
100
+ model?: string;
101
+ /**
102
+ * Timeout for embedding calls in milliseconds. Default: 10000.
103
+ */
104
+ timeoutMs?: number;
105
+ };
41
106
  /** Constructor-level configuration for pre-call tool routing. */
42
107
  export type ToolRoutingConfig = {
43
108
  /** Master switch. Routing runs only when true AND the server catalog is non-empty. */
@@ -88,6 +153,24 @@ export type ToolRoutingConfig = {
88
153
  /** Number of turns for which a previously-selected server stays warm. Default: 3. */
89
154
  turns?: number;
90
155
  };
156
+ /**
157
+ * L2 embedding fast-path (ITEM B). When enabled the SDK ranks tools by
158
+ * semantic + lexical relevance using a hybrid cosine/BM25 score and narrows
159
+ * the candidate set BEFORE (or instead of) the LLM router. Disabled by
160
+ * default for backward compatibility.
161
+ */
162
+ embedding?: ToolRoutingEmbeddingConfig;
163
+ /**
164
+ * Routing granularity (ITEM D).
165
+ *
166
+ * - `"server"` (default) — routing excludes the tools of entire unpicked
167
+ * servers. This is the original behavior.
168
+ * - `"tool"` — routing excludes individual tools that are not in the
169
+ * embedding top-K candidate set, regardless of which server they belong
170
+ * to. Requires `embedding.enabled: true`; if the embedding fast-path is
171
+ * off (or fails) the granularity falls back to `"server"` automatically.
172
+ */
173
+ granularity?: "server" | "tool";
91
174
  };
92
175
  /** Catalog entry pairing a server descriptor with its registered tool names. */
93
176
  export type ToolRoutingCatalogEntry = {
@@ -152,6 +235,19 @@ export type ToolRoutingDecision = {
152
235
  cacheHit: boolean;
153
236
  /** Wall-clock time spent in the routing resolution in milliseconds. */
154
237
  durationMs: number;
238
+ /** True when the L2 embedding fast-path ran and produced candidate results. */
239
+ embeddingActivated?: boolean;
240
+ /**
241
+ * Number of tool candidates produced by the embedding retriever before the
242
+ * post-embedding server or tool filtering step.
243
+ */
244
+ candidateToolCount?: number;
245
+ /**
246
+ * Granularity at which exclusions were applied ("server" or "tool").
247
+ * Matches `ToolRoutingConfig.granularity`; present only when routing was
248
+ * applied (outcome === "applied").
249
+ */
250
+ granularity?: "server" | "tool";
155
251
  };
156
252
  /** Parameters for `resolveToolRoutingExclusions()`. */
157
253
  export type ToolRoutingResolutionParams = {
@@ -176,4 +272,79 @@ export type ToolRoutingResolutionParams = {
176
272
  * the resolver.
177
273
  */
178
274
  emitDecision?: (decision: ToolRoutingDecision) => void;
275
+ /**
276
+ * Injected async function that converts an array of texts into embedding
277
+ * vectors. Built by the caller (NeuroLink) from the configured embedding
278
+ * provider so the resolver stays pure and free of provider imports.
279
+ * When undefined the embedding fast-path is skipped entirely.
280
+ */
281
+ embedFn?: (texts: string[]) => Promise<number[][]>;
282
+ /**
283
+ * Embedding fast-path configuration forwarded from `ToolRoutingConfig`.
284
+ * Only consulted when `embedFn` is provided.
285
+ */
286
+ embeddingConfig?: ToolRoutingEmbeddingConfig;
287
+ /**
288
+ * Routing granularity forwarded from `ToolRoutingConfig`. Default: "server".
289
+ */
290
+ granularity?: "server" | "tool";
291
+ /**
292
+ * Optional shared vector cache for the L2 embedding fast-path. When
293
+ * supplied, tool embedding vectors computed on prior turns are reused rather
294
+ * than re-fetched from the embedding provider on every call.
295
+ *
296
+ * The NeuroLink instance manages the lifecycle: it creates the Map once and
297
+ * passes the same reference across turns. It clears the reference when the
298
+ * tool catalog changes (via `setToolRoutingServers`) so stale vectors are
299
+ * never used after a catalog update.
300
+ */
301
+ embeddingVectorCache?: Map<string, number[]>;
302
+ };
303
+ /**
304
+ * A single item in the tool retrieval catalog, pairing a tool name with the
305
+ * text (tool description + server context) used to build its embedding vector.
306
+ */
307
+ export type ToolRetrievalItem = {
308
+ /** Fully-qualified tool name (e.g. `${serverId}_${toolName}`). */
309
+ name: string;
310
+ /** Descriptive text used as the embedding document for this tool. */
311
+ text: string;
312
+ };
313
+ /**
314
+ * One ranked result from `ToolEmbeddingIndex.rank()` or
315
+ * `selectRelevantToolNames()`.
316
+ */
317
+ export type ToolRetrievalRankedResult = {
318
+ /** Tool name (mirrors `ToolRetrievalItem.name`). */
319
+ name: string;
320
+ /** Combined hybrid score (higher = more relevant). */
321
+ score: number;
322
+ };
323
+ /**
324
+ * Options passed to `selectRelevantToolNames()` — the high-level convenience
325
+ * wrapper around `ToolEmbeddingIndex`.
326
+ */
327
+ export type ToolRetrievalSelectOptions = {
328
+ /** Maximum number of tool names to return. */
329
+ topK: number;
330
+ /** Optional weight override (defaults to `{ cosine: 0.8, bm25: 0.2 }`). */
331
+ weights?: ToolRetrievalWeights;
332
+ /**
333
+ * Async function that converts an array of text strings into embedding
334
+ * vectors. Must return one vector per input text in the same order.
335
+ * Errors thrown here propagate to the caller (so it can fail open).
336
+ */
337
+ embedFn: (texts: string[]) => Promise<number[][]>;
338
+ /**
339
+ * Optional shared vector cache (keyed by text string). When supplied the
340
+ * underlying `ToolEmbeddingIndex` reads from and writes to this Map so that
341
+ * tool vectors computed on a prior call are reused on subsequent calls for
342
+ * the same item text. Callers that want warm-cache behavior across turns
343
+ * should pass the same Map instance each time.
344
+ */
345
+ vectorCache?: Map<string, number[]>;
346
+ /**
347
+ * Timeout for each embedding provider call in milliseconds. Default: 10000.
348
+ */
349
+ timeoutMs?: number;
179
350
  };
@@ -128,7 +128,6 @@ async function executeModel(options, workflowDefaultSystemPrompt) {
128
128
  let result;
129
129
  let attempts = 0;
130
130
  const MAX_ATTEMPTS = 2;
131
- /* eslint-disable no-constant-condition */
132
131
  while (true) {
133
132
  attempts++;
134
133
  result = await executeWithTimeout(async () => {
@@ -145,7 +144,6 @@ async function executeModel(options, workflowDefaultSystemPrompt) {
145
144
  }
146
145
  logger.warn(`[${functionTag}] Model returned empty content — retrying once`, { provider: model.provider, model: model.model, attempt: attempts });
147
146
  }
148
- /* eslint-enable no-constant-condition */
149
147
  const responseTime = Date.now() - startTime;
150
148
  logger.debug(`[${functionTag}] Model execution successful`, {
151
149
  provider: model.provider,
@@ -102,6 +102,7 @@ export declare class NeuroLink {
102
102
  private conversationMemoryConfig?;
103
103
  private toolRoutingConfig?;
104
104
  private toolRoutingCacheInstance?;
105
+ private toolRoutingVectorCache?;
105
106
  private toolDedupConfig?;
106
107
  private enableOrchestration;
107
108
  private authProvider?;