@juspay/neurolink 9.75.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.
@@ -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
  /**
@@ -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,194 @@
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
+ }
194
+ //# sourceMappingURL=toolDedup.js.map
@@ -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/lib/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)