@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.
- package/CHANGELOG.md +16 -0
- package/dist/browser/neurolink.min.js +349 -349
- package/dist/cli/factories/commandFactory.js +62 -0
- package/dist/cli/utils/toolRoutingFlags.d.ts +20 -0
- package/dist/cli/utils/toolRoutingFlags.js +115 -0
- 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/session/globalSessionState.d.ts +10 -1
- package/dist/lib/session/globalSessionState.js +18 -0
- package/dist/lib/types/cli.d.ts +29 -3
- package/dist/lib/types/toolRouting.d.ts +88 -0
- package/dist/neurolink.d.ts +8 -6
- package/dist/neurolink.js +187 -39
- package/dist/session/globalSessionState.d.ts +10 -1
- package/dist/session/globalSessionState.js +18 -0
- package/dist/types/cli.d.ts +29 -3
- package/dist/types/toolRouting.d.ts +88 -0
- package/package.json +6 -2
|
@@ -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
|
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
|
// ============================================================================
|