@juspay/neurolink 9.74.0 → 9.76.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 +12 -0
- package/dist/browser/neurolink.min.js +292 -292
- 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/baseProvider.js +29 -4
- package/dist/core/toolDedup.d.ts +51 -0
- package/dist/core/toolDedup.js +193 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/lib/core/baseProvider.js +29 -4
- package/dist/lib/core/toolDedup.d.ts +51 -0
- package/dist/lib/core/toolDedup.js +194 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/neurolink.d.ts +15 -1
- package/dist/lib/neurolink.js +20 -0
- 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/config.d.ts +14 -2
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/toolDedup.d.ts +45 -0
- package/dist/lib/types/toolDedup.js +14 -0
- package/dist/neurolink.d.ts +15 -1
- package/dist/neurolink.js +20 -0
- 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/config.d.ts +14 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/toolDedup.d.ts +45 -0
- package/dist/types/toolDedup.js +13 -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
|
|
@@ -13,6 +13,7 @@ import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
|
13
13
|
import { getKeyCount, getKeysAsString } from "../utils/transformationUtils.js";
|
|
14
14
|
import { TTSProcessor } from "../utils/ttsProcessor.js";
|
|
15
15
|
import { executeVideoAnalysis, hasVideoFrames, } from "../utils/videoAnalysisProcessor.js";
|
|
16
|
+
import { dedupeTools } from "./toolDedup.js";
|
|
16
17
|
import { GenerationHandler } from "./modules/GenerationHandler.js";
|
|
17
18
|
// Import modules for composition
|
|
18
19
|
import { MessageBuilder } from "./modules/MessageBuilder.js";
|
|
@@ -538,13 +539,19 @@ export class BaseProvider {
|
|
|
538
539
|
* If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
|
|
539
540
|
*/
|
|
540
541
|
applyToolFiltering(tools, options) {
|
|
541
|
-
|
|
542
|
-
|
|
542
|
+
const hasWhitelist = options.toolFilter && options.toolFilter.length > 0;
|
|
543
|
+
const hasDenylist = options.excludeTools && options.excludeTools.length > 0;
|
|
544
|
+
// Check whether the dedup pass is requested — even when no whitelist/
|
|
545
|
+
// denylist is set we still need to run the dedup pass if enabled.
|
|
546
|
+
const dedupConfig = this.neurolink?.getToolDedupConfig();
|
|
547
|
+
const hasDedupEnabled = dedupConfig !== undefined && dedupConfig.enabled === true;
|
|
548
|
+
if (!hasWhitelist && !hasDenylist && !hasDedupEnabled) {
|
|
549
|
+
// Fast path: nothing to do.
|
|
543
550
|
return tools;
|
|
544
551
|
}
|
|
545
552
|
const beforeCount = Object.keys(tools).length;
|
|
546
553
|
let filtered = { ...tools };
|
|
547
|
-
if (
|
|
554
|
+
if (hasWhitelist) {
|
|
548
555
|
const allowSet = new Set(options.toolFilter);
|
|
549
556
|
const result = {};
|
|
550
557
|
for (const [name, tool] of Object.entries(filtered)) {
|
|
@@ -554,7 +561,7 @@ export class BaseProvider {
|
|
|
554
561
|
}
|
|
555
562
|
filtered = result;
|
|
556
563
|
}
|
|
557
|
-
if (
|
|
564
|
+
if (hasDenylist) {
|
|
558
565
|
const denySet = new Set(options.excludeTools);
|
|
559
566
|
for (const name of Object.keys(filtered)) {
|
|
560
567
|
if (denySet.has(name)) {
|
|
@@ -572,6 +579,24 @@ export class BaseProvider {
|
|
|
572
579
|
excludeTools: options.excludeTools,
|
|
573
580
|
});
|
|
574
581
|
}
|
|
582
|
+
// Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
|
|
583
|
+
// BEFORE the tool set reaches the provider call. Fails open: any error
|
|
584
|
+
// inside dedupeTools returns the original filtered set unchanged.
|
|
585
|
+
if (dedupConfig !== undefined && dedupConfig.enabled) {
|
|
586
|
+
const { tools: dedupedTools, removed } = dedupeTools(filtered, dedupConfig);
|
|
587
|
+
if (removed.length > 0 && logger.shouldLog("debug")) {
|
|
588
|
+
logger.debug(`Tool signature dedup removed duplicates`, {
|
|
589
|
+
provider: this.providerName,
|
|
590
|
+
removedCount: removed.length,
|
|
591
|
+
removed: removed.map((r) => ({
|
|
592
|
+
name: r.name,
|
|
593
|
+
duplicateOf: r.duplicateOf,
|
|
594
|
+
similarity: r.similarity,
|
|
595
|
+
})),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return dedupedTools;
|
|
599
|
+
}
|
|
575
600
|
return filtered;
|
|
576
601
|
}
|
|
577
602
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-signature deduplication — pure module, no I/O, no side effects.
|
|
3
|
+
*
|
|
4
|
+
* ## Algorithm
|
|
5
|
+
*
|
|
6
|
+
* `computeToolSignature` builds a canonical, order-insensitive string from:
|
|
7
|
+
* 1. The tool's name (exact, case-preserved).
|
|
8
|
+
* 2. A normalised description: lowercased, all whitespace runs collapsed to a
|
|
9
|
+
* single space, leading/trailing whitespace stripped.
|
|
10
|
+
* 3. A sorted, deduplicated list of JSON-schema property names (top-level
|
|
11
|
+
* `properties` keys from `inputSchema`). If the schema exposes a `type`
|
|
12
|
+
* field on each property, that type string is appended as `name:type`.
|
|
13
|
+
*
|
|
14
|
+
* `dedupeTools` clusters tools by pairwise token-set Jaccard similarity:
|
|
15
|
+
*
|
|
16
|
+
* Jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
|
17
|
+
*
|
|
18
|
+
* where A and B are the _sets_ of whitespace-split tokens from each tool's
|
|
19
|
+
* canonical signature. This is deterministic, symmetric, and independent of
|
|
20
|
+
* token ordering — making it robust to minor rewording while being fast (O(n²)
|
|
21
|
+
* in the number of tools, which is bounded in practice by provider limits).
|
|
22
|
+
*
|
|
23
|
+
* The first tool encountered in stable input-iteration order becomes the
|
|
24
|
+
* cluster representative; subsequent tools in the same cluster are dropped.
|
|
25
|
+
*/
|
|
26
|
+
import type { Tool } from "../types/index.js";
|
|
27
|
+
import type { ToolDedupConfig, ToolDedupResult } from "../types/index.js";
|
|
28
|
+
/**
|
|
29
|
+
* Build a canonical, order-insensitive signature string for a named tool.
|
|
30
|
+
*
|
|
31
|
+
* The signature is composed of:
|
|
32
|
+
* - the tool's name
|
|
33
|
+
* - a normalised description (lowercased, whitespace-collapsed)
|
|
34
|
+
* - sorted parameter property names (with types when available)
|
|
35
|
+
*
|
|
36
|
+
* Stable regardless of property declaration order in the schema.
|
|
37
|
+
*/
|
|
38
|
+
export declare function computeToolSignature(name: string, tool: Tool): string;
|
|
39
|
+
/**
|
|
40
|
+
* Collapse near-duplicate tools in a name→Tool record.
|
|
41
|
+
*
|
|
42
|
+
* When `options.enabled` is falsy (the default), returns the original record
|
|
43
|
+
* and an empty `removed` array — byte-for-byte unchanged behaviour.
|
|
44
|
+
*
|
|
45
|
+
* When enabled, tools whose token-set Jaccard similarity (over their canonical
|
|
46
|
+
* signatures) meets or exceeds `options.threshold` (default 0.9) are collapsed
|
|
47
|
+
* to a single representative (the first in stable iteration order).
|
|
48
|
+
*
|
|
49
|
+
* Any exception thrown internally returns the ORIGINAL tool set (fail-open).
|
|
50
|
+
*/
|
|
51
|
+
export declare function dedupeTools<T extends Tool>(tools: Record<string, T>, options: ToolDedupConfig): ToolDedupResult<Record<string, T>>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-signature deduplication — pure module, no I/O, no side effects.
|
|
3
|
+
*
|
|
4
|
+
* ## Algorithm
|
|
5
|
+
*
|
|
6
|
+
* `computeToolSignature` builds a canonical, order-insensitive string from:
|
|
7
|
+
* 1. The tool's name (exact, case-preserved).
|
|
8
|
+
* 2. A normalised description: lowercased, all whitespace runs collapsed to a
|
|
9
|
+
* single space, leading/trailing whitespace stripped.
|
|
10
|
+
* 3. A sorted, deduplicated list of JSON-schema property names (top-level
|
|
11
|
+
* `properties` keys from `inputSchema`). If the schema exposes a `type`
|
|
12
|
+
* field on each property, that type string is appended as `name:type`.
|
|
13
|
+
*
|
|
14
|
+
* `dedupeTools` clusters tools by pairwise token-set Jaccard similarity:
|
|
15
|
+
*
|
|
16
|
+
* Jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
|
17
|
+
*
|
|
18
|
+
* where A and B are the _sets_ of whitespace-split tokens from each tool's
|
|
19
|
+
* canonical signature. This is deterministic, symmetric, and independent of
|
|
20
|
+
* token ordering — making it robust to minor rewording while being fast (O(n²)
|
|
21
|
+
* in the number of tools, which is bounded in practice by provider limits).
|
|
22
|
+
*
|
|
23
|
+
* The first tool encountered in stable input-iteration order becomes the
|
|
24
|
+
* cluster representative; subsequent tools in the same cluster are dropped.
|
|
25
|
+
*/
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Internal helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Extract top-level property names (and optionally their types) from a JSON
|
|
31
|
+
* Schema-shaped `inputSchema`. Returns a sorted, stable list.
|
|
32
|
+
*
|
|
33
|
+
* The `inputSchema` on a `Tool` is typed as `FlexibleSchema<INPUT>` (opaque at
|
|
34
|
+
* runtime), so we access it via index to avoid casting.
|
|
35
|
+
*/
|
|
36
|
+
function extractSchemaTokens(inputSchema) {
|
|
37
|
+
if (!inputSchema) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
// FlexibleSchema may be a Zod schema, a jsonSchema() wrapper, or a plain
|
|
41
|
+
// JSON Schema object. All three expose `jsonSchema` or fall through to the
|
|
42
|
+
// raw object. We look for the plain JSON Schema `properties` key.
|
|
43
|
+
const schema = inputSchema;
|
|
44
|
+
// Vercel AI SDK wraps plain schemas in { jsonSchema: {...}, ... }
|
|
45
|
+
const rawSchema = schema["jsonSchema"] !== undefined
|
|
46
|
+
? schema["jsonSchema"]
|
|
47
|
+
: schema;
|
|
48
|
+
const properties = rawSchema["properties"];
|
|
49
|
+
if (!properties || typeof properties !== "object" || properties === null) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const tokens = [];
|
|
53
|
+
const props = properties;
|
|
54
|
+
for (const propName of Object.keys(props)) {
|
|
55
|
+
const propDef = props[propName];
|
|
56
|
+
if (propDef && typeof propDef === "object" && propDef !== null) {
|
|
57
|
+
const t = propDef["type"];
|
|
58
|
+
tokens.push(typeof t === "string" ? `${propName}:${t}` : propName);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
tokens.push(propName);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return tokens.sort();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Normalise a description string: lowercase, collapse whitespace, strip ends.
|
|
68
|
+
*/
|
|
69
|
+
function normaliseDescription(desc) {
|
|
70
|
+
if (!desc) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
return desc.toLowerCase().replace(/\s+/g, " ").trim();
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Public API
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Build a canonical, order-insensitive signature string for a named tool.
|
|
80
|
+
*
|
|
81
|
+
* The signature is composed of:
|
|
82
|
+
* - the tool's name
|
|
83
|
+
* - a normalised description (lowercased, whitespace-collapsed)
|
|
84
|
+
* - sorted parameter property names (with types when available)
|
|
85
|
+
*
|
|
86
|
+
* Stable regardless of property declaration order in the schema.
|
|
87
|
+
*/
|
|
88
|
+
export function computeToolSignature(name, tool) {
|
|
89
|
+
const descPart = normaliseDescription(tool.description);
|
|
90
|
+
const schemaParts = extractSchemaTokens(tool.inputSchema);
|
|
91
|
+
// Pipe-separated sections keep tokens within each section unambiguous.
|
|
92
|
+
return [name, descPart, schemaParts.join(",")].join("|");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compute the token-set Jaccard similarity between two signature strings.
|
|
96
|
+
*
|
|
97
|
+
* Jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
|
98
|
+
*
|
|
99
|
+
* Splits on whitespace and also on the pipe/comma delimiters used by
|
|
100
|
+
* `computeToolSignature` so that structural differences (e.g. a parameter
|
|
101
|
+
* missing from one tool) are properly reflected.
|
|
102
|
+
*/
|
|
103
|
+
function jaccardSimilarity(sigA, sigB) {
|
|
104
|
+
const tokenise = (s) => new Set(s.split(/[\s|,]+/).filter((t) => t.length > 0));
|
|
105
|
+
const setA = tokenise(sigA);
|
|
106
|
+
const setB = tokenise(sigB);
|
|
107
|
+
if (setA.size === 0 && setB.size === 0) {
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
if (setA.size === 0 || setB.size === 0) {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
let intersection = 0;
|
|
114
|
+
for (const token of setA) {
|
|
115
|
+
if (setB.has(token)) {
|
|
116
|
+
intersection += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const union = setA.size + setB.size - intersection;
|
|
120
|
+
return intersection / union;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Collapse near-duplicate tools in a name→Tool record.
|
|
124
|
+
*
|
|
125
|
+
* When `options.enabled` is falsy (the default), returns the original record
|
|
126
|
+
* and an empty `removed` array — byte-for-byte unchanged behaviour.
|
|
127
|
+
*
|
|
128
|
+
* When enabled, tools whose token-set Jaccard similarity (over their canonical
|
|
129
|
+
* signatures) meets or exceeds `options.threshold` (default 0.9) are collapsed
|
|
130
|
+
* to a single representative (the first in stable iteration order).
|
|
131
|
+
*
|
|
132
|
+
* Any exception thrown internally returns the ORIGINAL tool set (fail-open).
|
|
133
|
+
*/
|
|
134
|
+
export function dedupeTools(tools, options) {
|
|
135
|
+
const noOp = { tools, removed: [] };
|
|
136
|
+
if (!options.enabled) {
|
|
137
|
+
return noOp;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const rawThreshold = options.threshold ?? 0.9;
|
|
141
|
+
const threshold = Math.min(1, Math.max(0, rawThreshold));
|
|
142
|
+
const entries = Object.entries(tools);
|
|
143
|
+
if (entries.length <= 1) {
|
|
144
|
+
return noOp;
|
|
145
|
+
}
|
|
146
|
+
// Pre-compute signatures once.
|
|
147
|
+
const signatures = new Map();
|
|
148
|
+
for (const [name, tool] of entries) {
|
|
149
|
+
signatures.set(name, computeToolSignature(name, tool));
|
|
150
|
+
}
|
|
151
|
+
// representative[toolName] = name of the cluster representative.
|
|
152
|
+
// Tools not yet assigned to a cluster act as their own representative.
|
|
153
|
+
const representativeOf = new Map();
|
|
154
|
+
for (const [nameA] of entries) {
|
|
155
|
+
if (representativeOf.has(nameA)) {
|
|
156
|
+
// Already absorbed into another cluster.
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const sigA = signatures.get(nameA) ?? "";
|
|
160
|
+
for (const [nameB] of entries) {
|
|
161
|
+
if (nameA === nameB || representativeOf.has(nameB)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const sigB = signatures.get(nameB) ?? "";
|
|
165
|
+
const sim = jaccardSimilarity(sigA, sigB);
|
|
166
|
+
if (sim >= threshold) {
|
|
167
|
+
representativeOf.set(nameB, nameA);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (representativeOf.size === 0) {
|
|
172
|
+
return noOp;
|
|
173
|
+
}
|
|
174
|
+
const dedupedTools = {};
|
|
175
|
+
const removed = [];
|
|
176
|
+
for (const [name, tool] of entries) {
|
|
177
|
+
const rep = representativeOf.get(name);
|
|
178
|
+
if (rep !== undefined) {
|
|
179
|
+
// Compute similarity again to include in the removed record.
|
|
180
|
+
const sim = jaccardSimilarity(signatures.get(name) ?? "", signatures.get(rep) ?? "");
|
|
181
|
+
removed.push({ name, duplicateOf: rep, similarity: sim });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
dedupedTools[name] = tool;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { tools: dedupedTools, removed };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Fail open — return the original set.
|
|
191
|
+
return noOp;
|
|
192
|
+
}
|
|
193
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -214,6 +214,7 @@ export declare function createBestAIProvider(requestedProvider?: string, modelNa
|
|
|
214
214
|
* ```
|
|
215
215
|
*/
|
|
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
|
+
export { computeToolSignature, dedupeTools } from "./core/toolDedup.js";
|
|
217
218
|
export { logger } from "./utils/logger.js";
|
|
218
219
|
export { getPoolStats } from "./utils/redis.js";
|
|
219
220
|
export { ToolRoutingCache } from "./core/toolRoutingCache.js";
|
package/dist/index.js
CHANGED
|
@@ -354,6 +354,8 @@ neuroLinkToolToMCP, mcpToolToNeuroLink, batchConvertToMCP, batchConvertToNeuroLi
|
|
|
354
354
|
ToolIntegrationManager, globalToolIntegrationManager, wrapToolWithElicitation, wrapToolsWithElicitation, createElicitationContext, confirmationMiddleware, validationMiddleware, loggingMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolMiddlewareChain,
|
|
355
355
|
// MCP Enhancements - Elicitation Protocol
|
|
356
356
|
ElicitationProtocolAdapter, globalElicitationProtocol, isElicitationProtocolMessage, protocolMessageToElicitation, elicitationResponseToProtocol, createElicitationRequest, createElicitationResponse, createElicitationCancel, createTextInputRequest, createSelectRequest, createConfirmationRequest, createFormRequest, } from "./mcp/index.js";
|
|
357
|
+
// Tool signature deduplication utilities (opt-in, fail-open)
|
|
358
|
+
export { computeToolSignature, dedupeTools } from "./core/toolDedup.js";
|
|
357
359
|
export { logger } from "./utils/logger.js";
|
|
358
360
|
export { getPoolStats } from "./utils/redis.js";
|
|
359
361
|
// Pre-call tool routing cache (ITEM C: LRU+TTL cache + session stickiness)
|
|
@@ -13,6 +13,7 @@ import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
|
13
13
|
import { getKeyCount, getKeysAsString } from "../utils/transformationUtils.js";
|
|
14
14
|
import { TTSProcessor } from "../utils/ttsProcessor.js";
|
|
15
15
|
import { executeVideoAnalysis, hasVideoFrames, } from "../utils/videoAnalysisProcessor.js";
|
|
16
|
+
import { dedupeTools } from "./toolDedup.js";
|
|
16
17
|
import { GenerationHandler } from "./modules/GenerationHandler.js";
|
|
17
18
|
// Import modules for composition
|
|
18
19
|
import { MessageBuilder } from "./modules/MessageBuilder.js";
|
|
@@ -538,13 +539,19 @@ export class BaseProvider {
|
|
|
538
539
|
* If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
|
|
539
540
|
*/
|
|
540
541
|
applyToolFiltering(tools, options) {
|
|
541
|
-
|
|
542
|
-
|
|
542
|
+
const hasWhitelist = options.toolFilter && options.toolFilter.length > 0;
|
|
543
|
+
const hasDenylist = options.excludeTools && options.excludeTools.length > 0;
|
|
544
|
+
// Check whether the dedup pass is requested — even when no whitelist/
|
|
545
|
+
// denylist is set we still need to run the dedup pass if enabled.
|
|
546
|
+
const dedupConfig = this.neurolink?.getToolDedupConfig();
|
|
547
|
+
const hasDedupEnabled = dedupConfig !== undefined && dedupConfig.enabled === true;
|
|
548
|
+
if (!hasWhitelist && !hasDenylist && !hasDedupEnabled) {
|
|
549
|
+
// Fast path: nothing to do.
|
|
543
550
|
return tools;
|
|
544
551
|
}
|
|
545
552
|
const beforeCount = Object.keys(tools).length;
|
|
546
553
|
let filtered = { ...tools };
|
|
547
|
-
if (
|
|
554
|
+
if (hasWhitelist) {
|
|
548
555
|
const allowSet = new Set(options.toolFilter);
|
|
549
556
|
const result = {};
|
|
550
557
|
for (const [name, tool] of Object.entries(filtered)) {
|
|
@@ -554,7 +561,7 @@ export class BaseProvider {
|
|
|
554
561
|
}
|
|
555
562
|
filtered = result;
|
|
556
563
|
}
|
|
557
|
-
if (
|
|
564
|
+
if (hasDenylist) {
|
|
558
565
|
const denySet = new Set(options.excludeTools);
|
|
559
566
|
for (const name of Object.keys(filtered)) {
|
|
560
567
|
if (denySet.has(name)) {
|
|
@@ -572,6 +579,24 @@ export class BaseProvider {
|
|
|
572
579
|
excludeTools: options.excludeTools,
|
|
573
580
|
});
|
|
574
581
|
}
|
|
582
|
+
// Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
|
|
583
|
+
// BEFORE the tool set reaches the provider call. Fails open: any error
|
|
584
|
+
// inside dedupeTools returns the original filtered set unchanged.
|
|
585
|
+
if (dedupConfig !== undefined && dedupConfig.enabled) {
|
|
586
|
+
const { tools: dedupedTools, removed } = dedupeTools(filtered, dedupConfig);
|
|
587
|
+
if (removed.length > 0 && logger.shouldLog("debug")) {
|
|
588
|
+
logger.debug(`Tool signature dedup removed duplicates`, {
|
|
589
|
+
provider: this.providerName,
|
|
590
|
+
removedCount: removed.length,
|
|
591
|
+
removed: removed.map((r) => ({
|
|
592
|
+
name: r.name,
|
|
593
|
+
duplicateOf: r.duplicateOf,
|
|
594
|
+
similarity: r.similarity,
|
|
595
|
+
})),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return dedupedTools;
|
|
599
|
+
}
|
|
575
600
|
return filtered;
|
|
576
601
|
}
|
|
577
602
|
/**
|