@juspay/neurolink 12.21.1 → 12.22.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.
@@ -9,7 +9,7 @@ import { logger } from "../../utils/logger.js";
9
9
  import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
10
10
  import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
11
11
  import { runAgenticLoop } from "../../core/loopEngine.js";
12
- import { DEFAULT_TOOL_MAX_RETRIES } from "../../core/constants.js";
12
+ import { DEFAULT_TOOL_MAX_RETRIES, resolveToolTimeoutMs, } from "../../core/constants.js";
13
13
  import { isToolsSchemaExclusionInForce } from "../../core/modules/structuredOutputPolicy.js";
14
14
  import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
15
15
  import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
@@ -18,7 +18,7 @@ import { withTimeout } from "../../utils/async/index.js";
18
18
  import { estimateTokens } from "../../utils/tokenEstimation.js";
19
19
  import { transformToolExecutions } from "../../utils/transformationUtils.js";
20
20
  import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
21
- import { buildDedupedEngineTools, buildGeminiResponseSchema, buildNativeConfig, computeMaxSteps, createContextGuard, buildUserPartsWithMultimodal, extractThoughtSignature, handleMaxStepsTermination, prependConversationMessages, } from "../googleNativeGemini3/index.js";
21
+ import { buildDedupedEngineTools, buildGeminiResponseSchema, buildLoopExitMessage, buildNativeConfig, buildWrapupNudgeText, computeMaxSteps, createContextGuard, createTurnClock, buildUserPartsWithMultimodal, extractThoughtSignature, handleMaxStepsTermination, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, } from "../googleNativeGemini3/index.js";
22
22
  import { createStreamChannel } from "../../core/streamChannel.js";
23
23
  import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
24
24
  import { warnGoogleSdkIgnoresProxy } from "../../proxy/proxyFetch.js";
@@ -687,8 +687,51 @@ export class GoogleAIStudioProvider extends BaseProvider {
687
687
  responseSchema: nativeResponseSchema,
688
688
  }, toolsConfig);
689
689
  const maxSteps = computeMaxSteps(options.maxSteps);
690
- // Compose abort signal from user signal + timeout
691
- const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
690
+ // Abort fan-in. The caller's signal, the pre-existing per-request
691
+ // timeout controller and the turn clock's watchdogs all trip one
692
+ // internal controller, and everything downstream (SDK request,
693
+ // engine, guarded tool executors) rides its signal — so a deadline
694
+ // reaches a tool mid-execution, not only the next step boundary.
695
+ const upstreamSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
696
+ const internalAbort = new AbortController();
697
+ const onUpstreamAbort = () => internalAbort.abort();
698
+ upstreamSignal?.addEventListener("abort", onUpstreamAbort);
699
+ if (upstreamSignal?.aborted) {
700
+ internalAbort.abort();
701
+ }
702
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
703
+ // No `defaultTurnTimeoutMs`: this path already carries a whole-turn
704
+ // bound in `timeoutController`, and arming a second timer for the
705
+ // same instant would make which one fired a race. The clock owns an
706
+ // explicit `turnTimeoutMs` and the stall watchdog; the controller
707
+ // keeps the default deadline it always had, and `timedOut` below
708
+ // reads both so either one is reported as a time-limit exit.
709
+ const turnClock = createTurnClock({
710
+ ...(options.turnTimeoutMs !== undefined
711
+ ? { turnTimeoutMs: options.turnTimeoutMs }
712
+ : {}),
713
+ ...(options.stallTimeoutMs !== undefined
714
+ ? { stallTimeoutMs: options.stallTimeoutMs }
715
+ : {}),
716
+ ...(options.wrapupTimeLeadMs !== undefined
717
+ ? { wrapupTimeLeadMs: options.wrapupTimeLeadMs }
718
+ : {}),
719
+ onDeadline: (kind) => {
720
+ logger.warn(kind === "timeout"
721
+ ? `[GoogleAIStudio] Native Gemini turn exceeded its ${options.turnTimeoutMs}ms time budget — aborting`
722
+ : `[GoogleAIStudio] Native Gemini turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
723
+ internalAbort.abort();
724
+ },
725
+ });
726
+ const composedSignal = internalAbort.signal;
727
+ /**
728
+ * True when a wall-clock bound ended the turn. Both the turn clock's
729
+ * explicit deadline and the pre-existing per-request controller are
730
+ * time limits, so an exit caused by either must not be reported as a
731
+ * bare caller abort.
732
+ */
733
+ const hitTimeLimit = () => turnClock.timedOut ||
734
+ timeoutController?.controller.signal.aborted === true;
692
735
  // Create a push-based text channel so the caller receives tokens as
693
736
  // they arrive from the network rather than after full buffering.
694
737
  const channel = createStreamChannel();
@@ -708,6 +751,9 @@ export class GoogleAIStudioProvider extends BaseProvider {
708
751
  });
709
752
  // Shared metadata object mutated by the background loop so the
710
753
  // returned object reflects the final values after stream completion.
754
+ // Typed against StreamResult so the turn-exit fields the loop fills
755
+ // in at close (stopReason / rawFinishReason / stepsUsed) are the
756
+ // declared ones rather than late additions to an inferred shape.
711
757
  const metadata = {
712
758
  streamId: `native-${Date.now()}`,
713
759
  startTime,
@@ -723,6 +769,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
723
769
  let totalCacheReadTokens = 0;
724
770
  let totalReasoningTokens = 0;
725
771
  let step = 0;
772
+ // Model calls the engine actually made, reported from the
773
+ // per-step request hook. `step` above counts only steps that
774
+ // produced tool calls, so it misses the final text-only step and
775
+ // would under-report `stepsUsed`, which is a public field.
776
+ let stepsTaken = 0;
777
+ let wasAborted = false;
778
+ let lastFinishReason;
726
779
  // Cheap trigger for the in-turn reclaim, mirroring the Vertex twin.
727
780
  // Planning serializes the WHOLE accumulated history to estimate it,
728
781
  // so running it unconditionally charges that once per step for the
@@ -744,6 +797,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
744
797
  // what makes an always-failing tool dispatch exactly twice.
745
798
  toolFailureBreaker: { maxRetries: DEFAULT_TOOL_MAX_RETRIES },
746
799
  liveTools: options.tools ?? {},
800
+ // A tool hydrated mid-turn gets the same bound, abort race
801
+ // and stall ping as one declared up front.
802
+ toolGuards: {
803
+ toolTimeoutMs: toolExecTimeoutMs,
804
+ abortSignal: composedSignal,
805
+ onProgress: () => turnClock.noteProgress(),
806
+ },
747
807
  ...(declarationsResult
748
808
  ? { declarations: declarationsResult }
749
809
  : {}),
@@ -751,11 +811,23 @@ export class GoogleAIStudioProvider extends BaseProvider {
751
811
  model: modelName,
752
812
  contents,
753
813
  config,
754
- ...(composedSignal
755
- ? { httpOptions: { signal: composedSignal } }
756
- : {}),
757
814
  }),
758
- sendStep: async (request) => client.models.generateContentStream(request),
815
+ // `config.abortSignal` is the ONLY cancellation channel
816
+ // @google/genai reads (ApiClient forwards
817
+ // `params.config.abortSignal` to fetch). This loop previously
818
+ // passed a top-level `httpOptions.signal`, which is not a key
819
+ // the SDK looks at, so nothing could cancel an in-flight
820
+ // request — a caller abort or a blown deadline only took
821
+ // effect at the next step boundary, and never at all against a
822
+ // provider that had gone quiet mid-stream.
823
+ sendStep: async (request, signal) => {
824
+ turnClock.noteProgress();
825
+ const built = request;
826
+ return client.models.generateContentStream({
827
+ ...built,
828
+ config: { ...(built.config ?? {}), abortSignal: signal },
829
+ });
830
+ },
759
831
  noteUsage: (inputTokens, outputTokens) => {
760
832
  contextGuard.noteUsage(inputTokens, outputTokens);
761
833
  },
@@ -780,6 +852,15 @@ export class GoogleAIStudioProvider extends BaseProvider {
780
852
  // late write and lose the per-step thought signature.
781
853
  const adapter = {
782
854
  ...baseAdapter,
855
+ // Counted HERE, not in buildToolResultMessages: this runs once
856
+ // per model call, including the final text-only step that asks
857
+ // for no tools. Counting in the tool-result hook reports one
858
+ // step fewer for every turn that ends normally.
859
+ buildStepRequest: (contents, engineStep) => {
860
+ stepsTaken = engineStep + 1;
861
+ turnClock.noteProgress();
862
+ return baseAdapter.buildStepRequest(contents, engineStep);
863
+ },
783
864
  buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
784
865
  // The engine's own step, not a count of times this hook ran.
785
866
  // The two agree only while nothing skips the hook mid-turn:
@@ -832,6 +913,19 @@ export class GoogleAIStudioProvider extends BaseProvider {
832
913
  });
833
914
  }
834
915
  const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
916
+ // Time-budget wrap-up nudge (twin of the Vertex Gemini
917
+ // loops'): with the turn deadline approaching, tell the
918
+ // model to consolidate. Rides as a trailing text part on the
919
+ // tool-response user turn. Only fires against an EXPLICIT
920
+ // turnTimeoutMs — createTurnClock refuses to nudge against a
921
+ // defensive default, so no caller gains injected prompt text
922
+ // without asking for a time budget.
923
+ if (turnClock.shouldNudgeWrapup()) {
924
+ const last = next[next.length - 1];
925
+ if (last && Array.isArray(last.parts)) {
926
+ last.parts.push({ text: buildWrapupNudgeText(false) });
927
+ }
928
+ }
835
929
  // Project this step's growth: the appended tool results ride
836
930
  // the next prompt, which the provider has not reported on yet.
837
931
  try {
@@ -847,45 +941,82 @@ export class GoogleAIStudioProvider extends BaseProvider {
847
941
  // Through the turn's DedupExecuteMap, NOT the raw executors:
848
942
  // `.get()` returns the dedup wrapper that answers an identical
849
943
  // repeated {name, args} from the per-turn cache (BZ-3327).
850
- const engineTools = buildDedupedEngineTools(declarationsResult, options.tools);
944
+ const engineTools = buildDedupedEngineTools(declarationsResult, options.tools, {
945
+ toolTimeoutMs: toolExecTimeoutMs,
946
+ abortSignal: composedSignal,
947
+ onProgress: () => turnClock.noteProgress(),
948
+ });
851
949
  const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
852
950
  tools: engineTools,
853
- ...(composedSignal ? { abortSignal: composedSignal } : {}),
854
- // This loop guards no executor of its own, so the engine's
855
- // per-tool bound is the only thing standing between a wedged
856
- // tool and a turn that never ends. Honour the caller's value
857
- // when there is one; the engine defaults otherwise.
858
- ...(options.toolTimeoutMs !== undefined
859
- ? { toolTimeoutMs: options.toolTimeoutMs }
860
- : {}),
951
+ abortSignal: composedSignal,
952
+ // The engine bounds every tool call itself. Passing the same
953
+ // value the guards above use keeps the engine's backstop from
954
+ // being tighter than what the caller asked for.
955
+ toolTimeoutMs: toolExecTimeoutMs,
861
956
  });
862
957
  const pump = (async () => {
863
958
  for await (const chunk of engineStream) {
959
+ // Every chunk is progress, which is what keeps a productive
960
+ // but slow turn away from the stall watchdog.
961
+ turnClock.noteProgress();
864
962
  channel.push(chunk);
865
963
  }
866
964
  })();
867
965
  let engineResult;
966
+ let turnFailure;
868
967
  try {
869
968
  engineResult = await resultPromise;
870
969
  }
871
970
  catch (error) {
872
- await drainDetachedPump(pump, "GoogleAIStudio");
873
- logger.error("[GoogleAIStudio] Native SDK error", error);
874
- throw this.handleProviderError(error);
971
+ turnFailure = error;
972
+ }
973
+ // Drained unconditionally and tolerantly: when the turn ends by
974
+ // abort the channel rejects too, and re-awaiting a settled
975
+ // rejection would rethrow the very error the branch below has
976
+ // already decided to absorb.
977
+ await drainDetachedPump(pump, "GoogleAIStudio");
978
+ if (turnFailure !== undefined) {
979
+ // A turn ended by its own time budget or by the caller is not
980
+ // a provider failure. Rethrowing one would surface a deadline
981
+ // as a network error and, worse, send the caller down an
982
+ // unbounded fallback path right after a blown budget. A
983
+ // provider timeout keeps throwing exactly as before, because
984
+ // classifying it is what produces the timeout error callers
985
+ // already handle.
986
+ if (turnClock.expired || options.abortSignal?.aborted) {
987
+ wasAborted = true;
988
+ }
989
+ else {
990
+ logger.error("[GoogleAIStudio] Native SDK error", turnFailure);
991
+ throw this.handleProviderError(turnFailure);
992
+ }
993
+ }
994
+ if (engineResult) {
995
+ totalInputTokens += engineResult.usage.inputTokens;
996
+ totalOutputTokens += engineResult.usage.outputTokens;
997
+ totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
998
+ totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
999
+ lastFinishReason =
1000
+ engineResult.rawStopReason ?? lastFinishReason;
1001
+ if (engineResult.aborted) {
1002
+ wasAborted = true;
1003
+ }
1004
+ }
1005
+ if (composedSignal.aborted) {
1006
+ wasAborted = true;
875
1007
  }
876
- await pump;
877
- totalInputTokens += engineResult.usage.inputTokens;
878
- totalOutputTokens += engineResult.usage.outputTokens;
879
- totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
880
- totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
881
1008
  // The turn produced a final answer when the model stopped
882
1009
  // calling tools of its own accord, rather than being cut off at
883
1010
  // the cap.
884
- const completedWithFinalAnswer = engineResult.toolCalls.length === 0 ||
885
- engineResult.finishReason !== "tool-calls";
1011
+ const completedWithFinalAnswer = engineResult !== undefined &&
1012
+ (engineResult.toolCalls.length === 0 ||
1013
+ engineResult.finishReason !== "tool-calls");
886
1014
  // Handle max-steps termination: if the model was still calling
887
1015
  // tools when we hit the limit, push a synthetic final message.
888
- const hitStepLimitWithoutFinalAnswer = step >= maxSteps && !completedWithFinalAnswer;
1016
+ // An aborted turn is NOT a step-cap turn — a killed 3-step turn
1017
+ // claiming it "reached the 200-step limit" is the exact
1018
+ // mislabeling this loop is being fixed for.
1019
+ const hitStepLimitWithoutFinalAnswer = !wasAborted && step >= maxSteps && !completedWithFinalAnswer;
889
1020
  if (hitStepLimitWithoutFinalAnswer) {
890
1021
  const fallback = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, "", // finalText is empty — model didn't stop on its own
891
1022
  lastStepText);
@@ -893,10 +1024,57 @@ export class GoogleAIStudioProvider extends BaseProvider {
893
1024
  channel.push({ content: fallback });
894
1025
  }
895
1026
  }
1027
+ else if (wasAborted && !completedWithFinalAnswer) {
1028
+ // Exactly one honest terminal chunk, matching the actual exit
1029
+ // cause, and only when the consumer has not already been given
1030
+ // the model's own prose.
1031
+ logger.warn(`[GoogleAIStudio] Tool call loop ended mid-turn ` +
1032
+ `(${hitTimeLimit() ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
1033
+ `returning an honest terminal message.`);
1034
+ channel.push({
1035
+ content: buildLoopExitMessage({
1036
+ timedOut: hitTimeLimit(),
1037
+ stalled: turnClock.stalled,
1038
+ elapsedMs: turnClock.elapsedMs(),
1039
+ wasAborted,
1040
+ ...(options.stallTimeoutMs !== undefined
1041
+ ? { stallTimeoutMs: options.stallTimeoutMs }
1042
+ : {}),
1043
+ maxSteps,
1044
+ toolCallCount: allToolCalls.length,
1045
+ }),
1046
+ });
1047
+ }
896
1048
  const responseTime = Date.now() - startTime;
1049
+ // Turn-exit discriminator, independent of the provider-shaped
1050
+ // finishReason — consumers branch on this instead of sniffing
1051
+ // strings. It reports only conditions this loop can actually
1052
+ // observe: it never claims a context-cap, because the reclaim
1053
+ // planner here has no stop-the-turn branch to report.
1054
+ const stopReason = resolveTurnStopReason({
1055
+ timedOut: hitTimeLimit(),
1056
+ stalled: turnClock.stalled,
1057
+ wasAborted,
1058
+ cappedWithoutAnswer: hitStepLimitWithoutFinalAnswer,
1059
+ finishReason: mapGeminiFinishReason(lastFinishReason),
1060
+ });
1061
+ if (stopReason !== "completed") {
1062
+ this.emitTurnEvent({
1063
+ phase: stopReason,
1064
+ step: stepsTaken,
1065
+ maxSteps,
1066
+ toolCallCount: allToolCalls.length,
1067
+ elapsedMs: turnClock.elapsedMs(),
1068
+ });
1069
+ }
897
1070
  // Update shared metadata so the returned object reflects final values.
898
1071
  metadata.responseTime = responseTime;
899
1072
  metadata.totalToolExecutions = allToolCalls.length;
1073
+ metadata.stopReason = stopReason;
1074
+ metadata.stepsUsed = stepsTaken;
1075
+ if (lastFinishReason !== undefined) {
1076
+ metadata.rawFinishReason = lastFinishReason;
1077
+ }
900
1078
  // Set token usage and finish reason on the span
901
1079
  span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
902
1080
  span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
@@ -929,6 +1107,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
929
1107
  },
930
1108
  requestDuration: responseTime,
931
1109
  timestamp: new Date().toISOString(),
1110
+ // Turn-lifecycle telemetry. The generate path gets these for
1111
+ // free because createAnalytics reads them off the result;
1112
+ // this path settles its own analytics, so they are set here.
1113
+ stepsUsed: stepsTaken,
1114
+ toolCallCount: allToolCalls.length,
1115
+ stopReason,
1116
+ elapsedMs: turnClock.elapsedMs(),
1117
+ ...(lastFinishReason !== undefined
1118
+ ? { rawFinishReason: lastFinishReason }
1119
+ : {}),
932
1120
  });
933
1121
  channel.close();
934
1122
  }
@@ -937,6 +1125,8 @@ export class GoogleAIStudioProvider extends BaseProvider {
937
1125
  analyticsReject(err);
938
1126
  }
939
1127
  finally {
1128
+ turnClock.dispose();
1129
+ upstreamSignal?.removeEventListener("abort", onUpstreamAbort);
940
1130
  timeoutController?.cleanup();
941
1131
  }
942
1132
  })();
@@ -990,6 +1180,10 @@ export class GoogleAIStudioProvider extends BaseProvider {
990
1180
  const startTime = Date.now();
991
1181
  const timeout = this.getTimeout(options);
992
1182
  const timeoutController = createTimeoutController(timeout, this.providerName, "generate");
1183
+ // Declared out here so the finally below can release the watchdog
1184
+ // timers and the caller-signal listener on every exit, including the
1185
+ // throws inside the try that never reach the clock's own scope.
1186
+ let releaseTurnResources = () => { };
993
1187
  try {
994
1188
  const apiKey = this.getApiKey();
995
1189
  const client = await createGoogleGenAIClient(apiKey, this.getBaseURL());
@@ -1064,7 +1258,44 @@ export class GoogleAIStudioProvider extends BaseProvider {
1064
1258
  wantsJsonOutput: wantsNativeJson,
1065
1259
  responseSchema: nativeResponseSchema,
1066
1260
  }, toolsConfig);
1067
- const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1261
+ // Abort fan-in — see the streaming twin for why the turn clock
1262
+ // drives an internal controller rather than composing a third
1263
+ // signal at each call site.
1264
+ const upstreamSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1265
+ const internalAbort = new AbortController();
1266
+ const onUpstreamAbort = () => internalAbort.abort();
1267
+ upstreamSignal?.addEventListener("abort", onUpstreamAbort);
1268
+ if (upstreamSignal?.aborted) {
1269
+ internalAbort.abort();
1270
+ }
1271
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
1272
+ // No `defaultTurnTimeoutMs`: the pre-existing whole-turn bound on
1273
+ // this path is `timeoutController`, and `hitTimeLimit()` reads it,
1274
+ // so no new default deadline is introduced by arming the clock.
1275
+ const turnClock = createTurnClock({
1276
+ ...(options.turnTimeoutMs !== undefined
1277
+ ? { turnTimeoutMs: options.turnTimeoutMs }
1278
+ : {}),
1279
+ ...(options.stallTimeoutMs !== undefined
1280
+ ? { stallTimeoutMs: options.stallTimeoutMs }
1281
+ : {}),
1282
+ ...(options.wrapupTimeLeadMs !== undefined
1283
+ ? { wrapupTimeLeadMs: options.wrapupTimeLeadMs }
1284
+ : {}),
1285
+ onDeadline: (kind) => {
1286
+ logger.warn(kind === "timeout"
1287
+ ? `[GoogleAIStudio] Native Gemini generate turn exceeded its ${options.turnTimeoutMs}ms time budget — aborting`
1288
+ : `[GoogleAIStudio] Native Gemini generate turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
1289
+ internalAbort.abort();
1290
+ },
1291
+ });
1292
+ const composedSignal = internalAbort.signal;
1293
+ releaseTurnResources = () => {
1294
+ turnClock.dispose();
1295
+ upstreamSignal?.removeEventListener("abort", onUpstreamAbort);
1296
+ };
1297
+ const hitTimeLimit = () => turnClock.timedOut ||
1298
+ timeoutController?.controller.signal.aborted === true;
1068
1299
  const maxSteps = computeMaxSteps(options.maxSteps);
1069
1300
  let finalText = "";
1070
1301
  let lastStepText = "";
@@ -1075,6 +1306,11 @@ export class GoogleAIStudioProvider extends BaseProvider {
1075
1306
  const allToolCalls = [];
1076
1307
  const toolExecutions = [];
1077
1308
  let step = 0;
1309
+ // Model calls made — see the streaming twin for why this is not
1310
+ // `step`.
1311
+ let stepsTaken = 0;
1312
+ let wasAborted = false;
1313
+ let lastFinishReason;
1078
1314
  // Cheap reclaim trigger — see the stream twin.
1079
1315
  const contextGuard = createContextGuard(getContextWindowSize("googleAiStudio", modelName));
1080
1316
  // Agentic loop for tool calling
@@ -1087,16 +1323,27 @@ export class GoogleAIStudioProvider extends BaseProvider {
1087
1323
  maxSteps,
1088
1324
  toolFailureBreaker: { maxRetries: DEFAULT_TOOL_MAX_RETRIES },
1089
1325
  liveTools: options.tools ?? {},
1326
+ toolGuards: {
1327
+ toolTimeoutMs: toolExecTimeoutMs,
1328
+ abortSignal: composedSignal,
1329
+ onProgress: () => turnClock.noteProgress(),
1330
+ },
1090
1331
  ...(declarationsResult ? { declarations: declarationsResult } : {}),
1091
1332
  buildRequest: (contents) => ({
1092
1333
  model: modelName,
1093
1334
  contents,
1094
1335
  config,
1095
- ...(composedSignal
1096
- ? { httpOptions: { signal: composedSignal } }
1097
- : {}),
1098
1336
  }),
1099
- sendStep: async (request) => client.models.generateContentStream(request),
1337
+ // See the streaming twin: `config.abortSignal` is the only
1338
+ // cancellation channel the SDK reads.
1339
+ sendStep: async (request, signal) => {
1340
+ turnClock.noteProgress();
1341
+ const built = request;
1342
+ return client.models.generateContentStream({
1343
+ ...built,
1344
+ config: { ...(built.config ?? {}), abortSignal: signal },
1345
+ });
1346
+ },
1100
1347
  noteUsage: (inputTokens, outputTokens) => {
1101
1348
  contextGuard.noteUsage(inputTokens, outputTokens);
1102
1349
  },
@@ -1114,6 +1361,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
1114
1361
  });
1115
1362
  const adapter = {
1116
1363
  ...baseAdapter,
1364
+ // Once per model call, including the final text-only step — see
1365
+ // the streaming twin.
1366
+ buildStepRequest: (contents, engineStep) => {
1367
+ stepsTaken = engineStep + 1;
1368
+ turnClock.noteProgress();
1369
+ return baseAdapter.buildStepRequest(contents, engineStep);
1370
+ },
1117
1371
  buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
1118
1372
  // Same as the streaming twin: the engine's step, not a count of
1119
1373
  // hook invocations. See the comment there.
@@ -1153,6 +1407,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
1153
1407
  });
1154
1408
  }
1155
1409
  const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
1410
+ // Wrap-up nudge — see the streaming twin.
1411
+ if (turnClock.shouldNudgeWrapup()) {
1412
+ const last = next[next.length - 1];
1413
+ if (last && Array.isArray(last.parts)) {
1414
+ last.parts.push({ text: buildWrapupNudgeText(false) });
1415
+ }
1416
+ }
1156
1417
  try {
1157
1418
  const appended = next[next.length - 1];
1158
1419
  contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
@@ -1164,40 +1425,102 @@ export class GoogleAIStudioProvider extends BaseProvider {
1164
1425
  },
1165
1426
  };
1166
1427
  // Same dedup routing as the streaming twin above.
1167
- const engineTools = buildDedupedEngineTools(declarationsResult, options.tools);
1428
+ const engineTools = buildDedupedEngineTools(declarationsResult, options.tools, {
1429
+ toolTimeoutMs: toolExecTimeoutMs,
1430
+ abortSignal: composedSignal,
1431
+ onProgress: () => turnClock.noteProgress(),
1432
+ });
1168
1433
  const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
1169
1434
  tools: engineTools,
1170
- ...(composedSignal ? { abortSignal: composedSignal } : {}),
1171
- // This loop guards no executor of its own — see the streaming
1172
- // path above for why the engine's bound has to be reachable.
1173
- ...(options.toolTimeoutMs !== undefined
1174
- ? { toolTimeoutMs: options.toolTimeoutMs }
1175
- : {}),
1435
+ abortSignal: composedSignal,
1436
+ toolTimeoutMs: toolExecTimeoutMs,
1176
1437
  });
1177
1438
  // Drained, not consumed: nothing streams out of generate(), but an
1178
1439
  // undrained channel would stall the engine mid-turn.
1179
1440
  const drain = (async () => {
1180
1441
  for await (const chunk of engineStream) {
1181
1442
  void chunk;
1443
+ turnClock.noteProgress();
1182
1444
  }
1183
1445
  })();
1184
1446
  let engineResult;
1447
+ let turnFailure;
1185
1448
  try {
1186
1449
  engineResult = await resultPromise;
1187
1450
  }
1188
1451
  catch (error) {
1189
- await drainDetachedPump(drain, "GoogleAIStudio");
1190
- logger.error("[GoogleAIStudio] Native SDK generate error", error);
1191
- throw this.handleProviderError(error);
1452
+ turnFailure = error;
1453
+ }
1454
+ await drainDetachedPump(drain, "GoogleAIStudio");
1455
+ if (turnFailure !== undefined) {
1456
+ // Same split as the streaming twin: a blown time budget or a
1457
+ // caller abort ends the turn honestly, a provider failure still
1458
+ // throws.
1459
+ if (turnClock.expired || options.abortSignal?.aborted) {
1460
+ wasAborted = true;
1461
+ }
1462
+ else {
1463
+ logger.error("[GoogleAIStudio] Native SDK generate error", turnFailure);
1464
+ throw this.handleProviderError(turnFailure);
1465
+ }
1466
+ }
1467
+ if (engineResult) {
1468
+ totalInputTokens += engineResult.usage.inputTokens;
1469
+ totalOutputTokens += engineResult.usage.outputTokens;
1470
+ totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
1471
+ totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
1472
+ lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
1473
+ finalText = engineResult.text;
1474
+ if (engineResult.aborted) {
1475
+ wasAborted = true;
1476
+ }
1477
+ }
1478
+ if (composedSignal.aborted) {
1479
+ wasAborted = true;
1480
+ }
1481
+ const hitStepLimitWithoutAnswer = !wasAborted && step >= maxSteps && !finalText;
1482
+ if (wasAborted && !finalText) {
1483
+ // Never the step-cap text for a turn the budget killed: prefer
1484
+ // the prose the model already produced, else an honest message
1485
+ // naming the actual exit cause.
1486
+ logger.warn(`[GoogleAIStudio] Generate tool call loop ended mid-turn ` +
1487
+ `(${hitTimeLimit() ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
1488
+ `returning gathered text or an honest terminal message.`);
1489
+ finalText =
1490
+ lastStepText ||
1491
+ buildLoopExitMessage({
1492
+ timedOut: hitTimeLimit(),
1493
+ stalled: turnClock.stalled,
1494
+ elapsedMs: turnClock.elapsedMs(),
1495
+ wasAborted,
1496
+ ...(options.stallTimeoutMs !== undefined
1497
+ ? { stallTimeoutMs: options.stallTimeoutMs }
1498
+ : {}),
1499
+ maxSteps,
1500
+ toolCallCount: allToolCalls.length,
1501
+ });
1502
+ }
1503
+ else {
1504
+ finalText = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, finalText, lastStepText);
1192
1505
  }
1193
- await drain;
1194
- totalInputTokens += engineResult.usage.inputTokens;
1195
- totalOutputTokens += engineResult.usage.outputTokens;
1196
- totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
1197
- totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
1198
- finalText = engineResult.text;
1199
- finalText = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, finalText, lastStepText);
1200
1506
  const responseTime = Date.now() - startTime;
1507
+ // Turn-exit discriminator — see the streaming twin.
1508
+ const stopReason = resolveTurnStopReason({
1509
+ timedOut: hitTimeLimit(),
1510
+ stalled: turnClock.stalled,
1511
+ wasAborted,
1512
+ cappedWithoutAnswer: hitStepLimitWithoutAnswer,
1513
+ finishReason: mapGeminiFinishReason(lastFinishReason),
1514
+ });
1515
+ if (stopReason !== "completed") {
1516
+ this.emitTurnEvent({
1517
+ phase: stopReason,
1518
+ step: stepsTaken,
1519
+ maxSteps,
1520
+ toolCallCount: allToolCalls.length,
1521
+ elapsedMs: turnClock.elapsedMs(),
1522
+ });
1523
+ }
1201
1524
  // Set token usage and finish reason on the span
1202
1525
  span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
1203
1526
  span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
@@ -1214,6 +1537,14 @@ export class GoogleAIStudioProvider extends BaseProvider {
1214
1537
  content: finalText,
1215
1538
  provider: this.providerName,
1216
1539
  model: modelName,
1540
+ // createAnalytics reads these off the result, so setting them
1541
+ // here is also what puts stepsUsed / stopReason / elapsedMs /
1542
+ // rawFinishReason on `result.analytics`.
1543
+ stopReason,
1544
+ stepsUsed: stepsTaken,
1545
+ ...(lastFinishReason !== undefined
1546
+ ? { rawFinishReason: lastFinishReason }
1547
+ : {}),
1217
1548
  usage: {
1218
1549
  input: adjustedInputTokens,
1219
1550
  // Thinking tokens are billed at the output rate but Gemini
@@ -1243,6 +1574,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
1243
1574
  return this.enhanceResult(baseResult, options, startTime);
1244
1575
  }
1245
1576
  finally {
1577
+ releaseTurnResources();
1246
1578
  timeoutController?.cleanup();
1247
1579
  }
1248
1580
  });