@juspay/neurolink 12.12.16 → 12.13.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.
@@ -24,6 +24,9 @@
24
24
  import { resolveDeferredTool } from "../../tools/toolDiscovery.js";
25
25
  import { stringifyAnthropicToolOutput } from "./toolOutput.js";
26
26
  import { stringifyFinalResultInput } from "./structuredOutput.js";
27
+ import { composeAbortSignalsScoped, createTimeoutController, } from "../../utils/timeout.js";
28
+ import { NeuroLinkError } from "../../utils/errorHandling.js";
29
+ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
27
30
  /** Map Anthropic's stop_reason onto the unified finish reason. */
28
31
  function mapAnthropicFinishReason(rawStopReason, hadToolCallsAtCap) {
29
32
  switch (rawStopReason) {
@@ -37,6 +40,298 @@ function mapAnthropicFinishReason(rawStopReason, hadToolCallsAtCap) {
37
40
  return hadToolCallsAtCap ? "tool-calls" : "stop";
38
41
  }
39
42
  }
43
+ /**
44
+ * Read one streamed Anthropic step off the wire.
45
+ *
46
+ * Module-level rather than nested inside `executeStep` because it IS the step:
47
+ * folding an SSE event sequence into content blocks, usage and a stop reason.
48
+ * `executeStep` above it is now only the per-request deadline wiring, which is
49
+ * the part a reader looking for "what bounds this request" needs to find.
50
+ */
51
+ async function readAnthropicStep({ params, client, channel, stepSignal, requestTimeoutSignal, noteObservedPromptTokens, finalResultToolName, onTerminalResult, requireTerminalEvent, }) {
52
+ // Single assertion, not a double: `messages.create` returns a union of
53
+ // Message and Stream, and Stream<RawMessageStreamEvent> already IS an
54
+ // AsyncIterable of that event, so the two types overlap and the
55
+ // compiler still checks the narrowing.
56
+ const events = (await client.messages.create({ ...params, stream: true },
57
+ // The engine's signal composed with this step's own deadline.
58
+ // runAgenticLoop already derives the engine signal from the caller's
59
+ // abortSignal, so preferring config's would ignore engine-initiated
60
+ // cancellation entirely.
61
+ { signal: stepSignal }));
62
+ const textByIndex = new Map();
63
+ const toolByIndex = new Map();
64
+ const thinkingByIndex = new Map();
65
+ const redactedByIndex = new Map();
66
+ let text = "";
67
+ let reasoning = "";
68
+ let rawStopReason;
69
+ let inputTokens = 0;
70
+ let outputTokens = 0;
71
+ let cacheReadTokens = 0;
72
+ let cacheWriteTokens = 0;
73
+ // Reported alongside the total, not derivable from it: the two TTL
74
+ // tiers are priced differently, so a caller that reports them (the
75
+ // Claude-on-Vertex turn span does) cannot reconstruct the split from
76
+ // cacheWriteTokens alone.
77
+ let cacheWrite5mTokens = 0;
78
+ let cacheWrite1hTokens = 0;
79
+ let stepOutputTokens = 0;
80
+ // Anthropic closes every complete message with `message_stop`. Its
81
+ // absence is the only signal that the response ended early, because the
82
+ // content blocks that DID arrive are syntactically complete.
83
+ let sawTerminalEvent = false;
84
+ for await (const rawEvent of events) {
85
+ if (stepSignal.aborted) {
86
+ break;
87
+ }
88
+ // Narrowed through the SDK's own discriminated union rather than
89
+ // re-declared: `RawMessageStreamEvent` already describes every event
90
+ // shape, so `event.type` checks below are compiler-checked instead of
91
+ // asserted.
92
+ const event = rawEvent;
93
+ if (event.type === "message_start") {
94
+ const usage = event.message?.usage;
95
+ inputTokens += usage?.input_tokens ?? 0;
96
+ const startOutput = usage?.output_tokens ?? 0;
97
+ outputTokens += startOutput - stepOutputTokens;
98
+ stepOutputTokens = startOutput;
99
+ // Anthropic reports cache reads/writes separately from input_tokens
100
+ // on this same event; without these the stream drops all cache
101
+ // accounting.
102
+ cacheReadTokens += usage?.cache_read_input_tokens ?? 0;
103
+ cacheWriteTokens += usage?.cache_creation_input_tokens ?? 0;
104
+ // BEST EFFORT, and the limit is worth stating. The nested TTL
105
+ // breakdown exists only on `Usage` (this event); `MessageDeltaUsage`
106
+ // carries the cache TOTALS but not the split, so message_start is
107
+ // the only place in the raw event stream it can come from. The
108
+ // pre-migration loop read it off `stream.finalMessage()` — the SDK's
109
+ // ACCUMULATED message — so if the API leaves `cache_creation` null
110
+ // here and fills it only on the assembled message, these two stay
111
+ // zero and the totals above remain correct regardless.
112
+ // Reported as undefined rather than a false zero when absent.
113
+ cacheWrite5mTokens +=
114
+ usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
115
+ cacheWrite1hTokens +=
116
+ usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
117
+ // The guard calibrates from the FULL prompt size, not input_tokens
118
+ // alone: on a cache hit the uncached remainder is tiny and using it
119
+ // would let the guard drift far under the real cost.
120
+ noteObservedPromptTokens?.((usage?.input_tokens ?? 0) +
121
+ (usage?.cache_read_input_tokens ?? 0) +
122
+ (usage?.cache_creation_input_tokens ?? 0));
123
+ continue;
124
+ }
125
+ if (event.type === "content_block_start") {
126
+ const index = event.index ?? 0;
127
+ // A redacted_thinking block carries its whole payload here and
128
+ // produces no deltas, so if it is not captured on this event it is
129
+ // never seen again. Anthropic validates the thinking chain when
130
+ // extended thinking continues across a tool-use turn, so a missing
131
+ // one fails the NEXT request — and only for accounts where safety
132
+ // redaction actually triggers, which is why it survives testing.
133
+ if (event.content_block?.type === "redacted_thinking") {
134
+ const data = event.content_block.data;
135
+ if (typeof data === "string") {
136
+ redactedByIndex.set(index, data);
137
+ }
138
+ }
139
+ if (event.content_block?.type === "tool_use") {
140
+ toolByIndex.set(index, {
141
+ id: event.content_block.id ?? "",
142
+ name: event.content_block.name ?? "",
143
+ inputJson: "",
144
+ });
145
+ }
146
+ continue;
147
+ }
148
+ if (event.type === "content_block_delta") {
149
+ const index = event.index ?? 0;
150
+ const delta = event.delta;
151
+ if (delta?.type === "text_delta" && delta.text) {
152
+ text += delta.text;
153
+ textByIndex.set(index, (textByIndex.get(index) ?? "") + delta.text);
154
+ channel.push({ content: delta.text });
155
+ }
156
+ else if (delta?.type === "thinking_delta" && delta.thinking) {
157
+ const acc = thinkingByIndex.get(index) ?? {
158
+ text: "",
159
+ signature: "",
160
+ };
161
+ acc.text += delta.thinking;
162
+ thinkingByIndex.set(index, acc);
163
+ reasoning += delta.thinking;
164
+ // Reasoning rides its own field; `content` stays a present string
165
+ // so plain-text consumers are unaffected.
166
+ channel.push({ content: "", reasoning: delta.thinking });
167
+ }
168
+ else if (delta?.type === "signature_delta" && delta.signature) {
169
+ const acc = thinkingByIndex.get(index) ?? {
170
+ text: "",
171
+ signature: "",
172
+ };
173
+ acc.signature += delta.signature;
174
+ thinkingByIndex.set(index, acc);
175
+ }
176
+ else if (delta?.type === "input_json_delta" && delta.partial_json) {
177
+ const pending = toolByIndex.get(index);
178
+ if (pending) {
179
+ pending.inputJson += delta.partial_json;
180
+ }
181
+ }
182
+ continue;
183
+ }
184
+ if (event.type === "message_delta") {
185
+ rawStopReason = event.delta?.stop_reason ?? rawStopReason;
186
+ const cumulative = event.usage?.output_tokens ?? stepOutputTokens;
187
+ outputTokens += cumulative - stepOutputTokens;
188
+ stepOutputTokens = cumulative;
189
+ continue;
190
+ }
191
+ if (event.type === "message_stop") {
192
+ sawTerminalEvent = true;
193
+ }
194
+ }
195
+ // Ordered ahead of the terminal-event check because a deadline that
196
+ // fired IS why the terminal event is missing, and the timer's identity
197
+ // is the more useful of the two answers. The SDK's stream iterator exits
198
+ // without throwing on an aborted read, so nothing else would report it:
199
+ // the step would return the partial content as though the model had
200
+ // simply said less.
201
+ if (requestTimeoutSignal?.aborted) {
202
+ throw requestTimeoutSignal.reason;
203
+ }
204
+ // A response that ended before its terminal event is not a turn. Its
205
+ // tool_use blocks parse perfectly, so returning them here would dispatch
206
+ // tools the model never finished asking for, and the turn would go on to
207
+ // report itself as a normal stop. Not retriable: the request was already
208
+ // answered, and re-sending it would double any side effect the truncated
209
+ // half already caused upstream.
210
+ if (requireTerminalEvent && !sawTerminalEvent && !stepSignal.aborted) {
211
+ throw new NeuroLinkError({
212
+ code: "ANTHROPIC_STREAM_TRUNCATED",
213
+ message: "Anthropic stream ended before its terminal message_stop event; the turn is incomplete and its content blocks were not dispatched.",
214
+ category: ErrorCategory.NETWORK,
215
+ severity: ErrorSeverity.HIGH,
216
+ retriable: false,
217
+ context: { provider: "anthropic", rawStopReason },
218
+ });
219
+ }
220
+ // JSON.parse happily yields null, an array or a primitive, and an
221
+ // assertion converts none of them. Both `tool_use.input` and
222
+ // AgenticLoopToolCall.args require an object, so anything else becomes
223
+ // {} rather than being passed through as invalid wire content.
224
+ const parseArgs = (json) => {
225
+ if (!json) {
226
+ return {};
227
+ }
228
+ try {
229
+ const parsed = JSON.parse(json);
230
+ return typeof parsed === "object" &&
231
+ parsed !== null &&
232
+ !Array.isArray(parsed)
233
+ ? parsed
234
+ : {};
235
+ }
236
+ catch {
237
+ return {};
238
+ }
239
+ };
240
+ // Rebuild the assistant turn in wire order so it can be replayed as the
241
+ // assistant message on the next step. Text that arrived before a tool
242
+ // call has to survive here, or the model stops seeing its own reasoning
243
+ // mid-turn.
244
+ const blocks = [];
245
+ const indices = new Set([
246
+ ...textByIndex.keys(),
247
+ ...toolByIndex.keys(),
248
+ ...thinkingByIndex.keys(),
249
+ ...redactedByIndex.keys(),
250
+ ]);
251
+ for (const index of [...indices].sort((a, b) => a - b)) {
252
+ // Thinking blocks are replayed with their signature. Anthropic
253
+ // validates that signature when extended thinking continues across
254
+ // turns, so dropping the block — or keeping the text without the
255
+ // signature — breaks the next step of a thinking turn.
256
+ const redacted = redactedByIndex.get(index);
257
+ if (redacted) {
258
+ blocks.push({ type: "redacted_thinking", data: redacted });
259
+ }
260
+ const thinking = thinkingByIndex.get(index);
261
+ // Both halves required: Anthropic's thinking block carries a
262
+ // mandatory signature, and replaying one with an empty string is
263
+ // rejected outright. A thinking block that never received a
264
+ // signature_delta is dropped rather than sent unsigned.
265
+ if (thinking?.text && thinking.signature) {
266
+ blocks.push({
267
+ type: "thinking",
268
+ thinking: thinking.text,
269
+ signature: thinking.signature,
270
+ });
271
+ }
272
+ const blockText = textByIndex.get(index);
273
+ if (blockText) {
274
+ blocks.push({
275
+ type: "text",
276
+ text: blockText,
277
+ });
278
+ }
279
+ const pending = toolByIndex.get(index);
280
+ if (pending) {
281
+ blocks.push({
282
+ type: "tool_use",
283
+ id: pending.id,
284
+ name: pending.name,
285
+ input: parseArgs(pending.inputJson),
286
+ });
287
+ }
288
+ }
289
+ const allCalls = [...toolByIndex.values()].map((pending) => ({
290
+ id: pending.id,
291
+ name: pending.name,
292
+ args: parseArgs(pending.inputJson),
293
+ }));
294
+ // A terminal structured-output call ends the turn: its arguments are the
295
+ // answer. Reporting it as text and leaving it out of `toolCalls` is what
296
+ // routes it through the engine's ordinary zero-tool-calls exit, so it is
297
+ // never dispatched, never counted against the breaker, and never shows
298
+ // up as a tool execution.
299
+ const terminal = finalResultToolName
300
+ ? [...toolByIndex.values()].find((pending) => pending.name === finalResultToolName)
301
+ : undefined;
302
+ const toolCalls = terminal ? [] : allCalls;
303
+ // The RAW accumulated input_json, never the parsed-then-restringified
304
+ // args. `parseArgs` yields {} for a payload the token cap cut off
305
+ // mid-string, so re-stringifying would turn a truncated answer into
306
+ // "{}" and lose it outright. `stringifyFinalResultInput` canonicalizes
307
+ // when the JSON parses and returns it verbatim when it does not, which
308
+ // is what lets the caller's coercion layer repair a partial payload
309
+ // into a partial object instead of nothing.
310
+ const finalText = terminal
311
+ ? stringifyFinalResultInput(terminal.inputJson)
312
+ : text;
313
+ if (terminal) {
314
+ onTerminalResult?.(finalText);
315
+ }
316
+ return {
317
+ text: finalText,
318
+ ...(reasoning ? { reasoning } : {}),
319
+ toolCalls,
320
+ usage: {
321
+ inputTokens,
322
+ outputTokens,
323
+ cacheReadTokens,
324
+ cacheWriteTokens,
325
+ // Omitted entirely when the stream never reported a split, so a
326
+ // consumer can tell "no TTL breakdown available" from "zero tokens
327
+ // in that tier".
328
+ ...(cacheWrite5mTokens ? { cacheWrite5mTokens } : {}),
329
+ ...(cacheWrite1hTokens ? { cacheWrite1hTokens } : {}),
330
+ },
331
+ rawStopReason,
332
+ raw: blocks,
333
+ };
334
+ }
40
335
  export function createAnthropicLoopAdapter(config) {
41
336
  return {
42
337
  providerLabel: "anthropic",
@@ -79,254 +374,67 @@ export function createAnthropicLoopAdapter(config) {
79
374
  },
80
375
  async executeStep(request, channel, signal) {
81
376
  const params = request.raw;
82
- // Single assertion, not a double: `messages.create` returns a union of
83
- // Message and Stream, and Stream<RawMessageStreamEvent> already IS an
84
- // AsyncIterable of that event, so the two types overlap and the
85
- // compiler still checks the narrowing.
86
- const events = (await config.client.messages.create({ ...params, stream: true },
87
- // The engine's signal, not config's: runAgenticLoop already derives
88
- // it from the caller's abortSignal, so preferring config's would
89
- // ignore engine-initiated cancellation entirely.
90
- { signal }));
91
- const textByIndex = new Map();
92
- const toolByIndex = new Map();
93
- const thinkingByIndex = new Map();
94
- const redactedByIndex = new Map();
95
- let text = "";
96
- let reasoning = "";
97
- let rawStopReason;
98
- let inputTokens = 0;
99
- let outputTokens = 0;
100
- let cacheReadTokens = 0;
101
- let cacheWriteTokens = 0;
102
- // Reported alongside the total, not derivable from it: the two TTL
103
- // tiers are priced differently, so a caller that reports them (the
104
- // Claude-on-Vertex turn span does) cannot reconstruct the split from
105
- // cacheWriteTokens alone.
106
- let cacheWrite5mTokens = 0;
107
- let cacheWrite1hTokens = 0;
108
- let stepOutputTokens = 0;
109
- for await (const rawEvent of events) {
110
- if (signal.aborted) {
111
- break;
112
- }
113
- // Narrowed through the SDK's own discriminated union rather than
114
- // re-declared: `RawMessageStreamEvent` already describes every event
115
- // shape, so `event.type` checks below are compiler-checked instead of
116
- // asserted.
117
- const event = rawEvent;
118
- if (event.type === "message_start") {
119
- const usage = event.message?.usage;
120
- inputTokens += usage?.input_tokens ?? 0;
121
- const startOutput = usage?.output_tokens ?? 0;
122
- outputTokens += startOutput - stepOutputTokens;
123
- stepOutputTokens = startOutput;
124
- // Anthropic reports cache reads/writes separately from input_tokens
125
- // on this same event; without these the stream drops all cache
126
- // accounting.
127
- cacheReadTokens += usage?.cache_read_input_tokens ?? 0;
128
- cacheWriteTokens += usage?.cache_creation_input_tokens ?? 0;
129
- // BEST EFFORT, and the limit is worth stating. The nested TTL
130
- // breakdown exists only on `Usage` (this event); `MessageDeltaUsage`
131
- // carries the cache TOTALS but not the split, so message_start is
132
- // the only place in the raw event stream it can come from. The
133
- // pre-migration loop read it off `stream.finalMessage()` — the SDK's
134
- // ACCUMULATED message — so if the API leaves `cache_creation` null
135
- // here and fills it only on the assembled message, these two stay
136
- // zero and the totals above remain correct regardless.
137
- // Reported as undefined rather than a false zero when absent.
138
- cacheWrite5mTokens +=
139
- usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
140
- cacheWrite1hTokens +=
141
- usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
142
- // The guard calibrates from the FULL prompt size, not input_tokens
143
- // alone: on a cache hit the uncached remainder is tiny and using it
144
- // would let the guard drift far under the real cost.
145
- config.noteObservedPromptTokens?.((usage?.input_tokens ?? 0) +
146
- (usage?.cache_read_input_tokens ?? 0) +
147
- (usage?.cache_creation_input_tokens ?? 0));
148
- continue;
149
- }
150
- if (event.type === "content_block_start") {
151
- const index = event.index ?? 0;
152
- // A redacted_thinking block carries its whole payload here and
153
- // produces no deltas, so if it is not captured on this event it is
154
- // never seen again. Anthropic validates the thinking chain when
155
- // extended thinking continues across a tool-use turn, so a missing
156
- // one fails the NEXT request — and only for accounts where safety
157
- // redaction actually triggers, which is why it survives testing.
158
- if (event.content_block?.type === "redacted_thinking") {
159
- const data = event.content_block.data;
160
- if (typeof data === "string") {
161
- redactedByIndex.set(index, data);
162
- }
163
- }
164
- if (event.content_block?.type === "tool_use") {
165
- toolByIndex.set(index, {
166
- id: event.content_block.id ?? "",
167
- name: event.content_block.name ?? "",
168
- inputJson: "",
169
- });
170
- }
171
- continue;
172
- }
173
- if (event.type === "content_block_delta") {
174
- const index = event.index ?? 0;
175
- const delta = event.delta;
176
- if (delta?.type === "text_delta" && delta.text) {
177
- text += delta.text;
178
- textByIndex.set(index, (textByIndex.get(index) ?? "") + delta.text);
179
- channel.push({ content: delta.text });
180
- }
181
- else if (delta?.type === "thinking_delta" && delta.thinking) {
182
- const acc = thinkingByIndex.get(index) ?? {
183
- text: "",
184
- signature: "",
185
- };
186
- acc.text += delta.thinking;
187
- thinkingByIndex.set(index, acc);
188
- reasoning += delta.thinking;
189
- // Reasoning rides its own field; `content` stays a present string
190
- // so plain-text consumers are unaffected.
191
- channel.push({ content: "", reasoning: delta.thinking });
192
- }
193
- else if (delta?.type === "signature_delta" && delta.signature) {
194
- const acc = thinkingByIndex.get(index) ?? {
195
- text: "",
196
- signature: "",
197
- };
198
- acc.signature += delta.signature;
199
- thinkingByIndex.set(index, acc);
200
- }
201
- else if (delta?.type === "input_json_delta" && delta.partial_json) {
202
- const pending = toolByIndex.get(index);
203
- if (pending) {
204
- pending.inputJson += delta.partial_json;
205
- }
206
- }
207
- continue;
208
- }
209
- if (event.type === "message_delta") {
210
- rawStopReason = event.delta?.stop_reason ?? rawStopReason;
211
- const cumulative = event.usage?.output_tokens ?? stepOutputTokens;
212
- outputTokens += cumulative - stepOutputTokens;
213
- stepOutputTokens = cumulative;
214
- }
377
+ // Per-REQUEST deadline, armed fresh for this step and disposed the moment
378
+ // it settles. Being armed per step is what makes it unresettable from a
379
+ // step boundary: the boundary runs between two steps, when no request
380
+ // deadline is running at all.
381
+ //
382
+ // Scoped composition rather than AbortSignal.any: the engine signal lives
383
+ // for the whole turn, and `any` keeps its registration on that signal
384
+ // until the derived one is collected, so composing per step accumulates
385
+ // listeners for the turn's whole length.
386
+ const requestTimeout = config.requestTimeoutMs
387
+ ? createTimeoutController(config.requestTimeoutMs, "anthropic", "stream")
388
+ : null;
389
+ const composed = composeAbortSignalsScoped(signal, requestTimeout?.controller.signal);
390
+ try {
391
+ return await readAnthropicStep({
392
+ params,
393
+ client: config.client,
394
+ channel,
395
+ // The engine's signal, not config's: runAgenticLoop already derives
396
+ // it from the caller's abortSignal, so preferring config's would
397
+ // ignore engine-initiated cancellation entirely.
398
+ stepSignal: composed.signal ?? signal,
399
+ requestTimeoutSignal: requestTimeout?.controller.signal,
400
+ ...(config.noteObservedPromptTokens
401
+ ? { noteObservedPromptTokens: config.noteObservedPromptTokens }
402
+ : {}),
403
+ ...(config.finalResultToolName
404
+ ? { finalResultToolName: config.finalResultToolName }
405
+ : {}),
406
+ ...(config.onTerminalResult
407
+ ? { onTerminalResult: config.onTerminalResult }
408
+ : {}),
409
+ ...(config.requireTerminalEvent
410
+ ? { requireTerminalEvent: config.requireTerminalEvent }
411
+ : {}),
412
+ });
215
413
  }
216
- // JSON.parse happily yields null, an array or a primitive, and an
217
- // assertion converts none of them. Both `tool_use.input` and
218
- // AgenticLoopToolCall.args require an object, so anything else becomes
219
- // {} rather than being passed through as invalid wire content.
220
- const parseArgs = (json) => {
221
- if (!json) {
222
- return {};
223
- }
224
- try {
225
- const parsed = JSON.parse(json);
226
- return typeof parsed === "object" &&
227
- parsed !== null &&
228
- !Array.isArray(parsed)
229
- ? parsed
230
- : {};
231
- }
232
- catch {
233
- return {};
234
- }
235
- };
236
- // Rebuild the assistant turn in wire order so it can be replayed as the
237
- // assistant message on the next step. Text that arrived before a tool
238
- // call has to survive here, or the model stops seeing its own reasoning
239
- // mid-turn.
240
- const blocks = [];
241
- const indices = new Set([
242
- ...textByIndex.keys(),
243
- ...toolByIndex.keys(),
244
- ...thinkingByIndex.keys(),
245
- ...redactedByIndex.keys(),
246
- ]);
247
- for (const index of [...indices].sort((a, b) => a - b)) {
248
- // Thinking blocks are replayed with their signature. Anthropic
249
- // validates that signature when extended thinking continues across
250
- // turns, so dropping the block — or keeping the text without the
251
- // signature — breaks the next step of a thinking turn.
252
- const redacted = redactedByIndex.get(index);
253
- if (redacted) {
254
- blocks.push({ type: "redacted_thinking", data: redacted });
255
- }
256
- const thinking = thinkingByIndex.get(index);
257
- // Both halves required: Anthropic's thinking block carries a
258
- // mandatory signature, and replaying one with an empty string is
259
- // rejected outright. A thinking block that never received a
260
- // signature_delta is dropped rather than sent unsigned.
261
- if (thinking?.text && thinking.signature) {
262
- blocks.push({
263
- type: "thinking",
264
- thinking: thinking.text,
265
- signature: thinking.signature,
266
- });
267
- }
268
- const blockText = textByIndex.get(index);
269
- if (blockText) {
270
- blocks.push({
271
- type: "text",
272
- text: blockText,
273
- });
274
- }
275
- const pending = toolByIndex.get(index);
276
- if (pending) {
277
- blocks.push({
278
- type: "tool_use",
279
- id: pending.id,
280
- name: pending.name,
281
- input: parseArgs(pending.inputJson),
282
- });
283
- }
414
+ finally {
415
+ composed.dispose();
416
+ requestTimeout?.cleanup();
284
417
  }
285
- const allCalls = [...toolByIndex.values()].map((pending) => ({
286
- id: pending.id,
287
- name: pending.name,
288
- args: parseArgs(pending.inputJson),
289
- }));
290
- // A terminal structured-output call ends the turn: its arguments are the
291
- // answer. Reporting it as text and leaving it out of `toolCalls` is what
292
- // routes it through the engine's ordinary zero-tool-calls exit, so it is
293
- // never dispatched, never counted against the breaker, and never shows
294
- // up as a tool execution.
295
- const terminal = config.finalResultToolName
296
- ? [...toolByIndex.values()].find((pending) => pending.name === config.finalResultToolName)
297
- : undefined;
298
- const toolCalls = terminal ? [] : allCalls;
299
- // The RAW accumulated input_json, never the parsed-then-restringified
300
- // args. `parseArgs` yields {} for a payload the token cap cut off
301
- // mid-string, so re-stringifying would turn a truncated answer into
302
- // "{}" and lose it outright. `stringifyFinalResultInput` canonicalizes
303
- // when the JSON parses and returns it verbatim when it does not, which
304
- // is what lets the caller's coercion layer repair a partial payload
305
- // into a partial object instead of nothing.
306
- const finalText = terminal
307
- ? stringifyFinalResultInput(terminal.inputJson)
308
- : text;
309
- if (terminal) {
310
- config.onTerminalResult?.(finalText);
418
+ },
419
+ appendPlanningNudge(conversation, text) {
420
+ // Merged into the trailing user turn when there is one. The step boundary
421
+ // runs immediately after `buildToolResultMessages`, so history ends on a
422
+ // user tool_result message; opening a second consecutive user message
423
+ // there is a shape the wire format does not require anyone to accept.
424
+ const nudgeBlock = { type: "text", text };
425
+ const last = conversation[conversation.length - 1];
426
+ if (last?.role === "user" && Array.isArray(last.content)) {
427
+ const merged = {
428
+ ...last,
429
+ content: [...last.content, nudgeBlock],
430
+ };
431
+ return [...conversation.slice(0, -1), merged];
311
432
  }
312
- return {
313
- text: finalText,
314
- ...(reasoning ? { reasoning } : {}),
315
- toolCalls,
316
- usage: {
317
- inputTokens,
318
- outputTokens,
319
- cacheReadTokens,
320
- cacheWriteTokens,
321
- // Omitted entirely when the stream never reported a split, so a
322
- // consumer can tell "no TTL breakdown available" from "zero tokens
323
- // in that tier".
324
- ...(cacheWrite5mTokens ? { cacheWrite5mTokens } : {}),
325
- ...(cacheWrite1hTokens ? { cacheWrite1hTokens } : {}),
326
- },
327
- rawStopReason,
328
- raw: blocks,
433
+ const message = {
434
+ role: "user",
435
+ content: [nudgeBlock],
329
436
  };
437
+ return [...conversation, message];
330
438
  },
331
439
  buildToolResultMessages(conversation, stepResult, toolResults) {
332
440
  const assistantMessage = {
@@ -851,6 +851,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
851
851
  const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
852
852
  tools: engineTools,
853
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
+ : {}),
854
861
  });
855
862
  const pump = (async () => {
856
863
  for await (const chunk of engineStream) {
@@ -1161,6 +1168,11 @@ export class GoogleAIStudioProvider extends BaseProvider {
1161
1168
  const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
1162
1169
  tools: engineTools,
1163
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
+ : {}),
1164
1176
  });
1165
1177
  // Drained, not consumed: nothing streams out of generate(), but an
1166
1178
  // undrained channel would stall the engine mid-turn.