@juspay/neurolink 12.9.5 → 12.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -38,6 +38,9 @@ export { PROVIDER_DESCRIPTORS, PROVIDER_DESCRIPTORS_BY_NAME, PROVIDER_ALIAS_INDE
38
38
  export { NeuroLinkConfigManager as ConfigManager } from "./config/configManager.js";
39
39
  export { BaseFactory, BaseRegistry, NeuroLinkFeatureError, createErrorFactory, withRetry, TypedEventEmitter, } from "./core/infrastructure/index.js";
40
40
  export { NeuroLinkClient, createClient, NeuroLinkApiError, } from "./client/httpClient.js";
41
+ export { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
42
+ export { RedisArtifactStore } from "./artifacts/redisArtifactStore.js";
43
+ export { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
41
44
  export { NeuroLinkLanguageModel, NeuroLinkProvider as NeuroLinkAIProvider, createNeuroLinkProvider, createNeuroLinkModel, createStreamingResponse, neurolink as neuroLinkAIInstance, } from "./client/aiSdkAdapter.js";
42
45
  export { createApiKeyAuthInterceptor, createBearerAuthInterceptor, createDynamicAuthInterceptor, createLoggingInterceptor, createRetryInterceptor, createRateLimitInterceptor, createRequestTransformInterceptor, createResponseTransformInterceptor, createCacheInterceptor, createTimeoutInterceptor, createErrorHandlerInterceptor, composeMiddleware, conditionalMiddleware, } from "./client/interceptors.js";
43
46
  export { SSEClient, WebSocketStreamingClient, createStreamingClient, createAsyncStream, collectStream, } from "./client/streamingClient.js";
package/dist/index.js CHANGED
@@ -51,6 +51,13 @@ export { BaseFactory, BaseRegistry, NeuroLinkFeatureError, createErrorFactory, w
51
51
  export {
52
52
  // HTTP Client
53
53
  NeuroLinkClient, createClient, NeuroLinkApiError, } from "./client/httpClient.js";
54
+ // ============================================================================
55
+ // ARTIFACT STORAGE — backends for externalized tool outputs and banked payloads.
56
+ // Pick one with `artifacts.storage` / STORAGE_TYPE, or implement ArtifactStore.
57
+ // ============================================================================
58
+ export { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
59
+ export { RedisArtifactStore } from "./artifacts/redisArtifactStore.js";
60
+ export { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
54
61
  export {
55
62
  // AI SDK Adapter
56
63
  NeuroLinkLanguageModel, NeuroLinkProvider as NeuroLinkAIProvider, createNeuroLinkProvider, createNeuroLinkModel, createStreamingResponse, neurolink as neuroLinkAIInstance, } from "./client/aiSdkAdapter.js";
@@ -6,12 +6,15 @@ import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../
6
6
  import { withSpan } from "../telemetry/withSpan.js";
7
7
  import { tracers } from "../telemetry/tracers.js";
8
8
  import { tool } from "../utils/tool.js";
9
+ import { MAX_ARTIFACT_SEARCH_MATCHES, readArtifactWindow, searchArtifactContent, validateSearchPattern, } from "../artifacts/artifactReader.js";
9
10
  /** Maximum characters returned per retrieval request */
10
11
  const DEFAULT_RETRIEVAL_LIMIT = 50_000;
11
12
  /** Hard maximum for user/LLM-supplied limit to prevent massive tool outputs */
12
13
  const MAX_RETRIEVAL_LIMIT = 200_000;
13
14
  /** Maximum number of search matches returned */
14
15
  const MAX_SEARCH_MATCHES = 50;
16
+ /** Bound on one artifact backend round trip, so a stalled store never hangs the tool. */
17
+ const ARTIFACT_READ_TIMEOUT_MS = 10_000;
15
18
  /**
16
19
  * Factory function that creates memory retrieval tools bound to a memory manager.
17
20
  *
@@ -27,13 +30,16 @@ const MAX_SEARCH_MATCHES = 50;
27
30
  export function createMemoryRetrievalTools(memoryManager, artifactStore) {
28
31
  return {
29
32
  retrieve_context: tool({
30
- description: "Retrieve messages from conversation memory, or fetch the full payload of " +
31
- "an externalized MCP tool output by artifact ID. Use this to:\n" +
33
+ description: "Retrieve messages from conversation memory, or read an externalized " +
34
+ "tool output / banked payload by artifact ID. Use this to:\n" +
32
35
  "• Access full tool outputs when a result was truncated or externalized\n" +
33
36
  "• Review previous assistant responses\n" +
34
- "• Search through conversation history\n" +
35
- "Supports filtering by role, pagination for large content, and regex search.\n" +
36
- "To fetch an externalized artifact, provide `artifactId` (omit sessionId).",
37
+ "• Search a session's history, or an artifact, for literal text\n" +
38
+ "Supports filtering by role, offset/limit pagination for large content, " +
39
+ "and case-insensitive literal search (not regex).\n" +
40
+ "To read an artifact, provide `artifactId` (omit sessionId): pass " +
41
+ "`offset`/`limit` to page, or `search` to get match offsets and jump " +
42
+ "straight to them instead of paging.",
37
43
  inputSchema: z.object({
38
44
  sessionId: z
39
45
  .string()
@@ -44,8 +50,9 @@ export function createMemoryRetrievalTools(memoryManager, artifactStore) {
44
50
  .string()
45
51
  .optional()
46
52
  .describe("Artifact ID from an externalized MCP tool output " +
47
- "(visible in the tool output as neurolinkArtifactId=<id>). " +
48
- "When provided, returns the full stored payload directly."),
53
+ "(visible in the tool output as neurolinkArtifactId=<id>) or a " +
54
+ "banked payload. When provided, reads the stored payload: a " +
55
+ "window at `offset`/`limit`, or with `search`, the matches."),
49
56
  messageId: z
50
57
  .string()
51
58
  .optional()
@@ -75,8 +82,13 @@ export function createMemoryRetrievalTools(memoryManager, artifactStore) {
75
82
  search: z
76
83
  .string()
77
84
  .optional()
78
- .describe("Regex pattern to search within message content. " +
79
- "Returns matching lines with line numbers."),
85
+ .describe("Case-insensitive literal text to find (regex metacharacters are " +
86
+ "matched literally). Session history: returns matching lines " +
87
+ "with line numbers. Artifact: returns up to " +
88
+ `${MAX_ARTIFACT_SEARCH_MATCHES} matches, each with the character ` +
89
+ "`offset` of the hit and a short snippet — pass that offset back " +
90
+ "as `offset` to read around it. With `offset`, the artifact " +
91
+ "search starts there; use `nextSearchOffset` to continue."),
80
92
  }),
81
93
  execute: async (args) => withSpan({
82
94
  name: "neurolink.memory.retrieve_context",
@@ -108,35 +120,12 @@ async function executeRetrieveContext(args, memoryManager, artifactStore, otelSp
108
120
  message: "Artifact store not configured",
109
121
  });
110
122
  return {
111
- error: "Artifact store not configured — " +
112
- "mcp.outputLimits.strategy must be set to 'externalize' to use artifactId retrieval",
123
+ error: "Artifact store not configured — this instance has never banked " +
124
+ "or externalized anything, so there is no artifact to read",
113
125
  artifactId: args.artifactId,
114
126
  };
115
127
  }
116
- const content = await withTimeout(artifactStore.retrieve(args.artifactId), 10_000, new Error(`ArtifactStore.retrieve() timed out for artifact "${args.artifactId}"`));
117
- if (content === null) {
118
- otelSpan.setStatus({
119
- code: SpanStatusCode.ERROR,
120
- message: "Artifact not found or has expired",
121
- });
122
- return {
123
- error: "Artifact not found or has expired",
124
- artifactId: args.artifactId,
125
- };
126
- }
127
- const charLimit = Math.min(args.limit ?? DEFAULT_RETRIEVAL_LIMIT, MAX_RETRIEVAL_LIMIT);
128
- const start = args.offset ?? 0;
129
- const slice = content.slice(start, start + charLimit);
130
- otelSpan.setAttribute("memory.artifact_size", content.length);
131
- otelSpan.setAttribute("memory.returned_bytes", slice.length);
132
- return {
133
- artifactId: args.artifactId,
134
- content: slice,
135
- totalSize: content.length,
136
- hasMore: start + charLimit < content.length,
137
- offset: start,
138
- limit: charLimit,
139
- };
128
+ return executeArtifactRetrieval(args.artifactId, args, artifactStore, otelSpan);
140
129
  }
141
130
  // ── End artifact resolution ─────────────────────────────────────────
142
131
  if (!args.sessionId) {
@@ -272,3 +261,83 @@ async function executeRetrieveContext(args, memoryManager, artifactStore, otelSp
272
261
  return { error: "Failed to retrieve context" };
273
262
  }
274
263
  }
264
+ /**
265
+ * The artifact branch of `retrieve_context`.
266
+ *
267
+ * Two modes, chosen by `search`:
268
+ * - Paged read: one window through `readArtifactWindow`, which lets a backend
269
+ * with range reads move only the window. `hasMore` comes from the window's
270
+ * `totalLength`, so it never needs the payload either.
271
+ * - Search: the whole payload is read once and scanned for the literal
272
+ * pattern; the model gets match offsets and bounded snippets and can jump
273
+ * straight to the hit with `offset` instead of paging to it. Before this
274
+ * branch existed `search` was accepted and silently ignored here, and a
275
+ * model could not tell — an unfiltered window looks like "no matches".
276
+ *
277
+ * Backend failures (a Redis outage, a timeout) are reported as errors, never
278
+ * as "not found": those are different facts and the model acts differently on
279
+ * each.
280
+ */
281
+ async function executeArtifactRetrieval(artifactId, args, artifactStore, otelSpan) {
282
+ const notFound = () => {
283
+ otelSpan.setStatus({
284
+ code: SpanStatusCode.ERROR,
285
+ message: "Artifact not found or has expired",
286
+ });
287
+ return { error: "Artifact not found or has expired", artifactId };
288
+ };
289
+ try {
290
+ if (args.search !== undefined) {
291
+ const invalid = validateSearchPattern(args.search);
292
+ if (invalid) {
293
+ otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: invalid });
294
+ return { error: invalid, artifactId };
295
+ }
296
+ // A search has to see the whole payload; the window contract is for reads.
297
+ const content = await withTimeout(artifactStore.retrieve(artifactId), ARTIFACT_READ_TIMEOUT_MS, new Error(`ArtifactStore.retrieve() timed out for artifact "${artifactId}"`));
298
+ if (content === null) {
299
+ return notFound();
300
+ }
301
+ const result = searchArtifactContent(content, args.search, {
302
+ from: args.offset,
303
+ });
304
+ otelSpan.setAttribute("memory.artifact_size", content.length);
305
+ otelSpan.setAttribute("memory.search_matches", result.totalMatches);
306
+ return {
307
+ artifactId,
308
+ search: args.search,
309
+ totalSize: content.length,
310
+ ...result,
311
+ };
312
+ }
313
+ const charLimit = Math.min(args.limit ?? DEFAULT_RETRIEVAL_LIMIT, MAX_RETRIEVAL_LIMIT);
314
+ const start = Math.max(0, args.offset ?? 0);
315
+ const window = await withTimeout(readArtifactWindow(artifactStore, artifactId, {
316
+ offset: start,
317
+ limit: charLimit,
318
+ }), ARTIFACT_READ_TIMEOUT_MS, new Error(`Artifact read timed out for artifact "${artifactId}"`));
319
+ if (window === null) {
320
+ return notFound();
321
+ }
322
+ otelSpan.setAttribute("memory.artifact_size", window.totalLength);
323
+ otelSpan.setAttribute("memory.returned_bytes", window.content.length);
324
+ otelSpan.setAttribute("memory.artifact_range_read", typeof artifactStore.retrieveRange === "function");
325
+ return {
326
+ artifactId,
327
+ content: window.content,
328
+ totalSize: window.totalLength,
329
+ hasMore: window.offset + window.content.length < window.totalLength,
330
+ offset: window.offset,
331
+ limit: charLimit,
332
+ };
333
+ }
334
+ catch (error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ logger.error("[MemoryRetrievalTools] Artifact read failed", {
337
+ artifactId,
338
+ error: message,
339
+ });
340
+ otelSpan.setStatus({ code: SpanStatusCode.ERROR, message });
341
+ return { error: `Artifact read failed: ${message}`, artifactId };
342
+ }
343
+ }
@@ -82,8 +82,19 @@ export declare class NeuroLink {
82
82
  private mcpToolBatcher?;
83
83
  private mcpEnhancedDiscovery?;
84
84
  private mcpToolMiddlewares;
85
- /** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
85
+ /** Artifact store for externalized MCP tool outputs and banked payloads. */
86
86
  private mcpArtifactStore?;
87
+ /** `artifacts` constructor config — consulted whenever a store is created. */
88
+ private artifactsConfig?;
89
+ /**
90
+ * True when this instance built `mcpArtifactStore` itself (local or Redis
91
+ * from config / STORAGE_TYPE). NeuroLink closes only stores it built — on
92
+ * replacement and on shutdown; a store handed in through `artifacts.store`
93
+ * or `setArtifactStore()` is the caller's to close.
94
+ */
95
+ private ownsArtifactStore;
96
+ /** Normalizer settings, kept so `setArtifactStore()` can rebuild it. */
97
+ private mcpOutputNormalizerConfig?;
87
98
  private _disableToolCacheForCurrentRequest;
88
99
  /**
89
100
  * (toolName + args) keys already served during the CURRENT request.
@@ -1943,9 +1954,37 @@ export declare class NeuroLink {
1943
1954
  * @param options - Execution options
1944
1955
  * @returns Tool execution result
1945
1956
  */
1946
- executeExternalMCPTool(serverId: string, toolName: string, parameters: JsonObject, options?: {
1957
+ executeExternalMCPTool(serverId: string, requestedToolName: string, parameters: JsonObject, options?: {
1947
1958
  timeout?: number;
1948
1959
  }): Promise<unknown>;
1960
+ /**
1961
+ * Resolve a tool name for a direct external MCP execution
1962
+ * (`executeExternalMCPTool`) against the server's currently discovered
1963
+ * tools.
1964
+ *
1965
+ * `experimental_repairToolCall` only runs inside the AI-SDK's own
1966
+ * streamText/generateText loop (see toolCallRepair.ts), so a near-miss
1967
+ * tool name reaching `executeExternalMCPTool` directly previously had no
1968
+ * recovery at all — just the generic `Tool 'x' not found for server 'y'`
1969
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the
1970
+ * stack. This reuses the same name-matching policy
1971
+ * (`resolveToolName`: case-insensitive exact → unambiguous substring →
1972
+ * Levenshtein) so a repair here is accepted under exactly the rules
1973
+ * already proven for the generation path.
1974
+ *
1975
+ * Exact match is a zero-risk fast path: it is returned unchanged before
1976
+ * any resolution attempt, so every existing caller that already sends a
1977
+ * valid name — including the AI-SDK path's `createExternalMCPTool`, which
1978
+ * always executes with a name it just discovered — sees no behaviour
1979
+ * change.
1980
+ *
1981
+ * Deliberately does NOT attempt a repair when the server is unknown or
1982
+ * not connected: `ExternalServerManager.executeTool` throws distinct,
1983
+ * more accurate errors for those states ("Server 'x' not found" /
1984
+ * "not in connected state"), and resolving against an empty tool list
1985
+ * here would replace those with a misleading "tool not found" instead.
1986
+ */
1987
+ private resolveDirectMcpToolName;
1949
1988
  /**
1950
1989
  * Get all tools from external MCP servers
1951
1990
  * @returns Array of external tool information
@@ -2707,6 +2746,39 @@ export declare class NeuroLink {
2707
2746
  * @returns The artifact store backing {@link bankArtifact} / {@link readArtifact}
2708
2747
  */
2709
2748
  getArtifactStore(): ArtifactStore;
2749
+ /**
2750
+ * Replace this instance's artifact store.
2751
+ *
2752
+ * Everything that writes or reads artifacts follows the swap: banking,
2753
+ * `retrieve_context`, host-side `readArtifact`, and the MCP output
2754
+ * normalizer — which is rebuilt here because it captured the previous store
2755
+ * at construction. Assigning the field alone would miss it, and
2756
+ * externalized tool outputs would keep landing in the old backend while
2757
+ * read-backs looked in the new one.
2758
+ *
2759
+ * Call it before the first bank or externalized tool output: artifacts
2760
+ * already in the previous store are not migrated, and their ids stop
2761
+ * resolving through this instance. `artifacts.store` in the constructor
2762
+ * config is the same thing without the ordering concern.
2763
+ *
2764
+ * Ownership: a store you hand in — here or via `artifacts.store` — stays
2765
+ * yours to close. NeuroLink closes only the stores it built itself, when
2766
+ * they are replaced here and on `shutdown()`.
2767
+ *
2768
+ * @param store - Any {@link ArtifactStore}
2769
+ */
2770
+ setArtifactStore(store: ArtifactStore): void;
2771
+ /**
2772
+ * Build the store the `artifacts` config (or `STORAGE_TYPE`) asks for. The
2773
+ * Redis connection falls back to conversation memory's, so one
2774
+ * `STORAGE_TYPE=redis` moves sessions and artifacts together.
2775
+ */
2776
+ private createConfiguredArtifactStore;
2777
+ /**
2778
+ * (Re)build the MCP output normalizer over `artifactStore`. A no-op unless
2779
+ * `mcp.outputLimits` was configured — without it nothing is externalized.
2780
+ */
2781
+ private installOutputNormalizer;
2710
2782
  /**
2711
2783
  * Bank a payload to a file and get back a pointer to it.
2712
2784
  *
package/dist/neurolink.js CHANGED
@@ -47,7 +47,7 @@ import { ToolResultCache } from "./mcp/caching/index.js";
47
47
  import { EnhancedToolDiscovery } from "./mcp/enhancedToolDiscovery.js";
48
48
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
49
49
  import { McpOutputNormalizer, DEFAULT_MAX_MCP_OUTPUT_BYTES, DEFAULT_WARN_MCP_OUTPUT_BYTES, } from "./mcp/mcpOutputNormalizer.js";
50
- import { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
50
+ import { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
51
51
  import { bankArtifact as bankArtifactPayload, readArtifact as readBankedArtifact, } from "./artifacts/artifactBanking.js";
52
52
  import { ToolRouter } from "./mcp/routing/index.js";
53
53
  // Import direct tools server for automatic registration
@@ -89,6 +89,7 @@ import { logger, mcpLogger } from "./utils/logger.js";
89
89
  import { redactUrlCredentials, safeDebugSerialize, sanitizeRecord, stringifyContentSafe, } from "./utils/logSanitize.js";
90
90
  import { extractMcpErrorText } from "./utils/mcpErrorText.js";
91
91
  import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
92
+ import { ExternalMcpToolNotFoundError, rankToolNameCandidates, resolveToolName, } from "./utils/toolCallRepair.js";
92
93
  import { resolveModel } from "./utils/modelAliasResolver.js";
93
94
  // Import orchestration components
94
95
  import { ModelRouter } from "./utils/modelRouter.js";
@@ -379,8 +380,19 @@ export class NeuroLink {
379
380
  mcpToolBatcher;
380
381
  mcpEnhancedDiscovery;
381
382
  mcpToolMiddlewares = [];
382
- /** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
383
+ /** Artifact store for externalized MCP tool outputs and banked payloads. */
383
384
  mcpArtifactStore;
385
+ /** `artifacts` constructor config — consulted whenever a store is created. */
386
+ artifactsConfig;
387
+ /**
388
+ * True when this instance built `mcpArtifactStore` itself (local or Redis
389
+ * from config / STORAGE_TYPE). NeuroLink closes only stores it built — on
390
+ * replacement and on shutdown; a store handed in through `artifacts.store`
391
+ * or `setArtifactStore()` is the caller's to close.
392
+ */
393
+ ownsArtifactStore = false;
394
+ /** Normalizer settings, kept so `setArtifactStore()` can rebuild it. */
395
+ mcpOutputNormalizerConfig;
384
396
  _disableToolCacheForCurrentRequest = false;
385
397
  /**
386
398
  * (toolName + args) keys already served during the CURRENT request.
@@ -1197,24 +1209,22 @@ export class NeuroLink {
1197
1209
  });
1198
1210
  }
1199
1211
  // ToolRouter — lazy-initialized when 2+ external servers exist (see addExternalMCPServer)
1212
+ // Artifact storage — where externalized MCP outputs and banked payloads
1213
+ // live. Kept so both creation sites (externalize here, and
1214
+ // getArtifactStore() on demand) resolve the same backend.
1215
+ this.artifactsConfig = config?.artifacts;
1200
1216
  // McpOutputNormalizer — active when mcp.outputLimits is configured
1201
1217
  if (mcpConfig?.outputLimits) {
1202
1218
  const strategy = mcpConfig.outputLimits.strategy ?? "externalize";
1203
1219
  const maxBytes = mcpConfig.outputLimits.maxBytes ?? DEFAULT_MAX_MCP_OUTPUT_BYTES;
1204
1220
  const warnBytes = mcpConfig.outputLimits.warnBytes ?? DEFAULT_WARN_MCP_OUTPUT_BYTES;
1221
+ this.mcpOutputNormalizerConfig = { strategy, maxBytes, warnBytes };
1205
1222
  let artifactStore;
1206
1223
  if (strategy === "externalize") {
1207
- artifactStore = new LocalTempArtifactStore();
1224
+ artifactStore = this.createConfiguredArtifactStore();
1208
1225
  this.mcpArtifactStore = artifactStore;
1209
- logger.debug("[NeuroLink] MCP artifact store initialized (local-temp)");
1210
1226
  }
1211
- const normalizer = new McpOutputNormalizer({ strategy, maxBytes, warnBytes }, artifactStore);
1212
- this.externalServerManager.setOutputNormalizer(normalizer);
1213
- logger.debug("[NeuroLink] MCP output normalizer initialized", {
1214
- strategy,
1215
- maxBytes,
1216
- warnBytes,
1217
- });
1227
+ this.installOutputNormalizer(artifactStore);
1218
1228
  }
1219
1229
  }
1220
1230
  /**
@@ -2805,6 +2815,17 @@ Current user's request: ${currentInput}`;
2805
2815
  this._taskManager = undefined;
2806
2816
  }
2807
2817
  }
2818
+ // Release the artifact store this instance built (a pooled Redis
2819
+ // connection, for one). An injected store is the caller's to close.
2820
+ if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
2821
+ try {
2822
+ await this.mcpArtifactStore.close();
2823
+ logger.debug("[NeuroLink] Artifact store closed");
2824
+ }
2825
+ catch (error) {
2826
+ logger.warn("[NeuroLink] Artifact store close failed:", error);
2827
+ }
2828
+ }
2808
2829
  // Close conversation memory manager (release Redis connections, etc.)
2809
2830
  if (this.conversationMemory?.close) {
2810
2831
  try {
@@ -11732,8 +11753,16 @@ Current user's request: ${currentInput}`;
11732
11753
  * @param options - Execution options
11733
11754
  * @returns Tool execution result
11734
11755
  */
11735
- async executeExternalMCPTool(serverId, toolName, parameters, options) {
11756
+ async executeExternalMCPTool(serverId, requestedToolName, parameters, options) {
11757
+ // Falls back to the requested name so the catch block below still has
11758
+ // something meaningful to log if resolution itself is what throws.
11759
+ let toolName = requestedToolName;
11736
11760
  try {
11761
+ // Direct-boundary name repair (see resolveDirectMcpToolName): an exact
11762
+ // match returns requestedToolName unchanged, so every existing caller
11763
+ // — including the AI-SDK generation path's createExternalMCPTool,
11764
+ // which always calls with an already-discovered name — is unaffected.
11765
+ toolName = this.resolveDirectMcpToolName(serverId, requestedToolName);
11737
11766
  mcpLogger.debug(`[NeuroLink] Executing external MCP tool: ${toolName} on ${serverId}`);
11738
11767
  // BZ-664: Check existing ToolResultCache before executing to avoid
11739
11768
  // duplicate identical calls within the same session.
@@ -11803,6 +11832,65 @@ Current user's request: ${currentInput}`;
11803
11832
  throw error;
11804
11833
  }
11805
11834
  }
11835
+ /**
11836
+ * Resolve a tool name for a direct external MCP execution
11837
+ * (`executeExternalMCPTool`) against the server's currently discovered
11838
+ * tools.
11839
+ *
11840
+ * `experimental_repairToolCall` only runs inside the AI-SDK's own
11841
+ * streamText/generateText loop (see toolCallRepair.ts), so a near-miss
11842
+ * tool name reaching `executeExternalMCPTool` directly previously had no
11843
+ * recovery at all — just the generic `Tool 'x' not found for server 'y'`
11844
+ * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the
11845
+ * stack. This reuses the same name-matching policy
11846
+ * (`resolveToolName`: case-insensitive exact → unambiguous substring →
11847
+ * Levenshtein) so a repair here is accepted under exactly the rules
11848
+ * already proven for the generation path.
11849
+ *
11850
+ * Exact match is a zero-risk fast path: it is returned unchanged before
11851
+ * any resolution attempt, so every existing caller that already sends a
11852
+ * valid name — including the AI-SDK path's `createExternalMCPTool`, which
11853
+ * always executes with a name it just discovered — sees no behaviour
11854
+ * change.
11855
+ *
11856
+ * Deliberately does NOT attempt a repair when the server is unknown or
11857
+ * not connected: `ExternalServerManager.executeTool` throws distinct,
11858
+ * more accurate errors for those states ("Server 'x' not found" /
11859
+ * "not in connected state"), and resolving against an empty tool list
11860
+ * here would replace those with a misleading "tool not found" instead.
11861
+ */
11862
+ resolveDirectMcpToolName(serverId, requestedName) {
11863
+ const server = this.getExternalMCPServer(serverId);
11864
+ if (!server || server.status !== "connected" || !server.client) {
11865
+ return requestedName;
11866
+ }
11867
+ const availableNames = this.getExternalMCPServerTools(serverId).map((tool) => tool.name);
11868
+ if (availableNames.includes(requestedName)) {
11869
+ return requestedName;
11870
+ }
11871
+ const resolution = resolveToolName(requestedName, availableNames);
11872
+ if (!resolution) {
11873
+ throw new ExternalMcpToolNotFoundError(requestedName, serverId, rankToolNameCandidates(requestedName, availableNames));
11874
+ }
11875
+ // Recorded on a short-lived span rather than the method's return value:
11876
+ // executeExternalMCPTool returns the raw upstream tool result verbatim
11877
+ // (widely consumed as-is, e.g. by createExternalMCPTool's execute()),
11878
+ // so wrapping it to carry resolution metadata would be a breaking
11879
+ // change for every existing direct caller.
11880
+ tracers.mcp.startActiveSpan("neurolink.mcp.toolNameRepair", {
11881
+ attributes: {
11882
+ "mcp.server_id": serverId,
11883
+ "mcp.tool_name.requested": requestedName,
11884
+ "mcp.tool_name.resolved": resolution.name,
11885
+ "mcp.tool_name.repair_strategy": resolution.strategy,
11886
+ ...(resolution.score !== undefined
11887
+ ? { "mcp.tool_name.repair_score": resolution.score }
11888
+ : {}),
11889
+ },
11890
+ }, (span) => span.end());
11891
+ mcpLogger.info(`[NeuroLink] Repaired external MCP tool name at direct execution boundary: "${requestedName}" → "${resolution.name}" (${resolution.strategy}) on server '${serverId}'`);
11892
+ return resolution.name;
11893
+ }
11806
11894
  /**
11807
11895
  * Get all tools from external MCP servers
11808
11896
  * @returns Array of external tool information
@@ -12942,13 +13030,86 @@ Current user's request: ${currentInput}`;
12942
13030
  */
12943
13031
  getArtifactStore() {
12944
13032
  if (!this.mcpArtifactStore) {
12945
- this.mcpArtifactStore = new LocalTempArtifactStore();
12946
- logger.debug("[NeuroLink] Artifact store created on demand (local-temp) for banking");
13033
+ this.mcpArtifactStore = this.createConfiguredArtifactStore();
12947
13034
  }
12948
13035
  // A no-op when the constructor already registered it.
12949
13036
  this.registerMemoryRetrievalTools();
12950
13037
  return this.mcpArtifactStore;
12951
13038
  }
13039
+ /**
13040
+ * Replace this instance's artifact store.
13041
+ *
13042
+ * Everything that writes or reads artifacts follows the swap: banking,
13043
+ * `retrieve_context`, host-side `readArtifact`, and the MCP output
13044
+ * normalizer — which is rebuilt here because it captured the previous store
13045
+ * at construction. Assigning the field alone would miss it, and
13046
+ * externalized tool outputs would keep landing in the old backend while
13047
+ * read-backs looked in the new one.
13048
+ *
13049
+ * Call it before the first bank or externalized tool output: artifacts
13050
+ * already in the previous store are not migrated, and their ids stop
13051
+ * resolving through this instance. `artifacts.store` in the constructor
13052
+ * config is the same thing without the ordering concern.
13053
+ *
13054
+ * Ownership: a store you hand in — here or via `artifacts.store` — stays
13055
+ * yours to close. NeuroLink closes only the stores it built itself, when
13056
+ * they are replaced here and on `shutdown()`.
13057
+ *
13058
+ * @param store - Any {@link ArtifactStore}
13059
+ */
13060
+ setArtifactStore(store) {
13061
+ const previous = this.mcpArtifactStore;
13062
+ if (previous === store) {
13063
+ return;
13064
+ }
13065
+ if (previous) {
13066
+ logger.warn("[NeuroLink] Artifact store replaced — artifacts already written to " +
13067
+ "the previous store will not resolve through this instance");
13068
+ if (this.ownsArtifactStore && previous.close) {
13069
+ void previous.close().catch((error) => {
13070
+ logger.warn("[NeuroLink] Previous artifact store close failed", {
13071
+ error: error instanceof Error ? error.message : String(error),
13072
+ });
13073
+ });
13074
+ }
13075
+ }
13076
+ this.mcpArtifactStore = store;
13077
+ this.ownsArtifactStore = false;
13078
+ this.installOutputNormalizer(store);
13079
+ this.registerMemoryRetrievalTools();
13080
+ }
13081
+ /**
13082
+ * Build the store the `artifacts` config (or `STORAGE_TYPE`) asks for. The
13083
+ * Redis connection falls back to conversation memory's, so one
13084
+ * `STORAGE_TYPE=redis` moves sessions and artifacts together.
13085
+ */
13086
+ createConfiguredArtifactStore() {
13087
+ const injected = this.artifactsConfig?.store !== undefined;
13088
+ const store = createArtifactStore(this.artifactsConfig, this.conversationMemoryConfig?.conversationMemory?.redisConfig);
13089
+ // An injected store is returned as-is by the factory and stays the
13090
+ // caller's; anything else was built here and is this instance's to close.
13091
+ this.ownsArtifactStore = !injected;
13092
+ logger.debug("[NeuroLink] Artifact store initialized", {
13093
+ backend: injected
13094
+ ? "custom"
13095
+ : resolveArtifactStorageType(this.artifactsConfig),
13096
+ });
13097
+ return store;
13098
+ }
13099
+ /**
13100
+ * (Re)build the MCP output normalizer over `artifactStore`. A no-op unless
13101
+ * `mcp.outputLimits` was configured — without it nothing is externalized.
13102
+ */
13103
+ installOutputNormalizer(artifactStore) {
13104
+ if (!this.mcpOutputNormalizerConfig) {
13105
+ return;
13106
+ }
13107
+ this.externalServerManager.setOutputNormalizer(new McpOutputNormalizer(this.mcpOutputNormalizerConfig, artifactStore));
13108
+ logger.debug("[NeuroLink] MCP output normalizer initialized", {
13109
+ ...this.mcpOutputNormalizerConfig,
13110
+ hasArtifactStore: artifactStore !== undefined,
13111
+ });
13112
+ }
12952
13113
  /**
12953
13114
  * Bank a payload to a file and get back a pointer to it.
12954
13115
  *
@@ -13287,7 +13448,24 @@ Current user's request: ${currentInput}`;
13287
13448
  logger.warn("[NeuroLink] Error shutting down external MCP servers:", error);
13288
13449
  }
13289
13450
  }
13290
- // 3. Clear all event listeners to prevent memory leaks
13451
+ // 3. Release the artifact store this instance built (a pooled Redis
13452
+ // connection, for one). Same ownership rule as shutdown(): an injected
13453
+ // store is the caller's to close. Without this step every
13454
+ // construct / use / dispose cycle left the pooled reference acquired.
13455
+ if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
13456
+ try {
13457
+ await this.mcpArtifactStore.close();
13458
+ logger.debug("[NeuroLink] Artifact store closed");
13459
+ }
13460
+ catch (error) {
13461
+ const err = error instanceof Error
13462
+ ? error
13463
+ : new Error(`Artifact store close error: ${String(error)}`);
13464
+ cleanupErrors.push(err);
13465
+ logger.warn("[NeuroLink] Error closing artifact store:", error);
13466
+ }
13467
+ }
13468
+ // 4. Clear all event listeners to prevent memory leaks
13291
13469
  if (this.emitter) {
13292
13470
  try {
13293
13471
  logger.debug("[NeuroLink] Removing all event listeners...");
@@ -13305,7 +13483,7 @@ Current user's request: ${currentInput}`;
13305
13483
  logger.warn("[NeuroLink] Error removing event listeners:", error);
13306
13484
  }
13307
13485
  }
13308
- // 4. Clear all circuit breakers
13486
+ // 5. Clear all circuit breakers
13309
13487
  if (this.toolCircuitBreakers && this.toolCircuitBreakers.size > 0) {
13310
13488
  try {
13311
13489
  logger.debug(`[NeuroLink] Clearing ${this.toolCircuitBreakers.size} circuit breakers...`);
@@ -13320,7 +13498,7 @@ Current user's request: ${currentInput}`;
13320
13498
  logger.warn("[NeuroLink] Error clearing circuit breakers:", error);
13321
13499
  }
13322
13500
  }
13323
- // 5. Clear all Maps and caches
13501
+ // 6. Clear all Maps and caches
13324
13502
  try {
13325
13503
  logger.debug("[NeuroLink] Clearing maps and caches...");
13326
13504
  if (this.toolExecutionMetrics) {
@@ -13371,7 +13549,7 @@ Current user's request: ${currentInput}`;
13371
13549
  this._taskManager = undefined;
13372
13550
  }
13373
13551
  }
13374
- // 6. Reset initialization flags
13552
+ // 7. Reset initialization flags
13375
13553
  try {
13376
13554
  logger.debug("[NeuroLink] Resetting initialization state...");
13377
13555
  this.mcpInitialized = false;
@@ -13387,7 +13565,7 @@ Current user's request: ${currentInput}`;
13387
13565
  cleanupErrors.push(err);
13388
13566
  logger.warn("[NeuroLink] Error resetting state:", error);
13389
13567
  }
13390
- // 6. Log completion
13568
+ // 7. Log completion
13391
13569
  if (cleanupErrors.length === 0) {
13392
13570
  logger.debug("[NeuroLink] ✅ Resource disposal completed successfully");
13393
13571
  }