@juspay/neurolink 9.80.4 → 9.81.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.
@@ -49,6 +49,22 @@ export const textGenerationOptionsSchema = {
49
49
  type: "number",
50
50
  description: "Timeout for the generation request in milliseconds.",
51
51
  },
52
+ turnTimeoutMs: {
53
+ type: "number",
54
+ description: "Wall-clock cap for the whole agentic turn in milliseconds (all steps + tool executions).",
55
+ },
56
+ stallTimeoutMs: {
57
+ type: "number",
58
+ description: "Maximum time with no progress (no chunk, no tool activity) before the turn ends as stalled, in milliseconds.",
59
+ },
60
+ wrapupTimeLeadMs: {
61
+ type: "number",
62
+ description: "Remaining-turn-time threshold that triggers a wrap-up nudge to the model, in milliseconds.",
63
+ },
64
+ toolTimeoutMs: {
65
+ type: "number",
66
+ description: "Per-tool-execution timeout in milliseconds (default 300000). A timed-out tool fails that step; the turn continues.",
67
+ },
52
68
  disableTools: {
53
69
  type: "boolean",
54
70
  description: "Disable all tool usage for the AI.",
@@ -18,6 +18,15 @@ export function createAnalytics(provider, model, result, responseTime, context)
18
18
  const tokens = extractTokenUsage(result);
19
19
  // Estimate cost based on provider and tokens
20
20
  const cost = estimateCost(provider, model, tokens);
21
+ // Turn-lifecycle telemetry from native agentic loops (Vertex
22
+ // Gemini/Claude): stopReason / rawFinishReason / stepsUsed ride the
23
+ // provider result; toolCallCount and elapsedMs derive from it.
24
+ const turnResult = result;
25
+ const toolCallCount = Array.isArray(turnResult.toolExecutions)
26
+ ? turnResult.toolExecutions.length
27
+ : Array.isArray(turnResult.toolsUsed)
28
+ ? turnResult.toolsUsed.length
29
+ : undefined;
21
30
  const analytics = {
22
31
  provider,
23
32
  model,
@@ -26,6 +35,19 @@ export function createAnalytics(provider, model, result, responseTime, context)
26
35
  requestDuration: responseTime,
27
36
  context: context,
28
37
  timestamp: new Date().toISOString(),
38
+ ...(typeof turnResult.stepsUsed === "number" && {
39
+ stepsUsed: turnResult.stepsUsed,
40
+ }),
41
+ ...(toolCallCount !== undefined && { toolCallCount }),
42
+ ...(typeof turnResult.stopReason === "string" && {
43
+ stopReason: turnResult.stopReason,
44
+ }),
45
+ ...(typeof turnResult.responseTime === "number" && {
46
+ elapsedMs: turnResult.responseTime,
47
+ }),
48
+ ...(typeof turnResult.rawFinishReason === "string" && {
49
+ rawFinishReason: turnResult.rawFinishReason,
50
+ }),
29
51
  };
30
52
  logger.debug(`[${functionTag}] Analytics created`, {
31
53
  provider,
@@ -23,6 +23,19 @@ export declare const DEFAULT_MAX_STEPS = 200;
23
23
  export declare const DEFAULT_TOOL_MAX_RETRIES = 2;
24
24
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
25
25
  export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
26
+ /**
27
+ * Default per-tool-execution timeout for native agentic loops. A tool that
28
+ * exceeds it fails with an error tool_result and costs one step — the turn
29
+ * continues instead of hanging on a wedged tool. Override per call with
30
+ * `toolTimeoutMs`.
31
+ */
32
+ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
36
+ * a wrap-up nudge rides the next tool-result turn.
37
+ */
38
+ export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
26
39
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
27
40
  export declare const STEP_LIMITS: {
28
41
  min: number;
@@ -109,6 +109,19 @@ export const DEFAULT_MAX_STEPS = 200;
109
109
  export const DEFAULT_TOOL_MAX_RETRIES = 2; // Maximum retries per tool before permanently failing
110
110
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
111
111
  export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
112
+ /**
113
+ * Default per-tool-execution timeout for native agentic loops. A tool that
114
+ * exceeds it fails with an error tool_result and costs one step — the turn
115
+ * continues instead of hanging on a wedged tool. Override per call with
116
+ * `toolTimeoutMs`.
117
+ */
118
+ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
119
+ /**
120
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
121
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
122
+ * a wrap-up nudge rides the next tool-result turn.
123
+ */
124
+ export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
112
125
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
113
126
  // Redis write; if breached, the .catch logs a warning.
114
127
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -18,6 +18,15 @@ export function createAnalytics(provider, model, result, responseTime, context)
18
18
  const tokens = extractTokenUsage(result);
19
19
  // Estimate cost based on provider and tokens
20
20
  const cost = estimateCost(provider, model, tokens);
21
+ // Turn-lifecycle telemetry from native agentic loops (Vertex
22
+ // Gemini/Claude): stopReason / rawFinishReason / stepsUsed ride the
23
+ // provider result; toolCallCount and elapsedMs derive from it.
24
+ const turnResult = result;
25
+ const toolCallCount = Array.isArray(turnResult.toolExecutions)
26
+ ? turnResult.toolExecutions.length
27
+ : Array.isArray(turnResult.toolsUsed)
28
+ ? turnResult.toolsUsed.length
29
+ : undefined;
21
30
  const analytics = {
22
31
  provider,
23
32
  model,
@@ -26,6 +35,19 @@ export function createAnalytics(provider, model, result, responseTime, context)
26
35
  requestDuration: responseTime,
27
36
  context: context,
28
37
  timestamp: new Date().toISOString(),
38
+ ...(typeof turnResult.stepsUsed === "number" && {
39
+ stepsUsed: turnResult.stepsUsed,
40
+ }),
41
+ ...(toolCallCount !== undefined && { toolCallCount }),
42
+ ...(typeof turnResult.stopReason === "string" && {
43
+ stopReason: turnResult.stopReason,
44
+ }),
45
+ ...(typeof turnResult.responseTime === "number" && {
46
+ elapsedMs: turnResult.responseTime,
47
+ }),
48
+ ...(typeof turnResult.rawFinishReason === "string" && {
49
+ rawFinishReason: turnResult.rawFinishReason,
50
+ }),
29
51
  };
30
52
  logger.debug(`[${functionTag}] Analytics created`, {
31
53
  provider,
@@ -23,6 +23,19 @@ export declare const DEFAULT_MAX_STEPS = 200;
23
23
  export declare const DEFAULT_TOOL_MAX_RETRIES = 2;
24
24
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
25
25
  export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
26
+ /**
27
+ * Default per-tool-execution timeout for native agentic loops. A tool that
28
+ * exceeds it fails with an error tool_result and costs one step — the turn
29
+ * continues instead of hanging on a wedged tool. Override per call with
30
+ * `toolTimeoutMs`.
31
+ */
32
+ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
36
+ * a wrap-up nudge rides the next tool-result turn.
37
+ */
38
+ export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
26
39
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
27
40
  export declare const STEP_LIMITS: {
28
41
  min: number;
@@ -109,6 +109,19 @@ export const DEFAULT_MAX_STEPS = 200;
109
109
  export const DEFAULT_TOOL_MAX_RETRIES = 2; // Maximum retries per tool before permanently failing
110
110
  /** Defensive wall-clock ceiling for a native Gemini-3 agentic turn (generate + stream). */
111
111
  export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
112
+ /**
113
+ * Default per-tool-execution timeout for native agentic loops. A tool that
114
+ * exceeds it fails with an error tool_result and costs one step — the turn
115
+ * continues instead of hanging on a wedged tool. Override per call with
116
+ * `toolTimeoutMs`.
117
+ */
118
+ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
119
+ /**
120
+ * Default wrap-up lead applied when `turnTimeoutMs` is set but
121
+ * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
122
+ * a wrap-up nudge rides the next tool-result turn.
123
+ */
124
+ export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
112
125
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
113
126
  // Redis write; if breached, the .catch logs a warning.
114
127
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -3513,6 +3513,10 @@ Current user's request: ${currentInput}`;
3513
3513
  stt: options.stt,
3514
3514
  fileRegistry: this.fileRegistry,
3515
3515
  timeout: options.timeout,
3516
+ turnTimeoutMs: options.turnTimeoutMs,
3517
+ stallTimeoutMs: options.stallTimeoutMs,
3518
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
3519
+ toolTimeoutMs: options.toolTimeoutMs,
3516
3520
  abortSignal: options.abortSignal,
3517
3521
  skipToolPromptInjection: options.skipToolPromptInjection,
3518
3522
  middleware: options.middleware,
@@ -3644,6 +3648,12 @@ Current user's request: ${currentInput}`;
3644
3648
  content: textResult.content,
3645
3649
  structuredData: textResult.structuredData,
3646
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,
3647
3657
  jsonRepaired: textResult.jsonRepaired,
3648
3658
  jsonTruncated: textResult.jsonTruncated,
3649
3659
  provider: textResult.provider,
@@ -5063,6 +5073,9 @@ Current user's request: ${currentInput}`;
5063
5073
  usage: result.usage,
5064
5074
  responseTime,
5065
5075
  finishReason: result.finishReason,
5076
+ stopReason: result.stopReason,
5077
+ rawFinishReason: result.rawFinishReason,
5078
+ stepsUsed: result.stepsUsed,
5066
5079
  toolsUsed: result.toolsUsed || [],
5067
5080
  toolExecutions: transformedToolExecutions,
5068
5081
  enhancedWithTools: Boolean(hasToolExecutions),
@@ -5198,6 +5211,9 @@ Current user's request: ${currentInput}`;
5198
5211
  usage: poolResult.usage,
5199
5212
  responseTime,
5200
5213
  finishReason: poolResult.finishReason,
5214
+ stopReason: poolResult.stopReason,
5215
+ rawFinishReason: poolResult.rawFinishReason,
5216
+ stepsUsed: poolResult.stepsUsed,
5201
5217
  toolsUsed: poolResult.toolsUsed || [],
5202
5218
  toolExecutions: poolResult.toolExecutions?.map((te) => {
5203
5219
  const t = te;
@@ -5474,6 +5490,9 @@ Current user's request: ${currentInput}`;
5474
5490
  usage: result.usage,
5475
5491
  responseTime,
5476
5492
  finishReason: result.finishReason,
5493
+ stopReason: result.stopReason,
5494
+ rawFinishReason: result.rawFinishReason,
5495
+ stepsUsed: result.stepsUsed,
5477
5496
  toolsUsed: result.toolsUsed || [],
5478
5497
  // Map toolExecutions from EnhancedGenerateResult shape ({name,input,output})
5479
5498
  // to TextGenerationResult shape ({toolName,executionTime,success}).
@@ -6471,7 +6490,7 @@ Current user's request: ${currentInput}`;
6471
6490
  factoryResult,
6472
6491
  sessionId: enhancedOptions.context?.sessionId,
6473
6492
  });
6474
- 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);
6475
6494
  const streamState = {
6476
6495
  finishReason: streamFinishReason ?? "stop",
6477
6496
  toolCalls: streamToolCalls,
@@ -6818,6 +6837,7 @@ Current user's request: ${currentInput}`;
6818
6837
  guardrailsBlocked: metadata.guardrailsBlocked,
6819
6838
  error: metadata.error,
6820
6839
  events: eventSequence,
6840
+ providerMetadata: providerStreamMetadata,
6821
6841
  });
6822
6842
  }
6823
6843
  catch (error) {
@@ -7612,6 +7632,7 @@ Current user's request: ${currentInput}`;
7612
7632
  toolCalls: poolStreamResult.toolCalls ?? [],
7613
7633
  toolResults: poolStreamResult.toolResults ?? [],
7614
7634
  analytics: poolStreamResult.analytics,
7635
+ metadata: poolStreamResult.metadata,
7615
7636
  };
7616
7637
  }
7617
7638
  catch (poolStreamError) {
@@ -7665,6 +7686,12 @@ Current user's request: ${currentInput}`;
7665
7686
  toolCalls: streamResult.toolCalls ?? [],
7666
7687
  toolResults: streamResult.toolResults ?? [],
7667
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,
7668
7695
  };
7669
7696
  }
7670
7697
  /**
@@ -7707,14 +7734,14 @@ Current user's request: ${currentInput}`;
7707
7734
  analytics: streamResult.analytics,
7708
7735
  evaluation: streamResult.evaluation,
7709
7736
  events: config.events && config.events.length > 0 ? config.events : undefined,
7710
- metadata: {
7737
+ metadata: Object.assign(config.providerMetadata ?? {}, {
7711
7738
  streamId: config.streamId,
7712
7739
  startTime: config.startTime,
7713
7740
  responseTime: config.responseTime,
7714
7741
  fallback: config.fallback || false,
7715
7742
  guardrailsBlocked: config.guardrailsBlocked,
7716
7743
  error: config.error,
7717
- },
7744
+ }),
7718
7745
  };
7719
7746
  }
7720
7747
  /**
@@ -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