@juspay/neurolink 9.90.0 → 9.91.1

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +370 -361
  3. package/dist/core/baseProvider.d.ts +37 -3
  4. package/dist/core/baseProvider.js +167 -44
  5. package/dist/core/modules/GenerationHandler.js +7 -1
  6. package/dist/core/modules/ToolsManager.js +5 -0
  7. package/dist/core/toolDedup.js +4 -1
  8. package/dist/lib/core/baseProvider.d.ts +37 -3
  9. package/dist/lib/core/baseProvider.js +167 -44
  10. package/dist/lib/core/modules/GenerationHandler.js +7 -1
  11. package/dist/lib/core/modules/ToolsManager.js +5 -0
  12. package/dist/lib/core/toolDedup.js +4 -1
  13. package/dist/lib/mcp/toolRegistry.js +7 -2
  14. package/dist/lib/neurolink.d.ts +31 -1
  15. package/dist/lib/neurolink.js +151 -26
  16. package/dist/lib/providers/sagemaker/client.d.ts +9 -0
  17. package/dist/lib/providers/sagemaker/client.js +75 -30
  18. package/dist/lib/server/routes/agentRoutes.js +25 -2
  19. package/dist/lib/tools/toolDiscovery.d.ts +48 -0
  20. package/dist/lib/tools/toolDiscovery.js +231 -0
  21. package/dist/lib/tools/toolGate.d.ts +16 -0
  22. package/dist/lib/tools/toolGate.js +51 -0
  23. package/dist/lib/tools/toolPolicy.d.ts +40 -0
  24. package/dist/lib/tools/toolPolicy.js +194 -0
  25. package/dist/lib/types/config.d.ts +43 -3
  26. package/dist/lib/types/index.d.ts +1 -0
  27. package/dist/lib/types/index.js +1 -0
  28. package/dist/lib/types/providers.d.ts +8 -0
  29. package/dist/lib/types/toolResolution.d.ts +73 -0
  30. package/dist/lib/types/toolResolution.js +11 -0
  31. package/dist/mcp/toolRegistry.js +7 -2
  32. package/dist/neurolink.d.ts +31 -1
  33. package/dist/neurolink.js +151 -26
  34. package/dist/providers/sagemaker/client.d.ts +9 -0
  35. package/dist/providers/sagemaker/client.js +75 -30
  36. package/dist/server/routes/agentRoutes.js +25 -2
  37. package/dist/tools/toolDiscovery.d.ts +48 -0
  38. package/dist/tools/toolDiscovery.js +230 -0
  39. package/dist/tools/toolGate.d.ts +16 -0
  40. package/dist/tools/toolGate.js +50 -0
  41. package/dist/tools/toolPolicy.d.ts +40 -0
  42. package/dist/tools/toolPolicy.js +193 -0
  43. package/dist/types/config.d.ts +43 -3
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +1 -0
  46. package/dist/types/providers.d.ts +8 -0
  47. package/dist/types/toolResolution.d.ts +73 -0
  48. package/dist/types/toolResolution.js +10 -0
  49. package/package.json +3 -2
@@ -77,10 +77,44 @@ export declare abstract class BaseProvider implements AIProvider {
77
77
  private executeFakeStreaming;
78
78
  /**
79
79
  * Apply per-call tool filtering (whitelist/blacklist) to a tools record.
80
- * If toolFilter is set, only tools whose names are in the list are kept.
81
- * If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
82
- */
80
+ *
81
+ * All filtering surfaces are merged into one ResolvedToolPolicy by
82
+ * `resolveToolPolicy()` — per-call `toolFilter` (whitelist),
83
+ * `enabledToolNames` (merged into the whitelist, as its docs always
84
+ * promised), `excludeTools` (denylist, applied after the whitelist), and
85
+ * the instance-level `tools` config (enabled/include/exclude, `*` globs) —
86
+ * then applied by `applyToolGate()`. This is the single filter semantics
87
+ * for every generate/stream path.
88
+ */
89
+ private getToolPolicy;
83
90
  private applyToolFiltering;
91
+ /**
92
+ * Deterministic name-sorted key order. External MCP servers connect and
93
+ * discover in parallel, so insertion order varies across process restarts;
94
+ * providers serialize this record in key order (and Anthropic pins its
95
+ * cache_control breakpoint to the LAST tool), so an unstable order silently
96
+ * busts provider prompt caches. Runs AFTER the dedup pass — dedup's
97
+ * keep-first policy must see phase order (built-ins first) so duplicate
98
+ * winners don't flip when an MCP tool name sorts earlier.
99
+ */
100
+ private sortToolRecord;
101
+ /**
102
+ * On-demand discovery (`tools.discovery: true`): defer external MCP tool
103
+ * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
104
+ * tools, explicitly whitelisted tools, and session-pinned (previously
105
+ * discovered) tools always stay hot. No-op when discovery is off — with a
106
+ * one-time WARN when the catalog is large enough that selection accuracy
107
+ * measurably degrades.
108
+ */
109
+ private applyToolDiscovery;
110
+ private static warnedLargeCatalog;
111
+ private warnLargeCatalogOnce;
112
+ /**
113
+ * Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
114
+ * BEFORE the tool set reaches the provider call. Fails open: any error
115
+ * inside dedupeTools returns the original filtered set unchanged.
116
+ */
117
+ private applyDedupPass;
84
118
  /**
85
119
  * Prepare generation context including tools and model
86
120
  */
@@ -14,6 +14,9 @@ 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
16
  import { dedupeTools } from "./toolDedup.js";
17
+ import { resolveToolPolicy, toolNameMatcher } from "../tools/toolPolicy.js";
18
+ import { applyToolGate } from "../tools/toolGate.js";
19
+ import { partitionToolsForDiscovery, isDiscoveryMetaTool, LARGE_CATALOG_WARN_THRESHOLD, } from "../tools/toolDiscovery.js";
17
20
  import { GenerationHandler } from "./modules/GenerationHandler.js";
18
21
  // Import modules for composition
19
22
  import { MessageBuilder } from "./modules/MessageBuilder.js";
@@ -535,69 +538,185 @@ export class BaseProvider {
535
538
  }
536
539
  /**
537
540
  * Apply per-call tool filtering (whitelist/blacklist) to a tools record.
538
- * If toolFilter is set, only tools whose names are in the list are kept.
539
- * If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
541
+ *
542
+ * All filtering surfaces are merged into one ResolvedToolPolicy by
543
+ * `resolveToolPolicy()` — per-call `toolFilter` (whitelist),
544
+ * `enabledToolNames` (merged into the whitelist, as its docs always
545
+ * promised), `excludeTools` (denylist, applied after the whitelist), and
546
+ * the instance-level `tools` config (enabled/include/exclude, `*` globs) —
547
+ * then applied by `applyToolGate()`. This is the single filter semantics
548
+ * for every generate/stream path.
540
549
  */
550
+ getToolPolicy(options) {
551
+ return resolveToolPolicy({
552
+ options: {
553
+ toolFilter: options.toolFilter,
554
+ excludeTools: options.excludeTools,
555
+ enabledToolNames: options.enabledToolNames,
556
+ },
557
+ instanceConfig: this.neurolink?.getToolsConfig(),
558
+ builtinToolNames: Object.keys(this.directTools ?? {}),
559
+ });
560
+ }
541
561
  applyToolFiltering(tools, options) {
542
- const hasWhitelist = options.toolFilter && options.toolFilter.length > 0;
543
- const hasDenylist = options.excludeTools && options.excludeTools.length > 0;
562
+ const policy = this.getToolPolicy(options);
544
563
  // Check whether the dedup pass is requested — even when no whitelist/
545
564
  // denylist is set we still need to run the dedup pass if enabled.
546
565
  const dedupConfig = this.neurolink?.getToolDedupConfig();
547
566
  const hasDedupEnabled = dedupConfig !== undefined && dedupConfig.enabled === true;
548
- if (!hasWhitelist && !hasDenylist && !hasDedupEnabled) {
549
- // Fast path: nothing to do.
550
- return tools;
551
- }
552
567
  const beforeCount = Object.keys(tools).length;
553
- let filtered = { ...tools };
554
- if (hasWhitelist) {
555
- const allowSet = new Set(options.toolFilter);
556
- const result = {};
557
- for (const [name, tool] of Object.entries(filtered)) {
558
- if (allowSet.has(name)) {
559
- result[name] = tool;
560
- }
561
- }
562
- filtered = result;
563
- }
564
- if (hasDenylist) {
565
- const denySet = new Set(options.excludeTools);
566
- for (const name of Object.keys(filtered)) {
567
- if (denySet.has(name)) {
568
- delete filtered[name];
569
- }
570
- }
571
- }
568
+ const filtered = applyToolGate(tools, policy);
572
569
  const afterCount = Object.keys(filtered).length;
573
570
  if (beforeCount !== afterCount) {
574
571
  logger.debug(`Tool filtering applied`, {
575
572
  provider: this.providerName,
576
573
  beforeCount,
577
574
  afterCount,
575
+ policySources: policy.sources,
578
576
  toolFilter: options.toolFilter,
579
577
  excludeTools: options.excludeTools,
578
+ enabledToolNames: options.enabledToolNames,
580
579
  });
581
580
  }
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
- });
581
+ if (!hasDedupEnabled || dedupConfig === undefined) {
582
+ return this.sortToolRecord(filtered);
583
+ }
584
+ const deduped = this.applyDedupPass(filtered, dedupConfig);
585
+ // A forced toolChoice must survive dedup: keep-first can collapse the
586
+ // forced tool into an earlier near-identical signature, and the provider
587
+ // would then reject the request for naming an unknown tool. Restore it
588
+ // from the pre-dedup record (whitelisted names are already safe — the
589
+ // gate runs before dedup, so a whitelist leaves no duplicate to lose to).
590
+ const forcedName = options.toolChoice?.toolName;
591
+ if (typeof forcedName === "string" &&
592
+ !Object.hasOwn(deduped, forcedName) &&
593
+ Object.hasOwn(filtered, forcedName)) {
594
+ deduped[forcedName] = filtered[forcedName];
595
+ logger.debug(`Restored toolChoice-forced tool removed by signature dedup`, { provider: this.providerName, toolName: forcedName });
596
+ }
597
+ return this.sortToolRecord(deduped);
598
+ }
599
+ /**
600
+ * Deterministic name-sorted key order. External MCP servers connect and
601
+ * discover in parallel, so insertion order varies across process restarts;
602
+ * providers serialize this record in key order (and Anthropic pins its
603
+ * cache_control breakpoint to the LAST tool), so an unstable order silently
604
+ * busts provider prompt caches. Runs AFTER the dedup pass — dedup's
605
+ * keep-first policy must see phase order (built-ins first) so duplicate
606
+ * winners don't flip when an MCP tool name sorts earlier.
607
+ */
608
+ sortToolRecord(tools) {
609
+ // Null prototype: a tool named "__proto__" must become an own entry, not
610
+ // a prototype mutation that silently drops the tool.
611
+ const sorted = Object.create(null);
612
+ for (const name of Object.keys(tools).sort()) {
613
+ sorted[name] = tools[name];
614
+ }
615
+ return sorted;
616
+ }
617
+ /**
618
+ * On-demand discovery (`tools.discovery: true`): defer external MCP tool
619
+ * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
620
+ * tools, explicitly whitelisted tools, and session-pinned (previously
621
+ * discovered) tools always stay hot. No-op when discovery is off — with a
622
+ * one-time WARN when the catalog is large enough that selection accuracy
623
+ * measurably degrades.
624
+ */
625
+ async applyToolDiscovery(toolsInput, options) {
626
+ // Strip a stale meta-tool left by a previous resolution pass (the
627
+ // stream → generate fallback re-enters resolution with options.tools
628
+ // already partitioned). Discovery re-partitions against the fresh merged
629
+ // record below; without this, the collision guard would see our own
630
+ // meta-tool and skip partitioning, shipping the full catalog AND a stale
631
+ // search_tools closure. Real user tools named "search_tools" are not
632
+ // marked and are left untouched.
633
+ let tools = toolsInput;
634
+ if (isDiscoveryMetaTool(tools["search_tools"])) {
635
+ const { search_tools: _stale, ...rest } = tools;
636
+ tools = rest;
637
+ }
638
+ const toolCount = Object.keys(tools).length;
639
+ const policy = this.getToolPolicy(options);
640
+ if (!policy.discovery) {
641
+ if (toolCount > LARGE_CATALOG_WARN_THRESHOLD) {
642
+ this.warnLargeCatalogOnce(toolCount);
597
643
  }
598
- return dedupedTools;
644
+ return tools;
645
+ }
646
+ const externalTools = this.neurolink?.getExternalMCPTools() ?? [];
647
+ if (externalTools.length === 0) {
648
+ return tools;
649
+ }
650
+ // Session pinning requires a caller-provided sessionId. Without one there
651
+ // is no session identity — pinning to a shared fallback key would leak
652
+ // one caller's discoveries into every other caller of a shared instance
653
+ // and monotonically defeat deferral, so pins are simply not persisted.
654
+ const rawSessionId = options.context?.sessionId;
655
+ const sessionKey = typeof rawSessionId === "string" && rawSessionId.length > 0
656
+ ? rawSessionId
657
+ : typeof rawSessionId === "number"
658
+ ? String(rawSessionId)
659
+ : undefined;
660
+ const pinnedNames = (sessionKey ? this.neurolink?.getDiscoveryPins(sessionKey) : undefined) ??
661
+ new Set();
662
+ const perCallNames = new Set(Object.keys(options.tools ?? {}));
663
+ // Explicitly requested tools stay hot. Allowlist entries may be globs
664
+ // (e.g. toolFilter: ["github*"]), so match with the same pattern matcher
665
+ // the gate uses — a Set of pattern STRINGS would defer glob-whitelisted
666
+ // tools the gate deliberately kept.
667
+ const explicitPatterns = [
668
+ ...(options.toolFilter ?? []),
669
+ ...(options.enabledToolNames ?? []),
670
+ ];
671
+ // A toolChoice that forces a named tool must never see that tool
672
+ // deferred — the provider would reject the request (unknown tool name).
673
+ const toolChoice = options.toolChoice;
674
+ if (toolChoice && typeof toolChoice.toolName === "string") {
675
+ explicitPatterns.push(toolChoice.toolName);
676
+ }
677
+ const explicitMatcher = explicitPatterns.length > 0 ? toolNameMatcher(explicitPatterns) : null;
678
+ const deferrableNames = externalTools
679
+ .map((t) => t.name)
680
+ .filter((name) => name in tools &&
681
+ !perCallNames.has(name) &&
682
+ !(explicitMatcher ? explicitMatcher(name) : false));
683
+ return partitionToolsForDiscovery(tools, {
684
+ deferrableNames,
685
+ pinnedNames,
686
+ onHydrate: (names) => {
687
+ if (sessionKey) {
688
+ this.neurolink?.pinDiscoveredTools(sessionKey, names);
689
+ }
690
+ },
691
+ });
692
+ }
693
+ static warnedLargeCatalog = false;
694
+ warnLargeCatalogOnce(toolCount) {
695
+ if (BaseProvider.warnedLargeCatalog) {
696
+ return;
697
+ }
698
+ BaseProvider.warnedLargeCatalog = true;
699
+ logger.warn(`[ToolDiscovery] ${toolCount} tools are being sent in full on every request (~${Math.round((toolCount * 175) / 100) / 10}K tokens). Tool-selection accuracy degrades past 30-50 tools — consider enabling on-demand discovery: new NeuroLink({ tools: { discovery: true } })`, { toolCount, provider: this.providerName });
700
+ }
701
+ /**
702
+ * Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
703
+ * BEFORE the tool set reaches the provider call. Fails open: any error
704
+ * inside dedupeTools returns the original filtered set unchanged.
705
+ */
706
+ applyDedupPass(filtered, dedupConfig) {
707
+ const { tools: dedupedTools, removed } = dedupeTools(filtered, dedupConfig);
708
+ if (removed.length > 0 && logger.shouldLog("debug")) {
709
+ logger.debug(`Tool signature dedup removed duplicates`, {
710
+ provider: this.providerName,
711
+ removedCount: removed.length,
712
+ removed: removed.map((r) => ({
713
+ name: r.name,
714
+ duplicateOf: r.duplicateOf,
715
+ similarity: r.similarity,
716
+ })),
717
+ });
599
718
  }
600
- return filtered;
719
+ return dedupedTools;
601
720
  }
602
721
  /**
603
722
  * Prepare generation context including tools and model
@@ -613,6 +732,8 @@ export class BaseProvider {
613
732
  : {};
614
733
  // Apply per-call tool filtering (whitelist/blacklist)
615
734
  tools = this.applyToolFiltering(tools, options);
735
+ // On-demand discovery: defer external MCP schemas behind search_tools
736
+ tools = await this.applyToolDiscovery(tools, options);
616
737
  logger.debug(`Final tools prepared for AI`, {
617
738
  provider: this.providerName,
618
739
  directTools: getKeyCount(baseTools),
@@ -644,6 +765,8 @@ export class BaseProvider {
644
765
  let merged = { ...baseTools, ...externalTools };
645
766
  // Apply per-call tool filtering (whitelist/blacklist)
646
767
  merged = this.applyToolFiltering(merged, options);
768
+ // On-demand discovery: defer external MCP schemas behind search_tools
769
+ merged = await this.applyToolDiscovery(merged, options);
647
770
  logger.debug(`Tools prepared for streaming`, {
648
771
  provider: this.providerName,
649
772
  baseToolCount: Object.keys(baseTools).length,
@@ -88,7 +88,13 @@ export class GenerationHandler {
88
88
  // Non-Anthropic providers harmlessly ignore unknown providerOptions.
89
89
  // Note: The AI SDK Tool type doesn't yet include providerOptions, so we
90
90
  // use a type assertion. The Anthropic adapter reads this at runtime.
91
- const toolsWithCache = { ...tools };
91
+ //
92
+ // Deliberately NOT a clone: the record is call-scoped (built fresh in
93
+ // BaseProvider.prepareGenerationContext) and the AI SDK re-reads it on
94
+ // every agent-loop step, so `search_tools` hydration (tools.discovery)
95
+ // can add discovered tools mid-loop and have them callable on the next
96
+ // step. A clone would freeze the tool set for the whole call.
97
+ const toolsWithCache = tools;
92
98
  if (isAnthropicProvider &&
93
99
  shouldUseTools &&
94
100
  Object.keys(toolsWithCache).length > 0) {
@@ -258,6 +258,11 @@ export class ToolsManager {
258
258
  `... and ${toolNames.length - 10} more`,
259
259
  ],
260
260
  });
261
+ // NOTE: insertion order here is phase order (direct → custom →
262
+ // external MCP), which the signature-dedup pass depends on (keep-first
263
+ // must prefer built-in implementations). Deterministic name-sorting
264
+ // for prompt-cache stability happens AFTER filtering+dedup, in
265
+ // BaseProvider.applyToolFiltering.
261
266
  return tools;
262
267
  });
263
268
  }
@@ -171,7 +171,10 @@ export function dedupeTools(tools, options) {
171
171
  if (representativeOf.size === 0) {
172
172
  return noOp;
173
173
  }
174
- const dedupedTools = {};
174
+ // Null prototype: a tool named "__proto__" must become an own entry,
175
+ // not a prototype mutation that silently drops it (mirrors the tool
176
+ // records built in BaseProvider.sortToolRecord / applyToolGate).
177
+ const dedupedTools = Object.create(null);
175
178
  const removed = [];
176
179
  for (const [name, tool] of entries) {
177
180
  const rep = representativeOf.get(name);
@@ -77,10 +77,44 @@ export declare abstract class BaseProvider implements AIProvider {
77
77
  private executeFakeStreaming;
78
78
  /**
79
79
  * Apply per-call tool filtering (whitelist/blacklist) to a tools record.
80
- * If toolFilter is set, only tools whose names are in the list are kept.
81
- * If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
82
- */
80
+ *
81
+ * All filtering surfaces are merged into one ResolvedToolPolicy by
82
+ * `resolveToolPolicy()` — per-call `toolFilter` (whitelist),
83
+ * `enabledToolNames` (merged into the whitelist, as its docs always
84
+ * promised), `excludeTools` (denylist, applied after the whitelist), and
85
+ * the instance-level `tools` config (enabled/include/exclude, `*` globs) —
86
+ * then applied by `applyToolGate()`. This is the single filter semantics
87
+ * for every generate/stream path.
88
+ */
89
+ private getToolPolicy;
83
90
  private applyToolFiltering;
91
+ /**
92
+ * Deterministic name-sorted key order. External MCP servers connect and
93
+ * discover in parallel, so insertion order varies across process restarts;
94
+ * providers serialize this record in key order (and Anthropic pins its
95
+ * cache_control breakpoint to the LAST tool), so an unstable order silently
96
+ * busts provider prompt caches. Runs AFTER the dedup pass — dedup's
97
+ * keep-first policy must see phase order (built-ins first) so duplicate
98
+ * winners don't flip when an MCP tool name sorts earlier.
99
+ */
100
+ private sortToolRecord;
101
+ /**
102
+ * On-demand discovery (`tools.discovery: true`): defer external MCP tool
103
+ * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
104
+ * tools, explicitly whitelisted tools, and session-pinned (previously
105
+ * discovered) tools always stay hot. No-op when discovery is off — with a
106
+ * one-time WARN when the catalog is large enough that selection accuracy
107
+ * measurably degrades.
108
+ */
109
+ private applyToolDiscovery;
110
+ private static warnedLargeCatalog;
111
+ private warnLargeCatalogOnce;
112
+ /**
113
+ * Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
114
+ * BEFORE the tool set reaches the provider call. Fails open: any error
115
+ * inside dedupeTools returns the original filtered set unchanged.
116
+ */
117
+ private applyDedupPass;
84
118
  /**
85
119
  * Prepare generation context including tools and model
86
120
  */
@@ -14,6 +14,9 @@ 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
16
  import { dedupeTools } from "./toolDedup.js";
17
+ import { resolveToolPolicy, toolNameMatcher } from "../tools/toolPolicy.js";
18
+ import { applyToolGate } from "../tools/toolGate.js";
19
+ import { partitionToolsForDiscovery, isDiscoveryMetaTool, LARGE_CATALOG_WARN_THRESHOLD, } from "../tools/toolDiscovery.js";
17
20
  import { GenerationHandler } from "./modules/GenerationHandler.js";
18
21
  // Import modules for composition
19
22
  import { MessageBuilder } from "./modules/MessageBuilder.js";
@@ -535,69 +538,185 @@ export class BaseProvider {
535
538
  }
536
539
  /**
537
540
  * Apply per-call tool filtering (whitelist/blacklist) to a tools record.
538
- * If toolFilter is set, only tools whose names are in the list are kept.
539
- * If excludeTools is set, matching tools are removed. excludeTools is applied after toolFilter.
541
+ *
542
+ * All filtering surfaces are merged into one ResolvedToolPolicy by
543
+ * `resolveToolPolicy()` — per-call `toolFilter` (whitelist),
544
+ * `enabledToolNames` (merged into the whitelist, as its docs always
545
+ * promised), `excludeTools` (denylist, applied after the whitelist), and
546
+ * the instance-level `tools` config (enabled/include/exclude, `*` globs) —
547
+ * then applied by `applyToolGate()`. This is the single filter semantics
548
+ * for every generate/stream path.
540
549
  */
550
+ getToolPolicy(options) {
551
+ return resolveToolPolicy({
552
+ options: {
553
+ toolFilter: options.toolFilter,
554
+ excludeTools: options.excludeTools,
555
+ enabledToolNames: options.enabledToolNames,
556
+ },
557
+ instanceConfig: this.neurolink?.getToolsConfig(),
558
+ builtinToolNames: Object.keys(this.directTools ?? {}),
559
+ });
560
+ }
541
561
  applyToolFiltering(tools, options) {
542
- const hasWhitelist = options.toolFilter && options.toolFilter.length > 0;
543
- const hasDenylist = options.excludeTools && options.excludeTools.length > 0;
562
+ const policy = this.getToolPolicy(options);
544
563
  // Check whether the dedup pass is requested — even when no whitelist/
545
564
  // denylist is set we still need to run the dedup pass if enabled.
546
565
  const dedupConfig = this.neurolink?.getToolDedupConfig();
547
566
  const hasDedupEnabled = dedupConfig !== undefined && dedupConfig.enabled === true;
548
- if (!hasWhitelist && !hasDenylist && !hasDedupEnabled) {
549
- // Fast path: nothing to do.
550
- return tools;
551
- }
552
567
  const beforeCount = Object.keys(tools).length;
553
- let filtered = { ...tools };
554
- if (hasWhitelist) {
555
- const allowSet = new Set(options.toolFilter);
556
- const result = {};
557
- for (const [name, tool] of Object.entries(filtered)) {
558
- if (allowSet.has(name)) {
559
- result[name] = tool;
560
- }
561
- }
562
- filtered = result;
563
- }
564
- if (hasDenylist) {
565
- const denySet = new Set(options.excludeTools);
566
- for (const name of Object.keys(filtered)) {
567
- if (denySet.has(name)) {
568
- delete filtered[name];
569
- }
570
- }
571
- }
568
+ const filtered = applyToolGate(tools, policy);
572
569
  const afterCount = Object.keys(filtered).length;
573
570
  if (beforeCount !== afterCount) {
574
571
  logger.debug(`Tool filtering applied`, {
575
572
  provider: this.providerName,
576
573
  beforeCount,
577
574
  afterCount,
575
+ policySources: policy.sources,
578
576
  toolFilter: options.toolFilter,
579
577
  excludeTools: options.excludeTools,
578
+ enabledToolNames: options.enabledToolNames,
580
579
  });
581
580
  }
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
- });
581
+ if (!hasDedupEnabled || dedupConfig === undefined) {
582
+ return this.sortToolRecord(filtered);
583
+ }
584
+ const deduped = this.applyDedupPass(filtered, dedupConfig);
585
+ // A forced toolChoice must survive dedup: keep-first can collapse the
586
+ // forced tool into an earlier near-identical signature, and the provider
587
+ // would then reject the request for naming an unknown tool. Restore it
588
+ // from the pre-dedup record (whitelisted names are already safe — the
589
+ // gate runs before dedup, so a whitelist leaves no duplicate to lose to).
590
+ const forcedName = options.toolChoice?.toolName;
591
+ if (typeof forcedName === "string" &&
592
+ !Object.hasOwn(deduped, forcedName) &&
593
+ Object.hasOwn(filtered, forcedName)) {
594
+ deduped[forcedName] = filtered[forcedName];
595
+ logger.debug(`Restored toolChoice-forced tool removed by signature dedup`, { provider: this.providerName, toolName: forcedName });
596
+ }
597
+ return this.sortToolRecord(deduped);
598
+ }
599
+ /**
600
+ * Deterministic name-sorted key order. External MCP servers connect and
601
+ * discover in parallel, so insertion order varies across process restarts;
602
+ * providers serialize this record in key order (and Anthropic pins its
603
+ * cache_control breakpoint to the LAST tool), so an unstable order silently
604
+ * busts provider prompt caches. Runs AFTER the dedup pass — dedup's
605
+ * keep-first policy must see phase order (built-ins first) so duplicate
606
+ * winners don't flip when an MCP tool name sorts earlier.
607
+ */
608
+ sortToolRecord(tools) {
609
+ // Null prototype: a tool named "__proto__" must become an own entry, not
610
+ // a prototype mutation that silently drops the tool.
611
+ const sorted = Object.create(null);
612
+ for (const name of Object.keys(tools).sort()) {
613
+ sorted[name] = tools[name];
614
+ }
615
+ return sorted;
616
+ }
617
+ /**
618
+ * On-demand discovery (`tools.discovery: true`): defer external MCP tool
619
+ * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
620
+ * tools, explicitly whitelisted tools, and session-pinned (previously
621
+ * discovered) tools always stay hot. No-op when discovery is off — with a
622
+ * one-time WARN when the catalog is large enough that selection accuracy
623
+ * measurably degrades.
624
+ */
625
+ async applyToolDiscovery(toolsInput, options) {
626
+ // Strip a stale meta-tool left by a previous resolution pass (the
627
+ // stream → generate fallback re-enters resolution with options.tools
628
+ // already partitioned). Discovery re-partitions against the fresh merged
629
+ // record below; without this, the collision guard would see our own
630
+ // meta-tool and skip partitioning, shipping the full catalog AND a stale
631
+ // search_tools closure. Real user tools named "search_tools" are not
632
+ // marked and are left untouched.
633
+ let tools = toolsInput;
634
+ if (isDiscoveryMetaTool(tools["search_tools"])) {
635
+ const { search_tools: _stale, ...rest } = tools;
636
+ tools = rest;
637
+ }
638
+ const toolCount = Object.keys(tools).length;
639
+ const policy = this.getToolPolicy(options);
640
+ if (!policy.discovery) {
641
+ if (toolCount > LARGE_CATALOG_WARN_THRESHOLD) {
642
+ this.warnLargeCatalogOnce(toolCount);
597
643
  }
598
- return dedupedTools;
644
+ return tools;
645
+ }
646
+ const externalTools = this.neurolink?.getExternalMCPTools() ?? [];
647
+ if (externalTools.length === 0) {
648
+ return tools;
649
+ }
650
+ // Session pinning requires a caller-provided sessionId. Without one there
651
+ // is no session identity — pinning to a shared fallback key would leak
652
+ // one caller's discoveries into every other caller of a shared instance
653
+ // and monotonically defeat deferral, so pins are simply not persisted.
654
+ const rawSessionId = options.context?.sessionId;
655
+ const sessionKey = typeof rawSessionId === "string" && rawSessionId.length > 0
656
+ ? rawSessionId
657
+ : typeof rawSessionId === "number"
658
+ ? String(rawSessionId)
659
+ : undefined;
660
+ const pinnedNames = (sessionKey ? this.neurolink?.getDiscoveryPins(sessionKey) : undefined) ??
661
+ new Set();
662
+ const perCallNames = new Set(Object.keys(options.tools ?? {}));
663
+ // Explicitly requested tools stay hot. Allowlist entries may be globs
664
+ // (e.g. toolFilter: ["github*"]), so match with the same pattern matcher
665
+ // the gate uses — a Set of pattern STRINGS would defer glob-whitelisted
666
+ // tools the gate deliberately kept.
667
+ const explicitPatterns = [
668
+ ...(options.toolFilter ?? []),
669
+ ...(options.enabledToolNames ?? []),
670
+ ];
671
+ // A toolChoice that forces a named tool must never see that tool
672
+ // deferred — the provider would reject the request (unknown tool name).
673
+ const toolChoice = options.toolChoice;
674
+ if (toolChoice && typeof toolChoice.toolName === "string") {
675
+ explicitPatterns.push(toolChoice.toolName);
676
+ }
677
+ const explicitMatcher = explicitPatterns.length > 0 ? toolNameMatcher(explicitPatterns) : null;
678
+ const deferrableNames = externalTools
679
+ .map((t) => t.name)
680
+ .filter((name) => name in tools &&
681
+ !perCallNames.has(name) &&
682
+ !(explicitMatcher ? explicitMatcher(name) : false));
683
+ return partitionToolsForDiscovery(tools, {
684
+ deferrableNames,
685
+ pinnedNames,
686
+ onHydrate: (names) => {
687
+ if (sessionKey) {
688
+ this.neurolink?.pinDiscoveredTools(sessionKey, names);
689
+ }
690
+ },
691
+ });
692
+ }
693
+ static warnedLargeCatalog = false;
694
+ warnLargeCatalogOnce(toolCount) {
695
+ if (BaseProvider.warnedLargeCatalog) {
696
+ return;
697
+ }
698
+ BaseProvider.warnedLargeCatalog = true;
699
+ logger.warn(`[ToolDiscovery] ${toolCount} tools are being sent in full on every request (~${Math.round((toolCount * 175) / 100) / 10}K tokens). Tool-selection accuracy degrades past 30-50 tools — consider enabling on-demand discovery: new NeuroLink({ tools: { discovery: true } })`, { toolCount, provider: this.providerName });
700
+ }
701
+ /**
702
+ * Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and
703
+ * BEFORE the tool set reaches the provider call. Fails open: any error
704
+ * inside dedupeTools returns the original filtered set unchanged.
705
+ */
706
+ applyDedupPass(filtered, dedupConfig) {
707
+ const { tools: dedupedTools, removed } = dedupeTools(filtered, dedupConfig);
708
+ if (removed.length > 0 && logger.shouldLog("debug")) {
709
+ logger.debug(`Tool signature dedup removed duplicates`, {
710
+ provider: this.providerName,
711
+ removedCount: removed.length,
712
+ removed: removed.map((r) => ({
713
+ name: r.name,
714
+ duplicateOf: r.duplicateOf,
715
+ similarity: r.similarity,
716
+ })),
717
+ });
599
718
  }
600
- return filtered;
719
+ return dedupedTools;
601
720
  }
602
721
  /**
603
722
  * Prepare generation context including tools and model
@@ -613,6 +732,8 @@ export class BaseProvider {
613
732
  : {};
614
733
  // Apply per-call tool filtering (whitelist/blacklist)
615
734
  tools = this.applyToolFiltering(tools, options);
735
+ // On-demand discovery: defer external MCP schemas behind search_tools
736
+ tools = await this.applyToolDiscovery(tools, options);
616
737
  logger.debug(`Final tools prepared for AI`, {
617
738
  provider: this.providerName,
618
739
  directTools: getKeyCount(baseTools),
@@ -644,6 +765,8 @@ export class BaseProvider {
644
765
  let merged = { ...baseTools, ...externalTools };
645
766
  // Apply per-call tool filtering (whitelist/blacklist)
646
767
  merged = this.applyToolFiltering(merged, options);
768
+ // On-demand discovery: defer external MCP schemas behind search_tools
769
+ merged = await this.applyToolDiscovery(merged, options);
647
770
  logger.debug(`Tools prepared for streaming`, {
648
771
  provider: this.providerName,
649
772
  baseToolCount: Object.keys(baseTools).length,