@juspay/neurolink 9.73.0 → 9.75.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.
@@ -10,6 +10,7 @@ import { checkRedisAvailability } from "../../lib/utils/conversationMemory.js";
10
10
  import { normalizeEvaluationData } from "../../lib/utils/evaluationUtils.js";
11
11
  import { logger } from "../../lib/utils/logger.js";
12
12
  import { createThinkingConfigFromRecord } from "../../lib/utils/thinkingConfig.js";
13
+ import { buildToolRoutingConfigFromCli } from "../utils/toolRoutingFlags.js";
13
14
  import { configManager } from "../commands/config.js";
14
15
  import { MCPCommandFactory } from "../commands/mcp.js";
15
16
  import { ModelsCommandFactory } from "../commands/models.js";
@@ -540,6 +541,42 @@ export class CLICommandFactory {
540
541
  description: "Thinking level for extended reasoning (Anthropic Claude, Gemini 2.5+, Gemini 3): minimal, low, medium, high",
541
542
  choices: ["minimal", "low", "medium", "high"],
542
543
  },
544
+ // Tool-routing options
545
+ toolRouting: {
546
+ type: "boolean",
547
+ description: "Enable pre-call per-turn tool routing (narrows MCP tools by relevance).",
548
+ },
549
+ toolRoutingTimeout: {
550
+ type: "number",
551
+ description: "Router LLM hard timeout in milliseconds.",
552
+ alias: "tool-routing-timeout",
553
+ },
554
+ toolRoutingRouterProvider: {
555
+ type: "string",
556
+ description: "Override the provider used for the router LLM call.",
557
+ alias: "tool-routing-router-provider",
558
+ },
559
+ toolRoutingRouterModel: {
560
+ type: "string",
561
+ description: "Override the model used for the router LLM call.",
562
+ alias: "tool-routing-router-model",
563
+ },
564
+ toolRoutingRouterRegion: {
565
+ type: "string",
566
+ description: "Override the region used for the router LLM call.",
567
+ alias: "tool-routing-router-region",
568
+ },
569
+ toolRoutingAlwaysInclude: {
570
+ type: "array",
571
+ description: "Server ids whose tools are always kept and never offered to the router (repeatable).",
572
+ alias: "tool-routing-always-include",
573
+ string: true,
574
+ },
575
+ toolRoutingServers: {
576
+ type: "string",
577
+ description: "Path to a JSON file OR inline JSON array of {id, description} server descriptors for the routable catalog.",
578
+ alias: "tool-routing-servers",
579
+ },
543
580
  region: {
544
581
  type: "string",
545
582
  description: "Vertex AI region (e.g., us-central1, europe-west1, asia-northeast1)",
@@ -821,6 +858,16 @@ export class CLICommandFactory {
821
858
  authMethod: argv.authMethod,
822
859
  subscriptionTier: argv.subscriptionTier,
823
860
  enableBeta: argv.enableBeta,
861
+ // Tool-routing flags — constructor-level config, not a per-call option.
862
+ // Passed through the options bag so handlers can inject into the SDK
863
+ // instance before the first getOrCreateNeuroLink() call.
864
+ toolRouting: argv.toolRouting,
865
+ toolRoutingTimeout: argv.toolRoutingTimeout,
866
+ toolRoutingRouterProvider: argv.toolRoutingRouterProvider,
867
+ toolRoutingRouterModel: argv.toolRoutingRouterModel,
868
+ toolRoutingRouterRegion: argv.toolRoutingRouterRegion,
869
+ toolRoutingAlwaysInclude: argv.toolRoutingAlwaysInclude,
870
+ toolRoutingServers: argv.toolRoutingServers,
824
871
  };
825
872
  }
826
873
  /**
@@ -2217,6 +2264,11 @@ export class CLICommandFactory {
2217
2264
  }
2218
2265
  return;
2219
2266
  }
2267
+ // Inject tool-routing config into the SDK instance before constructing it.
2268
+ const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
2269
+ if (toolRoutingConfig) {
2270
+ globalSession.setToolRoutingConfig(toolRoutingConfig);
2271
+ }
2220
2272
  // Initialize SDK and session
2221
2273
  const sdk = globalSession.getOrCreateNeuroLink();
2222
2274
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
@@ -2447,6 +2499,11 @@ export class CLICommandFactory {
2447
2499
  * Execute real streaming with timeout handling
2448
2500
  */
2449
2501
  static async executeRealStream(argv, options, inputText, contextMetadata) {
2502
+ // Inject tool-routing config into the SDK instance before constructing it.
2503
+ const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
2504
+ if (toolRoutingConfig) {
2505
+ globalSession.setToolRoutingConfig(toolRoutingConfig);
2506
+ }
2450
2507
  const sdk = globalSession.getOrCreateNeuroLink();
2451
2508
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
2452
2509
  const enhancedOptions = { ...options, ...sessionVariables };
@@ -2923,6 +2980,11 @@ export class CLICommandFactory {
2923
2980
  logger.always(chalk.blue(`📦 Processing ${prompts.length} prompts...\n`));
2924
2981
  }
2925
2982
  const results = [];
2983
+ // Inject tool-routing config into the SDK instance before constructing it.
2984
+ const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
2985
+ if (toolRoutingConfig) {
2986
+ globalSession.setToolRoutingConfig(toolRoutingConfig);
2987
+ }
2926
2988
  const sdk = globalSession.getOrCreateNeuroLink();
2927
2989
  const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
2928
2990
  const enhancedOptions = { ...options, ...sessionVariables };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * CLI helper: parse --tool-routing-* flags into a ToolRoutingConfig.
3
+ *
4
+ * This module is intentionally side-effect-free so it can be unit-tested
5
+ * without a running NeuroLink instance.
6
+ */
7
+ import type { CliToolRoutingFlags, ToolRoutingConfig } from "../../lib/types/index.js";
8
+ /**
9
+ * Build a {@link ToolRoutingConfig} from the parsed CLI flags.
10
+ *
11
+ * Returns `undefined` when `--tool-routing` is absent or false, so callers
12
+ * can skip the setter entirely with a simple truthiness check — behaviour is
13
+ * identical to routing being disabled.
14
+ *
15
+ * `--tool-routing-servers` is parsed permissively:
16
+ * - If the value looks like an existing file path, the file is read and JSON-parsed.
17
+ * - Otherwise the value is treated as an inline JSON string.
18
+ * - On any parse error a warning is logged and `servers` is omitted (fail open).
19
+ */
20
+ export declare function buildToolRoutingConfigFromCli(flags: CliToolRoutingFlags & Record<string, unknown>): ToolRoutingConfig | undefined;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * CLI helper: parse --tool-routing-* flags into a ToolRoutingConfig.
3
+ *
4
+ * This module is intentionally side-effect-free so it can be unit-tested
5
+ * without a running NeuroLink instance.
6
+ */
7
+ import fs from "node:fs";
8
+ import { logger } from "../../lib/utils/logger.js";
9
+ /**
10
+ * Build a {@link ToolRoutingConfig} from the parsed CLI flags.
11
+ *
12
+ * Returns `undefined` when `--tool-routing` is absent or false, so callers
13
+ * can skip the setter entirely with a simple truthiness check — behaviour is
14
+ * identical to routing being disabled.
15
+ *
16
+ * `--tool-routing-servers` is parsed permissively:
17
+ * - If the value looks like an existing file path, the file is read and JSON-parsed.
18
+ * - Otherwise the value is treated as an inline JSON string.
19
+ * - On any parse error a warning is logged and `servers` is omitted (fail open).
20
+ */
21
+ export function buildToolRoutingConfigFromCli(flags) {
22
+ if (!flags.toolRouting) {
23
+ return undefined;
24
+ }
25
+ const config = { enabled: true };
26
+ if (flags.toolRoutingTimeout !== undefined) {
27
+ const t = flags.toolRoutingTimeout;
28
+ if (Number.isFinite(t) && t > 0) {
29
+ config.timeoutMs = t;
30
+ }
31
+ else {
32
+ logger.warn(`[tool-routing] --tool-routing-timeout value ${String(t)} is not a positive finite number; ignoring (SDK default applies).`);
33
+ }
34
+ }
35
+ const hasRouterProvider = typeof flags.toolRoutingRouterProvider === "string";
36
+ const hasRouterModel = typeof flags.toolRoutingRouterModel === "string";
37
+ const hasRouterRegion = typeof flags.toolRoutingRouterRegion === "string";
38
+ if (hasRouterProvider || hasRouterModel || hasRouterRegion) {
39
+ config.routerModel = {
40
+ ...(hasRouterProvider && {
41
+ provider: flags.toolRoutingRouterProvider,
42
+ }),
43
+ ...(hasRouterModel && {
44
+ model: flags.toolRoutingRouterModel,
45
+ }),
46
+ ...(hasRouterRegion && {
47
+ region: flags.toolRoutingRouterRegion,
48
+ }),
49
+ };
50
+ }
51
+ if (Array.isArray(flags.toolRoutingAlwaysInclude) &&
52
+ flags.toolRoutingAlwaysInclude.length > 0) {
53
+ config.alwaysIncludeServerIds = flags.toolRoutingAlwaysInclude;
54
+ }
55
+ if (typeof flags.toolRoutingServers === "string" &&
56
+ flags.toolRoutingServers.trim() !== "") {
57
+ config.servers = parseServersFlag(flags.toolRoutingServers);
58
+ }
59
+ return config;
60
+ }
61
+ // Trust model: --tool-routing-servers is a local, user-supplied CLI flag.
62
+ // The invoking user already holds the process's OS permissions, so absolute
63
+ // and home-dir config paths are fully supported — there is no cross-trust-
64
+ // boundary to enforce. The caps below are a robustness guard against
65
+ // accidentally pointing at a huge file (e.g. a log or binary) that would
66
+ // silently OOM the process or stall the CLI, not a security boundary.
67
+ const MAX_SERVERS_INPUT_BYTES = 1_000_000; // 1 MB
68
+ const MAX_SERVERS_ENTRIES = 1_000;
69
+ /**
70
+ * Parse the `--tool-routing-servers` value.
71
+ *
72
+ * Tries file-path first (if the string exists on disk), then inline JSON.
73
+ * Logs a warning and returns `undefined` on any error (fail open).
74
+ */
75
+ function parseServersFlag(value) {
76
+ try {
77
+ // File-path branch: resolve relative to cwd, check existence.
78
+ const resolved = fs.existsSync(value) ? value : null;
79
+ if (resolved !== null) {
80
+ const { size } = fs.statSync(resolved);
81
+ if (size > MAX_SERVERS_INPUT_BYTES) {
82
+ logger.warn(`[tool-routing] --tool-routing-servers file exceeds ${MAX_SERVERS_INPUT_BYTES} bytes (got ${size}); ignoring to avoid excessive memory use.`);
83
+ return undefined;
84
+ }
85
+ }
86
+ else if (Buffer.byteLength(value, "utf8") > MAX_SERVERS_INPUT_BYTES) {
87
+ logger.warn(`[tool-routing] --tool-routing-servers inline JSON exceeds ${MAX_SERVERS_INPUT_BYTES} bytes; ignoring to avoid excessive memory use.`);
88
+ return undefined;
89
+ }
90
+ const jsonText = resolved ? fs.readFileSync(resolved, "utf8") : value;
91
+ const parsed = JSON.parse(jsonText);
92
+ if (!Array.isArray(parsed)) {
93
+ logger.warn("[tool-routing] --tool-routing-servers must be a JSON array; ignoring.");
94
+ return undefined;
95
+ }
96
+ // Validate shape loosely — only keep entries with id + description strings.
97
+ const valid = parsed.filter((entry) => typeof entry === "object" &&
98
+ entry !== null &&
99
+ typeof entry.id === "string" &&
100
+ typeof entry.description === "string");
101
+ if (valid.length !== parsed.length) {
102
+ logger.warn(`[tool-routing] ${parsed.length - valid.length} server descriptor(s) skipped — each must have string "id" and "description" fields.`);
103
+ }
104
+ if (valid.length > MAX_SERVERS_ENTRIES) {
105
+ logger.warn(`[tool-routing] --tool-routing-servers contains ${valid.length} entries; truncating to ${MAX_SERVERS_ENTRIES} to prevent pathological inputs.`);
106
+ return valid.slice(0, MAX_SERVERS_ENTRIES);
107
+ }
108
+ return valid.length > 0 ? valid : undefined;
109
+ }
110
+ catch (err) {
111
+ logger.warn(`[tool-routing] Failed to parse --tool-routing-servers: ${err.message}. Omitting servers (fail open).`);
112
+ return undefined;
113
+ }
114
+ }
115
+ //# sourceMappingURL=toolRoutingFlags.js.map
@@ -154,20 +154,48 @@ function parseRouterJson(rawText) {
154
154
  throw new Error("Router response is not valid JSON");
155
155
  }
156
156
  }
157
+ /**
158
+ * Safely calls the emitDecision callback, swallowing any error so telemetry
159
+ * can never interfere with the routing result or the caller's turn.
160
+ */
161
+ function safeEmitDecision(emitDecision, decision) {
162
+ if (!emitDecision) {
163
+ return;
164
+ }
165
+ try {
166
+ emitDecision(decision);
167
+ }
168
+ catch {
169
+ // Intentionally swallowed — telemetry must never affect routing behaviour.
170
+ }
171
+ }
157
172
  /**
158
173
  * Resolves which registered tool names to EXCLUDE for a single stream() turn.
159
174
  * Returns an empty list on any skip/failure path — see module doc.
160
175
  */
161
176
  export async function resolveToolRoutingExclusions(params) {
162
- const { catalog, alwaysIncludeServerIds, userQuery, routerPromptPrefix, routerModel, timeoutMs, generateFn, } = params;
177
+ const { catalog, alwaysIncludeServerIds, userQuery, routerPromptPrefix, routerModel, timeoutMs, generateFn, emitDecision, } = params;
163
178
  const routableServers = catalog.filter((server) => !alwaysIncludeServerIds.includes(server.id));
164
179
  const routingStartTime = Date.now();
165
180
  try {
166
181
  if (!userQuery || routableServers.length <= 1) {
182
+ const outcome = !userQuery
183
+ ? "skipped-no-query"
184
+ : "skipped-single-server";
167
185
  logger.debug("[ToolRouting] Routing skipped", {
168
186
  reason: !userQuery ? "missingUserQuery" : "singleRoutableServer",
169
187
  routableServerCount: routableServers.length,
170
188
  });
189
+ safeEmitDecision(emitDecision, {
190
+ outcome,
191
+ selectedServerIds: [],
192
+ excludedServerIds: [],
193
+ hallucinatedIds: [],
194
+ excludedToolCount: 0,
195
+ routableServerCount: routableServers.length,
196
+ cacheHit: false,
197
+ durationMs: Date.now() - routingStartTime,
198
+ });
171
199
  return [];
172
200
  }
173
201
  const routerPrompt = buildRouterPrompt(userQuery, routableServers, routerPromptPrefix);
@@ -190,14 +218,37 @@ export async function resolveToolRoutingExclusions(params) {
190
218
  : {}),
191
219
  }), timeoutMs, `Tool routing router call exceeded ${timeoutMs}ms`);
192
220
  const rawText = generateResult?.content ?? "";
193
- const parsed = routerOutputSchema.safeParse(parseRouterJson(rawText));
194
- if (!parsed.success) {
221
+ /** Shared fail-open handler for both JSON parse and schema validation failures. */
222
+ const failOpenParse = (extra) => {
195
223
  logger.warn("[ToolRouting] Router output validation failed, failing open", {
196
- validationErrors: parsed.error.issues.map((issue) => issue.message),
197
224
  rawResponse: rawText,
198
225
  durationMs: Date.now() - routingStartTime,
226
+ ...extra,
227
+ });
228
+ safeEmitDecision(emitDecision, {
229
+ outcome: "failed-open-parse",
230
+ selectedServerIds: [],
231
+ excludedServerIds: [],
232
+ hallucinatedIds: [],
233
+ excludedToolCount: 0,
234
+ routableServerCount: routableServers.length,
235
+ cacheHit: false,
236
+ durationMs: Date.now() - routingStartTime,
199
237
  });
200
238
  return [];
239
+ };
240
+ let parsed;
241
+ try {
242
+ parsed = routerOutputSchema.safeParse(parseRouterJson(rawText));
243
+ }
244
+ catch {
245
+ // parseRouterJson threw (non-JSON response)
246
+ return failOpenParse();
247
+ }
248
+ if (!parsed.success) {
249
+ return failOpenParse({
250
+ validationErrors: parsed.error.issues.map((issue) => issue.message),
251
+ });
201
252
  }
202
253
  const routableServerIds = new Set(routableServers.map((server) => server.id));
203
254
  const validSelectedIds = parsed.data.servers.filter((serverId) => routableServerIds.has(serverId));
@@ -208,6 +259,16 @@ export async function resolveToolRoutingExclusions(params) {
208
259
  hallucinatedIds,
209
260
  durationMs: Date.now() - routingStartTime,
210
261
  });
262
+ safeEmitDecision(emitDecision, {
263
+ outcome: "empty-pick",
264
+ selectedServerIds: [],
265
+ excludedServerIds: routableServers.map((server) => server.id),
266
+ hallucinatedIds,
267
+ excludedToolCount: 0,
268
+ routableServerCount: routableServers.length,
269
+ cacheHit: false,
270
+ durationMs: Date.now() - routingStartTime,
271
+ });
211
272
  return [];
212
273
  }
213
274
  const unselectedRoutableServers = routableServers.filter((server) => !validSelectedIds.includes(server.id));
@@ -220,13 +281,38 @@ export async function resolveToolRoutingExclusions(params) {
220
281
  routableServerCount: routableServers.length,
221
282
  durationMs: Date.now() - routingStartTime,
222
283
  });
284
+ safeEmitDecision(emitDecision, {
285
+ outcome: "applied",
286
+ selectedServerIds: validSelectedIds,
287
+ excludedServerIds: unselectedRoutableServers.map((server) => server.id),
288
+ hallucinatedIds,
289
+ excludedToolCount: excludedToolNames.length,
290
+ routableServerCount: routableServers.length,
291
+ cacheHit: false,
292
+ durationMs: Date.now() - routingStartTime,
293
+ });
223
294
  return excludedToolNames;
224
295
  }
225
296
  catch (error) {
297
+ const isTimeout = error instanceof Error &&
298
+ error.message.includes("Tool routing router call exceeded");
299
+ const outcome = isTimeout
300
+ ? "failed-open-timeout"
301
+ : "failed-open-error";
226
302
  logger.warn("[ToolRouting] Routing failed, failing open", {
227
303
  error: error instanceof Error ? error.message : String(error),
228
304
  durationMs: Date.now() - routingStartTime,
229
305
  });
306
+ safeEmitDecision(emitDecision, {
307
+ outcome,
308
+ selectedServerIds: [],
309
+ excludedServerIds: [],
310
+ hallucinatedIds: [],
311
+ excludedToolCount: 0,
312
+ routableServerCount: routableServers.length,
313
+ cacheHit: false,
314
+ durationMs: Date.now() - routingStartTime,
315
+ });
230
316
  return [];
231
317
  }
232
318
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * LRU+TTL cache for pre-call tool routing decisions, plus session stickiness
3
+ * tracking to reduce turn-to-turn server flapping.
4
+ *
5
+ * The cache is keyed by a caller-supplied string (typically a normalized hash
6
+ * of sessionId + routing query). On a hit the cached exclusion list is
7
+ * returned, skipping the router LLM entirely. On a miss the caller resolves
8
+ * normally, stores the result, and `recordSelection` is called with the
9
+ * selected server ids so stickiness can suppress their exclusion for the next
10
+ * N turns.
11
+ *
12
+ * All operations are synchronous and pure (no I/O). The clock is injected via
13
+ * `now` so tests can control time without `Date.now` side effects.
14
+ */
15
+ import type { ToolRoutingCacheOptions } from "../types/index.js";
16
+ export declare class ToolRoutingCache {
17
+ private readonly ttlMs;
18
+ private readonly maxEntries;
19
+ private readonly stickyTurns;
20
+ private readonly now;
21
+ /** Main LRU+TTL store keyed by routing key. */
22
+ private readonly store;
23
+ /** Per-session stickiness tracking keyed by session id. */
24
+ private readonly sticky;
25
+ /** Monotonic counter for LRU ordering — incremented on each access. */
26
+ private accessCounter;
27
+ constructor(opts?: ToolRoutingCacheOptions);
28
+ /**
29
+ * Returns the cached routing result for the given key, or `undefined` on a
30
+ * miss or expiry. Expired entries are deleted on access (lazy eviction).
31
+ */
32
+ get(key: string): {
33
+ excludedToolNames: string[];
34
+ selectedServerIds: string[];
35
+ } | undefined;
36
+ /**
37
+ * Stores a routing result under the given key. Evicts the least-recently-
38
+ * used entry when the store is at capacity. Silently no-ops on any error
39
+ * (caller is responsible for fail-open behaviour).
40
+ */
41
+ set(key: string, value: {
42
+ excludedToolNames: string[];
43
+ selectedServerIds: string[];
44
+ }): void;
45
+ /**
46
+ * Records the server ids selected by the router for a session so they stay
47
+ * warm for the next `stickyTurns` turns. Called after a successful
48
+ * (non-cached) routing resolution.
49
+ */
50
+ recordSelection(sessionId: string, serverIds: string[]): void;
51
+ /**
52
+ * Returns the server ids that should be kept warm (not excluded) for the
53
+ * given session due to stickiness. Decrements the turn counter; when it
54
+ * reaches zero the entry is removed. Returns an empty array when there is no
55
+ * active sticky state for the session.
56
+ */
57
+ getStickyServerIds(sessionId: string): string[];
58
+ /**
59
+ * Scans the store for the entry with the lowest accessOrder and removes it.
60
+ * O(n) scan is acceptable at the default maxEntries (256); very large caches
61
+ * should consider an O(1) doubly-linked-list approach instead.
62
+ */
63
+ private evictLRU;
64
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * LRU+TTL cache for pre-call tool routing decisions, plus session stickiness
3
+ * tracking to reduce turn-to-turn server flapping.
4
+ *
5
+ * The cache is keyed by a caller-supplied string (typically a normalized hash
6
+ * of sessionId + routing query). On a hit the cached exclusion list is
7
+ * returned, skipping the router LLM entirely. On a miss the caller resolves
8
+ * normally, stores the result, and `recordSelection` is called with the
9
+ * selected server ids so stickiness can suppress their exclusion for the next
10
+ * N turns.
11
+ *
12
+ * All operations are synchronous and pure (no I/O). The clock is injected via
13
+ * `now` so tests can control time without `Date.now` side effects.
14
+ */
15
+ const DEFAULT_TTL_MS = 60_000;
16
+ const DEFAULT_MAX_ENTRIES = 256;
17
+ const DEFAULT_STICKY_TURNS = 3;
18
+ /**
19
+ * Internal cap on the number of sessions tracked in the sticky map.
20
+ * Not configurable via public API — avoids unbounded memory growth when many
21
+ * ephemeral session ids are generated. Oldest entry (Map insertion order) is
22
+ * evicted when the cap is reached.
23
+ */
24
+ const MAX_STICKY_ENTRIES = 1024;
25
+ export class ToolRoutingCache {
26
+ ttlMs;
27
+ maxEntries;
28
+ stickyTurns;
29
+ now;
30
+ /** Main LRU+TTL store keyed by routing key. */
31
+ store = new Map();
32
+ /** Per-session stickiness tracking keyed by session id. */
33
+ sticky = new Map();
34
+ /** Monotonic counter for LRU ordering — incremented on each access. */
35
+ accessCounter = 0;
36
+ constructor(opts = {}) {
37
+ // Fall back to defaults for invalid (<=0 or NaN) values so callers cannot
38
+ // accidentally disable the cache by passing bad config.
39
+ const validOrDefault = (v, def) => v !== undefined && Number.isFinite(v) && v > 0 ? v : def;
40
+ this.ttlMs = validOrDefault(opts.ttlMs, DEFAULT_TTL_MS);
41
+ this.maxEntries = validOrDefault(opts.maxEntries, DEFAULT_MAX_ENTRIES);
42
+ this.stickyTurns = validOrDefault(opts.stickyTurns, DEFAULT_STICKY_TURNS);
43
+ this.now = opts.now ?? Date.now;
44
+ }
45
+ /**
46
+ * Returns the cached routing result for the given key, or `undefined` on a
47
+ * miss or expiry. Expired entries are deleted on access (lazy eviction).
48
+ */
49
+ get(key) {
50
+ const entry = this.store.get(key);
51
+ if (!entry) {
52
+ return undefined;
53
+ }
54
+ if (this.now() >= entry.expiresAt) {
55
+ this.store.delete(key);
56
+ return undefined;
57
+ }
58
+ // Bump access order for LRU.
59
+ entry.accessOrder = ++this.accessCounter;
60
+ // Return shallow copies so callers cannot mutate cached state.
61
+ return {
62
+ excludedToolNames: [...entry.excludedToolNames],
63
+ selectedServerIds: [...entry.selectedServerIds],
64
+ };
65
+ }
66
+ /**
67
+ * Stores a routing result under the given key. Evicts the least-recently-
68
+ * used entry when the store is at capacity. Silently no-ops on any error
69
+ * (caller is responsible for fail-open behaviour).
70
+ */
71
+ set(key, value) {
72
+ try {
73
+ if (this.store.size >= this.maxEntries && !this.store.has(key)) {
74
+ this.evictLRU();
75
+ }
76
+ // Defensive copies: guard against callers mutating arrays after storing.
77
+ this.store.set(key, {
78
+ excludedToolNames: [...value.excludedToolNames],
79
+ selectedServerIds: [...value.selectedServerIds],
80
+ expiresAt: this.now() + this.ttlMs,
81
+ accessOrder: ++this.accessCounter,
82
+ });
83
+ }
84
+ catch {
85
+ // Silently no-ops on any error — cache writes are non-fatal.
86
+ }
87
+ }
88
+ /**
89
+ * Records the server ids selected by the router for a session so they stay
90
+ * warm for the next `stickyTurns` turns. Called after a successful
91
+ * (non-cached) routing resolution.
92
+ */
93
+ recordSelection(sessionId, serverIds) {
94
+ if (!sessionId || serverIds.length === 0) {
95
+ return;
96
+ }
97
+ // Evict the oldest session when at the internal cap (Map preserves insertion
98
+ // order, so the first key is always the oldest).
99
+ if (this.sticky.size >= MAX_STICKY_ENTRIES && !this.sticky.has(sessionId)) {
100
+ const oldestKey = this.sticky.keys().next().value;
101
+ if (oldestKey !== undefined) {
102
+ this.sticky.delete(oldestKey);
103
+ }
104
+ }
105
+ this.sticky.set(sessionId, {
106
+ serverIds: [...serverIds],
107
+ turnsRemaining: this.stickyTurns,
108
+ });
109
+ }
110
+ /**
111
+ * Returns the server ids that should be kept warm (not excluded) for the
112
+ * given session due to stickiness. Decrements the turn counter; when it
113
+ * reaches zero the entry is removed. Returns an empty array when there is no
114
+ * active sticky state for the session.
115
+ */
116
+ getStickyServerIds(sessionId) {
117
+ if (!sessionId) {
118
+ return [];
119
+ }
120
+ const entry = this.sticky.get(sessionId);
121
+ if (!entry) {
122
+ return [];
123
+ }
124
+ const ids = [...entry.serverIds];
125
+ if (entry.turnsRemaining <= 1) {
126
+ this.sticky.delete(sessionId);
127
+ }
128
+ else {
129
+ entry.turnsRemaining -= 1;
130
+ }
131
+ return ids;
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Private helpers
135
+ // ---------------------------------------------------------------------------
136
+ /**
137
+ * Scans the store for the entry with the lowest accessOrder and removes it.
138
+ * O(n) scan is acceptable at the default maxEntries (256); very large caches
139
+ * should consider an O(1) doubly-linked-list approach instead.
140
+ */
141
+ evictLRU() {
142
+ let lruKey;
143
+ let lruOrder = Infinity;
144
+ for (const [key, entry] of this.store) {
145
+ if (entry.accessOrder < lruOrder) {
146
+ lruOrder = entry.accessOrder;
147
+ lruKey = key;
148
+ }
149
+ }
150
+ if (lruKey !== undefined) {
151
+ this.store.delete(lruKey);
152
+ }
153
+ }
154
+ }
package/dist/index.d.ts CHANGED
@@ -216,6 +216,8 @@ export declare function createBestAIProvider(requestedProvider?: string, modelNa
216
216
  export { CircuitBreakerManager, calculateExpiresAt, createMCPServer, createOAuthProviderFromConfig, DEFAULT_HTTP_RETRY_CONFIG, DEFAULT_RATE_LIMIT_CONFIG, executeMCP, FileTokenStorage, getMCPStats, getServerInfo, globalCircuitBreakerManager, globalRateLimiterManager, HTTPRateLimiter, InMemoryTokenStorage, initializeMCPEcosystem, isRetryableHTTPError, isRetryableStatusCode, isTokenExpired, listMCPs, CircuitBreakerOpenError, MCPCircuitBreaker, mcpLogger, NeuroLinkOAuthProvider, RateLimiterManager, validateServerTools, validateTool as validateMCPTool, withHTTPRetry, MCPToolRegistry, ExternalServerManager, MCPClientFactory, ToolRouter, createToolRouter, DEFAULT_ROUTER_CONFIG, ToolCache, createToolCache, DEFAULT_CACHE_CONFIG, ToolResultCache, createToolResultCache, RequestBatcher, ToolCallBatcher, createRequestBatcher, createToolCallBatcher, DEFAULT_BATCH_CONFIG, inferAnnotations, createAnnotatedTool, validateAnnotations, filterToolsByAnnotations, mergeAnnotations, getAnnotationSummary, requiresConfirmation, isSafeToRetry, getToolSafetyLevel, ElicitationManager, globalElicitationManager, EnhancedToolDiscovery, MCPRegistryClient, globalMCPRegistryClient, getWellKnownServer, getAllWellKnownServers, MCPServerBase, MultiServerManager, globalMultiServerManager, AgentExposureManager, exposeAgentAsTool, exposeAgentsAsTools, exposeWorkflowAsTool, exposeWorkflowsAsTools, globalAgentExposureManager, ServerCapabilitiesManager, createTextResource, createJsonResource, createPrompt, neuroLinkToolToMCP, mcpToolToNeuroLink, batchConvertToMCP, batchConvertToNeuroLink, sanitizeToolName, validateToolName, createToolFromFunction, mcpProtocolToolToServerTool, serverToolToMCPProtocol, TOOL_COMPATIBILITY, ToolIntegrationManager, globalToolIntegrationManager, wrapToolWithElicitation, wrapToolsWithElicitation, createElicitationContext, confirmationMiddleware, validationMiddleware, loggingMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolMiddlewareChain, ElicitationProtocolAdapter, globalElicitationProtocol, isElicitationProtocolMessage, protocolMessageToElicitation, elicitationResponseToProtocol, createElicitationRequest, createElicitationResponse, createElicitationCancel, createTextInputRequest, createSelectRequest, createConfirmationRequest, createFormRequest, } from "./mcp/index.js";
217
217
  export { logger } from "./utils/logger.js";
218
218
  export { getPoolStats } from "./utils/redis.js";
219
+ export { ToolRoutingCache } from "./core/toolRoutingCache.js";
220
+ export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
219
221
  export declare function initializeTelemetry(): Promise<boolean>;
220
222
  export declare function getTelemetryStatus(): Promise<{
221
223
  enabled: boolean;
package/dist/index.js CHANGED
@@ -356,6 +356,10 @@ ToolIntegrationManager, globalToolIntegrationManager, wrapToolWithElicitation, w
356
356
  ElicitationProtocolAdapter, globalElicitationProtocol, isElicitationProtocolMessage, protocolMessageToElicitation, elicitationResponseToProtocol, createElicitationRequest, createElicitationResponse, createElicitationCancel, createTextInputRequest, createSelectRequest, createConfirmationRequest, createFormRequest, } from "./mcp/index.js";
357
357
  export { logger } from "./utils/logger.js";
358
358
  export { getPoolStats } from "./utils/redis.js";
359
+ // Pre-call tool routing cache (ITEM C: LRU+TTL cache + session stickiness)
360
+ export { ToolRoutingCache } from "./core/toolRoutingCache.js";
361
+ // Pre-call tool routing resolver — exported for host integrations and testing
362
+ export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
359
363
  // ============================================================================
360
364
  // REAL-TIME SERVICES & TELEMETRY - Enterprise Platform Features
361
365
  // ============================================================================