@juspay/neurolink 9.80.3 → 9.80.4

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.
@@ -7,6 +7,8 @@ import { AIProviderName, ErrorCategory, ErrorSeverity, } from "../constants/enum
7
7
  import { BaseProvider } from "../core/baseProvider.js";
8
8
  import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
9
9
  import { ModelConfigurationManager } from "../core/modelConfiguration.js";
10
+ import { isSchemaComplexityError } from "../core/modules/structuredOutputPolicy.js";
11
+ import { stringifyContentSafe } from "../utils/logSanitize.js";
10
12
  import { createProxyFetch } from "../proxy/proxyFetch.js";
11
13
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
12
14
  import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
@@ -2351,7 +2353,25 @@ export class GoogleVertexProvider extends BaseProvider {
2351
2353
  wasAborted = true;
2352
2354
  break;
2353
2355
  }
2354
- logger.error("[GoogleVertex] Native SDK generate error", error);
2356
+ logger.error("[GoogleVertex] Native SDK generate error", {
2357
+ error,
2358
+ model: modelName,
2359
+ location: effectiveLocation,
2360
+ status: error?.status,
2361
+ });
2362
+ // Best-effort request context for formatProviderError —
2363
+ // this.modelName can be stale when options.model overrides the
2364
+ // instance default.
2365
+ try {
2366
+ if (error && typeof error === "object") {
2367
+ const e = error;
2368
+ e.requestModel = modelName;
2369
+ e.requestRegion = effectiveLocation;
2370
+ }
2371
+ }
2372
+ catch {
2373
+ /* frozen/sealed error — context stays best-effort */
2374
+ }
2355
2375
  throw this.handleProviderError(error);
2356
2376
  }
2357
2377
  }
@@ -2585,9 +2605,7 @@ export class GoogleVertexProvider extends BaseProvider {
2585
2605
  if (msg.role === "user" || msg.role === "assistant") {
2586
2606
  messages.push({
2587
2607
  role: msg.role,
2588
- content: typeof msg.content === "string"
2589
- ? msg.content
2590
- : JSON.stringify(msg.content),
2608
+ content: stringifyContentSafe(msg.content),
2591
2609
  });
2592
2610
  }
2593
2611
  }
@@ -3190,7 +3208,7 @@ export class GoogleVertexProvider extends BaseProvider {
3190
3208
  // so coerce defensively to keep the follow-up turn valid.
3191
3209
  const resultContent = typeof result === "string"
3192
3210
  ? result
3193
- : (JSON.stringify(result ?? null) ?? String(result));
3211
+ : stringifyContentSafe(result ?? null);
3194
3212
  toolResults.push({
3195
3213
  type: "tool_result",
3196
3214
  tool_use_id: toolUse.id,
@@ -3651,9 +3669,7 @@ export class GoogleVertexProvider extends BaseProvider {
3651
3669
  if (msg.role === "user" || msg.role === "assistant") {
3652
3670
  messages.push({
3653
3671
  role: msg.role,
3654
- content: typeof msg.content === "string"
3655
- ? msg.content
3656
- : JSON.stringify(msg.content),
3672
+ content: stringifyContentSafe(msg.content),
3657
3673
  });
3658
3674
  }
3659
3675
  }
@@ -4032,7 +4048,7 @@ export class GoogleVertexProvider extends BaseProvider {
4032
4048
  // so coerce defensively to keep the follow-up turn valid.
4033
4049
  const resultContent = typeof result === "string"
4034
4050
  ? result
4035
- : (JSON.stringify(result ?? null) ?? String(result));
4051
+ : stringifyContentSafe(result ?? null);
4036
4052
  toolResults.push({
4037
4053
  type: "tool_result",
4038
4054
  tool_use_id: toolUse.id,
@@ -4158,7 +4174,22 @@ export class GoogleVertexProvider extends BaseProvider {
4158
4174
  wasAborted = true;
4159
4175
  break;
4160
4176
  }
4161
- logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
4177
+ logger.error("[GoogleVertex] Native Anthropic SDK generate error", {
4178
+ error,
4179
+ model: modelName,
4180
+ step,
4181
+ status: error?.status,
4182
+ });
4183
+ // Best-effort request context for formatProviderError — see the
4184
+ // native Gemini catch for rationale.
4185
+ try {
4186
+ if (error && typeof error === "object") {
4187
+ error.requestModel = modelName;
4188
+ }
4189
+ }
4190
+ catch {
4191
+ /* frozen/sealed error — context stays best-effort */
4192
+ }
4162
4193
  throw this.handleProviderError(error);
4163
4194
  }
4164
4195
  }
@@ -4622,8 +4653,49 @@ export class GoogleVertexProvider extends BaseProvider {
4622
4653
  model: modelName,
4623
4654
  totalToolCount: Object.keys(mergedOptions.tools).length,
4624
4655
  });
4625
- nativeResult =
4626
- await this.executeNativeGemini3Generate(mergedOptions);
4656
+ try {
4657
+ nativeResult =
4658
+ await this.executeNativeGemini3Generate(mergedOptions);
4659
+ }
4660
+ catch (nativeError) {
4661
+ // Vertex rejects over-constrained responseSchemas with a
4662
+ // deterministic 400 ("too many states" — constrained-decoding
4663
+ // state explosion). Re-sending the same schema can never
4664
+ // succeed, so retry ONCE with the schema dropped but JSON
4665
+ // mode kept: output.format "json" still sets
4666
+ // responseMimeType application/json on the no-tools path,
4667
+ // so the model is forced to emit JSON — just without the
4668
+ // offending schema constraints. The NeuroLink layer then
4669
+ // coerces the JSON text into the caller's original schema
4670
+ // (generate({schema}) guarantee holds).
4671
+ const requestedStructured = mergedOptions.schema !== undefined ||
4672
+ mergedOptions.output?.format === "json";
4673
+ // Tools present → the executor skips responseMimeType, so a
4674
+ // schema-less retry would NOT actually run in JSON mode and
4675
+ // the structured-output contract would silently degrade to
4676
+ // prose. Only the no-tools case retries faithfully; with
4677
+ // tools, surface the 400 to the caller instead.
4678
+ const hasTools = !mergedOptions.disableTools &&
4679
+ Object.keys(mergedOptions.tools ?? {}).length > 0;
4680
+ if (requestedStructured &&
4681
+ !hasTools &&
4682
+ isSchemaComplexityError(nativeError)) {
4683
+ logger.warn("[GoogleVertex] responseSchema too complex for constrained decoding — retrying native generate in schema-less JSON mode", {
4684
+ model: modelName,
4685
+ error: nativeError instanceof Error
4686
+ ? nativeError.message
4687
+ : String(nativeError),
4688
+ });
4689
+ nativeResult = await this.executeNativeGemini3Generate({
4690
+ ...mergedOptions,
4691
+ schema: undefined,
4692
+ output: { format: "json" },
4693
+ });
4694
+ }
4695
+ else {
4696
+ throw nativeError;
4697
+ }
4698
+ }
4627
4699
  }
4628
4700
  executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(nativeResult?.content ?? ""));
4629
4701
  return nativeResult;
@@ -4960,17 +5032,42 @@ export class GoogleVertexProvider extends BaseProvider {
4960
5032
  `3. Ensure your project has access to the model ` +
4961
5033
  `4. For Claude models, enable Anthropic integration in Google Cloud Console`, this.providerName);
4962
5034
  }
4963
- // Rate limit and quota errors
5035
+ // Rate limit / quota / capacity errors. Anthropic-on-Vertex capacity
5036
+ // exhaustion surfaces as overloaded_error (HTTP 529) — same operational
5037
+ // meaning as a 429, so classify it here instead of the generic 5xx branch.
4964
5038
  if (message.includes("QUOTA_EXCEEDED") ||
4965
5039
  message.includes("RATE_LIMIT_EXCEEDED") ||
4966
5040
  message.includes("rate limit") ||
4967
5041
  message.includes("429") ||
4968
- statusCode === 429) {
4969
- return new RateLimitError(`Google Vertex AI quota/rate limit exceeded. ` +
4970
- `Solutions: 1. Check your Vertex AI quotas in Google Cloud Console ` +
4971
- `2. Request quota increase if needed ` +
4972
- `3. Try a different model or reduce request frequency ` +
4973
- `4. Consider using a different region`, this.providerName);
5042
+ statusCode === 429 ||
5043
+ statusCode === 529 ||
5044
+ /overloaded/i.test(message)) {
5045
+ // Surface retry guidance when the SDK error carries it. @google/genai
5046
+ // ApiError nests RetryInfo inside the JSON error body's details array,
5047
+ // so fall back to scraping retryDelay out of the raw message.
5048
+ const retryDelay = typeof errorRecord?.retryDelay === "string"
5049
+ ? errorRecord.retryDelay
5050
+ : (/["']?retryDelay["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?s)/.exec(message)?.[1] ?? undefined);
5051
+ // Prefer the per-request context the native catches attach to the
5052
+ // error (this.modelName can be stale when options.model overrides the
5053
+ // instance default). Gemini models are force-routed to the "global"
5054
+ // endpoint regardless of configured location — report the region the
5055
+ // request actually hit.
5056
+ const requestModel = typeof errorRecord?.requestModel === "string"
5057
+ ? errorRecord.requestModel
5058
+ : this.modelName;
5059
+ const effectiveRegion = typeof errorRecord?.requestRegion === "string"
5060
+ ? errorRecord.requestRegion
5061
+ : resolveVertexRegionForModel(requestModel, this.location);
5062
+ return new RateLimitError(`Google Vertex AI rate limit / shared-capacity exhausted (429 RESOURCE_EXHAUSTED / overloaded) ` +
5063
+ `for model '${requestModel}' in region '${effectiveRegion}'.` +
5064
+ (retryDelay
5065
+ ? ` Upstream suggests retrying after ${retryDelay}.`
5066
+ : "") +
5067
+ ` Solutions: 1. Retry with backoff ` +
5068
+ `2. Check your Vertex AI quotas in Google Cloud Console (shared-capacity 429s can occur below quota) ` +
5069
+ `3. Try a different region or model ` +
5070
+ `4. Request provisioned throughput for sustained load`, this.providerName);
4974
5071
  }
4975
5072
  // Network connectivity errors
4976
5073
  if (message.includes("ECONNRESET") ||
@@ -296,6 +296,19 @@ export type GenerateOptions = {
296
296
  * ```
297
297
  */
298
298
  enabledToolNames?: string[];
299
+ /**
300
+ * Request timeout (e.g. 30000, '30s', '2m').
301
+ *
302
+ * PER-STEP semantics in agentic loops: on providers that run a native
303
+ * multi-step tool loop (Vertex Gemini / Vertex Claude), this bounds EACH
304
+ * model call in the loop, not the whole turn — a tool-heavy turn may run
305
+ * far longer than this value in total. Size it for the slowest single
306
+ * step (default 300s), and use `abortSignal` for a total-turn deadline.
307
+ *
308
+ * When set explicitly, a step timeout is surfaced immediately instead of
309
+ * burning internal retries/fallbacks that would re-run the same
310
+ * provider+model with the same doomed budget.
311
+ */
299
312
  timeout?: number | string;
300
313
  /** AbortSignal for external cancellation of the AI call */
301
314
  abortSignal?: AbortSignal;
@@ -5,6 +5,7 @@
5
5
  import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
6
6
  import { tracers } from "../telemetry/tracers.js";
7
7
  import { withTimeout } from "./errorHandling.js";
8
+ import { safeDebugSerialize, sanitizeRecord } from "./logSanitize.js";
8
9
  import { DEFAULT_FALLBACK_THRESHOLD, getConversationMemoryDefaults, MEMORY_THRESHOLD_PERCENTAGE, } from "../config/conversationMemory.js";
9
10
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
10
11
  import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
@@ -106,20 +107,30 @@ export function applyConversationMemoryDefaults(userConfig) {
106
107
  * Get conversation history as message array, summarizing if needed.
107
108
  */
108
109
  export async function getConversationMessages(conversationMemory, options) {
109
- logger.debug("[conversationMemoryUtils] getConversationMessages called", {
110
- hasMemory: !!conversationMemory,
111
- memoryType: conversationMemory?.constructor?.name || "NONE",
112
- hasContext: !!options.context,
113
- enableSummarization: options.enableSummarization ?? false,
114
- options: JSON.stringify(options, null, 2),
115
- });
110
+ // Logger Guard: options carries the full conversation history + tool
111
+ // outputs; eager JSON.stringify of it can throw RangeError: Invalid string
112
+ // length and abort the turn. Serialize lazily, bounded, never-throwing,
113
+ // and redacted (sanitizeRecord strips credential/PII keys).
114
+ if (logger.shouldLog("debug")) {
115
+ logger.debug("[conversationMemoryUtils] getConversationMessages called", {
116
+ hasMemory: !!conversationMemory,
117
+ memoryType: conversationMemory?.constructor?.name || "NONE",
118
+ hasContext: !!options.context,
119
+ enableSummarization: options.enableSummarization ?? false,
120
+ options: safeDebugSerialize(sanitizeRecord(options)),
121
+ });
122
+ }
116
123
  if (!conversationMemory || !options.context) {
117
124
  logger.warn("[conversationMemoryUtils] No memory or context, returning empty messages", {
118
125
  hasMemory: !!conversationMemory,
119
126
  memoryType: conversationMemory?.constructor?.name || "NONE",
120
127
  hasContext: !!options.context,
121
128
  enableSummarization: options.enableSummarization ?? false,
122
- options: JSON.stringify(options, null, 2),
129
+ // The options dump is debug-grade detail — don't pay for (or leak)
130
+ // the serialization on every memory-disabled call at warn level.
131
+ ...(logger.shouldLog("debug")
132
+ ? { options: safeDebugSerialize(sanitizeRecord(options)) }
133
+ : {}),
123
134
  });
124
135
  return [];
125
136
  }
@@ -26,6 +26,32 @@
26
26
  * @param maxLen - Maximum number of characters to keep (default 500).
27
27
  */
28
28
  export declare function sanitizeForLog(text: string, maxLen?: number): string;
29
+ /**
30
+ * Stringify non-string message/tool content without ever throwing.
31
+ * JSON.stringify on a giant multimodal/tool-result payload can exceed V8's
32
+ * maximum string length and throw `RangeError: Invalid string length` — a
33
+ * generation turn must degrade to a placeholder, not abort, when that
34
+ * happens. Shared by the SDK core and providers (single source of truth).
35
+ */
36
+ export declare function stringifyContentSafe(content: unknown): string;
37
+ /**
38
+ * Serialize an arbitrary value for a debug log without ever throwing or
39
+ * producing an unbounded string.
40
+ *
41
+ * `JSON.stringify` on a full options object (conversation history + tool
42
+ * outputs) can exceed V8's maximum string length and throw
43
+ * `RangeError: Invalid string length`, which — when evaluated eagerly inside
44
+ * a logger call — aborts the surrounding generation turn (observed in
45
+ * production). This helper caps the output and converts any stringify
46
+ * failure (RangeError, circular structure, BigInt, …) into a placeholder.
47
+ *
48
+ * Callers MUST still gate on `logger.shouldLog("debug")` per the Logger
49
+ * Guard rule — this helper makes serialization safe, not free.
50
+ *
51
+ * @param value - Arbitrary value to serialize.
52
+ * @param maxLen - Maximum number of characters to keep (default 10 000).
53
+ */
54
+ export declare function safeDebugSerialize(value: unknown, maxLen?: number): string;
29
55
  /**
30
56
  * Strip embedded `user:pass@` credentials from a URL's authority component
31
57
  * before logging it or surfacing it in a user-facing error.
@@ -74,6 +74,13 @@ const SENSITIVE_OBJECT_KEYS = [
74
74
  "oauth",
75
75
  "oauthToken",
76
76
  "credentials",
77
+ "authContext",
78
+ "authToken",
79
+ "sessionToken",
80
+ "serviceAccountKey",
81
+ "secretAccessKey",
82
+ // PII, not a secret — but it has no business in debug log dumps.
83
+ "userEmail",
77
84
  ];
78
85
  /**
79
86
  * Truncate `text` to `maxLen` chars then replace embedded secrets with `***`.
@@ -91,6 +98,55 @@ export function sanitizeForLog(text, maxLen = 500) {
91
98
  }
92
99
  return text.slice(0, maxLen).replace(SECRET_PATTERN, "***");
93
100
  }
101
+ /**
102
+ * Stringify non-string message/tool content without ever throwing.
103
+ * JSON.stringify on a giant multimodal/tool-result payload can exceed V8's
104
+ * maximum string length and throw `RangeError: Invalid string length` — a
105
+ * generation turn must degrade to a placeholder, not abort, when that
106
+ * happens. Shared by the SDK core and providers (single source of truth).
107
+ */
108
+ export function stringifyContentSafe(content) {
109
+ if (typeof content === "string") {
110
+ return content;
111
+ }
112
+ try {
113
+ return JSON.stringify(content) ?? String(content);
114
+ }
115
+ catch {
116
+ return "[content too large to serialize]";
117
+ }
118
+ }
119
+ /**
120
+ * Serialize an arbitrary value for a debug log without ever throwing or
121
+ * producing an unbounded string.
122
+ *
123
+ * `JSON.stringify` on a full options object (conversation history + tool
124
+ * outputs) can exceed V8's maximum string length and throw
125
+ * `RangeError: Invalid string length`, which — when evaluated eagerly inside
126
+ * a logger call — aborts the surrounding generation turn (observed in
127
+ * production). This helper caps the output and converts any stringify
128
+ * failure (RangeError, circular structure, BigInt, …) into a placeholder.
129
+ *
130
+ * Callers MUST still gate on `logger.shouldLog("debug")` per the Logger
131
+ * Guard rule — this helper makes serialization safe, not free.
132
+ *
133
+ * @param value - Arbitrary value to serialize.
134
+ * @param maxLen - Maximum number of characters to keep (default 10 000).
135
+ */
136
+ export function safeDebugSerialize(value, maxLen = 10_000) {
137
+ try {
138
+ const json = JSON.stringify(value);
139
+ if (json === undefined) {
140
+ return String(value);
141
+ }
142
+ return json.length > maxLen
143
+ ? `${json.slice(0, maxLen)}…[truncated ${json.length - maxLen} chars]`
144
+ : json;
145
+ }
146
+ catch (error) {
147
+ return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`;
148
+ }
149
+ }
94
150
  /**
95
151
  * Strip embedded `user:pass@` credentials from a URL's authority component
96
152
  * before logging it or surfacing it in a user-facing error.
@@ -348,7 +348,9 @@ export class ExternalServerManager extends EventEmitter {
348
348
  }
349
349
  catch (error) {
350
350
  const errorMsg = `Failed to load MCP server ${serverId}: ${error instanceof Error ? error.message : String(error)}`;
351
- mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`);
351
+ // No log here: the result-processing loop below owns the single
352
+ // ERROR record for this failure (logging in both places
353
+ // double-counted every parallel-load failure).
352
354
  return { serverId, error: errorMsg };
353
355
  }
354
356
  });
@@ -366,11 +368,16 @@ export class ExternalServerManager extends EventEmitter {
366
368
  }
367
369
  else if (error) {
368
370
  errors.push(error);
371
+ // Config-loaded servers never pass through
372
+ // NeuroLink.addExternalMCPServer, so this is the outermost point
373
+ // for them — it owns the single ERROR record per failed server.
374
+ mcpLogger.error(`[ExternalServerManager] Failed to load server ${serverId}: ${error}`);
369
375
  }
370
376
  else if (serverResult && !serverResult.success) {
371
377
  const errorMsg = `Failed to load server ${serverId}: ${serverResult.error}`;
372
378
  errors.push(errorMsg);
373
- mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`);
379
+ // See above: outermost point for config-loaded servers.
380
+ mcpLogger.error(`[ExternalServerManager] ${errorMsg}`);
374
381
  }
375
382
  }
376
383
  else {
@@ -482,13 +489,17 @@ export class ExternalServerManager extends EventEmitter {
482
489
  else {
483
490
  const error = `Failed to load server ${serverId}: ${result.error}`;
484
491
  errors.push(error);
485
- mcpLogger.warn(`[ExternalServerManager] ${error}`);
492
+ // Config-loaded servers never pass through
493
+ // NeuroLink.addExternalMCPServer — this sequential-load branch is
494
+ // their outermost point and owns the single ERROR record.
495
+ mcpLogger.error(`[ExternalServerManager] ${error}`);
486
496
  }
487
497
  }
488
498
  catch (error) {
489
499
  const errorMsg = `Failed to load MCP server ${serverId}: ${error instanceof Error ? error.message : String(error)}`;
490
500
  errors.push(errorMsg);
491
- mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`);
501
+ // See above: outermost point for config-loaded servers.
502
+ mcpLogger.error(`[ExternalServerManager] ${errorMsg}`);
492
503
  // Continue with other servers - don't let one failure break everything
493
504
  }
494
505
  }
@@ -708,7 +719,9 @@ export class ExternalServerManager extends EventEmitter {
708
719
  };
709
720
  }
710
721
  catch (error) {
711
- mcpLogger.error(`[ExternalServerManager] Failed to add server ${serverId}:`, error);
722
+ // debug, not error: NeuroLink.addExternalMCPServer (the public entry
723
+ // point) emits the single ERROR record for a failed registration.
724
+ mcpLogger.debug(`[ExternalServerManager] Failed to add server ${serverId}:`, error);
712
725
  // Clean up if instance was created
713
726
  this.servers.delete(serverId);
714
727
  return {
@@ -851,7 +864,9 @@ export class ExternalServerManager extends EventEmitter {
851
864
  mcpLogger.info(`[ExternalServerManager] Server started successfully: ${serverId}`);
852
865
  }
853
866
  catch (error) {
854
- mcpLogger.error(`[ExternalServerManager] Failed to start server ${serverId}:`, error);
867
+ // debug, not error: the failure is rethrown below and surfaces once at
868
+ // the NeuroLink.addExternalMCPServer entry point.
869
+ mcpLogger.debug(`[ExternalServerManager] Failed to start server ${serverId}:`, error);
855
870
  this.updateServerStatus(serverId, "failed");
856
871
  instance.lastError =
857
872
  error instanceof Error ? error.message : String(error);
@@ -142,7 +142,11 @@ export class MCPClientFactory {
142
142
  duration: Date.now() - startTime,
143
143
  };
144
144
  }
145
- mcpLogger.error(`[MCPClientFactory] Failed to create client for ${config.id}:`, error);
145
+ // debug, not error: this failure propagates up through
146
+ // ExternalServerManager to NeuroLink.addExternalMCPServer, which emits
147
+ // the single ERROR record. Logging at every layer turned one failed
148
+ // server registration into 5 ERROR lines in production.
149
+ mcpLogger.debug(`[MCPClientFactory] Failed to create client for ${config.id}:`, error);
146
150
  obsSpan.durationMs = Date.now() - startTime;
147
151
  const endedObsSpan = SpanSerializer.endSpan(obsSpan, SpanStatus.ERROR);
148
152
  endedObsSpan.statusMessage = errorMessage;
package/dist/neurolink.js CHANGED
@@ -72,6 +72,7 @@ import { coerceJsonToSchema } from "./utils/json/coerce.js";
72
72
  // Factory processing imports
73
73
  import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
74
74
  import { logger, mcpLogger } from "./utils/logger.js";
75
+ import { redactUrlCredentials, safeDebugSerialize, sanitizeRecord, stringifyContentSafe, } from "./utils/logSanitize.js";
75
76
  import { extractMcpErrorText } from "./utils/mcpErrorText.js";
76
77
  import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
77
78
  import { resolveModel } from "./utils/modelAliasResolver.js";
@@ -2156,9 +2157,7 @@ Current user's request: ${currentInput}`;
2156
2157
  const anyOptions = optionsOrPrompt;
2157
2158
  if (anyOptions.messages && anyOptions.messages.length > 0) {
2158
2159
  const lastMessage = anyOptions.messages[anyOptions.messages.length - 1];
2159
- return typeof lastMessage.content === "string"
2160
- ? lastMessage.content
2161
- : JSON.stringify(lastMessage.content);
2160
+ return stringifyContentSafe(lastMessage.content);
2162
2161
  }
2163
2162
  // Handle input.text format
2164
2163
  return optionsOrPrompt.input?.text || "";
@@ -3879,9 +3878,7 @@ Current user's request: ${currentInput}`;
3879
3878
  ?.filter((m) => m.role === "user" || m.role === "assistant")
3880
3879
  .map((m) => ({
3881
3880
  role: m.role,
3882
- content: typeof m.content === "string"
3883
- ? m.content
3884
- : JSON.stringify(m.content),
3881
+ content: stringifyContentSafe(m.content),
3885
3882
  })) ??
3886
3883
  options.conversationHistory,
3887
3884
  timeout: options.timeout,
@@ -3978,9 +3975,7 @@ Current user's request: ${currentInput}`;
3978
3975
  ?.filter((m) => m.role === "user" || m.role === "assistant")
3979
3976
  .map((m) => ({
3980
3977
  role: m.role,
3981
- content: typeof m.content === "string"
3982
- ? m.content
3983
- : JSON.stringify(m.content),
3978
+ content: stringifyContentSafe(m.content),
3984
3979
  })) ??
3985
3980
  options.conversationHistory,
3986
3981
  timeout: options.timeout,
@@ -4603,6 +4598,11 @@ Current user's request: ${currentInput}`;
4603
4598
  */
4604
4599
  async performMCPGenerationRetries(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag) {
4605
4600
  const maxMcpRetries = RETRY_ATTEMPTS.QUICK;
4601
+ // Captured BEFORE the first attempt: ensureMCPGenerationBudget (inside
4602
+ // tryMCPGeneration) auto-scales options.timeout for >100K-token contexts
4603
+ // when the caller left it unset, so checking options.timeout inside the
4604
+ // catch below could not tell caller-set apart from auto-scaled.
4605
+ const callerSetTimeout = options.timeout !== undefined;
4606
4606
  // NL-007: Track retry metadata for observability
4607
4607
  const retryErrors = [];
4608
4608
  let retryCount = 0;
@@ -4645,6 +4645,20 @@ Current user's request: ${currentInput}`;
4645
4645
  logger.debug(`[${functionTag}] AbortError detected on attempt ${attempt}, stopping retries`);
4646
4646
  throw error;
4647
4647
  }
4648
+ // A TimeoutError against an explicit caller-set options.timeout is
4649
+ // deterministic-by-construction: every remaining retry AND the
4650
+ // direct-generation fallback would re-run the same provider+model
4651
+ // under the same per-step budget. Observed in production as ~650s
4652
+ // of doomed follow-ups after a 90s step timeout. Surface it now.
4653
+ // Name check (not instanceof): two TimeoutError classes exist
4654
+ // (utils/timeout.ts and utils/async/withTimeout.ts) and both stamp
4655
+ // name = "TimeoutError".
4656
+ if (callerSetTimeout &&
4657
+ error instanceof Error &&
4658
+ error.name === "TimeoutError") {
4659
+ logger.warn(`[${functionTag}] Per-step timeout (${String(options.timeout)}) exhausted on attempt ${attempt} — surfacing instead of retrying with the same budget`);
4660
+ throw error;
4661
+ }
4648
4662
  // NL-007: Record retry error for observability
4649
4663
  retryCount++;
4650
4664
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -7220,11 +7234,18 @@ Current user's request: ${currentInput}`;
7220
7234
  */
7221
7235
  async storeStreamConversationMemory(params) {
7222
7236
  const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, } = params;
7223
- logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
7224
- options: JSON.stringify(enhancedOptions),
7225
- sessionId: enhancedOptions.context
7226
- ?.sessionId,
7227
- });
7237
+ // Logger Guard: the full options object (history + tool outputs) can be
7238
+ // enormous — an eager JSON.stringify here threw RangeError: Invalid
7239
+ // string length in production and killed the turn. Serialize lazily,
7240
+ // bounded, and only when debug is actually enabled; sanitizeRecord
7241
+ // redacts credentials/PII keys before anything reaches the sink.
7242
+ if (logger.shouldLog("debug")) {
7243
+ logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
7244
+ options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
7245
+ sessionId: enhancedOptions.context
7246
+ ?.sessionId,
7247
+ });
7248
+ }
7228
7249
  // Guard: skip storing if no meaningful content was produced (no text AND no tool activity)
7229
7250
  const hasToolEvents = eventSequence.some((e) => e.type === "tool:start" || e.type === "tool:end");
7230
7251
  if (!accumulatedContent.trim() && !hasToolEvents) {
@@ -7234,12 +7255,16 @@ Current user's request: ${currentInput}`;
7234
7255
  });
7235
7256
  return;
7236
7257
  }
7237
- logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
7238
- options: JSON.stringify(enhancedOptions),
7239
- sessionId: enhancedOptions.context
7240
- ?.sessionId,
7241
- conversationMemoryExists: this.conversationMemory ? true : false,
7242
- });
7258
+ // Logger Guard: see the matching block above — never eagerly stringify
7259
+ // the full options object, and always redact before serializing.
7260
+ if (logger.shouldLog("debug")) {
7261
+ logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
7262
+ options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
7263
+ sessionId: enhancedOptions.context
7264
+ ?.sessionId,
7265
+ conversationMemoryExists: this.conversationMemory ? true : false,
7266
+ });
7267
+ }
7243
7268
  // Store memory after stream consumption is complete
7244
7269
  if (this.conversationMemory && enhancedOptions.context?.sessionId) {
7245
7270
  const sessionId = enhancedOptions.context
@@ -8703,11 +8728,7 @@ Current user's request: ${currentInput}`;
8703
8728
  : this.getCustomTools().has(toolName)
8704
8729
  ? "custom"
8705
8730
  : "external";
8706
- const inputStr = typeof params === "string"
8707
- ? params
8708
- : params
8709
- ? JSON.stringify(params)
8710
- : "";
8731
+ const inputStr = params ? stringifyContentSafe(params) : "";
8711
8732
  const executionStartTime = Date.now();
8712
8733
  // Per-invocation id so consumers can correlate a tool:start with its matching
8713
8734
  // tool:end even when the same tool runs multiple times concurrently.
@@ -10271,8 +10292,15 @@ Current user's request: ${currentInput}`;
10271
10292
  });
10272
10293
  }
10273
10294
  else {
10295
+ // Single ERROR record for a failed registration — the inner layers
10296
+ // (client factory, server manager) log at debug so one root cause no
10297
+ // longer fans out into 5 ERROR lines. Carry enough context here to
10298
+ // diagnose without the inner lines.
10274
10299
  mcpLogger.error(`[NeuroLink] Failed to add external MCP server: ${serverId}`, {
10275
10300
  error: result.error,
10301
+ transport: config.transport,
10302
+ command: config.command,
10303
+ url: config.url ? redactUrlCredentials(config.url) : undefined,
10276
10304
  });
10277
10305
  }
10278
10306
  return result;
@@ -10304,6 +10332,11 @@ Current user's request: ${currentInput}`;
10304
10332
  timestamp: Date.now(),
10305
10333
  });
10306
10334
  }
10335
+ else if (result.error?.includes("not found")) {
10336
+ // Expected no-op: consumers commonly call remove as cleanup after a
10337
+ // failed add, when the server never registered. Not an error.
10338
+ mcpLogger.debug(`[NeuroLink] Remove skipped — external MCP server not registered: ${serverId}`);
10339
+ }
10307
10340
  else {
10308
10341
  mcpLogger.error(`[NeuroLink] Failed to remove external MCP server: ${serverId}`, {
10309
10342
  error: result.error,