@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.
- package/CHANGELOG.md +10 -0
- package/dist/browser/neurolink.min.js +349 -349
- package/dist/core/toolRouting.js +90 -4
- package/dist/core/toolRoutingCache.d.ts +64 -0
- package/dist/core/toolRoutingCache.js +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/lib/core/toolRouting.js +90 -4
- package/dist/lib/core/toolRoutingCache.d.ts +64 -0
- package/dist/lib/core/toolRoutingCache.js +155 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/neurolink.d.ts +8 -6
- package/dist/lib/neurolink.js +187 -39
- package/dist/lib/types/toolRouting.d.ts +88 -0
- package/dist/neurolink.d.ts +8 -6
- package/dist/neurolink.js +187 -39
- package/dist/types/toolRouting.d.ts +88 -0
- package/package.json +4 -2
package/dist/core/toolRouting.js
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
+
}
|