@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
@@ -72,6 +72,7 @@ import { coerceJsonToSchema } from "./utils/json/coerce.js";
72
72
  // Factory processing imports
73
73
  import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
74
74
  import { logger, mcpLogger } from "./utils/logger.js";
75
+ import { redactUrlCredentials, safeDebugSerialize, sanitizeRecord, stringifyContentSafe, } from "./utils/logSanitize.js";
75
76
  import { extractMcpErrorText } from "./utils/mcpErrorText.js";
76
77
  import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
77
78
  import { resolveModel } from "./utils/modelAliasResolver.js";
@@ -2156,9 +2157,7 @@ Current user's request: ${currentInput}`;
2156
2157
  const anyOptions = optionsOrPrompt;
2157
2158
  if (anyOptions.messages && anyOptions.messages.length > 0) {
2158
2159
  const lastMessage = anyOptions.messages[anyOptions.messages.length - 1];
2159
- return typeof lastMessage.content === "string"
2160
- ? lastMessage.content
2161
- : JSON.stringify(lastMessage.content);
2160
+ return stringifyContentSafe(lastMessage.content);
2162
2161
  }
2163
2162
  // Handle input.text format
2164
2163
  return optionsOrPrompt.input?.text || "";
@@ -3514,6 +3513,10 @@ Current user's request: ${currentInput}`;
3514
3513
  stt: options.stt,
3515
3514
  fileRegistry: this.fileRegistry,
3516
3515
  timeout: options.timeout,
3516
+ turnTimeoutMs: options.turnTimeoutMs,
3517
+ stallTimeoutMs: options.stallTimeoutMs,
3518
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
3519
+ toolTimeoutMs: options.toolTimeoutMs,
3517
3520
  abortSignal: options.abortSignal,
3518
3521
  skipToolPromptInjection: options.skipToolPromptInjection,
3519
3522
  middleware: options.middleware,
@@ -3645,6 +3648,12 @@ Current user's request: ${currentInput}`;
3645
3648
  content: textResult.content,
3646
3649
  structuredData: textResult.structuredData,
3647
3650
  finishReason: textResult.finishReason,
3651
+ // Turn-exit discriminator + raw provider stop reason + step count from
3652
+ // native agentic loops — this DTO is an explicit field list, so new
3653
+ // provider-result fields MUST be copied here or they silently vanish.
3654
+ stopReason: textResult.stopReason,
3655
+ rawFinishReason: textResult.rawFinishReason,
3656
+ stepsUsed: textResult.stepsUsed,
3648
3657
  jsonRepaired: textResult.jsonRepaired,
3649
3658
  jsonTruncated: textResult.jsonTruncated,
3650
3659
  provider: textResult.provider,
@@ -3879,9 +3888,7 @@ Current user's request: ${currentInput}`;
3879
3888
  ?.filter((m) => m.role === "user" || m.role === "assistant")
3880
3889
  .map((m) => ({
3881
3890
  role: m.role,
3882
- content: typeof m.content === "string"
3883
- ? m.content
3884
- : JSON.stringify(m.content),
3891
+ content: stringifyContentSafe(m.content),
3885
3892
  })) ??
3886
3893
  options.conversationHistory,
3887
3894
  timeout: options.timeout,
@@ -3978,9 +3985,7 @@ Current user's request: ${currentInput}`;
3978
3985
  ?.filter((m) => m.role === "user" || m.role === "assistant")
3979
3986
  .map((m) => ({
3980
3987
  role: m.role,
3981
- content: typeof m.content === "string"
3982
- ? m.content
3983
- : JSON.stringify(m.content),
3988
+ content: stringifyContentSafe(m.content),
3984
3989
  })) ??
3985
3990
  options.conversationHistory,
3986
3991
  timeout: options.timeout,
@@ -4603,6 +4608,11 @@ Current user's request: ${currentInput}`;
4603
4608
  */
4604
4609
  async performMCPGenerationRetries(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag) {
4605
4610
  const maxMcpRetries = RETRY_ATTEMPTS.QUICK;
4611
+ // Captured BEFORE the first attempt: ensureMCPGenerationBudget (inside
4612
+ // tryMCPGeneration) auto-scales options.timeout for >100K-token contexts
4613
+ // when the caller left it unset, so checking options.timeout inside the
4614
+ // catch below could not tell caller-set apart from auto-scaled.
4615
+ const callerSetTimeout = options.timeout !== undefined;
4606
4616
  // NL-007: Track retry metadata for observability
4607
4617
  const retryErrors = [];
4608
4618
  let retryCount = 0;
@@ -4645,6 +4655,20 @@ Current user's request: ${currentInput}`;
4645
4655
  logger.debug(`[${functionTag}] AbortError detected on attempt ${attempt}, stopping retries`);
4646
4656
  throw error;
4647
4657
  }
4658
+ // A TimeoutError against an explicit caller-set options.timeout is
4659
+ // deterministic-by-construction: every remaining retry AND the
4660
+ // direct-generation fallback would re-run the same provider+model
4661
+ // under the same per-step budget. Observed in production as ~650s
4662
+ // of doomed follow-ups after a 90s step timeout. Surface it now.
4663
+ // Name check (not instanceof): two TimeoutError classes exist
4664
+ // (utils/timeout.ts and utils/async/withTimeout.ts) and both stamp
4665
+ // name = "TimeoutError".
4666
+ if (callerSetTimeout &&
4667
+ error instanceof Error &&
4668
+ error.name === "TimeoutError") {
4669
+ logger.warn(`[${functionTag}] Per-step timeout (${String(options.timeout)}) exhausted on attempt ${attempt} — surfacing instead of retrying with the same budget`);
4670
+ throw error;
4671
+ }
4648
4672
  // NL-007: Record retry error for observability
4649
4673
  retryCount++;
4650
4674
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -5049,6 +5073,9 @@ Current user's request: ${currentInput}`;
5049
5073
  usage: result.usage,
5050
5074
  responseTime,
5051
5075
  finishReason: result.finishReason,
5076
+ stopReason: result.stopReason,
5077
+ rawFinishReason: result.rawFinishReason,
5078
+ stepsUsed: result.stepsUsed,
5052
5079
  toolsUsed: result.toolsUsed || [],
5053
5080
  toolExecutions: transformedToolExecutions,
5054
5081
  enhancedWithTools: Boolean(hasToolExecutions),
@@ -5184,6 +5211,9 @@ Current user's request: ${currentInput}`;
5184
5211
  usage: poolResult.usage,
5185
5212
  responseTime,
5186
5213
  finishReason: poolResult.finishReason,
5214
+ stopReason: poolResult.stopReason,
5215
+ rawFinishReason: poolResult.rawFinishReason,
5216
+ stepsUsed: poolResult.stepsUsed,
5187
5217
  toolsUsed: poolResult.toolsUsed || [],
5188
5218
  toolExecutions: poolResult.toolExecutions?.map((te) => {
5189
5219
  const t = te;
@@ -5460,6 +5490,9 @@ Current user's request: ${currentInput}`;
5460
5490
  usage: result.usage,
5461
5491
  responseTime,
5462
5492
  finishReason: result.finishReason,
5493
+ stopReason: result.stopReason,
5494
+ rawFinishReason: result.rawFinishReason,
5495
+ stepsUsed: result.stepsUsed,
5463
5496
  toolsUsed: result.toolsUsed || [],
5464
5497
  // Map toolExecutions from EnhancedGenerateResult shape ({name,input,output})
5465
5498
  // to TextGenerationResult shape ({toolName,executionTime,success}).
@@ -6457,7 +6490,7 @@ Current user's request: ${currentInput}`;
6457
6490
  factoryResult,
6458
6491
  sessionId: enhancedOptions.context?.sessionId,
6459
6492
  });
6460
- const { stream: mcpStream, provider: providerName, usage: streamUsage, model: streamModel, finishReason: streamFinishReason, toolCalls: streamToolCalls, toolResults: streamToolResults, analytics: streamAnalytics, } = await this.createMCPStream(enhancedOptions);
6493
+ const { stream: mcpStream, provider: providerName, usage: streamUsage, model: streamModel, finishReason: streamFinishReason, toolCalls: streamToolCalls, toolResults: streamToolResults, analytics: streamAnalytics, metadata: providerStreamMetadata, } = await this.createMCPStream(enhancedOptions);
6461
6494
  const streamState = {
6462
6495
  finishReason: streamFinishReason ?? "stop",
6463
6496
  toolCalls: streamToolCalls,
@@ -6804,6 +6837,7 @@ Current user's request: ${currentInput}`;
6804
6837
  guardrailsBlocked: metadata.guardrailsBlocked,
6805
6838
  error: metadata.error,
6806
6839
  events: eventSequence,
6840
+ providerMetadata: providerStreamMetadata,
6807
6841
  });
6808
6842
  }
6809
6843
  catch (error) {
@@ -7220,11 +7254,18 @@ Current user's request: ${currentInput}`;
7220
7254
  */
7221
7255
  async storeStreamConversationMemory(params) {
7222
7256
  const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, } = params;
7223
- logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
7224
- options: JSON.stringify(enhancedOptions),
7225
- sessionId: enhancedOptions.context
7226
- ?.sessionId,
7227
- });
7257
+ // Logger Guard: the full options object (history + tool outputs) can be
7258
+ // enormous — an eager JSON.stringify here threw RangeError: Invalid
7259
+ // string length in production and killed the turn. Serialize lazily,
7260
+ // bounded, and only when debug is actually enabled; sanitizeRecord
7261
+ // redacts credentials/PII keys before anything reaches the sink.
7262
+ if (logger.shouldLog("debug")) {
7263
+ logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
7264
+ options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
7265
+ sessionId: enhancedOptions.context
7266
+ ?.sessionId,
7267
+ });
7268
+ }
7228
7269
  // Guard: skip storing if no meaningful content was produced (no text AND no tool activity)
7229
7270
  const hasToolEvents = eventSequence.some((e) => e.type === "tool:start" || e.type === "tool:end");
7230
7271
  if (!accumulatedContent.trim() && !hasToolEvents) {
@@ -7234,12 +7275,16 @@ Current user's request: ${currentInput}`;
7234
7275
  });
7235
7276
  return;
7236
7277
  }
7237
- logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
7238
- options: JSON.stringify(enhancedOptions),
7239
- sessionId: enhancedOptions.context
7240
- ?.sessionId,
7241
- conversationMemoryExists: this.conversationMemory ? true : false,
7242
- });
7278
+ // Logger Guard: see the matching block above — never eagerly stringify
7279
+ // the full options object, and always redact before serializing.
7280
+ if (logger.shouldLog("debug")) {
7281
+ logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
7282
+ options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
7283
+ sessionId: enhancedOptions.context
7284
+ ?.sessionId,
7285
+ conversationMemoryExists: this.conversationMemory ? true : false,
7286
+ });
7287
+ }
7243
7288
  // Store memory after stream consumption is complete
7244
7289
  if (this.conversationMemory && enhancedOptions.context?.sessionId) {
7245
7290
  const sessionId = enhancedOptions.context
@@ -7587,6 +7632,7 @@ Current user's request: ${currentInput}`;
7587
7632
  toolCalls: poolStreamResult.toolCalls ?? [],
7588
7633
  toolResults: poolStreamResult.toolResults ?? [],
7589
7634
  analytics: poolStreamResult.analytics,
7635
+ metadata: poolStreamResult.metadata,
7590
7636
  };
7591
7637
  }
7592
7638
  catch (poolStreamError) {
@@ -7640,6 +7686,12 @@ Current user's request: ${currentInput}`;
7640
7686
  toolCalls: streamResult.toolCalls ?? [],
7641
7687
  toolResults: streamResult.toolResults ?? [],
7642
7688
  analytics: streamResult.analytics,
7689
+ // Pass the provider's metadata object THROUGH BY REFERENCE: native
7690
+ // background-loop streams (Vertex Gemini/Claude) resolve
7691
+ // finishReason/stopReason/rawFinishReason/stepsUsed onto it only when
7692
+ // the loop finishes — snapshotting fields here would freeze them as
7693
+ // undefined before the stream is drained.
7694
+ metadata: streamResult.metadata,
7643
7695
  };
7644
7696
  }
7645
7697
  /**
@@ -7682,14 +7734,14 @@ Current user's request: ${currentInput}`;
7682
7734
  analytics: streamResult.analytics,
7683
7735
  evaluation: streamResult.evaluation,
7684
7736
  events: config.events && config.events.length > 0 ? config.events : undefined,
7685
- metadata: {
7737
+ metadata: Object.assign(config.providerMetadata ?? {}, {
7686
7738
  streamId: config.streamId,
7687
7739
  startTime: config.startTime,
7688
7740
  responseTime: config.responseTime,
7689
7741
  fallback: config.fallback || false,
7690
7742
  guardrailsBlocked: config.guardrailsBlocked,
7691
7743
  error: config.error,
7692
- },
7744
+ }),
7693
7745
  };
7694
7746
  }
7695
7747
  /**
@@ -8703,11 +8755,7 @@ Current user's request: ${currentInput}`;
8703
8755
  : this.getCustomTools().has(toolName)
8704
8756
  ? "custom"
8705
8757
  : "external";
8706
- const inputStr = typeof params === "string"
8707
- ? params
8708
- : params
8709
- ? JSON.stringify(params)
8710
- : "";
8758
+ const inputStr = params ? stringifyContentSafe(params) : "";
8711
8759
  const executionStartTime = Date.now();
8712
8760
  // Per-invocation id so consumers can correlate a tool:start with its matching
8713
8761
  // tool:end even when the same tool runs multiple times concurrently.
@@ -10271,8 +10319,15 @@ Current user's request: ${currentInput}`;
10271
10319
  });
10272
10320
  }
10273
10321
  else {
10322
+ // Single ERROR record for a failed registration — the inner layers
10323
+ // (client factory, server manager) log at debug so one root cause no
10324
+ // longer fans out into 5 ERROR lines. Carry enough context here to
10325
+ // diagnose without the inner lines.
10274
10326
  mcpLogger.error(`[NeuroLink] Failed to add external MCP server: ${serverId}`, {
10275
10327
  error: result.error,
10328
+ transport: config.transport,
10329
+ command: config.command,
10330
+ url: config.url ? redactUrlCredentials(config.url) : undefined,
10276
10331
  });
10277
10332
  }
10278
10333
  return result;
@@ -10304,6 +10359,11 @@ Current user's request: ${currentInput}`;
10304
10359
  timestamp: Date.now(),
10305
10360
  });
10306
10361
  }
10362
+ else if (result.error?.includes("not found")) {
10363
+ // Expected no-op: consumers commonly call remove as cleanup after a
10364
+ // failed add, when the server never registered. Not an error.
10365
+ mcpLogger.debug(`[NeuroLink] Remove skipped — external MCP server not registered: ${serverId}`);
10366
+ }
10307
10367
  else {
10308
10368
  mcpLogger.error(`[NeuroLink] Failed to remove external MCP server: ${serverId}`, {
10309
10369
  error: result.error,
@@ -8,7 +8,7 @@
8
8
  * This module extracts the functions that are duplicated between the two
9
9
  * providers so they can share a single implementation.
10
10
  */
11
- import type { ThinkingConfig, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, TextChannel, VertexNativePart, GeminiMultimodalInput } from "../types/index.js";
11
+ import type { GenerateStopReason, ThinkingConfig, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, TextChannel, VertexNativePart, GeminiMultimodalInput } from "../types/index.js";
12
12
  import type { Tool } from "../types/index.js";
13
13
  export declare function sanitizeForGoogleFunctionName(name: string): string;
14
14
  /**
@@ -109,8 +109,14 @@ export declare function computeMaxSteps(rawMaxSteps?: number): number;
109
109
  * Returns a plain string (not a `{ unified, raw }` object): the Vertex result
110
110
  * builders and the consuming layer (neurolink.ts `finishReason || "unknown"`
111
111
  * and `finishReason === "length"`) compare against plain strings.
112
+ *
113
+ * MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL map to "error", NOT
114
+ * "tool-calls": they are provider/model failures, while "tool-calls" is the
115
+ * exclusive contract for "step budget exhausted while the model still wanted
116
+ * tools" — consumers (e.g. curator's step-cap intercept) branch on it and
117
+ * were rendering fake step-limit messages for 2-4-step malformed-call turns.
112
118
  */
113
- export declare function mapGeminiFinishReason(raw: string | null | undefined): "stop" | "length" | "tool-calls" | "content-filter";
119
+ export declare function mapGeminiFinishReason(raw: string | null | undefined): "stop" | "length" | "tool-calls" | "content-filter" | "error";
114
120
  /**
115
121
  * Append a step's text to a running cross-step accumulator, ignoring empty
116
122
  * steps and inserting a single newline between non-empty contributions.
@@ -215,10 +221,92 @@ export declare function handleMaxStepsTermination(logLabel: string, step: number
215
221
  export declare function isAbortError(error: unknown): boolean;
216
222
  /**
217
223
  * Build a graceful, user-facing message for when a single agentic turn hits
218
- * the step cap (or is aborted mid-turn) without producing a final answer.
219
- * Replaces the legacy bracketed "Tool execution limit reached" placeholder.
224
+ * the step cap without producing a final answer. Replaces the legacy
225
+ * bracketed "Tool execution limit reached" placeholder.
226
+ *
227
+ * Emit this ONLY when `step >= maxSteps` genuinely terminated the loop —
228
+ * aborted/timed-out/stalled turns must use {@link buildAbortedTurnMessage},
229
+ * {@link buildTurnTimeoutMessage}, or {@link buildTurnStalledMessage}, never
230
+ * this text (a killed 23-step turn once claimed "reached the 200-step limit").
220
231
  */
221
232
  export declare function buildToolLoopCapMessage(maxSteps: number, toolCallCount: number): string;
233
+ /**
234
+ * Honest message for a turn ended by the `turnTimeoutMs` wall-clock deadline
235
+ * (stopReason "time-limit"). Sibling of {@link buildToolLoopCapMessage} —
236
+ * time exits must never claim a step limit was reached.
237
+ */
238
+ export declare function buildTurnTimeoutMessage(elapsedMs: number, toolCallCount: number): string;
239
+ /**
240
+ * Honest message for a turn ended by the `stallTimeoutMs` no-progress
241
+ * watchdog (stopReason "stalled") — typically a wedged tool or a hung
242
+ * model call.
243
+ */
244
+ export declare function buildTurnStalledMessage(stallTimeoutMs: number, toolCallCount: number): string;
245
+ /**
246
+ * Honest message for a caller-aborted turn (admin kill, coding-task
247
+ * short-circuit — stopReason "aborted").
248
+ */
249
+ export declare function buildAbortedTurnMessage(toolCallCount: number): string;
250
+ /**
251
+ * Wrap-up nudge injected when the remaining turn time drops inside the
252
+ * `wrapupTimeLeadMs` window — the time-budget twin of the soft step-budget
253
+ * nudge. Rides as a trailing text block on the tool-result user turn.
254
+ */
255
+ export declare function buildWrapupNudgeText(useFinalResultTool: boolean): string;
256
+ /**
257
+ * Resolve the turn's `stopReason` discriminator from the loop's exit
258
+ * bookkeeping. Precedence: time conditions beat the generic abort flag
259
+ * (the deadline/stall watchdogs abort through the same internal controller),
260
+ * abort beats step-cap, and a provider-error finishReason (e.g. persistent
261
+ * MALFORMED_FUNCTION_CALL) beats "completed".
262
+ */
263
+ export declare function resolveTurnStopReason(params: {
264
+ timedOut: boolean;
265
+ stalled: boolean;
266
+ wasAborted: boolean;
267
+ /** Step budget ran out AND no clean/forced answer was produced. */
268
+ cappedWithoutAnswer: boolean;
269
+ /** The turn's resolved unified finishReason. */
270
+ finishReason?: string;
271
+ }): GenerateStopReason;
272
+ /**
273
+ * Wall-clock + progress watchdogs for a native agentic turn.
274
+ *
275
+ * - Deadline: `turnTimeoutMs` (caller knob) or `defaultTurnTimeoutMs` (the
276
+ * loop's pre-existing defensive bound) arms a whole-turn timer.
277
+ * - Stall: when `stallTimeoutMs` is set, a low-frequency interval checks the
278
+ * time since the last recorded progress (chunk received, tool started or
279
+ * finished, step started).
280
+ *
281
+ * Both fire `onDeadline(kind)` exactly once each and latch a flag the loop's
282
+ * terminal handling reads to pick the honest exit message and `stopReason`.
283
+ * Timers are unref'd so they never hold the process open; call `dispose()`
284
+ * in the loop's finally.
285
+ */
286
+ export declare function createTurnClock(params: {
287
+ /** Explicit whole-turn deadline (ms); wins over defaultTurnTimeoutMs. */
288
+ turnTimeoutMs?: number;
289
+ /** Loop-specific defensive default when turnTimeoutMs is unset (undefined = no deadline). */
290
+ defaultTurnTimeoutMs?: number;
291
+ /** No-progress watchdog (ms); undefined = disabled. */
292
+ stallTimeoutMs?: number;
293
+ /** Wrap-up lead (ms); only honored when turnTimeoutMs is explicitly set. */
294
+ wrapupTimeLeadMs?: number;
295
+ onDeadline: (kind: "timeout" | "stall") => void;
296
+ }): {
297
+ readonly timedOut: boolean;
298
+ readonly stalled: boolean;
299
+ /** True when the turn ended on a time condition (deadline or stall). */
300
+ readonly expired: boolean;
301
+ /** The effective whole-turn deadline in ms (explicit or defensive default). */
302
+ readonly turnTimeoutMs: number | undefined;
303
+ elapsedMs(): number;
304
+ /** Record progress: chunk received, tool started/finished, step started. */
305
+ noteProgress(): void;
306
+ /** True when an explicit deadline exists and remaining time is inside the wrap-up lead. */
307
+ shouldNudgeWrapup(): boolean;
308
+ dispose(): void;
309
+ };
222
310
  /**
223
311
  * Push model response parts to conversation history, preserving thoughtSignature
224
312
  * for Gemini 3 multi-turn tool calling.
@@ -11,7 +11,7 @@
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { extname } from "node:path";
14
- import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, } from "../core/constants.js";
14
+ import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../core/constants.js";
15
15
  import { logger } from "../utils/logger.js";
16
16
  import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../utils/schemaConversion.js";
17
17
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
@@ -463,6 +463,12 @@ export function computeMaxSteps(rawMaxSteps) {
463
463
  * Returns a plain string (not a `{ unified, raw }` object): the Vertex result
464
464
  * builders and the consuming layer (neurolink.ts `finishReason || "unknown"`
465
465
  * and `finishReason === "length"`) compare against plain strings.
466
+ *
467
+ * MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL map to "error", NOT
468
+ * "tool-calls": they are provider/model failures, while "tool-calls" is the
469
+ * exclusive contract for "step budget exhausted while the model still wanted
470
+ * tools" — consumers (e.g. curator's step-cap intercept) branch on it and
471
+ * were rendering fake step-limit messages for 2-4-step malformed-call turns.
466
472
  */
467
473
  export function mapGeminiFinishReason(raw) {
468
474
  switch (raw) {
@@ -470,7 +476,7 @@ export function mapGeminiFinishReason(raw) {
470
476
  return "length";
471
477
  case "MALFORMED_FUNCTION_CALL":
472
478
  case "UNEXPECTED_TOOL_CALL":
473
- return "tool-calls";
479
+ return "error";
474
480
  case "SAFETY":
475
481
  case "RECITATION":
476
482
  case "BLOCKLIST":
@@ -832,8 +838,13 @@ export function isAbortError(error) {
832
838
  }
833
839
  /**
834
840
  * Build a graceful, user-facing message for when a single agentic turn hits
835
- * the step cap (or is aborted mid-turn) without producing a final answer.
836
- * Replaces the legacy bracketed "Tool execution limit reached" placeholder.
841
+ * the step cap without producing a final answer. Replaces the legacy
842
+ * bracketed "Tool execution limit reached" placeholder.
843
+ *
844
+ * Emit this ONLY when `step >= maxSteps` genuinely terminated the loop —
845
+ * aborted/timed-out/stalled turns must use {@link buildAbortedTurnMessage},
846
+ * {@link buildTurnTimeoutMessage}, or {@link buildTurnStalledMessage}, never
847
+ * this text (a killed 23-step turn once claimed "reached the 200-step limit").
837
848
  */
838
849
  export function buildToolLoopCapMessage(maxSteps, toolCallCount) {
839
850
  const calls = toolCallCount > 0
@@ -842,6 +853,177 @@ export function buildToolLoopCapMessage(maxSteps, toolCallCount) {
842
853
  return (`${calls}reached the ${maxSteps}-step limit for a single turn before I could finish. ` +
843
854
  `Please narrow the request or break it into smaller asks and I'll continue.`);
844
855
  }
856
+ /** Format an elapsed duration as "Xm Ys" (or "Ys" under a minute). */
857
+ function formatElapsed(elapsedMs) {
858
+ const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
859
+ const minutes = Math.floor(totalSeconds / 60);
860
+ const seconds = totalSeconds % 60;
861
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
862
+ }
863
+ /**
864
+ * Honest message for a turn ended by the `turnTimeoutMs` wall-clock deadline
865
+ * (stopReason "time-limit"). Sibling of {@link buildToolLoopCapMessage} —
866
+ * time exits must never claim a step limit was reached.
867
+ */
868
+ export function buildTurnTimeoutMessage(elapsedMs, toolCallCount) {
869
+ const calls = toolCallCount > 0
870
+ ? ` I completed ${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} before stopping;`
871
+ : "";
872
+ return (`I had to stop after ${formatElapsed(elapsedMs)} — this turn hit its ` +
873
+ `processing time limit.${calls} ask me to continue and I'll pick up from there.`);
874
+ }
875
+ /**
876
+ * Honest message for a turn ended by the `stallTimeoutMs` no-progress
877
+ * watchdog (stopReason "stalled") — typically a wedged tool or a hung
878
+ * model call.
879
+ */
880
+ export function buildTurnStalledMessage(stallTimeoutMs, toolCallCount) {
881
+ const calls = toolCallCount > 0
882
+ ? ` I completed ${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} before stopping;`
883
+ : "";
884
+ return (`I had to stop because this turn made no progress for ` +
885
+ `${formatElapsed(stallTimeoutMs)} — a tool or model call appears to be stuck.` +
886
+ `${calls} ask me to continue and I'll pick up from there.`);
887
+ }
888
+ /**
889
+ * Honest message for a caller-aborted turn (admin kill, coding-task
890
+ * short-circuit — stopReason "aborted").
891
+ */
892
+ export function buildAbortedTurnMessage(toolCallCount) {
893
+ const calls = toolCallCount > 0
894
+ ? ` I completed ${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} before stopping.`
895
+ : "";
896
+ return `This turn was stopped before I could finish.${calls}`;
897
+ }
898
+ /**
899
+ * Wrap-up nudge injected when the remaining turn time drops inside the
900
+ * `wrapupTimeLeadMs` window — the time-budget twin of the soft step-budget
901
+ * nudge. Rides as a trailing text block on the tool-result user turn.
902
+ */
903
+ export function buildWrapupNudgeText(useFinalResultTool) {
904
+ return ("NOTE: processing time for this turn is nearly up. Consolidate what you have and " +
905
+ (useFinalResultTool
906
+ ? "call final_result with your best answer now."
907
+ : "provide your final answer now."));
908
+ }
909
+ /**
910
+ * Resolve the turn's `stopReason` discriminator from the loop's exit
911
+ * bookkeeping. Precedence: time conditions beat the generic abort flag
912
+ * (the deadline/stall watchdogs abort through the same internal controller),
913
+ * abort beats step-cap, and a provider-error finishReason (e.g. persistent
914
+ * MALFORMED_FUNCTION_CALL) beats "completed".
915
+ */
916
+ export function resolveTurnStopReason(params) {
917
+ if (params.timedOut) {
918
+ return "time-limit";
919
+ }
920
+ if (params.stalled) {
921
+ return "stalled";
922
+ }
923
+ if (params.wasAborted) {
924
+ return "aborted";
925
+ }
926
+ if (params.cappedWithoutAnswer) {
927
+ return "step-cap";
928
+ }
929
+ if (params.finishReason === "error") {
930
+ return "provider-error";
931
+ }
932
+ return "completed";
933
+ }
934
+ /**
935
+ * Wall-clock + progress watchdogs for a native agentic turn.
936
+ *
937
+ * - Deadline: `turnTimeoutMs` (caller knob) or `defaultTurnTimeoutMs` (the
938
+ * loop's pre-existing defensive bound) arms a whole-turn timer.
939
+ * - Stall: when `stallTimeoutMs` is set, a low-frequency interval checks the
940
+ * time since the last recorded progress (chunk received, tool started or
941
+ * finished, step started).
942
+ *
943
+ * Both fire `onDeadline(kind)` exactly once each and latch a flag the loop's
944
+ * terminal handling reads to pick the honest exit message and `stopReason`.
945
+ * Timers are unref'd so they never hold the process open; call `dispose()`
946
+ * in the loop's finally.
947
+ */
948
+ export function createTurnClock(params) {
949
+ const startedAt = Date.now();
950
+ const isValidMs = (value) => value !== undefined && Number.isFinite(value) && value > 0;
951
+ const effectiveTurnTimeoutMs = isValidMs(params.turnTimeoutMs)
952
+ ? params.turnTimeoutMs
953
+ : isValidMs(params.defaultTurnTimeoutMs)
954
+ ? params.defaultTurnTimeoutMs
955
+ : undefined;
956
+ // Nudging against the loop's defensive default would inject prompt text
957
+ // into turns whose caller never opted into a time budget — only nudge
958
+ // against an explicit turnTimeoutMs.
959
+ const wrapupLeadMs = isValidMs(params.turnTimeoutMs)
960
+ ? (params.wrapupTimeLeadMs ?? DEFAULT_WRAPUP_TIME_LEAD_MS)
961
+ : undefined;
962
+ let timedOut = false;
963
+ let stalled = false;
964
+ let lastProgressAt = startedAt;
965
+ let deadlineTimer;
966
+ let stallTimer;
967
+ if (effectiveTurnTimeoutMs !== undefined) {
968
+ deadlineTimer = setTimeout(() => {
969
+ timedOut = true;
970
+ params.onDeadline("timeout");
971
+ }, effectiveTurnTimeoutMs);
972
+ deadlineTimer.unref?.();
973
+ }
974
+ if (isValidMs(params.stallTimeoutMs)) {
975
+ const stallTimeoutMs = params.stallTimeoutMs;
976
+ const checkEveryMs = Math.min(Math.max(1000, Math.floor(stallTimeoutMs / 4)), 15_000);
977
+ stallTimer = setInterval(() => {
978
+ if (!stalled &&
979
+ !timedOut &&
980
+ Date.now() - lastProgressAt >= stallTimeoutMs) {
981
+ stalled = true;
982
+ params.onDeadline("stall");
983
+ }
984
+ }, checkEveryMs);
985
+ stallTimer.unref?.();
986
+ }
987
+ return {
988
+ get timedOut() {
989
+ return timedOut;
990
+ },
991
+ get stalled() {
992
+ return stalled;
993
+ },
994
+ /** True when the turn ended on a time condition (deadline or stall). */
995
+ get expired() {
996
+ return timedOut || stalled;
997
+ },
998
+ /** The effective whole-turn deadline in ms (explicit or defensive default). */
999
+ get turnTimeoutMs() {
1000
+ return effectiveTurnTimeoutMs;
1001
+ },
1002
+ elapsedMs() {
1003
+ return Date.now() - startedAt;
1004
+ },
1005
+ /** Record progress: chunk received, tool started/finished, step started. */
1006
+ noteProgress() {
1007
+ lastProgressAt = Date.now();
1008
+ },
1009
+ /** True when an explicit deadline exists and remaining time is inside the wrap-up lead. */
1010
+ shouldNudgeWrapup() {
1011
+ if (effectiveTurnTimeoutMs === undefined || wrapupLeadMs === undefined) {
1012
+ return false;
1013
+ }
1014
+ const remaining = effectiveTurnTimeoutMs - (Date.now() - startedAt);
1015
+ return remaining > 0 && remaining <= wrapupLeadMs;
1016
+ },
1017
+ dispose() {
1018
+ if (deadlineTimer) {
1019
+ clearTimeout(deadlineTimer);
1020
+ }
1021
+ if (stallTimer) {
1022
+ clearInterval(stallTimer);
1023
+ }
1024
+ },
1025
+ };
1026
+ }
845
1027
  /**
846
1028
  * Push model response parts to conversation history, preserving thoughtSignature
847
1029
  * for Gemini 3 multi-turn tool calling.
@@ -256,6 +256,21 @@ export declare class GoogleVertexProvider extends BaseProvider {
256
256
  * exporters. Mirrors the Bedrock + Ollama pattern.
257
257
  */
258
258
  private emitGenerationEnd;
259
+ /**
260
+ * Emit a `turn:lifecycle` event on the SDK emitter (alongside
261
+ * tool:start/tool:end) so loop conditions that previously only reached
262
+ * process logs — step-cap, time-limit, stall, abort, tool timeouts,
263
+ * malformed-call retries — are observable by consumers' own pipelines.
264
+ */
265
+ private emitTurnEvent;
266
+ /**
267
+ * Pick the honest terminal message for a native-loop exit by its ACTUAL
268
+ * cause. The step-cap text is the fallback for genuine budget exhaustion
269
+ * only — time/stall/abort exits must never claim a step limit was reached
270
+ * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
271
+ * abort wearing the cap message).
272
+ */
273
+ private buildLoopExitMessage;
259
274
  protected formatProviderError(error: unknown): Error;
260
275
  /**
261
276
  * Memory-safe cache management for model configurations