@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
@@ -0,0 +1,48 @@
1
+ /**
2
+ * On-demand tool discovery (`tools.discovery: true`).
3
+ *
4
+ * Instead of sending every external MCP tool's full schema on every request,
5
+ * the model receives the hot set (built-in tools, per-call tools, explicitly
6
+ * requested tools, and previously discovered tools) plus ONE `search_tools`
7
+ * meta-tool whose description embeds a compact name+summary catalog of the
8
+ * deferred tools. Calling `search_tools` loads matching tools:
9
+ * - immediately into the live tool record (providers that re-read the tools
10
+ * record between agent-loop steps — the AI SDK path — can call them on the
11
+ * very next step), and
12
+ * - into the session pin set (all providers include them in full on every
13
+ * subsequent call of the session).
14
+ *
15
+ * Evidence for this design: catalogs past ~30-50 tools measurably degrade
16
+ * tool-selection accuracy, and deferral+search both cuts definition tokens by
17
+ * ~85% and RAISES selection accuracy (Anthropic MCP-eval 49%→74% on Opus 4).
18
+ * The catalog construction (byte-stable, name-sorted, char-budgeted with a
19
+ * degradation ladder) mirrors the proven skills-index pattern in
20
+ * src/lib/skills/skillTools.ts.
21
+ */
22
+ import type { Tool } from "../types/index.js";
23
+ /**
24
+ * Above this many tools, a WARN suggests enabling `tools.discovery` when it
25
+ * is off. Chosen from the measured degradation range (30-50 tools).
26
+ */
27
+ export declare const LARGE_CATALOG_WARN_THRESHOLD = 40;
28
+ /** True when the value is a search_tools meta-tool generated by this module. */
29
+ export declare function isDiscoveryMetaTool(tool: unknown): boolean;
30
+ /**
31
+ * Partition a (post-gate) tools record for discovery: deferrable tools that
32
+ * are not pinned are removed from the record and replaced by one
33
+ * `search_tools` meta-tool. Returns the hot record; when nothing is
34
+ * deferrable the input record is returned unchanged.
35
+ *
36
+ * The returned record is mutated by search_tools on hydration (call-scoped —
37
+ * BaseProvider builds a fresh merged record per call), which is what makes
38
+ * discovered tools callable on subsequent steps of the same agent loop on
39
+ * providers that re-read the record between steps.
40
+ */
41
+ export declare function partitionToolsForDiscovery(tools: Record<string, Tool>, args: {
42
+ /** External-tool names present in `tools` that are allowed to defer. */
43
+ deferrableNames: string[];
44
+ /** Session-pinned names (previously discovered) — never deferred. */
45
+ pinnedNames: ReadonlySet<string>;
46
+ /** Called with newly discovered names — persists session pins. */
47
+ onHydrate: (names: string[]) => void;
48
+ }): Record<string, Tool>;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * On-demand tool discovery (`tools.discovery: true`).
3
+ *
4
+ * Instead of sending every external MCP tool's full schema on every request,
5
+ * the model receives the hot set (built-in tools, per-call tools, explicitly
6
+ * requested tools, and previously discovered tools) plus ONE `search_tools`
7
+ * meta-tool whose description embeds a compact name+summary catalog of the
8
+ * deferred tools. Calling `search_tools` loads matching tools:
9
+ * - immediately into the live tool record (providers that re-read the tools
10
+ * record between agent-loop steps — the AI SDK path — can call them on the
11
+ * very next step), and
12
+ * - into the session pin set (all providers include them in full on every
13
+ * subsequent call of the session).
14
+ *
15
+ * Evidence for this design: catalogs past ~30-50 tools measurably degrade
16
+ * tool-selection accuracy, and deferral+search both cuts definition tokens by
17
+ * ~85% and RAISES selection accuracy (Anthropic MCP-eval 49%→74% on Opus 4).
18
+ * The catalog construction (byte-stable, name-sorted, char-budgeted with a
19
+ * degradation ladder) mirrors the proven skills-index pattern in
20
+ * src/lib/skills/skillTools.ts.
21
+ */
22
+ import { z } from "zod";
23
+ import { tool as createAISDKTool } from "../utils/tool.js";
24
+ import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
25
+ import { logger } from "../utils/logger.js";
26
+ /**
27
+ * Above this many tools, a WARN suggests enabling `tools.discovery` when it
28
+ * is off. Chosen from the measured degradation range (30-50 tools).
29
+ */
30
+ export const LARGE_CATALOG_WARN_THRESHOLD = 40;
31
+ /** Char budget for the catalog embedded in the search_tools description. */
32
+ const CATALOG_CHAR_BUDGET = 15_000;
33
+ /** Max tools loaded per search_tools call. */
34
+ const MAX_SEARCH_RESULTS = 5;
35
+ const SUMMARY_MAX_CHARS = 100;
36
+ /**
37
+ * Marker property stamped on the generated search_tools meta-tool so
38
+ * re-entrant resolution passes (e.g. the stream → generate fallback, whose
39
+ * options.tools already contain a partitioned record) can distinguish OUR
40
+ * stale meta-tool from a real user tool that happens to share the name.
41
+ */
42
+ const DISCOVERY_META_TOOL_MARKER = "__nlDiscoveryMetaTool";
43
+ /** True when the value is a search_tools meta-tool generated by this module. */
44
+ export function isDiscoveryMetaTool(tool) {
45
+ return (!!tool &&
46
+ typeof tool === "object" &&
47
+ tool[DISCOVERY_META_TOOL_MARKER] === true);
48
+ }
49
+ let warnedSearchToolsCollision = false;
50
+ function warnSearchToolsCollisionOnce() {
51
+ if (warnedSearchToolsCollision) {
52
+ return;
53
+ }
54
+ warnedSearchToolsCollision = true;
55
+ logger.warn("[ToolDiscovery] A registered tool is already named 'search_tools' — on-demand discovery is skipped to avoid shadowing it. Rename that tool to use tools.discovery.");
56
+ }
57
+ /** First sentence of a description, capped for the catalog line. */
58
+ function summarize(description) {
59
+ const firstSentence = description.split(/(?<=[.!?])\s/)[0] ?? description;
60
+ const trimmed = firstSentence.trim().replace(/\s+/g, " ");
61
+ return trimmed.length > SUMMARY_MAX_CHARS
62
+ ? trimmed.slice(0, SUMMARY_MAX_CHARS - 1) + "…"
63
+ : trimmed;
64
+ }
65
+ /**
66
+ * Render the deferred-tool catalog, byte-stable (name-sorted input) and
67
+ * char-budgeted with a degradation ladder: full one-liners → name-only lines
68
+ * → truncated with a count note. Entries are never silently dropped without
69
+ * the note, so the model always knows the catalog's true size.
70
+ */
71
+ function renderCatalog(entries) {
72
+ const full = entries.map((e) => `- ${e.name}: ${e.summary}`);
73
+ let text = full.join("\n");
74
+ if (text.length <= CATALOG_CHAR_BUDGET) {
75
+ return text;
76
+ }
77
+ const nameOnly = entries.map((e) => `- ${e.name}`);
78
+ text = nameOnly.join("\n");
79
+ if (text.length <= CATALOG_CHAR_BUDGET) {
80
+ return text;
81
+ }
82
+ const kept = [];
83
+ let used = 0;
84
+ for (const line of nameOnly) {
85
+ if (used + line.length + 1 > CATALOG_CHAR_BUDGET - 80) {
86
+ break;
87
+ }
88
+ kept.push(line);
89
+ used += line.length + 1;
90
+ }
91
+ const remaining = entries.length - kept.length;
92
+ return `${kept.join("\n")}\n… and ${remaining} more (search by task description to find them)`;
93
+ }
94
+ /** Lexical relevance score of one catalog entry for a query. Zero = no match. */
95
+ function scoreEntry(queryLower, queryTokens, entry, fullDescription) {
96
+ const nameLower = entry.name.toLowerCase();
97
+ if (nameLower === queryLower) {
98
+ return 100;
99
+ }
100
+ let score = 0;
101
+ if (queryLower.includes(nameLower) || nameLower.includes(queryLower)) {
102
+ score += 20;
103
+ }
104
+ const haystack = `${nameLower} ${fullDescription.toLowerCase()}`;
105
+ for (const token of queryTokens) {
106
+ if (token.length < 3) {
107
+ continue;
108
+ }
109
+ if (nameLower.includes(token)) {
110
+ score += 8;
111
+ }
112
+ else if (haystack.includes(token)) {
113
+ score += 2;
114
+ }
115
+ }
116
+ if (entry.serverId && queryLower.includes(entry.serverId.toLowerCase())) {
117
+ score += 3;
118
+ }
119
+ return score;
120
+ }
121
+ /**
122
+ * Partition a (post-gate) tools record for discovery: deferrable tools that
123
+ * are not pinned are removed from the record and replaced by one
124
+ * `search_tools` meta-tool. Returns the hot record; when nothing is
125
+ * deferrable the input record is returned unchanged.
126
+ *
127
+ * The returned record is mutated by search_tools on hydration (call-scoped —
128
+ * BaseProvider builds a fresh merged record per call), which is what makes
129
+ * discovered tools callable on subsequent steps of the same agent loop on
130
+ * providers that re-read the record between steps.
131
+ */
132
+ export function partitionToolsForDiscovery(tools, args) {
133
+ const deferredNames = args.deferrableNames
134
+ .filter((n) => n in tools && !args.pinnedNames.has(n))
135
+ .sort();
136
+ if (deferredNames.length === 0) {
137
+ return tools;
138
+ }
139
+ // Never shadow a real tool that happens to be named "search_tools" (a
140
+ // custom tool or an external MCP tool could claim the name). Discovery is
141
+ // skipped for the request rather than silently replacing the caller's tool.
142
+ if ("search_tools" in tools) {
143
+ warnSearchToolsCollisionOnce();
144
+ return tools;
145
+ }
146
+ const deferredSet = new Set(deferredNames);
147
+ // Null prototype: a tool named "__proto__" must become an own entry, not a
148
+ // prototype mutation (mirrors BaseProvider.sortToolRecord) — this record is
149
+ // also mutated later by search_tools hydration.
150
+ const hot = Object.create(null);
151
+ for (const [name, toolDef] of Object.entries(tools)) {
152
+ if (!deferredSet.has(name)) {
153
+ hot[name] = toolDef;
154
+ }
155
+ }
156
+ const entries = deferredNames.map((name) => {
157
+ const t = tools[name];
158
+ return {
159
+ name,
160
+ summary: summarize(t.description ?? ""),
161
+ serverId: typeof t.serverId === "string" ? t.serverId : undefined,
162
+ };
163
+ });
164
+ hot["search_tools"] = buildSearchTool(entries, tools, hot, args.onHydrate);
165
+ logger.debug("[ToolDiscovery] Deferred external tools behind search_tools", {
166
+ hotCount: Object.keys(hot).length - 1,
167
+ deferredCount: deferredNames.length,
168
+ pinnedCount: args.pinnedNames.size,
169
+ });
170
+ return hot;
171
+ }
172
+ function buildSearchTool(entries, allTools, hotRecord, onHydrate) {
173
+ const catalog = renderCatalog(entries);
174
+ const searchTool = createAISDKTool({
175
+ description: `Search and load additional tools on demand. ${entries.length} more tools are available but not yet loaded:\n` +
176
+ `${catalog}\n` +
177
+ `Call this with a task description or an exact tool name; matching tools are loaded and become directly callable.`,
178
+ inputSchema: z.object({
179
+ query: z
180
+ .string()
181
+ .describe("What you need to do, or an exact tool name to load"),
182
+ }),
183
+ execute: async ({ query }) => {
184
+ const queryLower = query.toLowerCase().trim();
185
+ const queryTokens = queryLower.split(/[^a-z0-9_.-]+/).filter(Boolean);
186
+ const scored = entries
187
+ .map((entry) => {
188
+ const full = allTools[entry.name];
189
+ return {
190
+ entry,
191
+ score: scoreEntry(queryLower, queryTokens, entry, full?.description ?? ""),
192
+ };
193
+ })
194
+ .filter((s) => s.score > 0)
195
+ .sort((a, b) => b.score - a.score || (a.entry.name < b.entry.name ? -1 : 1))
196
+ .slice(0, MAX_SEARCH_RESULTS);
197
+ if (scored.length === 0) {
198
+ return {
199
+ found: 0,
200
+ message: "No tools matched. Try different words, or use one of the exact names from the catalog in this tool's description.",
201
+ };
202
+ }
203
+ const loaded = scored.map(({ entry }) => {
204
+ const toolDef = allTools[entry.name];
205
+ // Live hydration: callable on subsequent loop steps where the
206
+ // provider re-reads the record.
207
+ hotRecord[entry.name] = toolDef;
208
+ const def = toolDef;
209
+ return {
210
+ name: entry.name,
211
+ description: def.description ?? "",
212
+ inputSchema: convertZodToJsonSchema(def.inputSchema),
213
+ };
214
+ });
215
+ // Session pinning: included in full on every subsequent call.
216
+ onHydrate(loaded.map((t) => t.name));
217
+ logger.debug("[ToolDiscovery] search_tools hydrated tools", {
218
+ query,
219
+ loaded: loaded.map((t) => t.name),
220
+ });
221
+ return {
222
+ found: loaded.length,
223
+ tools: loaded,
224
+ message: "These tools are now loaded for this session. Call them directly by name with arguments matching their inputSchema. If a call reports the tool as unavailable, it will be available from the next message onward.",
225
+ };
226
+ },
227
+ });
228
+ searchTool[DISCOVERY_META_TOOL_MARKER] = true;
229
+ return searchTool;
230
+ }
231
+ //# sourceMappingURL=toolDiscovery.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tool gate — applies a ResolvedToolPolicy to a tools record. This is the
3
+ * ONE place where include/exclude filtering of the native tool set happens;
4
+ * every generate/stream path reaches it via BaseProvider.
5
+ *
6
+ * Generic over the tool value type so it works for AI-SDK Tool records,
7
+ * ToolInfo listings, and provider wire formats alike.
8
+ */
9
+ import type { ResolvedToolPolicy } from "../types/index.js";
10
+ /**
11
+ * Filter a tools record according to the resolved policy.
12
+ * Order: enabled kill-switch → include allowlist → exclude denylist.
13
+ * Preserves the input record's key order for surviving tools (callers rely
14
+ * on deterministic ordering for provider prompt-cache stability).
15
+ */
16
+ export declare function applyToolGate<T>(tools: Record<string, T>, policy: ResolvedToolPolicy): Record<string, T>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tool gate — applies a ResolvedToolPolicy to a tools record. This is the
3
+ * ONE place where include/exclude filtering of the native tool set happens;
4
+ * every generate/stream path reaches it via BaseProvider.
5
+ *
6
+ * Generic over the tool value type so it works for AI-SDK Tool records,
7
+ * ToolInfo listings, and provider wire formats alike.
8
+ */
9
+ import { toolNameMatcher } from "./toolPolicy.js";
10
+ /**
11
+ * Filter a tools record according to the resolved policy.
12
+ * Order: enabled kill-switch → include allowlist → exclude denylist.
13
+ * Preserves the input record's key order for surviving tools (callers rely
14
+ * on deterministic ordering for provider prompt-cache stability).
15
+ */
16
+ export function applyToolGate(tools, policy) {
17
+ if (!policy.enabled) {
18
+ return {};
19
+ }
20
+ const include = policy.include;
21
+ const includeBound = policy.includeBound;
22
+ const hasExclude = policy.exclude.length > 0;
23
+ if (include === undefined && includeBound === undefined && !hasExclude) {
24
+ return tools;
25
+ }
26
+ const includeMatcher = include !== undefined ? toolNameMatcher(include) : undefined;
27
+ // Secondary allowlist clause ANDed with `include` (see ResolvedToolPolicy):
28
+ // a name must match BOTH when a per-call filter is bounded by the
29
+ // instance-level tools.include.
30
+ const boundMatcher = includeBound !== undefined ? toolNameMatcher(includeBound) : undefined;
31
+ const excludeMatcher = hasExclude
32
+ ? toolNameMatcher(policy.exclude)
33
+ : undefined;
34
+ // Null prototype: a tool named "__proto__" must become an own entry, not
35
+ // a prototype mutation (mirrors BaseProvider.sortToolRecord).
36
+ const result = Object.create(null);
37
+ for (const [name, tool] of Object.entries(tools)) {
38
+ if (includeMatcher && !includeMatcher(name)) {
39
+ continue;
40
+ }
41
+ if (boundMatcher && !boundMatcher(name)) {
42
+ continue;
43
+ }
44
+ if (excludeMatcher && excludeMatcher(name)) {
45
+ continue;
46
+ }
47
+ result[name] = tool;
48
+ }
49
+ return result;
50
+ }
51
+ //# sourceMappingURL=toolGate.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tool policy resolution — the single, pure mapping from every tool-selection
3
+ * surface (per-call legacy options + instance-level `tools` config) to one
4
+ * ResolvedToolPolicy that the gate applies.
5
+ *
6
+ * Legacy semantics are preserved deliberately:
7
+ * - `toolFilter: []` and `enabledToolNames: []` are no-ops (fail-open), as
8
+ * they always were.
9
+ * - Malformed shapes (non-string-arrays) are ignored with a once-per-process
10
+ * WARN instead of throwing — some internal callers historically passed
11
+ * wrong shapes and relied on the silent no-op.
12
+ * - `enabledToolNames` now filters the native tool set (its docs always
13
+ * promised this; it previously only filtered the system-prompt listing) —
14
+ * but ONLY when `toolFilter` is absent. When both are set, `toolFilter`
15
+ * alone bounds the native set, exactly as before this refactor: the native
16
+ * set must never become WIDER than a caller's `toolFilter`.
17
+ *
18
+ * New semantics:
19
+ * - `tools.include: []` (the instance config surface) means NO tools
20
+ * (fail-closed); legacy empty arrays stay fail-open as above.
21
+ * - A per-call `toolFilter` is bounded by `tools.include` — it can narrow
22
+ * the instance allowlist but never widen past it.
23
+ * - Pattern entries containing `*` match as globs (e.g. `"github*"`) on ALL
24
+ * surfaces. Real tool names cannot contain `*` (MCP names are sanitized),
25
+ * so a legacy list entry with `*` previously matched nothing (dead entry);
26
+ * it now matches as a glob. Entries without `*` match exactly, unchanged.
27
+ */
28
+ import type { ResolvedToolPolicy, ToolPolicyResolutionInput } from "../types/index.js";
29
+ /**
30
+ * Compile a list of tool-name patterns into a matcher. Patterns without `*`
31
+ * match exactly (case-sensitive, preserving legacy toolFilter semantics);
32
+ * patterns containing `*` match as globs.
33
+ */
34
+ export declare function toolNameMatcher(patterns: string[]): (name: string) => boolean;
35
+ /**
36
+ * Resolve the effective tool policy for one request. Pure function — all
37
+ * inputs are explicit, so the legacy→policy mapping is unit-testable as a
38
+ * table.
39
+ */
40
+ export declare function resolveToolPolicy(input: ToolPolicyResolutionInput): ResolvedToolPolicy;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Tool policy resolution — the single, pure mapping from every tool-selection
3
+ * surface (per-call legacy options + instance-level `tools` config) to one
4
+ * ResolvedToolPolicy that the gate applies.
5
+ *
6
+ * Legacy semantics are preserved deliberately:
7
+ * - `toolFilter: []` and `enabledToolNames: []` are no-ops (fail-open), as
8
+ * they always were.
9
+ * - Malformed shapes (non-string-arrays) are ignored with a once-per-process
10
+ * WARN instead of throwing — some internal callers historically passed
11
+ * wrong shapes and relied on the silent no-op.
12
+ * - `enabledToolNames` now filters the native tool set (its docs always
13
+ * promised this; it previously only filtered the system-prompt listing) —
14
+ * but ONLY when `toolFilter` is absent. When both are set, `toolFilter`
15
+ * alone bounds the native set, exactly as before this refactor: the native
16
+ * set must never become WIDER than a caller's `toolFilter`.
17
+ *
18
+ * New semantics:
19
+ * - `tools.include: []` (the instance config surface) means NO tools
20
+ * (fail-closed); legacy empty arrays stay fail-open as above.
21
+ * - A per-call `toolFilter` is bounded by `tools.include` — it can narrow
22
+ * the instance allowlist but never widen past it.
23
+ * - Pattern entries containing `*` match as globs (e.g. `"github*"`) on ALL
24
+ * surfaces. Real tool names cannot contain `*` (MCP names are sanitized),
25
+ * so a legacy list entry with `*` previously matched nothing (dead entry);
26
+ * it now matches as a glob. Entries without `*` match exactly, unchanged.
27
+ */
28
+ import { logger } from "../utils/logger.js";
29
+ const warnedOnce = new Set();
30
+ function warnOnce(key, message, detail) {
31
+ if (!warnedOnce.has(key)) {
32
+ warnedOnce.add(key);
33
+ logger.warn(message, detail);
34
+ }
35
+ }
36
+ function escapeRegExp(s) {
37
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ }
39
+ /**
40
+ * Compile a list of tool-name patterns into a matcher. Patterns without `*`
41
+ * match exactly (case-sensitive, preserving legacy toolFilter semantics);
42
+ * patterns containing `*` match as globs.
43
+ */
44
+ export function toolNameMatcher(patterns) {
45
+ const exact = new Set();
46
+ const globs = [];
47
+ for (const pattern of patterns) {
48
+ if (pattern.includes("*")) {
49
+ // Split on RUNS of asterisks: "a**b" must compile to /^a.*b$/, not
50
+ // /^a.*.*b$/ — adjacent .* pairs invite catastrophic backtracking on
51
+ // long non-matching names (ReDoS).
52
+ const source = "^" + pattern.split(/\*+/).map(escapeRegExp).join(".*") + "$";
53
+ globs.push(new RegExp(source));
54
+ }
55
+ else {
56
+ exact.add(pattern);
57
+ }
58
+ }
59
+ return (name) => exact.has(name) || globs.some((g) => g.test(name));
60
+ }
61
+ /**
62
+ * Normalize a possibly-malformed option value to a string array.
63
+ * Returns undefined (and WARNs once per source) for anything that is not a
64
+ * proper string array — preserving the historical silent-no-op behavior for
65
+ * wrong shapes while making it observable. Used for the LEGACY per-call
66
+ * surfaces only; the new config surface uses normalizeConfigList, which does
67
+ * not fail open.
68
+ */
69
+ function asStringArray(value, sourceName) {
70
+ if (value === undefined || value === null) {
71
+ return undefined;
72
+ }
73
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
74
+ return value;
75
+ }
76
+ warnOnce(`shape:${sourceName}`, `[ToolPolicy] Ignoring ${sourceName}: expected string[]`, { receivedType: typeof value });
77
+ return undefined;
78
+ }
79
+ /**
80
+ * Normalize a `tools.include`/`tools.exclude` config value. Unlike the
81
+ * legacy surfaces, the NEW config surface must not silently fail open — a
82
+ * caller who fat-fingers `tools.include` intending to lock an instance down
83
+ * must not get every tool exposed:
84
+ * - a bare string is coerced to a one-element list (obvious intent),
85
+ * - an array is salvaged to its string entries (dropping the rest, WARNed —
86
+ * for `include` this converges toward fewer tools, never more),
87
+ * - anything else fails CLOSED for `include` (empty list = no tools) and is
88
+ * ignored-with-WARN for `exclude` (a denylist has no safe "closed" other
89
+ * than excluding everything, which would be equally surprising).
90
+ */
91
+ function normalizeConfigList(value, sourceName, failClosed) {
92
+ if (value === undefined || value === null) {
93
+ return undefined;
94
+ }
95
+ if (typeof value === "string") {
96
+ warnOnce(`shape:${sourceName}`, `[ToolPolicy] ${sourceName} was a bare string; coercing to a one-element list`, { received: value });
97
+ return [value];
98
+ }
99
+ if (Array.isArray(value)) {
100
+ const strings = value.filter((v) => typeof v === "string");
101
+ if (strings.length !== value.length) {
102
+ warnOnce(`shape:${sourceName}`, `[ToolPolicy] Dropped ${value.length - strings.length} non-string entr(ies) from ${sourceName}`);
103
+ }
104
+ return strings;
105
+ }
106
+ warnOnce(`shape:${sourceName}`, failClosed
107
+ ? `[ToolPolicy] ${sourceName} is malformed (expected string[]); failing CLOSED — no tools will be sent until it is fixed`
108
+ : `[ToolPolicy] Ignoring malformed ${sourceName}: expected string[]`, { receivedType: typeof value });
109
+ return failClosed ? [] : undefined;
110
+ }
111
+ /**
112
+ * Resolve the effective tool policy for one request. Pure function — all
113
+ * inputs are explicit, so the legacy→policy mapping is unit-testable as a
114
+ * table.
115
+ */
116
+ export function resolveToolPolicy(input) {
117
+ const { options, instanceConfig, builtinToolNames } = input;
118
+ const sources = [];
119
+ // --- enabled -------------------------------------------------------------
120
+ let enabled = true;
121
+ if (options.disableTools === true) {
122
+ enabled = false;
123
+ sources.push("options.disableTools");
124
+ }
125
+ if (instanceConfig?.enabled === false) {
126
+ enabled = false;
127
+ sources.push("tools.enabled");
128
+ }
129
+ // --- allowlist -----------------------------------------------------------
130
+ // Legacy per-call allowlists: fail-open on empty (historical behavior).
131
+ const toolFilter = asStringArray(options.toolFilter, "options.toolFilter");
132
+ const enabledNames = asStringArray(options.enabledToolNames, "options.enabledToolNames");
133
+ let legacyInclude;
134
+ if (toolFilter && toolFilter.length > 0) {
135
+ // toolFilter alone bounds the native set — merging enabledToolNames in
136
+ // as a union would WIDEN the set beyond toolFilter, breaking the
137
+ // pre-refactor contract in the unsafe direction.
138
+ legacyInclude = [...new Set(toolFilter)];
139
+ sources.push("options.toolFilter");
140
+ if (enabledNames && enabledNames.length > 0) {
141
+ warnOnce("both-allowlists", "[ToolPolicy] Both toolFilter and enabledToolNames are set; toolFilter bounds the native tool set and enabledToolNames is ignored for it (pre-existing contract).");
142
+ }
143
+ }
144
+ else if (enabledNames && enabledNames.length > 0) {
145
+ legacyInclude = [...new Set(enabledNames)];
146
+ sources.push("options.enabledToolNames");
147
+ }
148
+ // New instance allowlist: fail-closed on empty ([] = no tools) AND on
149
+ // malformed shapes — a broken lockdown config must never expose all tools.
150
+ const configInclude = normalizeConfigList(instanceConfig?.include, "tools.include", true);
151
+ if (configInclude !== undefined) {
152
+ sources.push("tools.include");
153
+ }
154
+ let include;
155
+ let includeBound;
156
+ if (legacyInclude !== undefined && configInclude !== undefined) {
157
+ // Per-call filter is bounded by the instance allowlist: a tool must
158
+ // match BOTH lists. Kept as two matcher clauses (include AND
159
+ // includeBound) because glob pattern lists cannot be losslessly
160
+ // pre-intersected into one pattern array — filtering one list's pattern
161
+ // STRINGS through the other list's matcher would zero out overlapping
162
+ // globs like toolFilter:["github*"] vs include:["github_read*"].
163
+ include = legacyInclude;
164
+ includeBound = configInclude;
165
+ }
166
+ else {
167
+ include = legacyInclude ?? configInclude;
168
+ }
169
+ // --- denylist ------------------------------------------------------------
170
+ const exclude = [];
171
+ const optExclude = asStringArray(options.excludeTools, "options.excludeTools");
172
+ if (optExclude && optExclude.length > 0) {
173
+ exclude.push(...optExclude);
174
+ sources.push("options.excludeTools");
175
+ }
176
+ const cfgExclude = normalizeConfigList(instanceConfig?.exclude, "tools.exclude", false);
177
+ if (cfgExclude && cfgExclude.length > 0) {
178
+ exclude.push(...cfgExclude);
179
+ sources.push("tools.exclude");
180
+ }
181
+ if (instanceConfig?.disableBuiltinTools === true &&
182
+ builtinToolNames &&
183
+ builtinToolNames.length > 0) {
184
+ exclude.push(...builtinToolNames);
185
+ sources.push("tools.disableBuiltinTools");
186
+ }
187
+ // --- discovery -----------------------------------------------------------
188
+ const discovery = instanceConfig?.discovery === true;
189
+ if (discovery) {
190
+ sources.push("tools.discovery");
191
+ }
192
+ return { enabled, include, includeBound, exclude, discovery, sources };
193
+ }
194
+ //# sourceMappingURL=toolPolicy.js.map
@@ -42,6 +42,12 @@ export type NeurolinkConstructorConfig = {
42
42
  conversationMemory?: Partial<ConversationMemoryConfig>;
43
43
  enableOrchestration?: boolean;
44
44
  hitl?: HITLConfig;
45
+ /**
46
+ * Instance-level tool policy: master switch, include/exclude lists
47
+ * (with `*` glob support), and on-demand MCP tool discovery.
48
+ * See {@link ToolConfig}.
49
+ */
50
+ tools?: ToolConfig;
45
51
  toolRegistry?: MCPToolRegistry;
46
52
  observability?: ObservabilityConfig;
47
53
  modelAliasConfig?: ModelAliasConfig;
@@ -329,14 +335,48 @@ export type AnalyticsConfig = {
329
335
  };
330
336
  };
331
337
  /**
332
- * Tool configuration
338
+ * Instance-level tool configuration (`new NeuroLink({ tools: {...} })`).
339
+ *
340
+ * The four primary keys (`enabled`, `include`, `exclude`, `discovery`) form
341
+ * the complete modern surface; per-call options (`toolFilter`,
342
+ * `excludeTools`, `enabledToolNames`, `disableTools`) keep working and are
343
+ * merged with this config by `resolveToolPolicy()`.
333
344
  */
334
345
  export type ToolConfig = {
335
- /** Whether built-in tools should be disabled */
346
+ /**
347
+ * Master switch. `false` disables all tools for every call from this
348
+ * instance (equivalent to passing `disableTools: true` on each call).
349
+ * Default: true.
350
+ */
351
+ enabled?: boolean;
352
+ /**
353
+ * Allowlist of tool names. Supports `*` globs (e.g. `"github*"`).
354
+ * Undefined = all tools; an EMPTY array means no tools (fail-closed).
355
+ * Per-call `toolFilter` is bounded by this list (a per-call filter can
356
+ * narrow it further but never widen past it).
357
+ */
358
+ include?: string[];
359
+ /** Denylist of tool names (supports `*` globs). Applied after `include`. */
360
+ exclude?: string[];
361
+ /**
362
+ * Defer external MCP tool schemas behind a `search_tools` meta-tool: the
363
+ * model sees a compact name+summary catalog instead of full schemas and
364
+ * loads a tool on demand by searching for it. Built-in tools, per-call
365
+ * tools, per-call whitelists (`toolFilter`/`enabledToolNames`), forced
366
+ * `toolChoice` tools, and already-discovered tools are always sent in
367
+ * full. Note: the instance-level `include` list deliberately does NOT
368
+ * force tools hot — it scopes the catalog, and discovery defers within
369
+ * that scope (a scoped-but-large catalog is exactly where deferral pays).
370
+ * Default: false.
371
+ */
372
+ discovery?: boolean;
373
+ /** Whether built-in tools should be disabled (equivalent to excluding all direct tools) */
336
374
  disableBuiltinTools?: boolean;
337
375
  /** Whether custom tools are allowed */
338
376
  allowCustomTools?: boolean;
339
- /** Maximum number of tools per provider */
377
+ /**
378
+ * @deprecated Never enforced; retained for compile compatibility only.
379
+ */
340
380
  maxToolsPerProvider?: number;
341
381
  /** Whether MCP tools should be enabled */
342
382
  enableMCPTools?: boolean;
@@ -52,6 +52,7 @@ export * from "./subscription.js";
52
52
  export * from "./task.js";
53
53
  export * from "./taskClassification.js";
54
54
  export * from "./toolDedup.js";
55
+ export * from "./toolResolution.js";
55
56
  export * from "./toolRouting.js";
56
57
  export * from "./tools.js";
57
58
  export * from "./vectorStoreChroma.js";
@@ -53,6 +53,7 @@ export * from "./subscription.js";
53
53
  export * from "./task.js";
54
54
  export * from "./taskClassification.js";
55
55
  export * from "./toolDedup.js";
56
+ export * from "./toolResolution.js";
56
57
  export * from "./toolRouting.js";
57
58
  export * from "./tools.js";
58
59
  export * from "./vectorStoreChroma.js";