@juspay/neurolink 9.80.3 → 9.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +352 -352
  3. package/dist/cli/loop/optionsSchema.js +16 -0
  4. package/dist/context/stages/structuredSummarizer.js +15 -4
  5. package/dist/core/analytics.js +22 -0
  6. package/dist/core/constants.d.ts +13 -0
  7. package/dist/core/constants.js +13 -0
  8. package/dist/core/modules/GenerationHandler.js +28 -10
  9. package/dist/core/modules/structuredOutputPolicy.d.ts +19 -0
  10. package/dist/core/modules/structuredOutputPolicy.js +26 -0
  11. package/dist/lib/context/stages/structuredSummarizer.js +15 -4
  12. package/dist/lib/core/analytics.js +22 -0
  13. package/dist/lib/core/constants.d.ts +13 -0
  14. package/dist/lib/core/constants.js +13 -0
  15. package/dist/lib/core/modules/GenerationHandler.js +28 -10
  16. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +19 -0
  17. package/dist/lib/core/modules/structuredOutputPolicy.js +26 -0
  18. package/dist/lib/mcp/externalServerManager.js +21 -6
  19. package/dist/lib/mcp/mcpClientFactory.js +5 -1
  20. package/dist/lib/neurolink.js +88 -28
  21. package/dist/lib/providers/googleNativeGemini3.d.ts +92 -4
  22. package/dist/lib/providers/googleNativeGemini3.js +186 -4
  23. package/dist/lib/providers/googleVertex.d.ts +15 -0
  24. package/dist/lib/providers/googleVertex.js +719 -139
  25. package/dist/lib/types/analytics.d.ts +10 -0
  26. package/dist/lib/types/generate.d.ts +88 -0
  27. package/dist/lib/types/stream.d.ts +21 -1
  28. package/dist/lib/utils/conversationMemory.js +19 -8
  29. package/dist/lib/utils/logSanitize.d.ts +26 -0
  30. package/dist/lib/utils/logSanitize.js +56 -0
  31. package/dist/mcp/externalServerManager.js +21 -6
  32. package/dist/mcp/mcpClientFactory.js +5 -1
  33. package/dist/neurolink.js +88 -28
  34. package/dist/providers/googleNativeGemini3.d.ts +92 -4
  35. package/dist/providers/googleNativeGemini3.js +186 -4
  36. package/dist/providers/googleVertex.d.ts +15 -0
  37. package/dist/providers/googleVertex.js +719 -139
  38. package/dist/types/analytics.d.ts +10 -0
  39. package/dist/types/generate.d.ts +88 -0
  40. package/dist/types/stream.d.ts +21 -1
  41. package/dist/utils/conversationMemory.js +19 -8
  42. package/dist/utils/logSanitize.d.ts +26 -0
  43. package/dist/utils/logSanitize.js +56 -0
  44. package/package.json +1 -1
@@ -49,6 +49,22 @@ export const textGenerationOptionsSchema = {
49
49
  type: "number",
50
50
  description: "Timeout for the generation request in milliseconds.",
51
51
  },
52
+ turnTimeoutMs: {
53
+ type: "number",
54
+ description: "Wall-clock cap for the whole agentic turn in milliseconds (all steps + tool executions).",
55
+ },
56
+ stallTimeoutMs: {
57
+ type: "number",
58
+ description: "Maximum time with no progress (no chunk, no tool activity) before the turn ends as stalled, in milliseconds.",
59
+ },
60
+ wrapupTimeLeadMs: {
61
+ type: "number",
62
+ description: "Remaining-turn-time threshold that triggers a wrap-up nudge to the model, in milliseconds.",
63
+ },
64
+ toolTimeoutMs: {
65
+ type: "number",
66
+ description: "Per-tool-execution timeout in milliseconds (default 300000). A timed-out tool fails that step; the turn continues.",
67
+ },
52
68
  disableTools: {
53
69
  type: "boolean",
54
70
  description: "Disable all tool usage for the AI.",
@@ -17,10 +17,21 @@ function findSplitIndexByTokens(messages, targetRecentTokens, provider) {
17
17
  let recentTokens = 0;
18
18
  let splitIndex = messages.length;
19
19
  for (let i = messages.length - 1; i >= 0; i--) {
20
- const content = typeof messages[i].content === "string"
21
- ? messages[i].content
22
- : JSON.stringify(messages[i].content);
23
- const msgTokens = estimateTokens(content, provider);
20
+ // Guarded: JSON.stringify on a giant single message can throw RangeError
21
+ // (V8 max string length). A message that big cannot be "recent-window"
22
+ // material anyway — treat it as effectively infinite tokens so it (and
23
+ // everything older) lands on the summarized side instead of aborting
24
+ // compaction.
25
+ let msgTokens;
26
+ try {
27
+ const content = typeof messages[i].content === "string"
28
+ ? messages[i].content
29
+ : (JSON.stringify(messages[i].content) ?? "");
30
+ msgTokens = estimateTokens(content, provider);
31
+ }
32
+ catch {
33
+ msgTokens = Number.MAX_SAFE_INTEGER;
34
+ }
24
35
  if (recentTokens + msgTokens > targetRecentTokens) {
25
36
  splitIndex = i + 1;
26
37
  break;
@@ -18,6 +18,15 @@ export function createAnalytics(provider, model, result, responseTime, context)
18
18
  const tokens = extractTokenUsage(result);
19
19
  // Estimate cost based on provider and tokens
20
20
  const cost = estimateCost(provider, model, tokens);
21
+ // Turn-lifecycle telemetry from native agentic loops (Vertex
22
+ // Gemini/Claude): stopReason / rawFinishReason / stepsUsed ride the
23
+ // provider result; toolCallCount and elapsedMs derive from it.
24
+ const turnResult = result;
25
+ const toolCallCount = Array.isArray(turnResult.toolExecutions)
26
+ ? turnResult.toolExecutions.length
27
+ : Array.isArray(turnResult.toolsUsed)
28
+ ? turnResult.toolsUsed.length
29
+ : undefined;
21
30
  const analytics = {
22
31
  provider,
23
32
  model,
@@ -26,6 +35,19 @@ export function createAnalytics(provider, model, result, responseTime, context)
26
35
  requestDuration: responseTime,
27
36
  context: context,
28
37
  timestamp: new Date().toISOString(),
38
+ ...(typeof turnResult.stepsUsed === "number" && {
39
+ stepsUsed: turnResult.stepsUsed,
40
+ }),
41
+ ...(toolCallCount !== undefined && { toolCallCount }),
42
+ ...(typeof turnResult.stopReason === "string" && {
43
+ stopReason: turnResult.stopReason,
44
+ }),
45
+ ...(typeof turnResult.responseTime === "number" && {
46
+ elapsedMs: turnResult.responseTime,
47
+ }),
48
+ ...(typeof turnResult.rawFinishReason === "string" && {
49
+ rawFinishReason: turnResult.rawFinishReason,
50
+ }),
29
51
  };
30
52
  logger.debug(`[${functionTag}] Analytics created`, {
31
53
  provider,
@@ -23,6 +23,19 @@ export declare const DEFAULT_MAX_STEPS = 200;
23
23
  export declare const DEFAULT_TOOL_MAX_RETRIES = 2;
24
24
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
25
25
  export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
26
+ /**
27
+ * Default per-tool-execution timeout for native agentic loops. A tool that
28
+ * exceeds it fails with an error tool_result and costs one step — the turn
29
+ * continues instead of hanging on a wedged tool. Override per call with
30
+ * `toolTimeoutMs`.
31
+ */
32
+ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
36
+ * a wrap-up nudge rides the next tool-result turn.
37
+ */
38
+ export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
26
39
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
27
40
  export declare const STEP_LIMITS: {
28
41
  min: number;
@@ -109,6 +109,19 @@ export const DEFAULT_MAX_STEPS = 200;
109
109
  export const DEFAULT_TOOL_MAX_RETRIES = 2; // Maximum retries per tool before permanently failing
110
110
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
111
111
  export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
112
+ /**
113
+ * Default per-tool-execution timeout for native agentic loops. A tool that
114
+ * exceeds it fails with an error tool_result and costs one step — the turn
115
+ * continues instead of hanging on a wedged tool. Override per call with
116
+ * `toolTimeoutMs`.
117
+ */
118
+ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
119
+ /**
120
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
121
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
122
+ * a wrap-up nudge rides the next tool-result turn.
123
+ */
124
+ export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
112
125
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
113
126
  // Redis write; if breached, the .catch logs a warning.
114
127
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -21,7 +21,7 @@ import { calculateCost } from "../../utils/pricing.js";
21
21
  import { withProviderRetry } from "../../utils/providerRetry.js";
22
22
  import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
23
23
  import { DEFAULT_MAX_STEPS } from "../constants.js";
24
- import { isTemperatureDeprecatedError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
24
+ import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
25
25
  import { coerceJsonToSchema } from "../../utils/json/coerce.js";
26
26
  import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
27
27
  import { Output, stepCountIs } from "../../utils/tool.js";
@@ -284,15 +284,20 @@ export class GenerationHandler {
284
284
  }
285
285
  catch (error) {
286
286
  // Fall back to text-mode (no experimental_output) when structured
287
- // output + tools failed, in two cases:
287
+ // output + tools failed, in three cases:
288
288
  // 1. NoObjectGeneratedError — the SDK couldn't coerce the object.
289
289
  // 2. The provider rejected json-mode-with-tools outright (e.g. Groq:
290
290
  // "json mode cannot be combined with tool/function calling").
291
- // In both cases we retry without structured output and let
291
+ // 3. The provider rejected the schema as too complex for its
292
+ // constrained decoding (Vertex Gemini 400 "too many states") —
293
+ // deterministic, so re-sending the schema can never succeed.
294
+ // In all cases we retry without structured output and let
292
295
  // formatEnhancedResult coerce the text response into valid JSON.
296
+ const schemaTooComplex = useStructuredOutput && isSchemaComplexityError(error);
293
297
  const isStructuredOutputConflict = useStructuredOutput &&
294
298
  (error instanceof NoObjectGeneratedError ||
295
- isToolsSchemaConflictError(error));
299
+ isToolsSchemaConflictError(error) ||
300
+ schemaTooComplex);
296
301
  if (isStructuredOutputConflict) {
297
302
  span.setAttribute("neurolink.has_fallback", true);
298
303
  // NLK-GAP-007: Record initial failure event before fallback retry
@@ -301,13 +306,26 @@ export class GenerationHandler {
301
306
  "retry.attempt": 1,
302
307
  "retry.reason": error instanceof NoObjectGeneratedError
303
308
  ? "NoObjectGeneratedError_structured_output_fallback"
304
- : "tools_schema_conflict_structured_output_fallback",
305
- });
306
- logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
307
- provider: this.providerName,
308
- model: this.modelName,
309
- error: error instanceof Error ? error.message : String(error),
309
+ : schemaTooComplex
310
+ ? "schema_complexity_structured_output_fallback"
311
+ : "tools_schema_conflict_structured_output_fallback",
310
312
  });
313
+ if (schemaTooComplex) {
314
+ // warn (not debug): callers should simplify their schema — the
315
+ // fallback keeps the turn alive but skips native enforcement.
316
+ logger.warn("[GenerationHandler] schema too complex for provider constrained decoding — retrying with prompt-based JSON", {
317
+ provider: this.providerName,
318
+ model: this.modelName,
319
+ error: error instanceof Error ? error.message : String(error),
320
+ });
321
+ }
322
+ else {
323
+ logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
324
+ provider: this.providerName,
325
+ model: this.modelName,
326
+ error: error instanceof Error ? error.message : String(error),
327
+ });
328
+ }
311
329
  // Retry without experimental_output - the formatEnhancedResult method
312
330
  // will extract JSON from the text response
313
331
  const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, shouldUseTools, false), span, "generateText(fallback)");
@@ -41,6 +41,25 @@ export declare function isToolsSchemaExclusionInForce(providerName: string, mode
41
41
  * and retried without structured output instead of failing the call.
42
42
  */
43
43
  export declare function isToolsSchemaConflictError(error: unknown): boolean;
44
+ /**
45
+ * True when a provider error indicates the request was rejected because the
46
+ * JSON schema is too complex for the provider's constrained decoding. Vertex
47
+ * Gemini enforces response schemas as a state machine and rejects
48
+ * "complex" schemas (regex patterns, string min/max lengths, nested array
49
+ * caps, long enums) with a deterministic 400 INVALID_ARGUMENT:
50
+ *
51
+ * "The specified schema produces a constraint that has too many states
52
+ * for serving. Typical causes of this error are schemas with lots of
53
+ * text …, schemas with long array length limits …, or schemas using
54
+ * complex value matchers …"
55
+ *
56
+ * The error arrives wrapped (provider error formatter + upstream JSON
57
+ * envelope), so match on the distinctive substring. Retrying with the same
58
+ * schema can never succeed — the correct recovery is to drop native schema
59
+ * enforcement and coerce the text response into the schema instead
60
+ * (same flow as isToolsSchemaConflictError).
61
+ */
62
+ export declare function isSchemaComplexityError(error: unknown): boolean;
44
63
  /**
45
64
  * True when a provider error indicates the request was rejected because the
46
65
  * `temperature` parameter is deprecated / unsupported for the model. The newest
@@ -68,6 +68,32 @@ export function isToolsSchemaConflictError(error) {
68
68
  /(tool|function)[\s-]?call[^.]{0,60}json[\s_-]?(mode|schema)/i.test(message) ||
69
69
  /response_format[^.]{0,60}(tool|function)/i.test(message));
70
70
  }
71
+ /**
72
+ * True when a provider error indicates the request was rejected because the
73
+ * JSON schema is too complex for the provider's constrained decoding. Vertex
74
+ * Gemini enforces response schemas as a state machine and rejects
75
+ * "complex" schemas (regex patterns, string min/max lengths, nested array
76
+ * caps, long enums) with a deterministic 400 INVALID_ARGUMENT:
77
+ *
78
+ * "The specified schema produces a constraint that has too many states
79
+ * for serving. Typical causes of this error are schemas with lots of
80
+ * text …, schemas with long array length limits …, or schemas using
81
+ * complex value matchers …"
82
+ *
83
+ * The error arrives wrapped (provider error formatter + upstream JSON
84
+ * envelope), so match on the distinctive substring. Retrying with the same
85
+ * schema can never succeed — the correct recovery is to drop native schema
86
+ * enforcement and coerce the text response into the schema instead
87
+ * (same flow as isToolsSchemaConflictError).
88
+ */
89
+ export function isSchemaComplexityError(error) {
90
+ const message = error instanceof Error
91
+ ? error.message
92
+ : typeof error === "string"
93
+ ? error
94
+ : "";
95
+ return /too many states/i.test(message);
96
+ }
71
97
  /**
72
98
  * True when a provider error indicates the request was rejected because the
73
99
  * `temperature` parameter is deprecated / unsupported for the model. The newest
@@ -17,10 +17,21 @@ function findSplitIndexByTokens(messages, targetRecentTokens, provider) {
17
17
  let recentTokens = 0;
18
18
  let splitIndex = messages.length;
19
19
  for (let i = messages.length - 1; i >= 0; i--) {
20
- const content = typeof messages[i].content === "string"
21
- ? messages[i].content
22
- : JSON.stringify(messages[i].content);
23
- const msgTokens = estimateTokens(content, provider);
20
+ // Guarded: JSON.stringify on a giant single message can throw RangeError
21
+ // (V8 max string length). A message that big cannot be "recent-window"
22
+ // material anyway — treat it as effectively infinite tokens so it (and
23
+ // everything older) lands on the summarized side instead of aborting
24
+ // compaction.
25
+ let msgTokens;
26
+ try {
27
+ const content = typeof messages[i].content === "string"
28
+ ? messages[i].content
29
+ : (JSON.stringify(messages[i].content) ?? "");
30
+ msgTokens = estimateTokens(content, provider);
31
+ }
32
+ catch {
33
+ msgTokens = Number.MAX_SAFE_INTEGER;
34
+ }
24
35
  if (recentTokens + msgTokens > targetRecentTokens) {
25
36
  splitIndex = i + 1;
26
37
  break;
@@ -18,6 +18,15 @@ export function createAnalytics(provider, model, result, responseTime, context)
18
18
  const tokens = extractTokenUsage(result);
19
19
  // Estimate cost based on provider and tokens
20
20
  const cost = estimateCost(provider, model, tokens);
21
+ // Turn-lifecycle telemetry from native agentic loops (Vertex
22
+ // Gemini/Claude): stopReason / rawFinishReason / stepsUsed ride the
23
+ // provider result; toolCallCount and elapsedMs derive from it.
24
+ const turnResult = result;
25
+ const toolCallCount = Array.isArray(turnResult.toolExecutions)
26
+ ? turnResult.toolExecutions.length
27
+ : Array.isArray(turnResult.toolsUsed)
28
+ ? turnResult.toolsUsed.length
29
+ : undefined;
21
30
  const analytics = {
22
31
  provider,
23
32
  model,
@@ -26,6 +35,19 @@ export function createAnalytics(provider, model, result, responseTime, context)
26
35
  requestDuration: responseTime,
27
36
  context: context,
28
37
  timestamp: new Date().toISOString(),
38
+ ...(typeof turnResult.stepsUsed === "number" && {
39
+ stepsUsed: turnResult.stepsUsed,
40
+ }),
41
+ ...(toolCallCount !== undefined && { toolCallCount }),
42
+ ...(typeof turnResult.stopReason === "string" && {
43
+ stopReason: turnResult.stopReason,
44
+ }),
45
+ ...(typeof turnResult.responseTime === "number" && {
46
+ elapsedMs: turnResult.responseTime,
47
+ }),
48
+ ...(typeof turnResult.rawFinishReason === "string" && {
49
+ rawFinishReason: turnResult.rawFinishReason,
50
+ }),
29
51
  };
30
52
  logger.debug(`[${functionTag}] Analytics created`, {
31
53
  provider,
@@ -23,6 +23,19 @@ export declare const DEFAULT_MAX_STEPS = 200;
23
23
  export declare const DEFAULT_TOOL_MAX_RETRIES = 2;
24
24
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
25
25
  export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
26
+ /**
27
+ * Default per-tool-execution timeout for native agentic loops. A tool that
28
+ * exceeds it fails with an error tool_result and costs one step — the turn
29
+ * continues instead of hanging on a wedged tool. Override per call with
30
+ * `toolTimeoutMs`.
31
+ */
32
+ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
36
+ * a wrap-up nudge rides the next tool-result turn.
37
+ */
38
+ export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
26
39
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
27
40
  export declare const STEP_LIMITS: {
28
41
  min: number;
@@ -109,6 +109,19 @@ export const DEFAULT_MAX_STEPS = 200;
109
109
  export const DEFAULT_TOOL_MAX_RETRIES = 2; // Maximum retries per tool before permanently failing
110
110
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
111
111
  export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
112
+ /**
113
+ * Default per-tool-execution timeout for native agentic loops. A tool that
114
+ * exceeds it fails with an error tool_result and costs one step — the turn
115
+ * continues instead of hanging on a wedged tool. Override per call with
116
+ * `toolTimeoutMs`.
117
+ */
118
+ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
119
+ /**
120
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
121
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
122
+ * a wrap-up nudge rides the next tool-result turn.
123
+ */
124
+ export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
112
125
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
113
126
  // Redis write; if breached, the .catch logs a warning.
114
127
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -21,7 +21,7 @@ import { calculateCost } from "../../utils/pricing.js";
21
21
  import { withProviderRetry } from "../../utils/providerRetry.js";
22
22
  import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
23
23
  import { DEFAULT_MAX_STEPS } from "../constants.js";
24
- import { isTemperatureDeprecatedError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
24
+ import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
25
25
  import { coerceJsonToSchema } from "../../utils/json/coerce.js";
26
26
  import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
27
27
  import { Output, stepCountIs } from "../../utils/tool.js";
@@ -284,15 +284,20 @@ export class GenerationHandler {
284
284
  }
285
285
  catch (error) {
286
286
  // Fall back to text-mode (no experimental_output) when structured
287
- // output + tools failed, in two cases:
287
+ // output + tools failed, in three cases:
288
288
  // 1. NoObjectGeneratedError — the SDK couldn't coerce the object.
289
289
  // 2. The provider rejected json-mode-with-tools outright (e.g. Groq:
290
290
  // "json mode cannot be combined with tool/function calling").
291
- // In both cases we retry without structured output and let
291
+ // 3. The provider rejected the schema as too complex for its
292
+ // constrained decoding (Vertex Gemini 400 "too many states") —
293
+ // deterministic, so re-sending the schema can never succeed.
294
+ // In all cases we retry without structured output and let
292
295
  // formatEnhancedResult coerce the text response into valid JSON.
296
+ const schemaTooComplex = useStructuredOutput && isSchemaComplexityError(error);
293
297
  const isStructuredOutputConflict = useStructuredOutput &&
294
298
  (error instanceof NoObjectGeneratedError ||
295
- isToolsSchemaConflictError(error));
299
+ isToolsSchemaConflictError(error) ||
300
+ schemaTooComplex);
296
301
  if (isStructuredOutputConflict) {
297
302
  span.setAttribute("neurolink.has_fallback", true);
298
303
  // NLK-GAP-007: Record initial failure event before fallback retry
@@ -301,13 +306,26 @@ export class GenerationHandler {
301
306
  "retry.attempt": 1,
302
307
  "retry.reason": error instanceof NoObjectGeneratedError
303
308
  ? "NoObjectGeneratedError_structured_output_fallback"
304
- : "tools_schema_conflict_structured_output_fallback",
305
- });
306
- logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
307
- provider: this.providerName,
308
- model: this.modelName,
309
- error: error instanceof Error ? error.message : String(error),
309
+ : schemaTooComplex
310
+ ? "schema_complexity_structured_output_fallback"
311
+ : "tools_schema_conflict_structured_output_fallback",
310
312
  });
313
+ if (schemaTooComplex) {
314
+ // warn (not debug): callers should simplify their schema — the
315
+ // fallback keeps the turn alive but skips native enforcement.
316
+ logger.warn("[GenerationHandler] schema too complex for provider constrained decoding — retrying with prompt-based JSON", {
317
+ provider: this.providerName,
318
+ model: this.modelName,
319
+ error: error instanceof Error ? error.message : String(error),
320
+ });
321
+ }
322
+ else {
323
+ logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
324
+ provider: this.providerName,
325
+ model: this.modelName,
326
+ error: error instanceof Error ? error.message : String(error),
327
+ });
328
+ }
311
329
  // Retry without experimental_output - the formatEnhancedResult method
312
330
  // will extract JSON from the text response
313
331
  const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, shouldUseTools, false), span, "generateText(fallback)");
@@ -41,6 +41,25 @@ export declare function isToolsSchemaExclusionInForce(providerName: string, mode
41
41
  * and retried without structured output instead of failing the call.
42
42
  */
43
43
  export declare function isToolsSchemaConflictError(error: unknown): boolean;
44
+ /**
45
+ * True when a provider error indicates the request was rejected because the
46
+ * JSON schema is too complex for the provider's constrained decoding. Vertex
47
+ * Gemini enforces response schemas as a state machine and rejects
48
+ * "complex" schemas (regex patterns, string min/max lengths, nested array
49
+ * caps, long enums) with a deterministic 400 INVALID_ARGUMENT:
50
+ *
51
+ * "The specified schema produces a constraint that has too many states
52
+ * for serving. Typical causes of this error are schemas with lots of
53
+ * text …, schemas with long array length limits …, or schemas using
54
+ * complex value matchers …"
55
+ *
56
+ * The error arrives wrapped (provider error formatter + upstream JSON
57
+ * envelope), so match on the distinctive substring. Retrying with the same
58
+ * schema can never succeed — the correct recovery is to drop native schema
59
+ * enforcement and coerce the text response into the schema instead
60
+ * (same flow as isToolsSchemaConflictError).
61
+ */
62
+ export declare function isSchemaComplexityError(error: unknown): boolean;
44
63
  /**
45
64
  * True when a provider error indicates the request was rejected because the
46
65
  * `temperature` parameter is deprecated / unsupported for the model. The newest
@@ -68,6 +68,32 @@ export function isToolsSchemaConflictError(error) {
68
68
  /(tool|function)[\s-]?call[^.]{0,60}json[\s_-]?(mode|schema)/i.test(message) ||
69
69
  /response_format[^.]{0,60}(tool|function)/i.test(message));
70
70
  }
71
+ /**
72
+ * True when a provider error indicates the request was rejected because the
73
+ * JSON schema is too complex for the provider's constrained decoding. Vertex
74
+ * Gemini enforces response schemas as a state machine and rejects
75
+ * "complex" schemas (regex patterns, string min/max lengths, nested array
76
+ * caps, long enums) with a deterministic 400 INVALID_ARGUMENT:
77
+ *
78
+ * "The specified schema produces a constraint that has too many states
79
+ * for serving. Typical causes of this error are schemas with lots of
80
+ * text …, schemas with long array length limits …, or schemas using
81
+ * complex value matchers …"
82
+ *
83
+ * The error arrives wrapped (provider error formatter + upstream JSON
84
+ * envelope), so match on the distinctive substring. Retrying with the same
85
+ * schema can never succeed — the correct recovery is to drop native schema
86
+ * enforcement and coerce the text response into the schema instead
87
+ * (same flow as isToolsSchemaConflictError).
88
+ */
89
+ export function isSchemaComplexityError(error) {
90
+ const message = error instanceof Error
91
+ ? error.message
92
+ : typeof error === "string"
93
+ ? error
94
+ : "";
95
+ return /too many states/i.test(message);
96
+ }
71
97
  /**
72
98
  * True when a provider error indicates the request was rejected because the
73
99
  * `temperature` parameter is deprecated / unsupported for the model. The newest
@@ -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;