@juspay/neurolink 10.2.2 → 10.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +425 -422
  3. package/dist/cli/loop/optionsSchema.js +4 -0
  4. package/dist/constants/contextWindows.d.ts +24 -0
  5. package/dist/constants/contextWindows.js +50 -0
  6. package/dist/context/errorDetection.d.ts +6 -0
  7. package/dist/context/errorDetection.js +18 -0
  8. package/dist/context/stepBudgetGuard.d.ts +61 -0
  9. package/dist/context/stepBudgetGuard.js +328 -0
  10. package/dist/core/baseProvider.d.ts +12 -0
  11. package/dist/core/baseProvider.js +18 -0
  12. package/dist/core/modules/GenerationHandler.js +257 -43
  13. package/dist/core/modules/ToolsManager.d.ts +17 -0
  14. package/dist/core/modules/ToolsManager.js +99 -1
  15. package/dist/lib/constants/contextWindows.d.ts +24 -0
  16. package/dist/lib/constants/contextWindows.js +50 -0
  17. package/dist/lib/context/errorDetection.d.ts +6 -0
  18. package/dist/lib/context/errorDetection.js +18 -0
  19. package/dist/lib/context/stepBudgetGuard.d.ts +61 -0
  20. package/dist/lib/context/stepBudgetGuard.js +329 -0
  21. package/dist/lib/core/baseProvider.d.ts +12 -0
  22. package/dist/lib/core/baseProvider.js +18 -0
  23. package/dist/lib/core/modules/GenerationHandler.js +257 -43
  24. package/dist/lib/core/modules/ToolsManager.d.ts +17 -0
  25. package/dist/lib/core/modules/ToolsManager.js +99 -1
  26. package/dist/lib/mcp/toolDiscoveryService.js +59 -20
  27. package/dist/lib/neurolink.js +30 -9
  28. package/dist/lib/providers/litellm.d.ts +27 -19
  29. package/dist/lib/providers/litellm.js +171 -92
  30. package/dist/lib/providers/openaiChatCompletionsBase.d.ts +37 -1
  31. package/dist/lib/providers/openaiChatCompletionsBase.js +201 -33
  32. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +23 -5
  33. package/dist/lib/providers/openaiChatCompletionsClient.js +94 -14
  34. package/dist/lib/proxy/proxyFetch.d.ts +17 -0
  35. package/dist/lib/proxy/proxyFetch.js +42 -4
  36. package/dist/lib/types/context.d.ts +22 -0
  37. package/dist/lib/types/generate.d.ts +18 -2
  38. package/dist/lib/types/openaiCompatible.d.ts +2 -0
  39. package/dist/lib/types/providers.d.ts +9 -0
  40. package/dist/lib/utils/errorHandling.js +8 -2
  41. package/dist/lib/utils/schemaConversion.d.ts +16 -0
  42. package/dist/lib/utils/schemaConversion.js +165 -8
  43. package/dist/lib/utils/timeout.d.ts +22 -0
  44. package/dist/lib/utils/timeout.js +72 -12
  45. package/dist/lib/utils/tokenLimits.js +22 -0
  46. package/dist/lib/utils/toolCallRepair.d.ts +8 -0
  47. package/dist/lib/utils/toolCallRepair.js +4 -1
  48. package/dist/mcp/toolDiscoveryService.js +59 -20
  49. package/dist/neurolink.js +30 -9
  50. package/dist/providers/litellm.d.ts +27 -19
  51. package/dist/providers/litellm.js +171 -92
  52. package/dist/providers/openaiChatCompletionsBase.d.ts +37 -1
  53. package/dist/providers/openaiChatCompletionsBase.js +201 -33
  54. package/dist/providers/openaiChatCompletionsClient.d.ts +23 -5
  55. package/dist/providers/openaiChatCompletionsClient.js +94 -14
  56. package/dist/proxy/proxyFetch.d.ts +17 -0
  57. package/dist/proxy/proxyFetch.js +42 -4
  58. package/dist/types/context.d.ts +22 -0
  59. package/dist/types/generate.d.ts +18 -2
  60. package/dist/types/openaiCompatible.d.ts +2 -0
  61. package/dist/types/providers.d.ts +9 -0
  62. package/dist/utils/errorHandling.js +8 -2
  63. package/dist/utils/schemaConversion.d.ts +16 -0
  64. package/dist/utils/schemaConversion.js +165 -8
  65. package/dist/utils/timeout.d.ts +22 -0
  66. package/dist/utils/timeout.js +72 -12
  67. package/dist/utils/tokenLimits.js +22 -0
  68. package/dist/utils/toolCallRepair.d.ts +8 -0
  69. package/dist/utils/toolCallRepair.js +4 -1
  70. package/package.json +6 -3
@@ -19,8 +19,10 @@ import { logger } from "../../utils/logger.js";
19
19
  import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
20
20
  import { calculateCost } from "../../utils/pricing.js";
21
21
  import { withProviderRetry } from "../../utils/providerRetry.js";
22
+ import { parseTimeout } from "../../utils/timeout.js";
22
23
  import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
23
- import { DEFAULT_MAX_STEPS } from "../constants.js";
24
+ import { DEFAULT_MAX_STEPS, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../constants.js";
25
+ import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
24
26
  import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
25
27
  import { coerceJsonToSchema } from "../../utils/json/coerce.js";
26
28
  import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
@@ -44,6 +46,94 @@ function safePreview(v) {
44
46
  return "[unserializable]";
45
47
  }
46
48
  }
49
+ /**
50
+ * Turn budget + wrap-up deadline (parity with the googleVertex native loops).
51
+ * A deadline is engaged only when the caller expressed one: turnTimeoutMs
52
+ * wins, else an explicit generate timeout. Callers that set neither keep the
53
+ * pre-existing behaviour (no wrap-up; the outer defensive timeout in
54
+ * executeStandardGenerateFlow still applies). With `wrapupTimeLeadMs` left of
55
+ * the deadline, the loop stops offering tools (toolChoice: "none") so the
56
+ * model spends the remaining budget producing a final answer instead of being
57
+ * guillotined mid-tool-loop with all work discarded. The lead is clamped to a
58
+ * quarter of the budget so short explicit timeouts (e.g. 30s) don't trigger
59
+ * wrap-up on the very first step.
60
+ *
61
+ * `turnStartMs` anchors the deadline to the ORIGINAL generation start:
62
+ * callGenerateText re-runs on executeGeneration's fallback retries
63
+ * (structured-output conflict, temperature-deprecated) and provider retries,
64
+ * and a deadline computed from Date.now() per attempt would hand each retry
65
+ * a fresh budget — multiplying the caller's wall-clock cap.
66
+ */
67
+ function resolveTurnBudget(options, turnStartMs) {
68
+ const callerTimeoutMs = parseTimeout(options.timeout);
69
+ const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
70
+ Number.isFinite(options.turnTimeoutMs) &&
71
+ options.turnTimeoutMs > 0;
72
+ if (options.turnTimeoutMs !== undefined && !hasValidTurnTimeout) {
73
+ logger.warn("[GenerationHandler] Ignoring invalid turnTimeoutMs — expected a positive number of milliseconds; falling back to the timeout option", { turnTimeoutMs: options.turnTimeoutMs });
74
+ }
75
+ const turnBudgetMs = hasValidTurnTimeout
76
+ ? options.turnTimeoutMs
77
+ : callerTimeoutMs;
78
+ const wrapupLeadMs = turnBudgetMs
79
+ ? Math.min(options.wrapupTimeLeadMs ?? DEFAULT_WRAPUP_TIME_LEAD_MS, Math.floor(turnBudgetMs / 4))
80
+ : 0;
81
+ const turnDeadline = turnBudgetMs ? turnStartMs + turnBudgetMs : undefined;
82
+ return { callerTimeoutMs, turnBudgetMs, wrapupLeadMs, turnDeadline };
83
+ }
84
+ /**
85
+ * Merge the per-call providerOptions namespaces for generateText. Both the
86
+ * timeout forwarding (`neurolink.timeoutMs`, read by NeuroLink's delegating
87
+ * chat-completions models) and Gemini thinking (`google.thinkingConfig`) may
88
+ * apply on the same call — built here as ONE object because two conditional
89
+ * `providerOptions:` spreads in the args literal would silently clobber each
90
+ * other (object spread does not deep-merge).
91
+ */
92
+ function buildProviderOptions(options, isGoogleProvider, callerTimeoutMs) {
93
+ const providerOptions = {};
94
+ if (callerTimeoutMs !== undefined) {
95
+ providerOptions.neurolink = { timeoutMs: callerTimeoutMs };
96
+ }
97
+ if (options.thinkingConfig?.enabled && isGoogleProvider) {
98
+ // Gemini 3 uses thinkingLevel; Gemini 2.5 uses thinkingBudget.
99
+ providerOptions.google = {
100
+ thinkingConfig: {
101
+ ...(options.thinkingConfig.thinkingLevel && {
102
+ thinkingLevel: options.thinkingConfig.thinkingLevel,
103
+ }),
104
+ ...(options.thinkingConfig.budgetTokens &&
105
+ !options.thinkingConfig.thinkingLevel && {
106
+ thinkingBudget: options.thinkingConfig.budgetTokens,
107
+ }),
108
+ includeThoughts: true,
109
+ },
110
+ };
111
+ }
112
+ return Object.keys(providerOptions).length > 0
113
+ ? providerOptions
114
+ : undefined;
115
+ }
116
+ /**
117
+ * Build the prepareStep result for a forced wrap-up step: tools withdrawn
118
+ * (toolChoice: "none") plus an honest time message (native-loop parity) —
119
+ * without the message, weaker models keep trying to emit tool calls and leak
120
+ * raw tool-call tokens into the text answer.
121
+ */
122
+ function buildWrapupStepResult(prepared, stepMessages) {
123
+ const baseMessages = prepared?.messages ??
124
+ stepMessages;
125
+ return {
126
+ ...(prepared ?? {}),
127
+ messages: [
128
+ ...baseMessages,
129
+ {
130
+ role: "user",
131
+ content: "The time budget for this task is nearly exhausted. Do not call any more tools. Give your best final answer NOW from the information already gathered, and note anything you could not verify in the remaining time.",
132
+ },
133
+ ],
134
+ toolChoice: "none",
135
+ };
136
+ }
47
137
  /**
48
138
  * GenerationHandler class - Handles text generation operations for AI providers
49
139
  */
@@ -66,7 +156,8 @@ export class GenerationHandler {
66
156
  * Helper method to call generateText with optional structured output
67
157
  * @private
68
158
  */
69
- async callGenerateText(model, messages, tools, options, shouldUseTools, includeStructuredOutput) {
159
+ async callGenerateText(model, messages, tools, options, callConfig) {
160
+ const { shouldUseTools, includeStructuredOutput, turnStartMs } = callConfig;
70
161
  // Check if this is a Google provider (for provider-specific options)
71
162
  const isGoogleProvider = this.providerName === "google-ai" || this.providerName === "vertex";
72
163
  // Check if this is an Anthropic provider (includes Vertex+Claude)
@@ -112,11 +203,45 @@ export class GenerationHandler {
112
203
  }
113
204
  }
114
205
  const prepareStep = options.prepareStep;
206
+ const { callerTimeoutMs, turnBudgetMs, wrapupLeadMs, turnDeadline } = resolveTurnBudget(options, turnStartMs);
207
+ let wrapupForced = false;
208
+ const providerOptions = buildProviderOptions(options, isGoogleProvider, callerTimeoutMs);
115
209
  // Hoist system-role messages into generateText's top-level `system` option
116
210
  // rather than passing them inside `messages` (deprecated by the AI SDK,
117
211
  // rejected in v7). See extractSystemMessages for the rationale. (#1024)
118
212
  const { system, messages: nonSystemMessages } = extractSystemMessages(messages);
119
- return await generateText({
213
+ // Per-step context budget guard: the tool loop appends assistant turns and
214
+ // tool results on every step — growth the pre-call budget check never
215
+ // sees. Estimate each step's projected request and deterministically
216
+ // reclaim budget (truncate old tool outputs, then drop oldest exchanges)
217
+ // so long agentic runs cannot overflow the model's window mid-loop.
218
+ // Parity with the googleVertex native loops' createContextGuard, upgraded
219
+ // from stop-only to compact-and-continue. The caller's prepareStep result
220
+ // wins on conflicts; the guard only contributes `messages`.
221
+ //
222
+ // Overhead is resolved PER STEP because `toolsWithCache` is deliberately
223
+ // mutable (search_tools hydration adds discovered tools mid-loop) — a
224
+ // once-captured estimate would undercount later steps. Tools are only
225
+ // ever added, so memoizing on tool count keeps the common step O(1).
226
+ let cachedOverhead = { toolCount: -1, tokens: 0 };
227
+ const stepBudgetGuard = createStepBudgetGuard({
228
+ provider: this.providerName ?? "unknown",
229
+ model: this.modelName,
230
+ maxTokens: options.maxTokens,
231
+ getFixedOverheadTokens: () => {
232
+ const toolCount = shouldUseTools
233
+ ? Object.keys(toolsWithCache).length
234
+ : 0;
235
+ if (toolCount !== cachedOverhead.toolCount) {
236
+ cachedOverhead = {
237
+ toolCount,
238
+ tokens: estimateFixedOverheadTokens(system, shouldUseTools ? toolsWithCache : undefined, this.providerName),
239
+ };
240
+ }
241
+ return cachedOverhead.tokens;
242
+ },
243
+ });
244
+ const result = await generateText({
120
245
  model,
121
246
  ...(system && { system }),
122
247
  messages: nonSystemMessages,
@@ -125,52 +250,88 @@ export class GenerationHandler {
125
250
  stopWhen: stepCountIs(options.maxSteps ?? DEFAULT_MAX_STEPS),
126
251
  ...(shouldUseTools &&
127
252
  options.toolChoice && { toolChoice: options.toolChoice }),
128
- ...(prepareStep && {
129
- experimental_prepareStep: ((stepOptions) => prepareStep({
130
- ...stepOptions,
131
- maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
132
- })),
253
+ experimental_prepareStep: (async (stepOptions) => {
254
+ // Public contract preserved: a caller-supplied prepareStep receives
255
+ // the ORIGINAL AI-SDK step options, exactly as before the guard
256
+ // existed callers that inspect message history see the real thing.
257
+ const callerResult = prepareStep
258
+ ? await prepareStep({
259
+ ...stepOptions,
260
+ maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
261
+ })
262
+ : undefined;
263
+ // The guard runs LAST, on the messages that will actually be sent:
264
+ // the caller's override when one was returned (out-of-contract for
265
+ // NeuroLink's public prepareStep type, but possible at runtime), else
266
+ // the step's own messages. It never replaces a caller's content
267
+ // choices — it only reclaims budget from whatever was chosen.
268
+ const callerMessages = callerResult?.messages;
269
+ // Usage feedback: the provider's REAL prompt-token count for the
270
+ // previous step calibrates the guard's char-based estimator (see
271
+ // createStepBudgetGuard) — free precision, no tokenizer.
272
+ const previousStep = stepOptions.steps[stepOptions.steps.length - 1];
273
+ const compacted = stepBudgetGuard(callerMessages ?? stepOptions.messages, previousStep?.usage?.inputTokens);
274
+ const prepared = compacted
275
+ ? { ...(callerResult ?? {}), messages: compacted }
276
+ : callerResult;
277
+ // Wrap-up: inside the lead window before the turn deadline, stop
278
+ // offering tools so this step produces the final answer. Overrides
279
+ // any caller toolChoice — an honest partial beats a discarded turn.
280
+ if (turnDeadline !== undefined &&
281
+ shouldUseTools &&
282
+ Date.now() >= turnDeadline - wrapupLeadMs) {
283
+ if (!wrapupForced) {
284
+ wrapupForced = true;
285
+ logger.warn("[GenerationHandler] Turn budget nearly exhausted — forcing wrap-up (toolChoice: none)", {
286
+ provider: this.providerName,
287
+ turnBudgetMs,
288
+ wrapupLeadMs,
289
+ stepNumber: stepOptions.stepNumber,
290
+ });
291
+ }
292
+ return buildWrapupStepResult(prepared, stepOptions.messages);
293
+ }
294
+ return prepared;
133
295
  }),
134
296
  temperature: options.temperature,
135
297
  maxOutputTokens: options.maxTokens,
136
298
  maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
137
299
  abortSignal: options.abortSignal,
300
+ // Schema-driven tool-call repair (BZ-665): fixes near-miss tool names
301
+ // (case/substring/Levenshtein) and — for tools whose schema carries a
302
+ // validator — coerces mis-typed arguments ("123" → 123) and remaps
303
+ // near-miss parameter names before the call is marked invalid. Wired
304
+ // for every AI-SDK-loop provider; native loops have their own paths.
305
+ ...(shouldUseTools &&
306
+ !options.disableToolCallRepair && {
307
+ experimental_repairToolCall: (async (...repairArgs) => {
308
+ // Lazy import to avoid a circular dependency at module load time
309
+ const { createToolCallRepair } = await import("../../utils/toolCallRepair.js");
310
+ return createToolCallRepair()(...repairArgs);
311
+ }),
312
+ }),
313
+ // Forward the caller's resolved timeout to the model layer: the AI-SDK
314
+ // V3 call options carry no `timeout`, so delegating chat-completions
315
+ // models (litellm & friends) could otherwise only ever apply their
316
+ // provider default per step — an explicit `timeout: "15m"` bounded the
317
+ // outer loop while each step stayed capped at the default.
318
+ // Merged namespaces (neurolink timeout forwarding + Gemini thinking) —
319
+ // built as ONE object; see buildProviderOptions.
320
+ ...(providerOptions && { providerOptions }),
138
321
  ...(useStructuredOutput &&
139
322
  options.schema && {
140
323
  experimental_output: Output.object({ schema: options.schema }),
141
324
  }),
142
- // Add thinking configuration for extended reasoning
143
- // Gemini 3 models use providerOptions.google.thinkingConfig with thinkingLevel
144
- // Gemini 2.5 models use thinkingBudget
145
- // Anthropic models use experimental_thinking with budgetTokens
146
- ...(options.thinkingConfig?.enabled && {
147
- // For Anthropic: experimental_thinking with budgetTokens
148
- ...(isAnthropicProvider &&
149
- options.thinkingConfig.budgetTokens &&
150
- !options.thinkingConfig.thinkingLevel && {
151
- experimental_thinking: {
152
- type: "enabled",
153
- budgetTokens: options.thinkingConfig.budgetTokens,
154
- },
155
- }),
156
- // For Google Gemini 3: providerOptions with thinkingLevel
157
- // For Gemini 2.5: providerOptions with thinkingBudget
158
- ...(isGoogleProvider && {
159
- providerOptions: {
160
- google: {
161
- thinkingConfig: {
162
- ...(options.thinkingConfig.thinkingLevel && {
163
- thinkingLevel: options.thinkingConfig.thinkingLevel,
164
- }),
165
- ...(options.thinkingConfig.budgetTokens &&
166
- !options.thinkingConfig.thinkingLevel && {
167
- thinkingBudget: options.thinkingConfig.budgetTokens,
168
- }),
169
- includeThoughts: true,
170
- },
171
- },
172
- },
173
- }),
325
+ // Anthropic thinking: experimental_thinking with budgetTokens.
326
+ // (Gemini thinking rides providerOptions.google above.)
327
+ ...(options.thinkingConfig?.enabled &&
328
+ isAnthropicProvider &&
329
+ options.thinkingConfig.budgetTokens &&
330
+ !options.thinkingConfig.thinkingLevel && {
331
+ experimental_thinking: {
332
+ type: "enabled",
333
+ budgetTokens: options.thinkingConfig.budgetTokens,
334
+ },
174
335
  }),
175
336
  experimental_telemetry: this.getTelemetryConfigFn(options, "generate"),
176
337
  onStepFinish: ({ toolCalls, toolResults }) => {
@@ -189,6 +350,16 @@ export class GenerationHandler {
189
350
  });
190
351
  },
191
352
  });
353
+ if (wrapupForced) {
354
+ // Non-enumerable marker read by formatEnhancedResult to report
355
+ // stopReason "time-limit" — the result object itself is the only
356
+ // artifact that travels from this call to result formatting.
357
+ Object.defineProperty(result, "__nlTurnWrapup", {
358
+ value: true,
359
+ enumerable: false,
360
+ });
361
+ }
362
+ return result;
192
363
  }
193
364
  /**
194
365
  * Execute the generation with AI SDK
@@ -245,7 +416,11 @@ export class GenerationHandler {
245
416
  }
246
417
  const genStartTime = Date.now();
247
418
  try {
248
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, shouldUseTools, true), span, "generateText");
419
+ const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, {
420
+ shouldUseTools,
421
+ includeStructuredOutput: true,
422
+ turnStartMs: genStartTime,
423
+ }), span, "generateText");
249
424
  logger.info("[GenerationHandler] generateText returned", {
250
425
  requestId,
251
426
  durationMs: Date.now() - genStartTime,
@@ -340,7 +515,12 @@ export class GenerationHandler {
340
515
  }
341
516
  // Retry without experimental_output - the formatEnhancedResult method
342
517
  // will extract JSON from the text response
343
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, shouldUseTools, false), span, "generateText(fallback)");
518
+ const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, {
519
+ shouldUseTools,
520
+ // includeStructuredOutput intentionally omitted
521
+ includeStructuredOutput: false,
522
+ turnStartMs: genStartTime,
523
+ }), span, "generateText(fallback)");
344
524
  // NLK-GAP-007: Record recovery event after successful fallback
345
525
  span.addEvent("retry.recovered", {
346
526
  "retry.attempts": 2,
@@ -391,7 +571,12 @@ export class GenerationHandler {
391
571
  model: this.modelName,
392
572
  error: error instanceof Error ? error.message : String(error),
393
573
  });
394
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, { ...options, temperature: undefined }, shouldUseTools, true), span, "generateText(no-temperature)");
574
+ const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, { ...options, temperature: undefined }, {
575
+ shouldUseTools,
576
+ // mirror the initial call; the structured-output policy still applies
577
+ includeStructuredOutput: true,
578
+ turnStartMs: genStartTime,
579
+ }), span, "generateText(no-temperature)");
395
580
  span.addEvent("retry.recovered", {
396
581
  "retry.attempts": 2,
397
582
  "retry.strategy": "temperature_omitted",
@@ -674,11 +859,40 @@ export class GenerationHandler {
674
859
  : String(rawReasoning)
675
860
  : undefined;
676
861
  const reasoningTokens = usage.reasoning ?? undefined;
862
+ // stopReason / stepsUsed parity with the native loops (Vertex Gemini /
863
+ // Claude / Bedrock): the AI-SDK loop path previously left both undefined,
864
+ // so consumers could not distinguish a completed turn from one truncated
865
+ // by the step cap or ended by the turn budget.
866
+ const steps = generateResult.steps;
867
+ const stepsUsed = Array.isArray(steps) ? steps.length : undefined;
868
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
869
+ let stopReason;
870
+ if (generateResult.__nlTurnWrapup === true) {
871
+ stopReason = "time-limit";
872
+ }
873
+ else if (stepsUsed !== undefined &&
874
+ stepsUsed >= maxSteps &&
875
+ generateResult.finishReason === "tool-calls") {
876
+ stopReason = "step-cap";
877
+ }
878
+ else if (generateResult.finishReason === "error") {
879
+ // Parity with resolveTurnStopReason (native loops): a turn that ended
880
+ // on a provider "error" finish is not a completion. length /
881
+ // content-filter DO map to "completed" — deliberately matching the
882
+ // native contract, where truncation is signaled via finishReason /
883
+ // rawFinishReason / jsonTruncated, never via stopReason.
884
+ stopReason = "provider-error";
885
+ }
886
+ else if (stepsUsed !== undefined) {
887
+ stopReason = "completed";
888
+ }
677
889
  return {
678
890
  content,
679
891
  structuredData,
680
892
  usage,
681
893
  finishReason: generateResult.finishReason,
894
+ stopReason,
895
+ stepsUsed,
682
896
  jsonRepaired: jsonRepaired || undefined,
683
897
  jsonTruncated: jsonTruncated || undefined,
684
898
  provider: this.providerName,
@@ -1,6 +1,23 @@
1
1
  import type { AIProviderName, ToolUtilities } from "../../types/index.js";
2
2
  import type { NeuroLink } from "../../neurolink.js";
3
3
  import type { Tool } from "../../types/index.js";
4
+ /**
5
+ * Build an argument validator for an external MCP tool's JSON Schema, in the
6
+ * shape the AI SDK's `jsonSchema()` wrapper expects. Returns undefined when
7
+ * the schema can't be compiled (exotic dialect) — the tool then keeps the
8
+ * previous declarative-only behaviour instead of failing registration.
9
+ *
10
+ * The error message is written for the MODEL (it is fed back verbatim as the
11
+ * tool-error text): it names the offending fields and restates the contract
12
+ * (required properties + types) so the retry can succeed on the first attempt.
13
+ */
14
+ export declare function buildMCPSchemaValidator(toolName: string, schema: Record<string, unknown>): Promise<((value: unknown) => {
15
+ success: true;
16
+ value: unknown;
17
+ } | {
18
+ success: false;
19
+ error: Error;
20
+ }) | undefined>;
4
21
  /**
5
22
  * ToolsManager class - Handles all tool management operations
6
23
  */
@@ -13,6 +13,87 @@ function makeToolAbortError() {
13
13
  e.name = "AbortError";
14
14
  return e;
15
15
  }
16
+ /**
17
+ * Compiled-validator cache, keyed by the ORIGINAL MCP tool inputSchema
18
+ * object. createExternalMCPTool runs once per external tool on EVERY
19
+ * getAllTools() (i.e. every generation call), and the schema object identity
20
+ * is stable for the lifetime of a server connection — without the cache the
21
+ * same JSON Schema is recompiled on every generate() (hot-path CPU for
22
+ * 50+-tool deployments). Rediscovery produces new schema objects, so stale
23
+ * entries fall out via WeakMap semantics. `undefined` values (schemas that
24
+ * failed to compile) are cached too — hence has()/get() rather than a
25
+ * get()-only check.
26
+ */
27
+ const mcpValidatorCache = new WeakMap();
28
+ /**
29
+ * Build an argument validator for an external MCP tool's JSON Schema, in the
30
+ * shape the AI SDK's `jsonSchema()` wrapper expects. Returns undefined when
31
+ * the schema can't be compiled (exotic dialect) — the tool then keeps the
32
+ * previous declarative-only behaviour instead of failing registration.
33
+ *
34
+ * The error message is written for the MODEL (it is fed back verbatim as the
35
+ * tool-error text): it names the offending fields and restates the contract
36
+ * (required properties + types) so the retry can succeed on the first attempt.
37
+ */
38
+ export async function buildMCPSchemaValidator(toolName, schema) {
39
+ try {
40
+ const { Validator } = await import("@cfworker/json-schema");
41
+ // draft-07: the lingua franca of MCP server inputSchemas. shortCircuit
42
+ // false so the error lists every violation, not just the first.
43
+ const validator = new Validator(schema, "7", false);
44
+ const required = Array.isArray(schema.required)
45
+ ? schema.required
46
+ : [];
47
+ const properties = schema.properties && typeof schema.properties === "object"
48
+ ? schema.properties
49
+ : {};
50
+ const contract = Object.entries(properties)
51
+ .map(([key, prop]) => `${key}${required.includes(key) ? "" : "?"}: ${prop?.type ?? "any"}`)
52
+ .join(", ");
53
+ return (value) => {
54
+ try {
55
+ const result = validator.validate(value);
56
+ if (result.valid) {
57
+ return { success: true, value };
58
+ }
59
+ // Root-level entries ("#") are mostly generic "instance does not
60
+ // match schema" noise — EXCEPT missing-required errors, which carry
61
+ // the offending property name and must reach the model.
62
+ const details = result.errors
63
+ .filter((e) => e.instanceLocation !== "#" || /required property/i.test(e.error))
64
+ .slice(0, 3)
65
+ .map((e) => {
66
+ const loc = e.instanceLocation.replace(/^#\/?/, "");
67
+ return loc ? `${loc}: ${e.error}` : e.error;
68
+ })
69
+ .join("; ");
70
+ return {
71
+ success: false,
72
+ error: new Error(`Invalid arguments for tool '${toolName}'${details ? ` — ${details}` : ""}. ` +
73
+ `Expected: { ${contract} } (send every non-optional property with the exact name and JSON type).`),
74
+ };
75
+ }
76
+ catch (validationError) {
77
+ // Validator crashed on this instance — treat as valid rather than
78
+ // block the call; the MCP-layer validator still backstops execution.
79
+ logger.debug(`[ToolsManager] Schema validator failed for '${toolName}', passing through`, {
80
+ error: validationError instanceof Error
81
+ ? validationError.message
82
+ : String(validationError),
83
+ });
84
+ return { success: true, value };
85
+ }
86
+ };
87
+ }
88
+ catch (compileError) {
89
+ logger.debug(`[ToolsManager] Could not compile schema validator for '${toolName}' — arguments will not be pre-validated`, {
90
+ error: compileError instanceof Error
91
+ ? compileError.message
92
+ : String(compileError),
93
+ });
94
+ return undefined;
95
+ }
96
+ }
16
97
  /**
17
98
  * Race a tool-execution promise against an AbortSignal so the calling loop
18
99
  * observes a deadline/caller abort IMMEDIATELY instead of waiting for the
@@ -583,7 +664,24 @@ export class ToolsManager {
583
664
  const fixedSchema = this.utilities?.fixSchemaForOpenAIStrictMode
584
665
  ? this.utilities.fixSchemaForOpenAIStrictMode(originalSchema)
585
666
  : originalSchema;
586
- finalSchema = jsonSchema(fixedSchema);
667
+ // A jsonSchema() wrapper WITHOUT a validate function is declarative
668
+ // only — the AI SDK passes any parsed arguments straight through
669
+ // (safeValidateTypes short-circuits on `validate == null`). That let
670
+ // malformed calls (missing required params, "123" for a number)
671
+ // reach execution, where the MCP-layer validator rejected them at
672
+ // the cost of a full model round-trip. Attach a real validator so
673
+ // invalid calls fail at parse time, where experimental_repairToolCall
674
+ // can still fix them silently. Compiled once per schema object —
675
+ // see mcpValidatorCache.
676
+ let validate;
677
+ if (mcpValidatorCache.has(originalSchema)) {
678
+ validate = mcpValidatorCache.get(originalSchema);
679
+ }
680
+ else {
681
+ validate = await buildMCPSchemaValidator(tool.name, fixedSchema);
682
+ mcpValidatorCache.set(originalSchema, validate);
683
+ }
684
+ finalSchema = jsonSchema(fixedSchema, validate ? { validate } : {});
587
685
  }
588
686
  else {
589
687
  finalSchema = this.utilities?.createPermissiveZodSchema
@@ -9,6 +9,7 @@ import { globalCircuitBreakerManager, CircuitBreakerOpenError, } from "./mcpCirc
9
9
  import { isObject, isNullish } from "../utils/typeUtils.js";
10
10
  import { validateToolName, validateToolDescription, } from "../utils/parameterValidation.js";
11
11
  import { withTimeout } from "../utils/errorHandling.js";
12
+ import { coerceType } from "../utils/toolCallRepair.js";
12
13
  import { extractMcpErrorText } from "../utils/mcpErrorText.js";
13
14
  import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
14
15
  import { tracers } from "../telemetry/tracers.js";
@@ -416,12 +417,16 @@ export class ToolDiscoveryService extends EventEmitter {
416
417
  if (!toolInfo.isAvailable) {
417
418
  throw new Error(`Tool '${toolName}' is not available`);
418
419
  }
419
- // Validate input parameters if requested
420
+ // Validate input parameters if requested. Validation coerces
421
+ // recoverable mismatches (numeric strings for number params, "true"
422
+ // for booleans, JSON-encoded objects/arrays) instead of rejecting —
423
+ // a rejection here costs the agent loop a full model round-trip.
424
+ let effectiveParameters = parameters;
420
425
  if (options.validateInput !== false) {
421
- this.validateToolParameters(toolInfo, parameters);
426
+ effectiveParameters = this.validateToolParameters(toolInfo, parameters);
422
427
  }
423
428
  mcpLogger.debug(`[ToolDiscoveryService] Executing tool: ${toolName} on ${serverId}`, {
424
- parameters,
429
+ parameters: effectiveParameters,
425
430
  });
426
431
  // Create circuit breaker for tool execution
427
432
  const effectiveTimeout = options.timeout || DEFAULT_TOOL_TIMEOUT;
@@ -448,16 +453,22 @@ export class ToolDiscoveryService extends EventEmitter {
448
453
  "gen_ai.tool.name": toolName,
449
454
  "gen_ai.request": safeJsonStringify({
450
455
  name: toolName,
451
- arguments: redactForPreview(parameters),
456
+ arguments: redactForPreview(effectiveParameters),
452
457
  }, 2048),
453
458
  },
454
459
  }, async (callSpan) => {
455
460
  try {
456
461
  const timeout = effectiveTimeout;
462
+ // Pass the timeout as MCP RequestOptions too: without it the
463
+ // SDK applies its own DEFAULT_REQUEST_TIMEOUT_MSEC (60s), so a
464
+ // configured server timeout above 60s never took effect — the
465
+ // SDK aborted first. The SDK timeout also cancels the transport
466
+ // request and sends a cancellation notification, which the
467
+ // outer Promise.race below (kept as a backstop) cannot do.
457
468
  const callResult = await withTimeout(client.callTool({
458
469
  name: toolName,
459
- arguments: parameters,
460
- }), timeout, new Error(`Tool execution timeout: ${toolName}`));
470
+ arguments: effectiveParameters,
471
+ }, undefined, { timeout: effectiveTimeout }), timeout, new Error(`Tool execution timeout: ${toolName}`));
461
472
  // Curator P0-1/P0-2: the MCP client does NOT throw on protocol
462
473
  // errors — it returns { isError: true, content: [...] }. Detect
463
474
  // that pattern so the span status reflects reality.
@@ -603,25 +614,45 @@ export class ToolDiscoveryService extends EventEmitter {
603
614
  */
604
615
  validateToolParameters(toolInfo, parameters) {
605
616
  if (!toolInfo.inputSchema) {
606
- return; // No schema to validate against
617
+ return parameters; // No schema to validate against
607
618
  }
608
- // Basic validation - check required properties
609
619
  const schema = toolInfo.inputSchema;
610
- if (schema.required && Array.isArray(schema.required)) {
611
- for (const requiredProp of schema.required) {
612
- if (typeof requiredProp === "string" && !(requiredProp in parameters)) {
613
- throw new Error(`Missing required parameter: ${requiredProp}`);
614
- }
615
- }
616
- }
617
- // Type validation for properties
618
- if (schema.properties) {
619
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
620
- if (propName in parameters) {
621
- this.validateParameterType(propName, parameters[propName], propSchema);
620
+ const properties = schema.properties && typeof schema.properties === "object"
621
+ ? schema.properties
622
+ : {};
623
+ const requiredProps = Array.isArray(schema.required)
624
+ ? schema.required.filter((r) => typeof r === "string")
625
+ : [];
626
+ // The thrown message is fed back to the MODEL as the tool result, so it
627
+ // restates the full contract — a bare "missing X" made weaker models
628
+ // guess again and burn another loop step per attempt.
629
+ const contract = () => Object.entries(properties)
630
+ .map(([key, prop]) => `${key}${requiredProps.includes(key) ? "" : "?"}: ${prop.type ?? "any"}`)
631
+ .join(", ");
632
+ // Basic validation - check required properties
633
+ const missing = requiredProps.filter((prop) => !(prop in parameters));
634
+ if (missing.length > 0) {
635
+ throw new Error(`Missing required parameter${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}. ` +
636
+ `Expected arguments: { ${contract()} }; received keys: [${Object.keys(parameters).join(", ")}]`);
637
+ }
638
+ // Type validation for properties — coerce recoverable mismatches first
639
+ // (numeric strings, "true"/"false", JSON-encoded objects/arrays) so a
640
+ // sloppy-but-unambiguous call executes instead of failing back to the
641
+ // model. Only genuinely wrong types still throw.
642
+ let coerced;
643
+ for (const [propName, propSchema] of Object.entries(properties)) {
644
+ if (propName in parameters) {
645
+ const originalValue = parameters[propName];
646
+ const coercedValue = coerceType(originalValue, propSchema);
647
+ if (coercedValue !== originalValue) {
648
+ mcpLogger.debug(`[ToolDiscoveryService] Coerced parameter '${propName}' for tool '${toolInfo.name}': ${typeof originalValue} → ${typeof coercedValue}`);
649
+ coerced = coerced ?? { ...parameters };
650
+ coerced[propName] = coercedValue;
622
651
  }
652
+ this.validateParameterType(propName, (coerced ? coerced[propName] : originalValue), propSchema);
623
653
  }
624
654
  }
655
+ return coerced ?? parameters;
625
656
  }
626
657
  /**
627
658
  * Validate parameter type
@@ -643,6 +674,14 @@ export class ToolDiscoveryService extends EventEmitter {
643
674
  throw new Error(`Parameter '${name}' must be a number, got ${actualType}`);
644
675
  }
645
676
  break;
677
+ case "integer":
678
+ // coerceType treats "integer" as distinct from "number"; without
679
+ // this case an uncoercible value ("3.7", "abc") passed through to
680
+ // the MCP server unvalidated.
681
+ if (actualType !== "number" || !Number.isInteger(value)) {
682
+ throw new Error(`Parameter '${name}' must be an integer, got ${actualType === "number" ? String(value) : actualType}`);
683
+ }
684
+ break;
646
685
  case "boolean":
647
686
  if (actualType !== "boolean") {
648
687
  throw new Error(`Parameter '${name}' must be a boolean, got ${actualType}`);