@juspay/neurolink 9.73.0 → 9.74.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.
@@ -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
  // ============================================================================
@@ -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
+ }