@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.
@@ -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
- if ((!options.toolFilter || options.toolFilter.length === 0) &&
542
- (!options.excludeTools || options.excludeTools.length === 0)) {
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 (options.toolFilter && options.toolFilter.length > 0) {
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 (options.excludeTools && options.excludeTools.length > 0) {
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
- if ((!options.toolFilter || options.toolFilter.length === 0) &&
542
- (!options.excludeTools || options.excludeTools.length === 0)) {
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 (options.toolFilter && options.toolFilter.length > 0) {
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 (options.excludeTools && options.excludeTools.length > 0) {
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
  /**