@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +368 -360
  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/server/routes/agentRoutes.js +25 -2
  17. package/dist/lib/tools/toolDiscovery.d.ts +48 -0
  18. package/dist/lib/tools/toolDiscovery.js +231 -0
  19. package/dist/lib/tools/toolGate.d.ts +16 -0
  20. package/dist/lib/tools/toolGate.js +51 -0
  21. package/dist/lib/tools/toolPolicy.d.ts +40 -0
  22. package/dist/lib/tools/toolPolicy.js +194 -0
  23. package/dist/lib/types/config.d.ts +43 -3
  24. package/dist/lib/types/index.d.ts +1 -0
  25. package/dist/lib/types/index.js +1 -0
  26. package/dist/lib/types/providers.d.ts +8 -0
  27. package/dist/lib/types/toolResolution.d.ts +73 -0
  28. package/dist/lib/types/toolResolution.js +11 -0
  29. package/dist/mcp/toolRegistry.js +7 -2
  30. package/dist/neurolink.d.ts +31 -1
  31. package/dist/neurolink.js +151 -26
  32. package/dist/server/routes/agentRoutes.js +25 -2
  33. package/dist/tools/toolDiscovery.d.ts +48 -0
  34. package/dist/tools/toolDiscovery.js +230 -0
  35. package/dist/tools/toolGate.d.ts +16 -0
  36. package/dist/tools/toolGate.js +50 -0
  37. package/dist/tools/toolPolicy.d.ts +40 -0
  38. package/dist/tools/toolPolicy.js +193 -0
  39. package/dist/types/config.d.ts +43 -3
  40. package/dist/types/index.d.ts +1 -0
  41. package/dist/types/index.js +1 -0
  42. package/dist/types/providers.d.ts +8 -0
  43. package/dist/types/toolResolution.d.ts +73 -0
  44. package/dist/types/toolResolution.js +10 -0
  45. package/package.json +2 -1
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tool resolution types — the single tool-policy pipeline that decides which
3
+ * tools are sent to a provider for a given request.
4
+ *
5
+ * One policy object is resolved per request from (a) per-call legacy options
6
+ * (`toolFilter`, `excludeTools`, `enabledToolNames`, `disableTools`) and
7
+ * (b) the instance-level `tools` config, then applied at the single gate in
8
+ * `BaseProvider` that every generate/stream path passes through.
9
+ */
10
+ import type { ToolConfig } from "./config.js";
11
+ /**
12
+ * The resolved, merged tool policy for one request. Produced by
13
+ * `resolveToolPolicy()` (src/lib/tools/toolPolicy.ts) and consumed by
14
+ * `applyToolGate()` (src/lib/tools/toolGate.ts).
15
+ */
16
+ export type ResolvedToolPolicy = {
17
+ /** false = no tools at all for this request (drops caller-supplied tools too). */
18
+ enabled: boolean;
19
+ /**
20
+ * Allowlist of tool-name patterns (exact names or `*` globs).
21
+ * `undefined` = all tools pass. An empty array means "no tools" — it can
22
+ * only come from the new `tools.include` config surface; legacy
23
+ * `toolFilter: []` is normalized to `undefined` (fail-open, preserving
24
+ * historical behavior) before it reaches here.
25
+ */
26
+ include?: string[];
27
+ /**
28
+ * Secondary allowlist clause ANDed with `include` — set when both a
29
+ * legacy per-call allowlist and the instance `tools.include` are present.
30
+ * Kept as a separate clause because two glob pattern lists cannot be
31
+ * losslessly pre-intersected into a single pattern array (a name must
32
+ * match BOTH lists to pass).
33
+ */
34
+ includeBound?: string[];
35
+ /** Denylist of tool-name patterns (exact names or `*` globs), applied after include. */
36
+ exclude: string[];
37
+ /** Defer external MCP tool schemas behind the search_tools meta-tool. */
38
+ discovery: boolean;
39
+ /** Which option/config sources contributed to this policy (telemetry/debugging). */
40
+ sources: string[];
41
+ };
42
+ /**
43
+ * Inputs to `resolveToolPolicy()`. Kept as a named type so the mapping is
44
+ * unit-testable as a pure function.
45
+ */
46
+ export type ToolPolicyResolutionInput = {
47
+ /** Per-call options (the legacy per-call filtering surface). */
48
+ options: {
49
+ disableTools?: boolean;
50
+ toolFilter?: string[];
51
+ enabledToolNames?: string[];
52
+ excludeTools?: string[];
53
+ };
54
+ /** Instance-level `tools` config passed to the NeuroLink constructor. */
55
+ instanceConfig?: ToolConfig;
56
+ /**
57
+ * Names of the built-in (direct) tools of the calling provider — used to
58
+ * honor `tools.disableBuiltinTools` without this module importing the
59
+ * direct-tools registry.
60
+ */
61
+ builtinToolNames?: string[];
62
+ };
63
+ /**
64
+ * One entry in the deferred-tool catalog embedded in the `search_tools`
65
+ * meta-tool description when `tools.discovery` is enabled.
66
+ */
67
+ export type DeferredToolIndexEntry = {
68
+ name: string;
69
+ /** One-line description (truncated) shown in the search_tools catalog. */
70
+ summary: string;
71
+ /** Originating MCP server id, when known. */
72
+ serverId?: string;
73
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tool resolution types — the single tool-policy pipeline that decides which
3
+ * tools are sent to a provider for a given request.
4
+ *
5
+ * One policy object is resolved per request from (a) per-call legacy options
6
+ * (`toolFilter`, `excludeTools`, `enabledToolNames`, `disableTools`) and
7
+ * (b) the instance-level `tools` config, then applied at the single gate in
8
+ * `BaseProvider` that every generate/stream path passes through.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=toolResolution.js.map
@@ -7,6 +7,7 @@ import { registryLogger } from "../utils/logger.js";
7
7
  import { randomUUID } from "crypto";
8
8
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
9
9
  import { directAgentTools } from "../agent/directTools.js";
10
+ import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
10
11
  import { detectCategory, createMCPServerInfo } from "../utils/mcpDefaults.js";
11
12
  import { FlexibleToolValidator } from "./flexibleToolValidator.js";
12
13
  import { ErrorFactory } from "../utils/errorHandling.js";
@@ -57,10 +58,14 @@ export class MCPToolRegistry extends MCPRegistry {
57
58
  continue;
58
59
  }
59
60
  const toolId = `direct.${toolName}`;
61
+ // Register the tool's real parameter schema (converted from Zod) instead
62
+ // of a `{}` placeholder — token-budget accounting and tool listings read
63
+ // this ToolInfo, and an empty schema makes both under-count reality.
64
+ const inputSchema = convertZodToJsonSchema(toolDef.inputSchema);
60
65
  const toolInfo = {
61
66
  name: toolName,
62
67
  description: toolDef.description || `Direct tool: ${toolName}`,
63
- inputSchema: {},
68
+ inputSchema,
64
69
  serverId: "direct",
65
70
  category: detectCategory({ isBuiltIn: true, serverId: "direct" }),
66
71
  };
@@ -98,7 +103,7 @@ export class MCPToolRegistry extends MCPRegistry {
98
103
  }
99
104
  },
100
105
  description: toolDef.description,
101
- inputSchema: {},
106
+ inputSchema,
102
107
  });
103
108
  registryLogger.debug(`Registered direct tool: ${toolName} as ${toolId}`);
104
109
  }
@@ -6,7 +6,7 @@
6
6
  * Uses real MCP infrastructure for tool discovery and execution.
7
7
  */
8
8
  import type { AgentDefinition, AgentNetworkConfig, NetworkExecutionInput, NetworkExecutionOptions, NetworkExecutionResult, NetworkStreamChunk } from "./types/index.js";
9
- import type { CompactionConfig, CompactionResult, SpanData, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig } from "./types/index.js";
9
+ import type { CompactionConfig, CompactionResult, SpanData, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig, ToolConfig } from "./types/index.js";
10
10
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
11
11
  import type { RedisConversationMemoryManager } from "./core/redisConversationMemoryManager.js";
12
12
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
@@ -107,6 +107,9 @@ export declare class NeuroLink {
107
107
  private toolRoutingCacheInstance?;
108
108
  private toolRoutingVectorCache?;
109
109
  private toolDedupConfig?;
110
+ private toolsConfig?;
111
+ /** Session-scoped pins of tools discovered via search_tools (tools.discovery mode). */
112
+ private discoveryPins;
110
113
  private enableOrchestration;
111
114
  private authProvider?;
112
115
  private pendingAuthConfig?;
@@ -812,6 +815,11 @@ export declare class NeuroLink {
812
815
  /**
813
816
  * Apply per-call tool filtering (whitelist/blacklist) to a ToolInfo array.
814
817
  * Used to filter the tool list before building the system prompt.
818
+ *
819
+ * Resolves the SAME policy as the native gate (`BaseProvider`
820
+ * `applyToolFiltering`) — per-call options plus the instance-level `tools`
821
+ * config — so what the model is told about tools never diverges from the
822
+ * tools it can actually call.
815
823
  */
816
824
  private applyToolInfoFiltering;
817
825
  private createToolAwareSystemPrompt;
@@ -1194,6 +1202,28 @@ export declare class NeuroLink {
1194
1202
  * parameter through the full call stack.
1195
1203
  */
1196
1204
  getToolDedupConfig(): ToolDedupConfig | undefined;
1205
+ /**
1206
+ * Returns the instance-level `tools` config (master switch, include/exclude
1207
+ * lists, discovery mode), or `undefined` when not provided at construction.
1208
+ *
1209
+ * Called by `BaseProvider.applyToolFiltering` so the tool gate composes the
1210
+ * instance policy with per-call options on every generate/stream call.
1211
+ */
1212
+ getToolsConfig(): ToolConfig | undefined;
1213
+ /**
1214
+ * Tools discovered via `search_tools` for a session (`tools.discovery`
1215
+ * mode). Pinned tools are sent in full on every subsequent call of that
1216
+ * session instead of being deferred — discovery cost is paid once.
1217
+ * Reading refreshes the session's recency (LRU), so active conversations
1218
+ * are never the ones evicted at the session cap.
1219
+ */
1220
+ getDiscoveryPins(sessionKey: string): ReadonlySet<string>;
1221
+ /**
1222
+ * Pin discovered tools to a session (called by the `search_tools`
1223
+ * meta-tool on hydration). Append-only within a session; the map is
1224
+ * bounded by evicting the least-recently-used session past 1000 sessions.
1225
+ */
1226
+ pinDiscoveredTools(sessionKey: string, toolNames: string[]): void;
1197
1227
  /**
1198
1228
  * Curator P1-1: synchronous credential health check for a single provider.
1199
1229
  *
package/dist/neurolink.js CHANGED
@@ -83,7 +83,10 @@ import { resolveModel } from "./utils/modelAliasResolver.js";
83
83
  // Import orchestration components
84
84
  import { ModelRouter } from "./utils/modelRouter.js";
85
85
  import { getBestProvider } from "./utils/providerUtils.js";
86
- import { isZodSchema } from "./utils/schemaConversion.js";
86
+ import { isZodSchema, convertZodToJsonSchema, } from "./utils/schemaConversion.js";
87
+ import { resolveToolPolicy } from "./tools/toolPolicy.js";
88
+ import { applyToolGate } from "./tools/toolGate.js";
89
+ import { directAgentTools } from "./agent/directTools.js";
87
90
  import { BinaryTaskClassifier } from "./utils/taskClassifier.js";
88
91
  // Tool detection and execution imports
89
92
  // Transformation utilities
@@ -284,6 +287,30 @@ export function markStreamProviderEmittedGenerationEnd(options) {
284
287
  * survives minification AND doesn't rely on method-name stability.
285
288
  */
286
289
  export const NEUROLINK_BRAND = Symbol.for("@juspay/neurolink/sdk-brand");
290
+ /**
291
+ * Providers whose native tool-calling support is model-dependent or absent —
292
+ * i.e. every provider that overrides `supportsTools()` and can return false
293
+ * (verified against src/lib/providers: ollama and openrouter are
294
+ * model-dependent; huggingface is deployment-dependent; the rest are
295
+ * image/embedding providers). Only these still receive the full tool listing
296
+ * in the system prompt on the generate path, where no provider instance
297
+ * exists yet to ask directly; every other provider gets tool definitions
298
+ * natively via its `tools` parameter, so repeating them in the prompt was
299
+ * pure token duplication. The stream path asks the provider instance
300
+ * (`provider.supportsTools()`) instead of this list. Keep in sync with
301
+ * `supportsTools()` overrides when adding providers.
302
+ */
303
+ const PROMPT_ONLY_TOOL_PROVIDERS = new Set([
304
+ "ollama",
305
+ "huggingface",
306
+ "openrouter",
307
+ "ideogram",
308
+ "recraft",
309
+ "replicate",
310
+ "stability",
311
+ "jina",
312
+ "voyage",
313
+ ]);
287
314
  /**
288
315
  * Type-guard for opaque values that should be a {@link NeuroLink} instance.
289
316
  *
@@ -379,6 +406,9 @@ export class NeuroLink {
379
406
  toolRoutingVectorCache;
380
407
  // Opt-in tool-signature deduplication config.
381
408
  toolDedupConfig;
409
+ toolsConfig;
410
+ /** Session-scoped pins of tools discovered via search_tools (tools.discovery mode). */
411
+ discoveryPins = new Map();
382
412
  // Add orchestration property
383
413
  enableOrchestration;
384
414
  // Authentication provider for secure access control
@@ -807,6 +837,11 @@ export class NeuroLink {
807
837
  if (config?.toolDedup) {
808
838
  this.toolDedupConfig = { ...config.toolDedup };
809
839
  }
840
+ if (config?.tools) {
841
+ // Shallow-clone so later caller mutations of the config object don't
842
+ // change this instance's tool policy mid-flight.
843
+ this.toolsConfig = { ...config.tools };
844
+ }
810
845
  // ModelPool: build one instance from config; null when not configured.
811
846
  this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
812
847
  // RequestRouter: store the host-supplied function; null when not configured.
@@ -1108,10 +1143,15 @@ export class NeuroLink {
1108
1143
  // Use void to handle async registration without blocking constructor
1109
1144
  const registrations = Object.entries(fileTools).map(async ([toolName, toolDef]) => {
1110
1145
  const toolId = `direct.${toolName}`;
1146
+ // Register the real parameter schema (converted from Zod) instead of a
1147
+ // `{}` placeholder so tool listings and token-budget accounting see the
1148
+ // schema that actually ships to providers.
1149
+ const fileToolSchema = convertZodToJsonSchema((toolDef.inputSchema ??
1150
+ toolDef.parameters));
1111
1151
  const toolInfo = {
1112
1152
  name: toolName,
1113
1153
  description: toolDef.description || `File tool: ${toolName}`,
1114
- inputSchema: {},
1154
+ inputSchema: fileToolSchema,
1115
1155
  serverId: "direct",
1116
1156
  category: "built-in",
1117
1157
  };
@@ -5143,10 +5183,21 @@ Current user's request: ${currentInput}`;
5143
5183
  const circuitBreakerNote = unavailableTools.length > 0
5144
5184
  ? `\n\nNOTE: The following tools are temporarily unavailable due to repeated failures: ${unavailableTools.join(", ")}. Do not attempt to call these tools.`
5145
5185
  : "";
5186
+ // Circuit-breaker-open tools are ENFORCED out of the native tool set via
5187
+ // the per-call denylist — asking the model nicely (the note above) is
5188
+ // informational, exclusion is the guarantee. Previously only the
5189
+ // system-prompt listing was filtered; the native array still shipped them.
5190
+ if (unavailableTools.length > 0) {
5191
+ options.excludeTools = [
5192
+ ...new Set([...(options.excludeTools ?? []), ...unavailableTools]),
5193
+ ];
5194
+ }
5195
+ // Providers with native tool calling receive full definitions via their
5196
+ // `tools` parameter; only prompt-based providers still get the listing.
5197
+ const nativeToolSupport = !PROMPT_ONLY_TOOL_PROVIDERS.has(String(providerName).toLowerCase());
5146
5198
  const enhancedSystemPrompt = options.skipToolPromptInjection
5147
5199
  ? (options.systemPrompt || "") + circuitBreakerNote
5148
- : this.createToolAwareSystemPrompt(options.systemPrompt, availableTools) +
5149
- circuitBreakerNote;
5200
+ : this.createToolAwareSystemPrompt(options.systemPrompt, availableTools, nativeToolSupport) + circuitBreakerNote;
5150
5201
  logger.debug("Tool-aware system prompt created", {
5151
5202
  requestId,
5152
5203
  originalPromptLength: options.systemPrompt?.length || 0,
@@ -5900,48 +5951,64 @@ Current user's request: ${currentInput}`;
5900
5951
  /**
5901
5952
  * Apply per-call tool filtering (whitelist/blacklist) to a ToolInfo array.
5902
5953
  * Used to filter the tool list before building the system prompt.
5954
+ *
5955
+ * Resolves the SAME policy as the native gate (`BaseProvider`
5956
+ * `applyToolFiltering`) — per-call options plus the instance-level `tools`
5957
+ * config — so what the model is told about tools never diverges from the
5958
+ * tools it can actually call.
5903
5959
  */
5904
5960
  applyToolInfoFiltering(tools, options) {
5905
- // enabledToolNames is an additional whitelist — merged into toolFilter
5906
- const whitelist = [
5907
- ...(options.toolFilter ?? []),
5908
- ...(options.enabledToolNames ?? []),
5909
- ];
5910
- if (whitelist.length === 0 &&
5911
- (!options.excludeTools || options.excludeTools.length === 0)) {
5912
- return tools;
5913
- }
5914
- let filtered = tools;
5915
- if (whitelist.length > 0) {
5916
- const allowSet = new Set(whitelist);
5917
- filtered = filtered.filter((t) => allowSet.has(t.name));
5918
- }
5919
- if (options.excludeTools && options.excludeTools.length > 0) {
5920
- const denySet = new Set(options.excludeTools);
5921
- filtered = filtered.filter((t) => !denySet.has(t.name));
5961
+ const policy = resolveToolPolicy({
5962
+ options: {
5963
+ disableTools: options.disableTools,
5964
+ toolFilter: options.toolFilter,
5965
+ enabledToolNames: options.enabledToolNames,
5966
+ excludeTools: options.excludeTools,
5967
+ },
5968
+ instanceConfig: this.toolsConfig,
5969
+ builtinToolNames: Object.keys(directAgentTools),
5970
+ });
5971
+ const record = {};
5972
+ for (const tool of tools) {
5973
+ if (!(tool.name in record)) {
5974
+ record[tool.name] = tool;
5975
+ }
5922
5976
  }
5977
+ const gated = applyToolGate(record, policy);
5978
+ const filtered = tools.filter((t) => t.name in gated);
5923
5979
  if (filtered.length !== tools.length) {
5924
5980
  logger.debug(`Tool info filtering applied for system prompt`, {
5925
5981
  beforeCount: tools.length,
5926
5982
  afterCount: filtered.length,
5983
+ policySources: policy.sources,
5927
5984
  toolFilter: options.toolFilter,
5928
5985
  excludeTools: options.excludeTools,
5929
5986
  });
5930
5987
  }
5931
5988
  return filtered;
5932
5989
  }
5933
- createToolAwareSystemPrompt(originalSystemPrompt, availableTools) {
5990
+ createToolAwareSystemPrompt(originalSystemPrompt, availableTools, nativeToolSupport) {
5934
5991
  // AI prompt generation with tool analysis and structured logging
5935
5992
  const promptGenerationData = {
5936
5993
  originalPromptLength: originalSystemPrompt?.length || 0,
5937
5994
  availableToolsCount: availableTools.length,
5938
5995
  hasOriginalPrompt: !!originalSystemPrompt,
5996
+ nativeToolSupport,
5939
5997
  };
5940
5998
  logger.debug("AI prompt generation with tool schemas", promptGenerationData);
5941
5999
  if (availableTools.length === 0) {
5942
6000
  logger.debug("No tools available - returning original prompt");
5943
6001
  return originalSystemPrompt || "";
5944
6002
  }
6003
+ if (nativeToolSupport) {
6004
+ // The provider receives full tool definitions natively via its `tools`
6005
+ // parameter — repeating name/description/parameters here was pure
6006
+ // duplication (~850 tokens on the default surface, tens of thousands
6007
+ // with MCP servers attached). Keep only a short, static (cache-stable)
6008
+ // damping line so models don't over-reach for tools.
6009
+ return ((originalSystemPrompt || "") +
6010
+ "\n\nTools are available via native tool calling. Use them only when they genuinely improve your response; for creative or conversational requests, respond naturally without tools.");
6011
+ }
5945
6012
  const toolDescriptions = transformToolsToDescriptions(availableTools.map((t) => ({
5946
6013
  name: t.name,
5947
6014
  description: t.description ?? "",
@@ -7746,10 +7813,13 @@ Current user's request: ${currentInput}`;
7746
7813
  let availableTools = await this.getAllAvailableTools();
7747
7814
  // Apply per-call tool filtering for system prompt tool descriptions
7748
7815
  availableTools = this.applyToolInfoFiltering(availableTools, options);
7749
- // Skip tool prompt injection if skipToolPromptInjection is true
7816
+ // Skip tool prompt injection if skipToolPromptInjection is true.
7817
+ // For providers with native tool calling (instance truth via
7818
+ // supportsTools()), the listing is skipped and only the short damping
7819
+ // line is appended — full definitions ship natively via `tools`.
7750
7820
  const enhancedSystemPrompt = options.skipToolPromptInjection
7751
7821
  ? options.systemPrompt || ""
7752
- : this.createToolAwareSystemPrompt(options.systemPrompt, availableTools);
7822
+ : this.createToolAwareSystemPrompt(options.systemPrompt, availableTools, provider.supportsTools?.() ?? true);
7753
7823
  // Get conversation messages for context.
7754
7824
  // If the caller already supplied conversationMessages (e.g. proxy routes
7755
7825
  // forwarding a multi-turn Claude request), honour them — including an
@@ -8450,6 +8520,58 @@ Current user's request: ${currentInput}`;
8450
8520
  getToolDedupConfig() {
8451
8521
  return this.toolDedupConfig;
8452
8522
  }
8523
+ /**
8524
+ * Returns the instance-level `tools` config (master switch, include/exclude
8525
+ * lists, discovery mode), or `undefined` when not provided at construction.
8526
+ *
8527
+ * Called by `BaseProvider.applyToolFiltering` so the tool gate composes the
8528
+ * instance policy with per-call options on every generate/stream call.
8529
+ */
8530
+ getToolsConfig() {
8531
+ return this.toolsConfig;
8532
+ }
8533
+ /**
8534
+ * Tools discovered via `search_tools` for a session (`tools.discovery`
8535
+ * mode). Pinned tools are sent in full on every subsequent call of that
8536
+ * session instead of being deferred — discovery cost is paid once.
8537
+ * Reading refreshes the session's recency (LRU), so active conversations
8538
+ * are never the ones evicted at the session cap.
8539
+ */
8540
+ getDiscoveryPins(sessionKey) {
8541
+ const pins = this.discoveryPins.get(sessionKey);
8542
+ if (!pins) {
8543
+ return new Set();
8544
+ }
8545
+ // Map iteration order is insertion order — re-inserting on access turns
8546
+ // the eviction below into LRU rather than FIFO.
8547
+ this.discoveryPins.delete(sessionKey);
8548
+ this.discoveryPins.set(sessionKey, pins);
8549
+ return pins;
8550
+ }
8551
+ /**
8552
+ * Pin discovered tools to a session (called by the `search_tools`
8553
+ * meta-tool on hydration). Append-only within a session; the map is
8554
+ * bounded by evicting the least-recently-used session past 1000 sessions.
8555
+ */
8556
+ pinDiscoveredTools(sessionKey, toolNames) {
8557
+ let pins = this.discoveryPins.get(sessionKey);
8558
+ if (!pins) {
8559
+ pins = new Set();
8560
+ }
8561
+ else {
8562
+ this.discoveryPins.delete(sessionKey);
8563
+ }
8564
+ this.discoveryPins.set(sessionKey, pins);
8565
+ for (const name of toolNames) {
8566
+ pins.add(name);
8567
+ }
8568
+ if (this.discoveryPins.size > 1000) {
8569
+ const oldest = this.discoveryPins.keys().next().value;
8570
+ if (oldest !== undefined) {
8571
+ this.discoveryPins.delete(oldest);
8572
+ }
8573
+ }
8574
+ }
8453
8575
  /**
8454
8576
  * Curator P1-1: synchronous credential health check for a single provider.
8455
8577
  *
@@ -9809,8 +9931,11 @@ Current user's request: ${currentInput}`;
9809
9931
  }
9810
9932
  }
9811
9933
  }
9812
- // Return canonical ToolInfo[]; defer presentation transforms to call sites
9813
- const tools = uniqueTools;
9934
+ // Return canonical ToolInfo[]; defer presentation transforms to call sites.
9935
+ // Name-sorted: external MCP discovery completes in parallel, so Map
9936
+ // insertion order varies across restarts — downstream consumers (tool
9937
+ // listings, prompt construction, budget accounting) need a stable order.
9938
+ const tools = uniqueTools.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
9814
9939
  // Update the cache
9815
9940
  this.toolCache = {
9816
9941
  tools,
@@ -6,6 +6,27 @@ import { SpanStatusCode } from "@opentelemetry/api";
6
6
  import { ProviderFactory } from "../../factories/providerFactory.js";
7
7
  import { withSpan } from "../../telemetry/withSpan.js";
8
8
  import { tracers } from "../../telemetry/tracers.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ /**
11
+ * Resolve `request.tools` (an array of tool NAMES shared by the execute and
12
+ * stream endpoints) into a `toolFilter` whitelist. The field was previously
13
+ * accepted and silently ignored, so entries are validated against registered
14
+ * names and the whole field fails open (undefined — keep all tools) when
15
+ * nothing matches: existing clients sending unknown names must not lose
16
+ * their tool set.
17
+ */
18
+ async function resolveRequestToolFilter(ctx, requestedTools) {
19
+ if (!Array.isArray(requestedTools) || requestedTools.length === 0) {
20
+ return undefined;
21
+ }
22
+ const registered = new Set((await ctx.neurolink.getAllAvailableTools()).map((t) => t.name));
23
+ const known = requestedTools.filter((name) => typeof name === "string" && registered.has(name));
24
+ if (known.length > 0) {
25
+ return known;
26
+ }
27
+ logger.warn("[agentRoutes] Ignoring request.tools — no entries match registered tool names (legacy fail-open)", { requested: requestedTools });
28
+ return undefined;
29
+ }
9
30
  import { createStreamRedactor } from "../utils/redaction.js";
10
31
  import { AgentExecuteRequestSchema, createErrorResponse as createError, EmbedManyRequestSchema, EmbedRequestSchema, SkillCreateRequestSchema, SkillUpdateRequestSchema, validateRequest, } from "../utils/validation.js";
11
32
  /**
@@ -65,6 +86,7 @@ export function createAgentRoutes(basePath = "/api") {
65
86
  const input = typeof request.input === "string"
66
87
  ? { text: request.input }
67
88
  : request.input;
89
+ const requestToolFilter = await resolveRequestToolFilter(ctx, request.tools);
68
90
  const result = await ctx.neurolink.generate({
69
91
  input,
70
92
  provider: request.provider,
@@ -72,8 +94,7 @@ export function createAgentRoutes(basePath = "/api") {
72
94
  systemPrompt: request.systemPrompt,
73
95
  temperature: request.temperature,
74
96
  maxTokens: request.maxTokens,
75
- // Note: tools should be passed as Record<string, Tool> in generate options
76
- // If request.tools is an array of tool names, we skip them
97
+ ...(requestToolFilter ? { toolFilter: requestToolFilter } : {}),
77
98
  context: {
78
99
  // When an authenticated user context exists (set by auth middleware),
79
100
  // always use its IDs to prevent caller-supplied impersonation.
@@ -118,6 +139,7 @@ export function createAgentRoutes(basePath = "/api") {
118
139
  const input = typeof request.input === "string"
119
140
  ? { text: request.input }
120
141
  : request.input;
142
+ const streamToolFilter = await resolveRequestToolFilter(ctx, request.tools);
121
143
  const result = await ctx.neurolink.stream({
122
144
  input,
123
145
  provider: request.provider,
@@ -125,6 +147,7 @@ export function createAgentRoutes(basePath = "/api") {
125
147
  systemPrompt: request.systemPrompt,
126
148
  temperature: request.temperature,
127
149
  maxTokens: request.maxTokens,
150
+ ...(streamToolFilter ? { toolFilter: streamToolFilter } : {}),
128
151
  context: {
129
152
  // When an authenticated user context exists (set by auth middleware),
130
153
  // always use its IDs to prevent caller-supplied impersonation.
@@ -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>;