@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.
@@ -35,6 +35,16 @@ export type AnalyticsData = {
35
35
  timestamp: string;
36
36
  cost?: number;
37
37
  context?: JsonValue;
38
+ /** Number of agentic steps (model calls) the turn used. */
39
+ stepsUsed?: number;
40
+ /** Number of external tool calls the turn made (final_result excluded). */
41
+ toolCallCount?: number;
42
+ /** Why the turn ended — see GenerateStopReason. */
43
+ stopReason?: string;
44
+ /** Wall-clock duration of the turn in milliseconds. */
45
+ elapsedMs?: number;
46
+ /** Verbatim provider finish/stop reason for the terminal model call. */
47
+ rawFinishReason?: string;
38
48
  };
39
49
  /**
40
50
  * Stream Analytics Data - Enhanced for performance tracking
@@ -310,6 +310,38 @@ export type GenerateOptions = {
310
310
  * provider+model with the same doomed budget.
311
311
  */
312
312
  timeout?: number | string;
313
+ /**
314
+ * Hard wall-clock cap for the WHOLE agentic turn (all model calls + tool
315
+ * executions), in milliseconds. When the deadline passes the turn ends
316
+ * gracefully with `stopReason: "time-limit"` and an honest time message —
317
+ * never the step-cap text. Unset = no turn-level deadline (the library
318
+ * imposes no product policy).
319
+ *
320
+ * Enforced by the native Vertex loops (Gemini + Claude); AI-SDK-driven
321
+ * providers currently ignore it.
322
+ */
323
+ turnTimeoutMs?: number;
324
+ /**
325
+ * Maximum time with NO progress — no stream chunk received, no tool
326
+ * execution started or finished, no step started — before the turn ends
327
+ * with `stopReason: "stalled"`. Catches wedged tools and hung model calls
328
+ * that a whole-turn deadline would let run to the bitter end.
329
+ * Unset = disabled.
330
+ */
331
+ stallTimeoutMs?: number;
332
+ /**
333
+ * When the remaining turn time drops below this, a wrap-up nudge rides the
334
+ * next tool-result turn telling the model to consolidate what it has and
335
+ * produce its final answer. Defaults to 120_000 when `turnTimeoutMs` is
336
+ * set; ignored when it is not.
337
+ */
338
+ wrapupTimeLeadMs?: number;
339
+ /**
340
+ * Per-tool-execution timeout in milliseconds (default 300_000). A tool
341
+ * that exceeds it fails with an error tool_result and costs one step —
342
+ * the turn continues instead of hanging on a wedged tool.
343
+ */
344
+ toolTimeoutMs?: number;
313
345
  /** AbortSignal for external cancellation of the AI call */
314
346
  abortSignal?: AbortSignal;
315
347
  /**
@@ -562,6 +594,21 @@ export type AdditionalMemoryUser = {
562
594
  /** Max words for this user's condensed memory. Overrides the default maxWords. */
563
595
  maxWords?: number;
564
596
  };
597
+ /**
598
+ * Why an agentic turn ended — the discriminator consumers should branch on
599
+ * instead of sniffing the provider-shaped `finishReason` (whose values are
600
+ * overloaded: e.g. "tool-calls" historically covered both step-cap exits and
601
+ * Gemini MALFORMED_FUNCTION_CALL provider errors).
602
+ *
603
+ * - `completed` — the model finished on its own (text answer or final_result)
604
+ * - `step-cap` — the `maxSteps` budget ran out while the model still wanted tools
605
+ * - `time-limit` — the `turnTimeoutMs` wall-clock deadline passed
606
+ * - `stalled` — no progress (no chunk, no tool start/finish) for `stallTimeoutMs`
607
+ * - `aborted` — the caller's `abortSignal` ended the turn
608
+ * - `provider-error` — the provider/model failed the turn (e.g. persistent
609
+ * MALFORMED_FUNCTION_CALL after retry); usually worth a caller-side retry
610
+ */
611
+ export type GenerateStopReason = "completed" | "step-cap" | "time-limit" | "stalled" | "aborted" | "provider-error";
565
612
  /**
566
613
  * Generate function result type - Primary output format
567
614
  * Future-ready for multi-modal outputs while maintaining text focus
@@ -662,6 +709,20 @@ export type GenerateResult = {
662
709
  provider?: string;
663
710
  model?: string;
664
711
  finishReason?: string;
712
+ /**
713
+ * Why the agentic turn ended, independent of the provider-shaped
714
+ * `finishReason`. Populated by the native Vertex loops (Gemini + Claude);
715
+ * undefined on providers that don't run a native loop — fall back to
716
+ * `finishReason` heuristics there.
717
+ */
718
+ stopReason?: GenerateStopReason;
719
+ /**
720
+ * Verbatim provider finish/stop reason for the turn's terminal model call
721
+ * (e.g. "MALFORMED_FUNCTION_CALL", "MAX_TOKENS", "max_tokens", "tool_use").
722
+ */
723
+ rawFinishReason?: string;
724
+ /** Number of agentic steps (model calls) the turn used. */
725
+ stepsUsed?: number;
665
726
  /**
666
727
  * True when the schema JSON in `content`/`structuredData` was repaired from
667
728
  * malformed model text (jsonrepair ran). The result is still valid JSON.
@@ -882,6 +943,14 @@ export type TextGenerationOptions = {
882
943
  */
883
944
  enabledToolNames?: string[];
884
945
  timeout?: number | string;
946
+ /** Wall-clock cap for the whole agentic turn (ms). See GenerateOptions.turnTimeoutMs. */
947
+ turnTimeoutMs?: number;
948
+ /** Max time with no progress before the turn ends as "stalled" (ms). See GenerateOptions.stallTimeoutMs. */
949
+ stallTimeoutMs?: number;
950
+ /** Remaining-time threshold that triggers the wrap-up nudge (ms). See GenerateOptions.wrapupTimeLeadMs. */
951
+ wrapupTimeLeadMs?: number;
952
+ /** Per-tool-execution timeout (ms, default 300_000). See GenerateOptions.toolTimeoutMs. */
953
+ toolTimeoutMs?: number;
885
954
  /** AbortSignal for external cancellation of the AI call */
886
955
  abortSignal?: AbortSignal;
887
956
  disableTools?: boolean;
@@ -1128,6 +1197,12 @@ export type TextGenerationResult = {
1128
1197
  /** Parsed structured object when a `schema` was requested (see GenerateResult.structuredData). */
1129
1198
  structuredData?: unknown;
1130
1199
  finishReason?: string;
1200
+ /** Turn-exit discriminator from native agentic loops (see GenerateStopReason). */
1201
+ stopReason?: GenerateStopReason;
1202
+ /** Verbatim provider finish/stop reason for the turn's terminal model call. */
1203
+ rawFinishReason?: string;
1204
+ /** Number of agentic steps (model calls) the turn used. */
1205
+ stepsUsed?: number;
1131
1206
  /** True when the schema JSON was repaired from malformed model text. */
1132
1207
  jsonRepaired?: boolean;
1133
1208
  /** True when the schema JSON appears truncated (output hit the token cap). */
@@ -8,7 +8,7 @@ import type { JsonValue, UnknownRecord } from "./common.js";
8
8
  import type { Content, ImageWithAltText } from "./content.js";
9
9
  import type { ChatMessage } from "./conversation.js";
10
10
  import type { StreamNoOutputSentinel } from "./noOutputSentinel.js";
11
- import type { AdditionalMemoryUser } from "./generate.js";
11
+ import type { AdditionalMemoryUser, GenerateStopReason } from "./generate.js";
12
12
  import type { AIModelProviderConfig, NeurolinkCredentials } from "./providers.js";
13
13
  import type { TTSChunk, TTSOptions, TTSResult } from "./tts.js";
14
14
  import type { STTOptions, STTResult } from "./stt.js";
@@ -326,6 +326,14 @@ export type StreamOptions = {
326
326
  schema?: ValidationSchema;
327
327
  tools?: Record<string, Tool>;
328
328
  timeout?: number | string;
329
+ /** Wall-clock cap for the whole agentic turn (ms). See GenerateOptions.turnTimeoutMs. */
330
+ turnTimeoutMs?: number;
331
+ /** Max time with no progress before the turn ends as "stalled" (ms). See GenerateOptions.stallTimeoutMs. */
332
+ stallTimeoutMs?: number;
333
+ /** Remaining-time threshold that triggers the wrap-up nudge (ms). See GenerateOptions.wrapupTimeLeadMs. */
334
+ wrapupTimeLeadMs?: number;
335
+ /** Per-tool-execution timeout (ms, default 300_000). See GenerateOptions.toolTimeoutMs. */
336
+ toolTimeoutMs?: number;
329
337
  /** AbortSignal for external cancellation of the AI call */
330
338
  abortSignal?: AbortSignal;
331
339
  disableTools?: boolean;
@@ -518,6 +526,15 @@ export type StreamResult = {
518
526
  model?: string;
519
527
  usage?: TokenUsage;
520
528
  finishReason?: string;
529
+ /**
530
+ * Why the agentic turn ended (see GenerateStopReason). For background-loop
531
+ * streams (native Vertex paths) prefer `metadata.stopReason` after draining
532
+ * the stream — this top-level field may be a getter that resolves late, and
533
+ * wrapper spreads can snapshot it before the loop finishes.
534
+ */
535
+ stopReason?: GenerateStopReason;
536
+ /** Verbatim provider finish/stop reason for the turn's terminal model call. */
537
+ rawFinishReason?: string;
521
538
  toolCalls?: StreamToolCall[];
522
539
  toolResults?: StreamToolResult[];
523
540
  toolEvents?: AsyncIterable<ToolExecutionEvent>;
@@ -537,6 +554,9 @@ export type StreamResult = {
537
554
  guardrailsBlocked?: boolean;
538
555
  error?: string;
539
556
  finishReason?: string;
557
+ stopReason?: GenerateStopReason;
558
+ rawFinishReason?: string;
559
+ stepsUsed?: number;
540
560
  thoughtSignature?: string;
541
561
  thoughts?: Array<{
542
562
  id?: string;
package/dist/neurolink.js CHANGED
@@ -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