@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
@@ -88,7 +88,13 @@ export class GenerationHandler {
88
88
  // Non-Anthropic providers harmlessly ignore unknown providerOptions.
89
89
  // Note: The AI SDK Tool type doesn't yet include providerOptions, so we
90
90
  // use a type assertion. The Anthropic adapter reads this at runtime.
91
- const toolsWithCache = { ...tools };
91
+ //
92
+ // Deliberately NOT a clone: the record is call-scoped (built fresh in
93
+ // BaseProvider.prepareGenerationContext) and the AI SDK re-reads it on
94
+ // every agent-loop step, so `search_tools` hydration (tools.discovery)
95
+ // can add discovered tools mid-loop and have them callable on the next
96
+ // step. A clone would freeze the tool set for the whole call.
97
+ const toolsWithCache = tools;
92
98
  if (isAnthropicProvider &&
93
99
  shouldUseTools &&
94
100
  Object.keys(toolsWithCache).length > 0) {
@@ -258,6 +258,11 @@ export class ToolsManager {
258
258
  `... and ${toolNames.length - 10} more`,
259
259
  ],
260
260
  });
261
+ // NOTE: insertion order here is phase order (direct → custom →
262
+ // external MCP), which the signature-dedup pass depends on (keep-first
263
+ // must prefer built-in implementations). Deterministic name-sorting
264
+ // for prompt-cache stability happens AFTER filtering+dedup, in
265
+ // BaseProvider.applyToolFiltering.
261
266
  return tools;
262
267
  });
263
268
  }
@@ -171,7 +171,10 @@ export function dedupeTools(tools, options) {
171
171
  if (representativeOf.size === 0) {
172
172
  return noOp;
173
173
  }
174
- const dedupedTools = {};
174
+ // Null prototype: a tool named "__proto__" must become an own entry,
175
+ // not a prototype mutation that silently drops it (mirrors the tool
176
+ // records built in BaseProvider.sortToolRecord / applyToolGate).
177
+ const dedupedTools = Object.create(null);
175
178
  const removed = [];
176
179
  for (const [name, tool] of entries) {
177
180
  const rep = representativeOf.get(name);
@@ -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
  *
@@ -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,
@@ -10,9 +10,18 @@ import type { SageMakerConfig, InvokeEndpointParams, InvokeEndpointResponse, Con
10
10
  */
11
11
  export declare class SageMakerRuntimeClient {
12
12
  private client;
13
+ private sdkModule;
13
14
  private config;
14
15
  private isDisposed;
15
16
  constructor(config: SageMakerConfig);
17
+ /**
18
+ * Lazily load (and cache) the `@aws-sdk/client-sagemaker-runtime` module.
19
+ */
20
+ private getSdk;
21
+ /**
22
+ * Lazily construct (and cache) the underlying AWS SDK client.
23
+ */
24
+ private getClient;
16
25
  /**
17
26
  * Invoke a SageMaker endpoint for synchronous inference
18
27
  *
@@ -4,38 +4,41 @@
4
4
  * This module provides a wrapper around the AWS SDK SageMaker Runtime client
5
5
  * with enhanced error handling, retry logic, and NeuroLink-specific features.
6
6
  */
7
- import { SageMakerRuntimeClient as AWSClient, InvokeEndpointCommand, InvokeEndpointWithResponseStreamCommand, } from "@aws-sdk/client-sagemaker-runtime";
8
7
  import { handleSageMakerError, SageMakerError, isRetryableError, getRetryDelay, } from "./errors.js";
9
8
  import { logger } from "../../utils/logger.js";
9
+ /**
10
+ * Lazily load `@aws-sdk/client-sagemaker-runtime`.
11
+ *
12
+ * The package is an optional dependency: importing it only happens here, on
13
+ * first actual endpoint invocation, so constructing a SageMakerRuntimeClient
14
+ * (e.g. while the CLI builds its command tree at startup) never requires the
15
+ * SDK to be installed. If it's missing, surface a comprehensible, actionable
16
+ * error instead of a raw resolution failure.
17
+ */
18
+ async function loadSageMakerRuntime() {
19
+ try {
20
+ return await import(/* @vite-ignore */ "@aws-sdk/client-sagemaker-runtime");
21
+ }
22
+ catch (err) {
23
+ const e = err instanceof Error ? err : null;
24
+ if (e?.code === "ERR_MODULE_NOT_FOUND" &&
25
+ e.message.includes("client-sagemaker-runtime")) {
26
+ throw new Error('SageMaker inference requires "@aws-sdk/client-sagemaker-runtime". Install it with:\n pnpm add @aws-sdk/client-sagemaker-runtime', { cause: err });
27
+ }
28
+ throw err;
29
+ }
30
+ }
10
31
  /**
11
32
  * Enhanced SageMaker Runtime client with retry logic and error handling
12
33
  */
13
34
  export class SageMakerRuntimeClient {
14
- client;
35
+ client = null;
36
+ sdkModule = null;
15
37
  config;
16
38
  isDisposed = false;
17
39
  constructor(config) {
18
40
  this.config = config;
19
- // Initialize AWS SDK client with configuration
20
- this.client = new AWSClient({
21
- region: config.region,
22
- credentials: {
23
- accessKeyId: config.accessKeyId,
24
- secretAccessKey: config.secretAccessKey,
25
- sessionToken: config.sessionToken,
26
- },
27
- maxAttempts: config.maxRetries || 3,
28
- requestHandler: {
29
- requestTimeout: config.timeout || 30000,
30
- httpsAgent: {
31
- // Keep connections alive for better performance
32
- keepAlive: true,
33
- maxSockets: 50,
34
- },
35
- },
36
- ...(config.endpoint && { endpoint: config.endpoint }),
37
- });
38
- logger.debug("SageMaker Runtime client initialized", {
41
+ logger.debug("SageMaker Runtime client configured", {
39
42
  region: config.region,
40
43
  timeout: config.timeout,
41
44
  maxRetries: config.maxRetries,
@@ -43,6 +46,51 @@ export class SageMakerRuntimeClient {
43
46
  customEndpoint: !!config.endpoint,
44
47
  });
45
48
  }
49
+ /**
50
+ * Lazily load (and cache) the `@aws-sdk/client-sagemaker-runtime` module.
51
+ */
52
+ async getSdk() {
53
+ if (!this.sdkModule) {
54
+ this.sdkModule = await loadSageMakerRuntime();
55
+ }
56
+ return this.sdkModule;
57
+ }
58
+ /**
59
+ * Lazily construct (and cache) the underlying AWS SDK client.
60
+ */
61
+ async getClient() {
62
+ if (this.isDisposed) {
63
+ throw new SageMakerError("Cannot perform operation on disposed SageMaker client", {
64
+ code: "VALIDATION_ERROR",
65
+ statusCode: 400,
66
+ });
67
+ }
68
+ if (!this.client) {
69
+ const { SageMakerRuntimeClient: AWSClientCtor } = await this.getSdk();
70
+ this.client = new AWSClientCtor({
71
+ region: this.config.region,
72
+ credentials: {
73
+ accessKeyId: this.config.accessKeyId,
74
+ secretAccessKey: this.config.secretAccessKey,
75
+ sessionToken: this.config.sessionToken,
76
+ },
77
+ maxAttempts: this.config.maxRetries || 3,
78
+ requestHandler: {
79
+ requestTimeout: this.config.timeout || 30000,
80
+ httpsAgent: {
81
+ // Keep connections alive for better performance
82
+ keepAlive: true,
83
+ maxSockets: 50,
84
+ },
85
+ },
86
+ ...(this.config.endpoint && { endpoint: this.config.endpoint }),
87
+ });
88
+ logger.debug("SageMaker Runtime AWS SDK client initialized", {
89
+ region: this.config.region,
90
+ });
91
+ }
92
+ return this.client;
93
+ }
46
94
  /**
47
95
  * Invoke a SageMaker endpoint for synchronous inference
48
96
  *
@@ -61,6 +109,7 @@ export class SageMakerRuntimeClient {
61
109
  ? params.Body.length
62
110
  : params.Body?.length || 0,
63
111
  });
112
+ const { InvokeEndpointCommand } = await this.getSdk();
64
113
  // Prepare the command input
65
114
  const input = {
66
115
  EndpointName: params.EndpointName,
@@ -73,10 +122,7 @@ export class SageMakerRuntimeClient {
73
122
  InferenceId: params.InferenceId,
74
123
  };
75
124
  const command = new InvokeEndpointCommand(input);
76
- const client = this.client;
77
- if (!client) {
78
- throw new Error("SageMaker client has been disposed");
79
- }
125
+ const client = await this.getClient();
80
126
  const response = (await this.executeWithRetry(() => client.send(command), params.EndpointName));
81
127
  const duration = Date.now() - startTime;
82
128
  logger.debug("SageMaker endpoint invocation successful", {
@@ -120,6 +166,7 @@ export class SageMakerRuntimeClient {
120
166
  ? params.Body.length
121
167
  : params.Body?.length || 0,
122
168
  });
169
+ const { InvokeEndpointWithResponseStreamCommand } = await this.getSdk();
123
170
  // Prepare the command input for streaming
124
171
  const input = {
125
172
  EndpointName: params.EndpointName,
@@ -130,10 +177,7 @@ export class SageMakerRuntimeClient {
130
177
  // Note: TargetModel, TargetVariant, InferenceId not available in streaming interface
131
178
  };
132
179
  const command = new InvokeEndpointWithResponseStreamCommand(input);
133
- const client = this.client;
134
- if (!client) {
135
- throw new Error("SageMaker client has been disposed");
136
- }
180
+ const client = await this.getClient();
137
181
  const response = (await this.executeWithRetry(() => client.send(command), params.EndpointName));
138
182
  logger.debug("SageMaker streaming invocation started", {
139
183
  endpointName: params.EndpointName,
@@ -415,6 +459,7 @@ export class SageMakerRuntimeClient {
415
459
  // Clear our client reference to enable garbage collection
416
460
  // Note: AWS SDK v3 handles all internal resource cleanup automatically
417
461
  this.client = null;
462
+ this.sdkModule = null;
418
463
  logger.debug("SageMaker Runtime client disposed", {
419
464
  note: "AWS SDK v3 handles internal resource cleanup automatically",
420
465
  });
@@ -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.