@juspay/neurolink 10.3.0 → 10.4.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 (127) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +430 -417
  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 +17 -3
  9. package/dist/context/stepBudgetGuard.js +53 -14
  10. package/dist/core/baseProvider.d.ts +12 -0
  11. package/dist/core/baseProvider.js +18 -0
  12. package/dist/core/modules/GenerationHandler.js +208 -42
  13. package/dist/core/modules/ToolsManager.d.ts +17 -0
  14. package/dist/core/modules/ToolsManager.js +99 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +3 -0
  17. package/dist/knowledge/context.d.ts +18 -0
  18. package/dist/knowledge/context.js +91 -0
  19. package/dist/knowledge/defaults.d.ts +24 -0
  20. package/dist/knowledge/defaults.js +29 -0
  21. package/dist/knowledge/engine.d.ts +35 -0
  22. package/dist/knowledge/engine.js +179 -0
  23. package/dist/knowledge/index.d.ts +15 -0
  24. package/dist/knowledge/index.js +15 -0
  25. package/dist/knowledge/indexCache.d.ts +19 -0
  26. package/dist/knowledge/indexCache.js +109 -0
  27. package/dist/knowledge/knowledgeIndex.d.ts +41 -0
  28. package/dist/knowledge/knowledgeIndex.js +204 -0
  29. package/dist/knowledge/normalize.d.ts +32 -0
  30. package/dist/knowledge/normalize.js +74 -0
  31. package/dist/knowledge/resolve.d.ts +18 -0
  32. package/dist/knowledge/resolve.js +156 -0
  33. package/dist/knowledge/retrieval.d.ts +16 -0
  34. package/dist/knowledge/retrieval.js +221 -0
  35. package/dist/lib/constants/contextWindows.d.ts +24 -0
  36. package/dist/lib/constants/contextWindows.js +50 -0
  37. package/dist/lib/context/errorDetection.d.ts +6 -0
  38. package/dist/lib/context/errorDetection.js +18 -0
  39. package/dist/lib/context/stepBudgetGuard.d.ts +17 -3
  40. package/dist/lib/context/stepBudgetGuard.js +53 -14
  41. package/dist/lib/core/baseProvider.d.ts +12 -0
  42. package/dist/lib/core/baseProvider.js +18 -0
  43. package/dist/lib/core/modules/GenerationHandler.js +208 -42
  44. package/dist/lib/core/modules/ToolsManager.d.ts +17 -0
  45. package/dist/lib/core/modules/ToolsManager.js +99 -1
  46. package/dist/lib/files/fileTools.d.ts +1 -1
  47. package/dist/lib/index.d.ts +1 -0
  48. package/dist/lib/index.js +3 -0
  49. package/dist/lib/knowledge/context.d.ts +18 -0
  50. package/dist/lib/knowledge/context.js +92 -0
  51. package/dist/lib/knowledge/defaults.d.ts +24 -0
  52. package/dist/lib/knowledge/defaults.js +30 -0
  53. package/dist/lib/knowledge/engine.d.ts +35 -0
  54. package/dist/lib/knowledge/engine.js +180 -0
  55. package/dist/lib/knowledge/index.d.ts +15 -0
  56. package/dist/lib/knowledge/index.js +16 -0
  57. package/dist/lib/knowledge/indexCache.d.ts +19 -0
  58. package/dist/lib/knowledge/indexCache.js +110 -0
  59. package/dist/lib/knowledge/knowledgeIndex.d.ts +41 -0
  60. package/dist/lib/knowledge/knowledgeIndex.js +205 -0
  61. package/dist/lib/knowledge/normalize.d.ts +32 -0
  62. package/dist/lib/knowledge/normalize.js +75 -0
  63. package/dist/lib/knowledge/resolve.d.ts +18 -0
  64. package/dist/lib/knowledge/resolve.js +157 -0
  65. package/dist/lib/knowledge/retrieval.d.ts +16 -0
  66. package/dist/lib/knowledge/retrieval.js +222 -0
  67. package/dist/lib/mcp/toolDiscoveryService.js +59 -20
  68. package/dist/lib/neurolink.d.ts +14 -1
  69. package/dist/lib/neurolink.js +142 -12
  70. package/dist/lib/providers/litellm.d.ts +27 -19
  71. package/dist/lib/providers/litellm.js +171 -92
  72. package/dist/lib/providers/openaiChatCompletionsBase.d.ts +37 -1
  73. package/dist/lib/providers/openaiChatCompletionsBase.js +201 -33
  74. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +23 -5
  75. package/dist/lib/providers/openaiChatCompletionsClient.js +94 -14
  76. package/dist/lib/proxy/proxyFetch.d.ts +17 -0
  77. package/dist/lib/proxy/proxyFetch.js +42 -4
  78. package/dist/lib/types/config.d.ts +12 -0
  79. package/dist/lib/types/conversation.d.ts +1 -1
  80. package/dist/lib/types/dynamic.d.ts +12 -0
  81. package/dist/lib/types/generate.d.ts +32 -2
  82. package/dist/lib/types/index.d.ts +1 -0
  83. package/dist/lib/types/index.js +1 -0
  84. package/dist/lib/types/knowledge.d.ts +342 -0
  85. package/dist/lib/types/knowledge.js +30 -0
  86. package/dist/lib/types/openaiCompatible.d.ts +2 -0
  87. package/dist/lib/types/providers.d.ts +9 -0
  88. package/dist/lib/types/stream.d.ts +14 -0
  89. package/dist/lib/utils/errorHandling.js +8 -2
  90. package/dist/lib/utils/schemaConversion.d.ts +16 -0
  91. package/dist/lib/utils/schemaConversion.js +165 -8
  92. package/dist/lib/utils/timeout.d.ts +22 -0
  93. package/dist/lib/utils/timeout.js +72 -12
  94. package/dist/lib/utils/tokenLimits.js +22 -0
  95. package/dist/lib/utils/toolCallRepair.d.ts +8 -0
  96. package/dist/lib/utils/toolCallRepair.js +4 -1
  97. package/dist/mcp/toolDiscoveryService.js +59 -20
  98. package/dist/neurolink.d.ts +14 -1
  99. package/dist/neurolink.js +142 -12
  100. package/dist/providers/litellm.d.ts +27 -19
  101. package/dist/providers/litellm.js +171 -92
  102. package/dist/providers/openaiChatCompletionsBase.d.ts +37 -1
  103. package/dist/providers/openaiChatCompletionsBase.js +201 -33
  104. package/dist/providers/openaiChatCompletionsClient.d.ts +23 -5
  105. package/dist/providers/openaiChatCompletionsClient.js +94 -14
  106. package/dist/proxy/proxyFetch.d.ts +17 -0
  107. package/dist/proxy/proxyFetch.js +42 -4
  108. package/dist/types/config.d.ts +12 -0
  109. package/dist/types/conversation.d.ts +1 -1
  110. package/dist/types/dynamic.d.ts +12 -0
  111. package/dist/types/generate.d.ts +32 -2
  112. package/dist/types/index.d.ts +1 -0
  113. package/dist/types/index.js +1 -0
  114. package/dist/types/knowledge.d.ts +342 -0
  115. package/dist/types/knowledge.js +29 -0
  116. package/dist/types/openaiCompatible.d.ts +2 -0
  117. package/dist/types/providers.d.ts +9 -0
  118. package/dist/types/stream.d.ts +14 -0
  119. package/dist/utils/errorHandling.js +8 -2
  120. package/dist/utils/schemaConversion.d.ts +16 -0
  121. package/dist/utils/schemaConversion.js +165 -8
  122. package/dist/utils/timeout.d.ts +22 -0
  123. package/dist/utils/timeout.js +72 -12
  124. package/dist/utils/tokenLimits.js +22 -0
  125. package/dist/utils/toolCallRepair.d.ts +8 -0
  126. package/dist/utils/toolCallRepair.js +4 -1
  127. package/package.json +4 -1
@@ -37,6 +37,13 @@ import { generateToolOutputPreview } from "./toolOutputLimits.js";
37
37
  import { logger } from "../utils/logger.js";
38
38
  /** Estimated tokens for a tool definition that fails to serialize. */
39
39
  const TOKENS_PER_TOOL_DEFINITION = 200;
40
+ /**
41
+ * Upper bound on the usage-feedback calibration ratio. Real tokenizers count
42
+ * dense code/diff content at up to ~1.3× the char-based estimate; anything
43
+ * far beyond that indicates inconsistent provider usage reporting, and an
44
+ * unbounded ratio would compact the loop into uselessness.
45
+ */
46
+ const MAX_CALIBRATION_RATIO = 3;
40
47
  /** Messages at the end of the conversation the guard never modifies. */
41
48
  const PROTECTED_TAIL_MESSAGES = 4;
42
49
  /** Stage-1 preview budget for an old tool output (bytes). */
@@ -230,19 +237,45 @@ function dropOldestToolExchanges(messages, budgetTokens, fixedOverheadTokens, pr
230
237
  }
231
238
  /**
232
239
  * Create a per-step budget guard. Returns a function that, given the step's
233
- * messages, returns a compacted replacement array when the projected request
234
- * exceeds the threshold or `undefined` when no change is needed.
240
+ * messages (and optionally the REAL input-token count the provider reported
241
+ * for the previous step), returns a compacted replacement array when the
242
+ * projected request exceeds the threshold — or `undefined` when no change is
243
+ * needed.
244
+ *
245
+ * Two dynamic behaviours:
246
+ * - the available-input budget is re-resolved on EVERY invocation, so
247
+ * runtime window discovery (`/model/info`, overflow self-healing) that
248
+ * lands mid-loop takes effect immediately instead of the guard staying
249
+ * frozen on the value captured at loop start;
250
+ * - usage feedback calibrates the estimator: the ratio between the real
251
+ * prompt tokens of the previous step and this guard's own estimate for
252
+ * what that step sent scales later estimates (only UP — underestimates
253
+ * overflow, overestimates merely compact earlier), eliminating the
254
+ * char-based estimator's drift on dense code/diff content without
255
+ * shipping a tokenizer.
235
256
  */
236
257
  export function createStepBudgetGuard(config) {
237
258
  const { provider, model, maxTokens, fixedOverheadTokens = 0, getFixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, } = config;
238
- const availableInput = getAvailableInputTokens(provider, model, maxTokens);
239
- const thresholdTokens = Math.floor(availableInput * thresholdRatio);
240
- return function guardStepMessages(messages) {
259
+ // Calibration state: raw estimate for the messages the PREVIOUS guard
260
+ // invocation let through (what was actually sent), and the current ratio.
261
+ let lastRawEstimate = 0;
262
+ let calibration = 1;
263
+ return function guardStepMessages(messages, observedInputTokensLastStep) {
264
+ const availableInput = getAvailableInputTokens(provider, model, maxTokens);
265
+ const thresholdTokens = Math.floor(availableInput * thresholdRatio);
241
266
  // Resolve overhead per invocation: the tool set can GROW mid-loop
242
267
  // (search_tools hydration adds discovered tools between steps), so a
243
268
  // once-captured value would undercount later steps.
244
269
  const overheadTokens = getFixedOverheadTokens?.() ?? fixedOverheadTokens;
245
- const estimate = overheadTokens + estimateStepMessagesTokens(messages, provider);
270
+ const rawEstimate = overheadTokens + estimateStepMessagesTokens(messages, provider);
271
+ if (observedInputTokensLastStep !== undefined &&
272
+ observedInputTokensLastStep > 0 &&
273
+ lastRawEstimate > 0) {
274
+ calibration = Math.min(MAX_CALIBRATION_RATIO, Math.max(1, observedInputTokensLastStep / lastRawEstimate));
275
+ }
276
+ // Apply calibration to the THRESHOLD instead of every estimate so the
277
+ // compaction stages keep operating on raw numbers.
278
+ const effectiveThreshold = Math.floor(thresholdTokens / calibration);
246
279
  // Logger Guard: per-step diagnostics for debugging why a long run does
247
280
  // (or does not) trigger compaction — gated so nothing is serialized when
248
281
  // debug logging is off.
@@ -251,12 +284,15 @@ export function createStepBudgetGuard(config) {
251
284
  provider,
252
285
  model,
253
286
  messageCount: messages.length,
254
- estimatedTokens: estimate,
255
- thresholdTokens,
256
- willCompact: estimate > thresholdTokens,
287
+ estimatedTokens: rawEstimate,
288
+ thresholdTokens: effectiveThreshold,
289
+ calibration,
290
+ observedInputTokensLastStep,
291
+ willCompact: rawEstimate > effectiveThreshold,
257
292
  });
258
293
  }
259
- if (estimate <= thresholdTokens) {
294
+ if (rawEstimate <= effectiveThreshold) {
295
+ lastRawEstimate = rawEstimate;
260
296
  return undefined;
261
297
  }
262
298
  // Stage 1: shrink old tool outputs to previews.
@@ -265,25 +301,28 @@ export function createStepBudgetGuard(config) {
265
301
  let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
266
302
  // Stage 2: drop oldest complete tool exchanges if still over.
267
303
  let droppedExchanges = 0;
268
- if (newEstimate > thresholdTokens) {
269
- const stage2 = dropOldestToolExchanges(compacted, thresholdTokens, overheadTokens, provider);
304
+ if (newEstimate > effectiveThreshold) {
305
+ const stage2 = dropOldestToolExchanges(compacted, effectiveThreshold, overheadTokens, provider);
270
306
  compacted = stage2.messages;
271
307
  droppedExchanges = stage2.droppedExchanges;
272
308
  newEstimate =
273
309
  overheadTokens + estimateStepMessagesTokens(compacted, provider);
274
310
  }
275
311
  if (stage1.truncated === 0 && droppedExchanges === 0) {
312
+ lastRawEstimate = rawEstimate;
276
313
  return undefined; // nothing actionable (already all-protected)
277
314
  }
278
315
  logger.info("[StepBudgetGuard] Compacted agent-loop step messages", {
279
316
  provider,
280
317
  model,
281
- estimatedTokens: estimate,
282
- thresholdTokens,
318
+ estimatedTokens: rawEstimate,
319
+ thresholdTokens: effectiveThreshold,
320
+ calibration,
283
321
  afterTokens: newEstimate,
284
322
  toolOutputsTruncated: stage1.truncated,
285
323
  exchangesDropped: droppedExchanges,
286
324
  });
325
+ lastRawEstimate = newEstimate;
287
326
  return compacted;
288
327
  };
289
328
  }
@@ -185,6 +185,18 @@ export declare abstract class BaseProvider implements AIProvider {
185
185
  * IMPLEMENTATION NOTE: Uses streamText() under the hood and accumulates results
186
186
  * for consistency and better performance
187
187
  */
188
+ /**
189
+ * Ensure runtime-discovered model limits (context window, output-token
190
+ * ceiling) are registered before any budget math runs. Generation
191
+ * pipelines await this BEFORE `checkContextBudget`, and generate()/stream()
192
+ * await it before options normalization so `getSafeMaxTokens` sees the
193
+ * discovered output ceiling.
194
+ *
195
+ * Default no-op. Providers with a runtime discovery source override it
196
+ * (e.g. LiteLLM's `/model/info`). Implementations must NEVER reject —
197
+ * discovery failure degrades to the static defaults.
198
+ */
199
+ ensureModelLimits(): Promise<void>;
188
200
  generate(optionsOrPrompt: TextGenerationOptions | string, _analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
189
201
  /**
190
202
  * Alias for generate method - implements AIProvider interface
@@ -118,6 +118,9 @@ export class BaseProvider {
118
118
  * When tools are involved, falls back to generate() with synthetic streaming
119
119
  */
120
120
  async stream(optionsOrPrompt, analysisSchema) {
121
+ // Runtime model limits must land before normalizeStreamOptions resolves
122
+ // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
123
+ await this.ensureModelLimits();
121
124
  let options = this.normalizeStreamOptions(optionsOrPrompt);
122
125
  logger.info(`Starting stream`, {
123
126
  provider: this.providerName,
@@ -855,7 +858,22 @@ export class BaseProvider {
855
858
  * IMPLEMENTATION NOTE: Uses streamText() under the hood and accumulates results
856
859
  * for consistency and better performance
857
860
  */
861
+ /**
862
+ * Ensure runtime-discovered model limits (context window, output-token
863
+ * ceiling) are registered before any budget math runs. Generation
864
+ * pipelines await this BEFORE `checkContextBudget`, and generate()/stream()
865
+ * await it before options normalization so `getSafeMaxTokens` sees the
866
+ * discovered output ceiling.
867
+ *
868
+ * Default no-op. Providers with a runtime discovery source override it
869
+ * (e.g. LiteLLM's `/model/info`). Implementations must NEVER reject —
870
+ * discovery failure degrades to the static defaults.
871
+ */
872
+ async ensureModelLimits() { }
858
873
  async generate(optionsOrPrompt, _analysisSchema) {
874
+ // Runtime model limits must land before normalizeTextOptions resolves
875
+ // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
876
+ await this.ensureModelLimits();
859
877
  const options = this.normalizeTextOptions(optionsOrPrompt);
860
878
  this.validateOptions(options);
861
879
  const startTime = Date.now();
@@ -19,8 +19,9 @@ 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";
24
25
  import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
25
26
  import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
26
27
  import { coerceJsonToSchema } from "../../utils/json/coerce.js";
@@ -45,6 +46,94 @@ function safePreview(v) {
45
46
  return "[unserializable]";
46
47
  }
47
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
+ }
48
137
  /**
49
138
  * GenerationHandler class - Handles text generation operations for AI providers
50
139
  */
@@ -67,7 +156,8 @@ export class GenerationHandler {
67
156
  * Helper method to call generateText with optional structured output
68
157
  * @private
69
158
  */
70
- async callGenerateText(model, messages, tools, options, shouldUseTools, includeStructuredOutput) {
159
+ async callGenerateText(model, messages, tools, options, callConfig) {
160
+ const { shouldUseTools, includeStructuredOutput, turnStartMs } = callConfig;
71
161
  // Check if this is a Google provider (for provider-specific options)
72
162
  const isGoogleProvider = this.providerName === "google-ai" || this.providerName === "vertex";
73
163
  // Check if this is an Anthropic provider (includes Vertex+Claude)
@@ -113,6 +203,9 @@ export class GenerationHandler {
113
203
  }
114
204
  }
115
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);
116
209
  // Hoist system-role messages into generateText's top-level `system` option
117
210
  // rather than passing them inside `messages` (deprecated by the AI SDK,
118
211
  // rejected in v7). See extractSystemMessages for the rationale. (#1024)
@@ -148,7 +241,7 @@ export class GenerationHandler {
148
241
  return cachedOverhead.tokens;
149
242
  },
150
243
  });
151
- return await generateText({
244
+ const result = await generateText({
152
245
  model,
153
246
  ...(system && { system }),
154
247
  messages: nonSystemMessages,
@@ -173,52 +266,72 @@ export class GenerationHandler {
173
266
  // the step's own messages. It never replaces a caller's content
174
267
  // choices — it only reclaims budget from whatever was chosen.
175
268
  const callerMessages = callerResult?.messages;
176
- const compacted = stepBudgetGuard(callerMessages ?? stepOptions.messages);
177
- if (!compacted) {
178
- return callerResult;
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);
179
293
  }
180
- return { ...(callerResult ?? {}), messages: compacted };
294
+ return prepared;
181
295
  }),
182
296
  temperature: options.temperature,
183
297
  maxOutputTokens: options.maxTokens,
184
298
  maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
185
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 }),
186
321
  ...(useStructuredOutput &&
187
322
  options.schema && {
188
323
  experimental_output: Output.object({ schema: options.schema }),
189
324
  }),
190
- // Add thinking configuration for extended reasoning
191
- // Gemini 3 models use providerOptions.google.thinkingConfig with thinkingLevel
192
- // Gemini 2.5 models use thinkingBudget
193
- // Anthropic models use experimental_thinking with budgetTokens
194
- ...(options.thinkingConfig?.enabled && {
195
- // For Anthropic: experimental_thinking with budgetTokens
196
- ...(isAnthropicProvider &&
197
- options.thinkingConfig.budgetTokens &&
198
- !options.thinkingConfig.thinkingLevel && {
199
- experimental_thinking: {
200
- type: "enabled",
201
- budgetTokens: options.thinkingConfig.budgetTokens,
202
- },
203
- }),
204
- // For Google Gemini 3: providerOptions with thinkingLevel
205
- // For Gemini 2.5: providerOptions with thinkingBudget
206
- ...(isGoogleProvider && {
207
- providerOptions: {
208
- google: {
209
- thinkingConfig: {
210
- ...(options.thinkingConfig.thinkingLevel && {
211
- thinkingLevel: options.thinkingConfig.thinkingLevel,
212
- }),
213
- ...(options.thinkingConfig.budgetTokens &&
214
- !options.thinkingConfig.thinkingLevel && {
215
- thinkingBudget: options.thinkingConfig.budgetTokens,
216
- }),
217
- includeThoughts: true,
218
- },
219
- },
220
- },
221
- }),
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
+ },
222
335
  }),
223
336
  experimental_telemetry: this.getTelemetryConfigFn(options, "generate"),
224
337
  onStepFinish: ({ toolCalls, toolResults }) => {
@@ -237,6 +350,16 @@ export class GenerationHandler {
237
350
  });
238
351
  },
239
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;
240
363
  }
241
364
  /**
242
365
  * Execute the generation with AI SDK
@@ -293,7 +416,11 @@ export class GenerationHandler {
293
416
  }
294
417
  const genStartTime = Date.now();
295
418
  try {
296
- 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");
297
424
  logger.info("[GenerationHandler] generateText returned", {
298
425
  requestId,
299
426
  durationMs: Date.now() - genStartTime,
@@ -388,7 +515,12 @@ export class GenerationHandler {
388
515
  }
389
516
  // Retry without experimental_output - the formatEnhancedResult method
390
517
  // will extract JSON from the text response
391
- 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)");
392
524
  // NLK-GAP-007: Record recovery event after successful fallback
393
525
  span.addEvent("retry.recovered", {
394
526
  "retry.attempts": 2,
@@ -439,7 +571,12 @@ export class GenerationHandler {
439
571
  model: this.modelName,
440
572
  error: error instanceof Error ? error.message : String(error),
441
573
  });
442
- 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)");
443
580
  span.addEvent("retry.recovered", {
444
581
  "retry.attempts": 2,
445
582
  "retry.strategy": "temperature_omitted",
@@ -722,11 +859,40 @@ export class GenerationHandler {
722
859
  : String(rawReasoning)
723
860
  : undefined;
724
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
+ }
725
889
  return {
726
890
  content,
727
891
  structuredData,
728
892
  usage,
729
893
  finishReason: generateResult.finishReason,
894
+ stopReason,
895
+ stepsUsed,
730
896
  jsonRepaired: jsonRepaired || undefined,
731
897
  jsonTruncated: jsonTruncated || undefined,
732
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