@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.
- package/CHANGELOG.md +1 -5
- package/dist/browser/neurolink.min.js +362 -362
- package/dist/core/geminiLoopAdapter.js +35 -12
- package/dist/core/loopEngine.js +180 -96
- package/dist/providers/anthropic/client.js +2 -2
- package/dist/providers/googleAiStudio/client.js +18 -8
- package/dist/providers/googleNativeGemini3/utils.d.ts +3 -3
- package/dist/providers/googleNativeGemini3/utils.js +47 -10
- package/dist/providers/googleVertex/client.js +447 -788
- package/dist/types/loopEngine.d.ts +108 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
53
|
-
refreshNativeToolDeclarations(config.liveTools, config.declarations)
|
|
54
|
-
|
|
55
|
-
return {
|
|
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
|
|
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
|
-
?
|
|
185
|
+
? result.output
|
|
163
186
|
: { result: result.output },
|
|
164
187
|
},
|
|
165
188
|
})),
|
package/dist/core/loopEngine.js
CHANGED
|
@@ -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) ??
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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):
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|