@juspay/neurolink 11.15.7 → 11.15.9

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,10 +49,13 @@ export function createGeminiLoopAdapter(config) {
49
49
  * step is callable in the same step, before the next refresh.
50
50
  */
51
51
  buildStepRequest(conversation, step) {
52
- if (config.declarations) {
53
- refreshNativeToolDeclarations(config.liveTools, config.declarations);
54
- }
55
- return { raw: config.buildRequest(conversation, step) };
52
+ const hydratedToolNames = config.declarations
53
+ ? refreshNativeToolDeclarations(config.liveTools, config.declarations)
54
+ : [];
55
+ return {
56
+ raw: config.buildRequest(conversation, step),
57
+ ...(hydratedToolNames.length > 0 ? { hydratedToolNames } : {}),
58
+ };
56
59
  },
57
60
  /**
58
61
  * The engine decides WHEN to reclaim; the provider decides HOW. Wiring
@@ -62,10 +65,7 @@ export function createGeminiLoopAdapter(config) {
62
65
  */
63
66
  ...(config.planReclaim
64
67
  ? {
65
- planReclaim: (conversation, step) => {
66
- const reclaimed = config.planReclaim?.(conversation, step);
67
- return reclaimed ? { conversation: reclaimed } : undefined;
68
- },
68
+ planReclaim: (conversation, step) => config.planReclaim?.(conversation, step),
69
69
  }
70
70
  : {}),
71
71
  /**
@@ -120,13 +120,26 @@ export function createGeminiLoopAdapter(config) {
120
120
  config.noteUsage?.(collected.inputTokens, collected.outputTokens);
121
121
  const text = extractTextFromParts(collected.rawResponseParts);
122
122
  // Names cross the engine boundary in their ORIGINAL form.
123
- const toolCalls = collected.stepFunctionCalls.map((call, index) => ({
123
+ const allCalls = collected.stepFunctionCalls.map((call, index) => ({
124
124
  id: `${config.providerLabel}_${index}_${call.name}`,
125
125
  name: originalNameFor(call.name),
126
126
  args: call.args,
127
127
  }));
128
+ // A call to the terminal structured-output tool is not a tool call at
129
+ // all — its arguments ARE the answer. Reporting it as text and leaving
130
+ // it out of `toolCalls` routes it through the engine's ordinary
131
+ // zero-tool-calls exit, so it is never dispatched, never struck against
132
+ // the breaker, and never recorded as a tool execution.
133
+ const terminal = config.finalResultToolName
134
+ ? allCalls.find((call) => call.name === config.finalResultToolName)
135
+ : undefined;
136
+ const toolCalls = terminal ? [] : allCalls;
137
+ const finalText = terminal ? JSON.stringify(terminal.args) : text;
138
+ if (terminal) {
139
+ config.onTerminalResult?.(finalText);
140
+ }
128
141
  return {
129
- text,
142
+ text: finalText,
130
143
  toolCalls,
131
144
  usage: {
132
145
  inputTokens: collected.inputTokens,
@@ -145,7 +158,12 @@ export function createGeminiLoopAdapter(config) {
145
158
  },
146
159
  };
147
160
  },
148
- buildToolResultMessages(conversation, stepResult, toolResults) {
161
+ buildToolResultMessages(conversation, stepResult, toolResults,
162
+ // Declared and unused: writing history needs no step number. It is here
163
+ // for the provider wrappers around this adapter, which record tool
164
+ // activity keyed by the loop's own step and must not count their own
165
+ // invocations to get it.
166
+ _step) {
149
167
  // Copied before `pushModelResponseToHistory` mutates it: the engine
150
168
  // treats the conversation as a value it hands in and gets back, so
151
169
  // mutating the caller's array in place would make a retried or
@@ -158,8 +176,13 @@ export function createGeminiLoopAdapter(config) {
158
176
  functionResponse: {
159
177
  // Back to the sanitized wire name the model actually called.
160
178
  name: sanitizedNameFor(result.name),
179
+ // The engine's own payload, verbatim. A blocked or burnt-out
180
+ // tool is reported as { error, status, do_not_retry }, and those
181
+ // extra fields are MODEL-VISIBLE instructions — re-wrapping just
182
+ // the message drops the "do not call this again" hint and the
183
+ // model keeps spending steps on a tool that will never work.
161
184
  response: result.error
162
- ? { error: result.error }
185
+ ? result.output
163
186
  : { result: result.output },
164
187
  },
165
188
  })),
@@ -30,6 +30,149 @@ function sumUsage(a, b) {
30
30
  reasoningTokens: (a.reasoningTokens ?? 0) + (b.reasoningTokens ?? 0) || undefined,
31
31
  };
32
32
  }
33
+ /**
34
+ * Dispatch one step's tool calls.
35
+ *
36
+ * Split out of `runAgenticLoop` because it is the one part of the turn with
37
+ * its own decision tree — breaker, hydration, execution, failure
38
+ * classification — and reading the loop should not mean reading all of it.
39
+ * It owns no state: everything it needs arrives as arguments, and it reports
40
+ * what happened by returning it, so the turn's accumulators stay in one place.
41
+ */
42
+ async function dispatchStepTools(params) {
43
+ const { calls, adapter, tools, failedTools, abortSignal } = params;
44
+ const toolResults = [];
45
+ const executions = [];
46
+ const dispatched = [];
47
+ let abortedMidBatch = false;
48
+ for (const call of calls) {
49
+ // Honour an abort BETWEEN tool executions. A step can carry several calls,
50
+ // and each one costs up to a full tool timeout, so without this a wide
51
+ // batch keeps running long past the moment the turn was cancelled — the
52
+ // step-top check only fires once the whole batch has drained. Passing the
53
+ // signal into execute() is not enough on its own: a tool that ignores it
54
+ // runs to completion, and every remaining call still gets STARTED.
55
+ if (abortSignal.aborted) {
56
+ abortedMidBatch = true;
57
+ break;
58
+ }
59
+ dispatched.push(call);
60
+ const breaker = adapter.toolFailureBreaker;
61
+ const failInfo = breaker ? failedTools.get(call.name) : undefined;
62
+ if (breaker && failInfo && failInfo.count >= breaker.maxRetries) {
63
+ const output = {
64
+ error: `TOOL_PERMANENTLY_FAILED: "${call.name}" has failed ${failInfo.count} times. Last error: ${failInfo.lastError}.`,
65
+ status: "permanently_failed",
66
+ do_not_retry: true,
67
+ };
68
+ toolResults.push({
69
+ ...call,
70
+ output,
71
+ error: output.error,
72
+ permanentlyFailed: true,
73
+ });
74
+ executions.push({
75
+ id: call.id,
76
+ name: call.name,
77
+ input: call.args,
78
+ output,
79
+ error: output.error,
80
+ });
81
+ continue;
82
+ }
83
+ // Second lookup path for adapters that discover tools mid-turn.
84
+ // The miss is defined as "nothing executable under this name"
85
+ // rather than "no key under this name", because the guard directly
86
+ // below already treats a present-but-unexecutable entry as absent —
87
+ // a deferred-catalog placeholder is exactly that shape, and it is
88
+ // precisely what hydration exists to resolve.
89
+ const declaredTool = tools?.[call.name];
90
+ const tool = declaredTool?.execute
91
+ ? declaredTool
92
+ : (adapter.resolveToolOnMiss?.(call.name) ?? declaredTool);
93
+ if (!tool?.execute) {
94
+ const output = breaker
95
+ ? {
96
+ error: `TOOL_NOT_FOUND: "${call.name}" does not exist.`,
97
+ status: "permanently_failed",
98
+ do_not_retry: true,
99
+ }
100
+ : { error: `Tool not found: ${call.name}` };
101
+ toolResults.push({
102
+ ...call,
103
+ output,
104
+ error: output.error,
105
+ permanentlyFailed: !!breaker,
106
+ });
107
+ executions.push({
108
+ id: call.id,
109
+ name: call.name,
110
+ input: call.args,
111
+ output,
112
+ error: output.error,
113
+ });
114
+ continue;
115
+ }
116
+ try {
117
+ const output = await tool.execute(call.args, {
118
+ toolCallId: call.id,
119
+ abortSignal: abortSignal,
120
+ });
121
+ // A result can report failure without throwing — an MCP isError
122
+ // payload, a proxy-blocked call resolving with `{ error }`. When
123
+ // the breaker is told how to recognise those, they strike it
124
+ // exactly as a throw does; otherwise the model can grind on a
125
+ // blocked tool for the whole step budget.
126
+ const resultFailure = breaker?.classifyResultFailure?.(output);
127
+ if (breaker) {
128
+ if (resultFailure) {
129
+ const current = failedTools.get(call.name) ?? {
130
+ count: 0,
131
+ lastError: "",
132
+ };
133
+ current.count++;
134
+ current.lastError = resultFailure;
135
+ failedTools.set(call.name, current);
136
+ }
137
+ else if (breaker.consecutive) {
138
+ // Genuinely consecutive: a clean result clears the count, so
139
+ // an argument-dependent soft error cannot accumulate its way
140
+ // to disabling a tool that works.
141
+ failedTools.delete(call.name);
142
+ }
143
+ }
144
+ toolResults.push({ ...call, output });
145
+ executions.push({
146
+ id: call.id,
147
+ name: call.name,
148
+ input: call.args,
149
+ output,
150
+ });
151
+ }
152
+ catch (err) {
153
+ const message = err instanceof Error ? err.message : String(err);
154
+ if (breaker) {
155
+ const current = failedTools.get(call.name) ?? {
156
+ count: 0,
157
+ lastError: "",
158
+ };
159
+ current.count++;
160
+ current.lastError = message;
161
+ failedTools.set(call.name, current);
162
+ }
163
+ const output = { error: message, status: "failed" };
164
+ toolResults.push({ ...call, output, error: message });
165
+ executions.push({
166
+ id: call.id,
167
+ name: call.name,
168
+ input: call.args,
169
+ output,
170
+ error: message,
171
+ });
172
+ }
173
+ }
174
+ return { toolResults, executions, dispatched, abortedMidBatch };
175
+ }
33
176
  /**
34
177
  * Run one adapter-parameterized agentic tool-calling turn. Owns the
35
178
  * maxSteps-bounded loop, generic tool dispatch (with an opt-in
@@ -73,11 +216,25 @@ export function runAgenticLoop(adapter, initialConversation, options) {
73
216
  }
74
217
  if (adapter.planReclaim) {
75
218
  const reclaimed = adapter.planReclaim(conversation, step);
76
- if (reclaimed) {
219
+ if (reclaimed?.conversation !== undefined) {
77
220
  conversation = reclaimed.conversation;
78
221
  }
222
+ // A guard that could not reclaim enough room ends the turn HERE,
223
+ // before the request goes out — stepping into a provider rejection
224
+ // would lose every completed step of the turn.
225
+ if (reclaimed?.stop) {
226
+ break;
227
+ }
79
228
  }
80
229
  const request = adapter.buildStepRequest(conversation, step);
230
+ // A tool that just became callable starts clean. Its TOOL_NOT_FOUND
231
+ // strikes were recorded against a name that genuinely did not resolve
232
+ // yet, and the breaker is consulted before the lookup, so without this
233
+ // a deferred tool the model named twice is refused for the rest of the
234
+ // turn at the exact moment it becomes usable.
235
+ for (const name of request.hydratedToolNames ?? []) {
236
+ failedTools.delete(name);
237
+ }
81
238
  // Pre-first-chunk 429/5xx retry: watch whether THIS attempt of
82
239
  // THIS step pushes anything to the shared channel before it
83
240
  // throws. `hasEmitted` resets at the top of every attempt
@@ -125,7 +282,8 @@ export function runAgenticLoop(adapter, initialConversation, options) {
125
282
  malformedRetryUsed = true;
126
283
  logger.warn(`[${adapter.providerLabel}] Malformed function call at step ${step + 1}/${adapter.maxSteps}; retrying once.`);
127
284
  conversation =
128
- adapter.buildMalformedRetryNote?.(conversation) ?? conversation;
285
+ adapter.buildMalformedRetryNote?.(conversation, step) ??
286
+ conversation;
129
287
  continue;
130
288
  }
131
289
  if (stepResult.toolCalls.length === 0) {
@@ -135,101 +293,27 @@ export function runAgenticLoop(adapter, initialConversation, options) {
135
293
  if (step === adapter.maxSteps - 1) {
136
294
  hadToolCallsAtCap = true;
137
295
  }
138
- const toolResults = [];
139
- for (const call of stepResult.toolCalls) {
140
- allToolCalls.push(call);
141
- const breaker = adapter.toolFailureBreaker;
142
- const failInfo = breaker ? failedTools.get(call.name) : undefined;
143
- if (breaker && failInfo && failInfo.count >= breaker.maxRetries) {
144
- const output = {
145
- error: `TOOL_PERMANENTLY_FAILED: "${call.name}" has failed ${failInfo.count} times. Last error: ${failInfo.lastError}.`,
146
- status: "permanently_failed",
147
- do_not_retry: true,
148
- };
149
- toolResults.push({
150
- ...call,
151
- output,
152
- error: output.error,
153
- permanentlyFailed: true,
154
- });
155
- allToolExecutions.push({
156
- id: call.id,
157
- name: call.name,
158
- input: call.args,
159
- output,
160
- error: output.error,
161
- });
162
- continue;
163
- }
164
- // Second lookup path for adapters that discover tools mid-turn.
165
- // The miss is defined as "nothing executable under this name"
166
- // rather than "no key under this name", because the guard directly
167
- // below already treats a present-but-unexecutable entry as absent —
168
- // a deferred-catalog placeholder is exactly that shape, and it is
169
- // precisely what hydration exists to resolve.
170
- const declaredTool = options.tools?.[call.name];
171
- const tool = declaredTool?.execute
172
- ? declaredTool
173
- : (adapter.resolveToolOnMiss?.(call.name) ?? declaredTool);
174
- if (!tool?.execute) {
175
- const output = breaker
176
- ? {
177
- error: `TOOL_NOT_FOUND: "${call.name}" does not exist.`,
178
- status: "permanently_failed",
179
- do_not_retry: true,
180
- }
181
- : { error: `Tool not found: ${call.name}` };
182
- toolResults.push({
183
- ...call,
184
- output,
185
- error: output.error,
186
- permanentlyFailed: !!breaker,
187
- });
188
- allToolExecutions.push({
189
- id: call.id,
190
- name: call.name,
191
- input: call.args,
192
- output,
193
- error: output.error,
194
- });
195
- continue;
196
- }
197
- try {
198
- const output = await tool.execute(call.args, {
199
- toolCallId: call.id,
200
- abortSignal: internalAbort.signal,
201
- });
202
- toolResults.push({ ...call, output });
203
- allToolExecutions.push({
204
- id: call.id,
205
- name: call.name,
206
- input: call.args,
207
- output,
208
- });
209
- }
210
- catch (err) {
211
- const message = err instanceof Error ? err.message : String(err);
212
- if (breaker) {
213
- const current = failedTools.get(call.name) ?? {
214
- count: 0,
215
- lastError: "",
216
- };
217
- current.count++;
218
- current.lastError = message;
219
- failedTools.set(call.name, current);
220
- }
221
- const output = { error: message, status: "failed" };
222
- toolResults.push({ ...call, output, error: message });
223
- allToolExecutions.push({
224
- id: call.id,
225
- name: call.name,
226
- input: call.args,
227
- output,
228
- error: message,
229
- });
230
- }
296
+ const dispatch = await dispatchStepTools({
297
+ calls: stepResult.toolCalls,
298
+ adapter,
299
+ tools: options.tools,
300
+ failedTools,
301
+ abortSignal: internalAbort.signal,
302
+ });
303
+ const toolResults = dispatch.toolResults;
304
+ allToolCalls.push(...dispatch.dispatched);
305
+ allToolExecutions.push(...dispatch.executions);
306
+ const abortedMidBatch = dispatch.abortedMidBatch;
307
+ // A batch cut short leaves some calls without results, and the
308
+ // tool-result turn is appended as one message: writing it here would
309
+ // put an unanswered tool call into history. Anthropic rejects exactly
310
+ // that on the next request, and Gemini carries a dangling call
311
+ // forward. Break instead, leaving history ending on the model turn —
312
+ // which is a valid place to stop.
313
+ if (abortedMidBatch) {
314
+ break;
231
315
  }
232
- conversation = adapter.buildToolResultMessages(conversation, stepResult, toolResults);
316
+ conversation = adapter.buildToolResultMessages(conversation, stepResult, toolResults, step);
233
317
  }
234
318
  const finishReason = adapter.mapFinishReason(rawStopReason, hadToolCallsAtCap);
235
319
  return {
@@ -1637,7 +1637,7 @@ export class AnthropicProvider extends BaseProvider {
1637
1637
  // instead would batch every step's tools into one late write.
1638
1638
  const adapter = {
1639
1639
  ...baseAdapter,
1640
- buildToolResultMessages: (conversation, stepResult, toolResults) => {
1640
+ buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
1641
1641
  for (const result of toolResults) {
1642
1642
  toolsUsed.push(result.name);
1643
1643
  }
@@ -1673,7 +1673,7 @@ export class AnthropicProvider extends BaseProvider {
1673
1673
  : String(storageErr),
1674
1674
  });
1675
1675
  });
1676
- return baseAdapter.buildToolResultMessages(conversation, stepResult, toolResults);
1676
+ return baseAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
1677
1677
  },
1678
1678
  };
1679
1679
  // Presented in the shape the engine dispatches through. The engine
@@ -769,7 +769,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
769
769
  return undefined;
770
770
  }
771
771
  contextGuard.resetAfterReclaim();
772
- return working;
772
+ return { conversation: working };
773
773
  },
774
774
  });
775
775
  // Wrapped because these fire once PER STEP in the loop this
@@ -779,8 +779,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
779
779
  // late write and lose the per-step thought signature.
780
780
  const adapter = {
781
781
  ...baseAdapter,
782
- buildToolResultMessages: (contents, stepResult, toolResults) => {
783
- step++;
782
+ buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
783
+ // The engine's own step, not a count of times this hook ran.
784
+ // The two agree only while nothing skips the hook mid-turn:
785
+ // a malformed-call retry `continue`s before it and still
786
+ // consumes a step, so a self-incrementing counter drifts by
787
+ // exactly the number of retries and mislabels every row
788
+ // after the first. Values are unchanged for this provider
789
+ // today — it enables no such retry — and stay correct if it
790
+ // ever does.
791
+ step = engineStep + 1;
784
792
  for (const call of stepResult.toolCalls) {
785
793
  span.addEvent("gen_ai.tool_call", {
786
794
  "tool.name": call.name,
@@ -822,7 +830,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
822
830
  });
823
831
  });
824
832
  }
825
- const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults);
833
+ const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
826
834
  // Project this step's growth: the appended tool results ride
827
835
  // the next prompt, which the provider has not reported on yet.
828
836
  try {
@@ -1093,13 +1101,15 @@ export class GoogleAIStudioProvider extends BaseProvider {
1093
1101
  return undefined;
1094
1102
  }
1095
1103
  contextGuard.resetAfterReclaim();
1096
- return working;
1104
+ return { conversation: working };
1097
1105
  },
1098
1106
  });
1099
1107
  const adapter = {
1100
1108
  ...baseAdapter,
1101
- buildToolResultMessages: (contents, stepResult, toolResults) => {
1102
- step++;
1109
+ buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
1110
+ // Same as the streaming twin: the engine's step, not a count of
1111
+ // hook invocations. See the comment there.
1112
+ step = engineStep + 1;
1103
1113
  for (const call of stepResult.toolCalls) {
1104
1114
  span.addEvent("gen_ai.tool_call", {
1105
1115
  "tool.name": call.name,
@@ -1134,7 +1144,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
1134
1144
  });
1135
1145
  });
1136
1146
  }
1137
- const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults);
1147
+ const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
1138
1148
  try {
1139
1149
  const appended = next[next.length - 1];
1140
1150
  contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
@@ -9,7 +9,7 @@
9
9
  * providers so they can share a single implementation.
10
10
  */
11
11
  import type { GenerateStopReason, ThinkingConfig, AgenticLoopOptions, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, StreamChannel, VertexNativePart, GeminiMultimodalInput, MultimodalAudioEntry } from "../../types/index.js";
12
- import type { Tool } from "../../types/index.js";
12
+ import type { Tool, GeminiToolExecutionGuards } from "../../types/index.js";
13
13
  /**
14
14
  * A per-turn tool execute map that deduplicates identical tool calls.
15
15
  *
@@ -108,8 +108,8 @@ export declare function buildNativeToolDeclarations(tools: Record<string, Tool>,
108
108
  * identical in every test that calls a tool once, and silently reintroduces
109
109
  * duplicate side effects the moment the model repeats itself.
110
110
  */
111
- export declare function buildDedupedEngineTools(declarations: NativeToolDeclarationsResult | undefined, tools: Record<string, Tool> | undefined): NonNullable<AgenticLoopOptions["tools"]>;
112
- export declare function refreshNativeToolDeclarations(liveTools: Record<string, Tool> | undefined, current: NativeToolDeclarationsResult): boolean;
111
+ export declare function buildDedupedEngineTools(declarations: NativeToolDeclarationsResult | undefined, tools: Record<string, Tool> | undefined, guards?: GeminiToolExecutionGuards): NonNullable<AgenticLoopOptions["tools"]>;
112
+ export declare function refreshNativeToolDeclarations(liveTools: Record<string, Tool> | undefined, current: NativeToolDeclarationsResult): string[];
113
113
  /**
114
114
  * Build the native @google/genai config object shared by stream and generate.
115
115
  *
@@ -18,6 +18,7 @@ import { resolveSamplingParams } from "../../models/modelRegistry.js";
18
18
  import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../../utils/schemaConversion.js";
19
19
  import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
20
20
  import { resolveLiveTool } from "../../tools/toolDiscovery.js";
21
+ import { raceWithAbort, withTimeout } from "../../utils/async/index.js";
21
22
  import { jsonSchema as aiJsonSchema, tool as createAISDKTool, } from "../../utils/tool.js";
22
23
  // ── Functions ──
23
24
  /** Stable, key-order-independent serialization of tool args for the dedup key. */
@@ -472,17 +473,50 @@ export function buildNativeToolDeclarations(tools, reservedNames) {
472
473
  * identical in every test that calls a tool once, and silently reintroduces
473
474
  * duplicate side effects the moment the model repeats itself.
474
475
  */
475
- export function buildDedupedEngineTools(declarations, tools) {
476
+ export function buildDedupedEngineTools(declarations, tools, guards) {
476
477
  const engineTools = {};
478
+ /**
479
+ * Everything a loop needs around a tool call that the engine does not do
480
+ * itself, in one place so both the declared and the fallback path get it.
481
+ *
482
+ * Order matters. `raceWithAbort` sits INSIDE `withTimeout` so a turn-level
483
+ * abort is observed the moment it fires rather than after the tool settles,
484
+ * and the timeout still bounds a tool that neither settles nor honours its
485
+ * signal. The progress pings bracket the await because the stall watchdog
486
+ * is a whole-turn interval comparing wall-clock against the last progress
487
+ * mark — without them a legitimately slow tool reads as a stalled turn and
488
+ * gets killed.
489
+ */
490
+ const guard = (name, execute) => {
491
+ return async (args, opts) => {
492
+ const call = () => Promise.resolve(execute(args, opts));
493
+ if (!guards) {
494
+ return call();
495
+ }
496
+ guards.onProgress?.();
497
+ try {
498
+ const raced = guards.abortSignal
499
+ ? raceWithAbort(call(), guards.abortSignal)
500
+ : call();
501
+ return await (guards.toolTimeoutMs === undefined
502
+ ? raced
503
+ : withTimeout(raced, guards.toolTimeoutMs, `Tool "${name}" execution timed out after ${guards.toolTimeoutMs}ms`));
504
+ }
505
+ finally {
506
+ // In `finally`, not after a successful await: a tool that times out or
507
+ // throws has still consumed real time, and skipping the mark there
508
+ // would leave the watchdog measuring from before the call.
509
+ guards.onProgress?.();
510
+ }
511
+ };
512
+ };
477
513
  if (declarations) {
478
514
  for (const [safeName, originalName] of declarations.originalNameMap) {
479
515
  const execute = declarations.executeMap.get(safeName);
480
516
  if (!execute) {
481
517
  continue;
482
518
  }
483
- engineTools[originalName] = {
484
- execute: async (args, opts) => execute(args, opts),
485
- };
519
+ engineTools[originalName] = { execute: guard(originalName, execute) };
486
520
  }
487
521
  return engineTools;
488
522
  }
@@ -494,20 +528,18 @@ export function buildDedupedEngineTools(declarations, tools) {
494
528
  if (!execute) {
495
529
  continue;
496
530
  }
497
- engineTools[name] = {
498
- execute: async (args, opts) => execute(args, opts),
499
- };
531
+ engineTools[name] = { execute: guard(name, execute) };
500
532
  }
501
533
  return engineTools;
502
534
  }
503
535
  export function refreshNativeToolDeclarations(liveTools, current) {
504
536
  if (!liveTools) {
505
- return false;
537
+ return [];
506
538
  }
507
539
  const declaredOriginals = new Set(current.originalNameMap.values());
508
540
  const missing = Object.entries(liveTools).filter(([name]) => !declaredOriginals.has(name));
509
541
  if (missing.length === 0) {
510
- return false;
542
+ return [];
511
543
  }
512
544
  const built = buildNativeToolDeclarations(Object.fromEntries(missing), new Set(current.originalNameMap.keys()));
513
545
  current.toolsConfig[0].functionDeclarations.push(...built.toolsConfig[0].functionDeclarations);
@@ -520,7 +552,12 @@ export function refreshNativeToolDeclarations(liveTools, current) {
520
552
  logger.info(`[buildNativeToolDeclarations] ${missing.length} tool(s) hydrated mid-turn via discovery: ${missing
521
553
  .map(([name]) => name)
522
554
  .join(", ")}`);
523
- return true;
555
+ // The ORIGINAL names, which is what a caller's breaker is keyed by. A tool
556
+ // that accrued TOOL_NOT_FOUND strikes while it was still deferred was never
557
+ // really failing — those strikes are snapshot artifacts, and leaving them in
558
+ // place disables the tool for the rest of the turn at the very moment it
559
+ // becomes callable.
560
+ return missing.map(([name]) => name);
524
561
  }
525
562
  /**
526
563
  * Build the native @google/genai config object shared by stream and generate.