@juspay/neurolink 9.90.0 → 9.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +368 -360
- package/dist/core/baseProvider.d.ts +37 -3
- package/dist/core/baseProvider.js +167 -44
- package/dist/core/modules/GenerationHandler.js +7 -1
- package/dist/core/modules/ToolsManager.js +5 -0
- package/dist/core/toolDedup.js +4 -1
- package/dist/lib/core/baseProvider.d.ts +37 -3
- package/dist/lib/core/baseProvider.js +167 -44
- package/dist/lib/core/modules/GenerationHandler.js +7 -1
- package/dist/lib/core/modules/ToolsManager.js +5 -0
- package/dist/lib/core/toolDedup.js +4 -1
- package/dist/lib/mcp/toolRegistry.js +7 -2
- package/dist/lib/neurolink.d.ts +31 -1
- package/dist/lib/neurolink.js +151 -26
- package/dist/lib/server/routes/agentRoutes.js +25 -2
- package/dist/lib/tools/toolDiscovery.d.ts +48 -0
- package/dist/lib/tools/toolDiscovery.js +231 -0
- package/dist/lib/tools/toolGate.d.ts +16 -0
- package/dist/lib/tools/toolGate.js +51 -0
- package/dist/lib/tools/toolPolicy.d.ts +40 -0
- package/dist/lib/tools/toolPolicy.js +194 -0
- package/dist/lib/types/config.d.ts +43 -3
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/providers.d.ts +8 -0
- package/dist/lib/types/toolResolution.d.ts +73 -0
- package/dist/lib/types/toolResolution.js +11 -0
- package/dist/mcp/toolRegistry.js +7 -2
- package/dist/neurolink.d.ts +31 -1
- package/dist/neurolink.js +151 -26
- package/dist/server/routes/agentRoutes.js +25 -2
- package/dist/tools/toolDiscovery.d.ts +48 -0
- package/dist/tools/toolDiscovery.js +230 -0
- package/dist/tools/toolGate.d.ts +16 -0
- package/dist/tools/toolGate.js +50 -0
- package/dist/tools/toolPolicy.d.ts +40 -0
- package/dist/tools/toolPolicy.js +193 -0
- package/dist/types/config.d.ts +43 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +8 -0
- package/dist/types/toolResolution.d.ts +73 -0
- package/dist/types/toolResolution.js +10 -0
- package/package.json +2 -1
|
@@ -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
|
-
*
|
|
81
|
-
*
|
|
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
|
-
*
|
|
539
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/toolDedup.js
CHANGED
|
@@ -171,7 +171,10 @@ export function dedupeTools(tools, options) {
|
|
|
171
171
|
if (representativeOf.size === 0) {
|
|
172
172
|
return noOp;
|
|
173
173
|
}
|
|
174
|
-
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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
|
-
*
|
|
539
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
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
|
|
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,
|