@oh-my-pi/pi-ai 16.2.9 → 16.2.12

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.
@@ -5,6 +5,12 @@ import type {
5
5
  ThinkingContent,
6
6
  ToolCall,
7
7
  } from "../types";
8
+ import {
9
+ clearStreamingPartialJson,
10
+ getStreamingPartialJson,
11
+ type StreamingPartialJsonCarrier,
12
+ setStreamingPartialJson,
13
+ } from "../utils/block-symbols";
8
14
  import { AssistantMessageEventStream } from "../utils/event-stream";
9
15
  import { buildStringArgsResolver } from "./coercion";
10
16
  import { createInbandScanner } from "./factory";
@@ -36,6 +42,35 @@ function firstTokenIndex(text: string, tokens: readonly string[]): number {
36
42
  type OpenText = { index: number } | undefined;
37
43
  type OpenThinking = { index: number; text: string } | undefined;
38
44
 
45
+ type StreamingToolCall = ToolCall & StreamingPartialJsonCarrier;
46
+
47
+ function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
48
+ const block: StreamingToolCall = {
49
+ type: "toolCall",
50
+ id: source.id,
51
+ name: source.name,
52
+ arguments: source.arguments,
53
+ ...(source.rawBlock !== undefined ? { rawBlock: source.rawBlock } : {}),
54
+ };
55
+ const partialJson = getStreamingPartialJson(source);
56
+ if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
57
+ return block;
58
+ }
59
+
60
+ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): void {
61
+ target.id = source.id;
62
+ target.name = source.name;
63
+ target.arguments = source.arguments;
64
+ target.rawBlock = source.rawBlock;
65
+ const partialJson = getStreamingPartialJson(source);
66
+ if (partialJson === undefined) clearStreamingPartialJson(target);
67
+ else setStreamingPartialJson(target, partialJson);
68
+ }
69
+
70
+ function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
71
+ return source !== undefined && source.name.trim().length > 0;
72
+ }
73
+
39
74
  export function parseInbandToolMessage(
40
75
  message: AssistantMessage,
41
76
  dialect: Dialect,
@@ -95,12 +130,18 @@ export function wrapInbandToolStream(
95
130
  // projector ignores nameless "ghost" parts and de-conflicts with the
96
131
  // in-band channel.
97
132
  const src = event.partial.content[event.contentIndex];
98
- projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src.name : "");
133
+ projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src : undefined);
99
134
  break;
100
135
  }
101
- case "toolcall_delta":
102
- projector?.nativeToolDelta(event.contentIndex, event.delta);
136
+ case "toolcall_delta": {
137
+ const src = event.partial.content[event.contentIndex];
138
+ projector?.nativeToolDelta(
139
+ event.contentIndex,
140
+ event.delta,
141
+ src?.type === "toolCall" ? src : undefined,
142
+ );
103
143
  break;
144
+ }
104
145
  case "toolcall_end":
105
146
  projector?.nativeToolEnd(event.contentIndex, event.toolCall);
106
147
  break;
@@ -138,7 +179,7 @@ class InbandStreamProjector {
138
179
  // `contentIndex`. `#toolChannel` records which channel produced the turn's
139
180
  // first real call so the other is dropped — no double-dispatch, and no
140
181
  // guessing from emptiness. Nameless "ghost" parts never lock a channel.
141
- #nativeBlocks = new Map<number, { index: number; block: ToolCall }>();
182
+ #nativeBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
142
183
  #toolChannel: "native" | "inband" | undefined;
143
184
 
144
185
  constructor(
@@ -167,27 +208,33 @@ class InbandStreamProjector {
167
208
  this.#partial.content.push(block);
168
209
  }
169
210
 
170
- // Forward a native tool call's lifecycle live. `name` comes from the inner
171
- // stream's partial (set at start for well-behaved providers). Empty `name`
172
- // means a not-yet-identified or "ghost" call skip until `nativeToolEnd`
173
- // can confirm. Once the in-band channel owns the turn, native calls are
174
- // dropped to avoid double-dispatch.
175
- nativeToolStart(srcIndex: number, name: string): void {
176
- if (this.#stopped || !name || this.#toolChannel === "inband") return;
211
+ // Forward a native tool call's lifecycle live. `source` comes from the inner
212
+ // stream's current partial block. When owned mode wraps a provider that still
213
+ // emits native tool calls, the projected block must mirror the provider's live
214
+ // id / args / partial-json state rather than inventing `{ id: "", arguments:
215
+ // {} }` placeholders — otherwise the UI loses streaming args, can mis-key the
216
+ // call until `toolcall_end`.
217
+ nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
218
+ if (this.#stopped || !hasNamedNativeToolCall(source) || this.#toolChannel === "inband") return;
177
219
  this.#toolChannel = "native";
178
220
  this.#closeText();
179
221
  this.#closeThinking();
180
- const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
222
+ const block = cloneToolCall(source);
181
223
  this.#partial.content.push(block);
182
224
  const index = this.#partial.content.length - 1;
183
225
  this.#nativeBlocks.set(srcIndex, { index, block });
184
226
  if (this.#emitEvents) this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
185
227
  }
186
228
 
187
- nativeToolDelta(srcIndex: number, delta: string): void {
229
+ nativeToolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
188
230
  if (this.#stopped) return;
189
- const entry = this.#nativeBlocks.get(srcIndex);
231
+ let entry = this.#nativeBlocks.get(srcIndex);
232
+ if (!entry && hasNamedNativeToolCall(source) && this.#toolChannel !== "inband") {
233
+ this.nativeToolStart(srcIndex, source);
234
+ entry = this.#nativeBlocks.get(srcIndex);
235
+ }
190
236
  if (!entry) return;
237
+ if (source) syncToolCall(entry.block, source);
191
238
  if (this.#emitEvents)
192
239
  this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
193
240
  }
@@ -196,7 +243,7 @@ class InbandStreamProjector {
196
243
  if (this.#stopped) return;
197
244
  const entry = this.#nativeBlocks.get(srcIndex);
198
245
  if (entry) {
199
- Object.assign(entry.block, toolCall);
246
+ syncToolCall(entry.block, toolCall);
200
247
  if (this.#emitEvents)
201
248
  this.#out.push({
202
249
  type: "toolcall_end",
@@ -210,11 +257,11 @@ class InbandStreamProjector {
210
257
  // Never streamed (name was empty at start). Salvage a real call whose name
211
258
  // only arrived now; drop nameless ghosts and anything the in-band channel
212
259
  // already claimed.
213
- if (!toolCall.name || this.#toolChannel === "inband") return;
260
+ if (!hasNamedNativeToolCall(toolCall) || this.#toolChannel === "inband") return;
214
261
  this.#toolChannel = "native";
215
262
  this.#closeText();
216
263
  this.#closeThinking();
217
- const block: ToolCall = { ...toolCall };
264
+ const block = cloneToolCall(toolCall);
218
265
  this.#partial.content.push(block);
219
266
  const index = this.#partial.content.length - 1;
220
267
  if (this.#emitEvents) {
@@ -1,7 +1,8 @@
1
1
  import { partialSuffixOverlapAny } from "./coercion";
2
+ import { FencedThinkingScanner } from "./fenced-thinking";
2
3
  import type { InbandScanEvent, InbandScanner } from "./types";
3
4
 
4
- type Tag = { readonly open: string; readonly close: string };
5
+ type Tag = { readonly open: string; readonly close: string; readonly fenced?: boolean };
5
6
 
6
7
  /**
7
8
  * Every dialect's in-band thinking section in its canonical `renderThinking`
@@ -18,7 +19,7 @@ const TAGS: readonly Tag[] = [
18
19
  { open: "<think>", close: "</think>" }, // deepseek, glm, hermes, kimi, qwen3 (and anthropic/minimax/xml)
19
20
  { open: "<thinking>", close: "</thinking>" }, // anthropic, minimax, xml
20
21
  { open: "<scratchpad>", close: "</scratchpad>" }, // anthropic
21
- { open: "```thinking\n", close: "```" }, // gemini fenced thinking
22
+ { open: "```thinking\n", close: "```", fenced: true }, // gemini fenced thinking
22
23
  { open: "<|channel>thought\n", close: "<channel|>" }, // gemma reasoning channel
23
24
  { open: "<|start|>assistant<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (rendered)
24
25
  { open: "<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (bare leak)
@@ -29,6 +30,8 @@ export class ThinkingInbandScanner implements InbandScanner {
29
30
  #buffer = "";
30
31
  #closeTag = "";
31
32
  #thinking = "";
33
+ /** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
34
+ #fenced: FencedThinkingScanner | undefined;
32
35
 
33
36
  feed(text: string): InbandScanEvent[] {
34
37
  if (text.length === 0) return [];
@@ -52,7 +55,22 @@ export class ThinkingInbandScanner implements InbandScanner {
52
55
 
53
56
  #consume(final: boolean): InbandScanEvent[] {
54
57
  const events: InbandScanEvent[] = [];
55
- while (this.#buffer.length > 0) {
58
+ for (;;) {
59
+ if (this.#fenced) {
60
+ // Run even with an empty buffer so a held partial close flushes on final.
61
+ const result = this.#fenced.feed(this.#buffer, final);
62
+ this.#buffer = result.closed ? result.rest : "";
63
+ this.#emitThinking(result.thinking, events);
64
+ if (result.closed || final) {
65
+ events.push({ type: "thinkingEnd", thinking: this.#thinking });
66
+ this.#thinking = "";
67
+ this.#closeTag = "";
68
+ this.#fenced = undefined;
69
+ }
70
+ if (this.#fenced) break;
71
+ continue;
72
+ }
73
+ if (this.#buffer.length === 0) break;
56
74
  if (this.#closeTag) {
57
75
  const close = this.#buffer.indexOf(this.#closeTag);
58
76
  if (close === -1) {
@@ -81,6 +99,7 @@ export class ThinkingInbandScanner implements InbandScanner {
81
99
  this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
82
100
  this.#closeTag = tag.close;
83
101
  this.#thinking = "";
102
+ if (tag.fenced) this.#fenced = new FencedThinkingScanner();
84
103
  events.push({ type: "thinkingStart" });
85
104
  }
86
105
  return events;
package/src/index.ts CHANGED
@@ -45,4 +45,5 @@ export * from "./utils/openrouter-headers";
45
45
  export * from "./utils/retry";
46
46
  export * from "./utils/schema";
47
47
  export * from "./utils/thinking-loop";
48
+ export * from "./utils/tool-call-loop-guard";
48
49
  export * from "./utils/validation";
@@ -102,7 +102,13 @@ import {
102
102
  WriteSuccessSchema,
103
103
  } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
104
104
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
105
- import { $env, parseJsonWithRepair, parseStreamingJson, sanitizeText } from "@oh-my-pi/pi-utils";
105
+ import {
106
+ $env,
107
+ parseJsonWithRepair,
108
+ parseStreamingJson,
109
+ parseStreamingJsonThrottled,
110
+ sanitizeText,
111
+ } from "@oh-my-pi/pi-utils";
106
112
  import * as AIError from "../error";
107
113
  import type {
108
114
  Api,
@@ -129,6 +135,7 @@ import {
129
135
  clearStreamingPartialJson,
130
136
  kStreamingBlockIndex,
131
137
  kStreamingBlockKind,
138
+ kStreamingLastParseLen,
132
139
  kStreamingPartialJson,
133
140
  } from "../utils/block-symbols";
134
141
  import { deterministicUuid } from "../utils/deterministic-id";
@@ -620,6 +627,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
620
627
  export type ToolCallState = ToolCall & {
621
628
  [kStreamingBlockIndex]: number;
622
629
  [kStreamingPartialJson]?: string;
630
+ [kStreamingLastParseLen]?: number;
623
631
  [kStreamingBlockKind]: "mcp" | "todo";
624
632
  };
625
633
 
@@ -2105,8 +2113,16 @@ export function processInteractionUpdate(
2105
2113
  if (chunk.length === 0) {
2106
2114
  return;
2107
2115
  }
2108
- state.currentToolCall[kStreamingPartialJson] = current + chunk;
2109
- state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall[kStreamingPartialJson]);
2116
+ const nextBuffer = current + chunk;
2117
+ state.currentToolCall[kStreamingPartialJson] = nextBuffer;
2118
+ // Throttle mid-stream parses to keep total parse work O(N) instead of O(N²)
2119
+ // in the argument-buffer length; the authoritative full parse runs in
2120
+ // `toolCallCompleted` (mcp branch) and the fallback end-of-stream path.
2121
+ const throttled = parseStreamingJsonThrottled(nextBuffer, state.currentToolCall[kStreamingLastParseLen] ?? 0);
2122
+ if (throttled) {
2123
+ state.currentToolCall.arguments = throttled.value;
2124
+ state.currentToolCall[kStreamingLastParseLen] = throttled.parsedLen;
2125
+ }
2110
2126
  const idx = output.content.indexOf(state.currentToolCall);
2111
2127
  stream.push({ type: "toolcall_delta", contentIndex: idx, delta: chunk, partial: output });
2112
2128
  }
@@ -2114,6 +2130,12 @@ export function processInteractionUpdate(
2114
2130
  if (state.currentToolCall) {
2115
2131
  const toolCall = update.message.value.toolCall;
2116
2132
  if (state.currentToolCall[kStreamingBlockKind] === "mcp") {
2133
+ // Authoritative full parse of the accumulated argument buffer; the delta
2134
+ // path throttles mid-stream parses, so `arguments` may lag the buffer.
2135
+ const partial = state.currentToolCall[kStreamingPartialJson];
2136
+ if (partial !== undefined) {
2137
+ state.currentToolCall.arguments = parseStreamingJson(partial);
2138
+ }
2117
2139
  const decodedArgs = decodeMcpArgsMap(toolCall?.mcpToolCall?.args?.args);
2118
2140
  state.currentToolCall.arguments = mergeCursorMcpToolCallArgs(
2119
2141
  state.currentToolCall.arguments as Record<string, unknown> | undefined,
@@ -28,7 +28,7 @@ import {
28
28
  StopReason,
29
29
  } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb";
30
30
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
31
- import { logger, parseStreamingJson } from "@oh-my-pi/pi-utils";
31
+ import { logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
32
32
  import * as AIError from "../error";
33
33
  import type {
34
34
  Api,
@@ -105,6 +105,11 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
105
105
  // accumulated per id (kept out of the content object so finalized tool calls stay clean).
106
106
  const toolBlocks = new Map<string, ToolCall>();
107
107
  const toolPartialJson = new Map<string, string>();
108
+ // Last-parsed argument-buffer length per tool-call id — bounds the
109
+ // mid-stream parse work to O(N) via `parseStreamingJsonThrottled`; the
110
+ // authoritative final parse still runs unconditionally in the toolcall_end
111
+ // loop below.
112
+ const toolLastParseLen = new Map<string, number>();
108
113
  let activeToolCallId: string | undefined;
109
114
  let latestStopReason = StopReason.UNSPECIFIED;
110
115
 
@@ -279,7 +284,11 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
279
284
  : previousJson + tc.argumentsJson;
280
285
  const delta = accumulated.slice(previousJson.length);
281
286
  toolPartialJson.set(toolCallId, accumulated);
282
- block.arguments = parseStreamingJson(accumulated);
287
+ const throttled = parseStreamingJsonThrottled(accumulated, toolLastParseLen.get(toolCallId) ?? 0);
288
+ if (throttled) {
289
+ block.arguments = throttled.value;
290
+ toolLastParseLen.set(toolCallId, throttled.parsedLen);
291
+ }
283
292
  stream.push({
284
293
  type: "toolcall_delta",
285
294
  contentIndex: output.content.indexOf(block),
@@ -26,7 +26,9 @@ import type {
26
26
  Model,
27
27
  SimpleStreamOptions,
28
28
  } from "../types";
29
+ import { createAbortSourceTracker } from "../utils/abort";
29
30
  import { AssistantMessageEventStream } from "../utils/event-stream";
31
+ import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
30
32
 
31
33
  /**
32
34
  * Fields that must not cross the wire — either non-serializable (functions,
@@ -47,6 +49,13 @@ const NON_WIRE_KEYS = new Set<keyof SimpleStreamOptions>([
47
49
  "cursorOnToolResult",
48
50
  "providerSessionState",
49
51
  ]);
52
+ const PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR = "pi-native stream stalled while waiting for the next event";
53
+ const PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR = "pi-native stream timed out while waiting for the first event";
54
+
55
+ function isPiNativeProgressEvent(event: unknown): boolean {
56
+ if (typeof event !== "object" || event === null || !("type" in event)) return true;
57
+ return event.type !== "start";
58
+ }
50
59
 
51
60
  function buildWireOptions(options: SimpleStreamOptions | undefined): Record<string, unknown> {
52
61
  if (!options) return {};
@@ -134,7 +143,8 @@ export function streamPiNative<TApi extends Api>(
134
143
  const stream = new AssistantMessageEventStream();
135
144
 
136
145
  void (async () => {
137
- const signal = options?.signal;
146
+ const callerSignal = options?.signal;
147
+ const abortTracker = createAbortSourceTracker(callerSignal);
138
148
  // Abort propagation: cancel the response body when the caller's signal
139
149
  // fires. Mirror `streamProxy`'s shape — explicit listener + finally
140
150
  // cleanup — so we don't leak listeners on the long-running case.
@@ -143,12 +153,16 @@ export function streamPiNative<TApi extends Api>(
143
153
  const body = response?.body;
144
154
  if (body) body.cancel("Request aborted by caller").catch(() => {});
145
155
  };
146
- if (signal) {
147
- if (signal.aborted) {
148
- stream.fail(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted")));
156
+ if (callerSignal) {
157
+ if (callerSignal.aborted) {
158
+ stream.fail(
159
+ callerSignal.reason instanceof Error
160
+ ? callerSignal.reason
161
+ : new Error(String(callerSignal.reason ?? "aborted")),
162
+ );
149
163
  return;
150
164
  }
151
- signal.addEventListener("abort", onAbort, { once: true });
165
+ callerSignal.addEventListener("abort", onAbort, { once: true });
152
166
  }
153
167
 
154
168
  try {
@@ -165,7 +179,7 @@ export function streamPiNative<TApi extends Api>(
165
179
  stream: true,
166
180
  });
167
181
 
168
- response = await fetchImpl(url, { method: "POST", headers, body, signal });
182
+ response = await fetchImpl(url, { method: "POST", headers, body, signal: abortTracker.requestSignal });
169
183
  if (!response.ok) {
170
184
  stream.fail(await decodeGatewayError(response));
171
185
  return;
@@ -177,11 +191,25 @@ export function streamPiNative<TApi extends Api>(
177
191
  return;
178
192
  }
179
193
 
180
- let sawTerminal = false;
181
- for await (const event of readSseJson<AssistantMessageEvent>(
194
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
195
+ const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
196
+ const source = readSseJson<AssistantMessageEvent>(
182
197
  response.body as ReadableStream<Uint8Array>,
183
- signal,
184
- )) {
198
+ abortTracker.requestSignal,
199
+ );
200
+ const watchedSource = iterateWithIdleTimeout(source, {
201
+ idleTimeoutMs,
202
+ firstItemTimeoutMs: firstEventTimeoutMs,
203
+ errorMessage: PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR,
204
+ firstItemErrorMessage: PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
205
+ onIdle: () =>
206
+ abortTracker.abortLocally(new AIError.StreamTimeoutError(PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR)),
207
+ onFirstItemTimeout: () =>
208
+ abortTracker.abortLocally(new AIError.StreamTimeoutError(PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
209
+ isProgressItem: isPiNativeProgressEvent,
210
+ });
211
+ let sawTerminal = false;
212
+ for await (const event of watchedSource) {
185
213
  if (event.type === "done" || event.type === "error") sawTerminal = true;
186
214
  stream.push(event);
187
215
  // `stream.push` resolves `.result()` on `done`/`error`; subsequent
@@ -195,7 +223,7 @@ export function streamPiNative<TApi extends Api>(
195
223
  // so awaiters of `.result()` resolve instead of hanging forever.
196
224
  // Matches the gateway's own defensive fallback in
197
225
  // `pi-native-server.encodeStream`.
198
- const aborted = signal?.aborted === true;
226
+ const aborted = abortTracker.wasCallerAbort();
199
227
  const partial = makeSyntheticAssistant(model as Model<Api>);
200
228
  if (aborted) {
201
229
  partial.stopReason = "aborted";
@@ -210,7 +238,7 @@ export function streamPiNative<TApi extends Api>(
210
238
  } catch (err) {
211
239
  stream.fail(err);
212
240
  } finally {
213
- if (signal) signal.removeEventListener("abort", onAbort);
241
+ if (callerSignal) callerSignal.removeEventListener("abort", onAbort);
214
242
  }
215
243
  })();
216
244
 
@@ -126,18 +126,20 @@ function deduplicateToolCallIds(
126
126
  }
127
127
 
128
128
  /**
129
- * Drop assistant `toolCall` blocks whose `name` is empty or whitespace-only,
129
+ * Drop assistant `toolCall` blocks whose `id` or `name` is empty / whitespace-only,
130
130
  * the `toolResult` messages they point at, and any assistant turn that has no
131
131
  * replayable content left.
132
132
  *
133
- * Models occasionally emit `{ "name": "", "arguments": "{}" }` (observed:
134
- * GLM-5.2 + thinking on long turns, #3458). The agent loop rejects the call
135
- * at execution time with `Tool not found`, but the malformed block and its
136
- * error tool-result stay in `currentContext.messages`, so every subsequent
137
- * request replays them. Every provider validates the function name
138
- * Anthropic 400s on `tool_use.name` (alongside an orphan `tool_result`),
139
- * OpenAI Chat Completions 400s on `tool_calls[i].function.name` wedging the
140
- * session in a 400 loop until manual `/clear`.
133
+ * Models occasionally emit malformed calls such as `{ "name": "", "arguments": "{}" }`
134
+ * (observed: GLM-5.2 + thinking on long turns, #3458) or a structurally valid
135
+ * `toolCall` whose provider/native passthrough id never materialized (`id: ""`).
136
+ * The agent loop rejects or skips these at execution time, but the malformed block
137
+ * and its error tool-result can stay in `currentContext.messages`, so every
138
+ * subsequent request replays them. Every provider validates the call shape —
139
+ * Anthropic 400s on `tool_use.name` / `tool_use.id` (alongside an orphan
140
+ * `tool_result`), OpenAI Chat Completions 400s on malformed
141
+ * `tool_calls[i].function.*` — wedging the session in a 400 loop until manual
142
+ * `/clear`.
141
143
  *
142
144
  * Run before any other transform so the rest of the pipeline never sees a
143
145
  * malformed call. Idempotent: a re-run on an already-sanitized list returns
@@ -147,13 +149,21 @@ function isMalformedToolCallName(name: string | undefined): boolean {
147
149
  return !name || name.trim().length === 0;
148
150
  }
149
151
 
152
+ function isMalformedToolCallId(id: string | undefined): boolean {
153
+ return !id || id.trim().length === 0;
154
+ }
155
+
156
+ function isMalformedToolCall(block: { id: string; name: string }): boolean {
157
+ return isMalformedToolCallId(block.id) || isMalformedToolCallName(block.name);
158
+ }
159
+
150
160
  function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
151
161
  // Fast path: skip the rewrite entirely when nothing is malformed.
152
162
  let hasMalformed = false;
153
163
  outer: for (const msg of messages) {
154
164
  if (msg.role !== "assistant") continue;
155
165
  for (const block of msg.content) {
156
- if (block.type === "toolCall" && isMalformedToolCallName(block.name)) {
166
+ if (block.type === "toolCall" && isMalformedToolCall(block)) {
157
167
  hasMalformed = true;
158
168
  break outer;
159
169
  }
@@ -179,7 +189,7 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
179
189
  const filtered: AssistantMessage["content"] = [];
180
190
  for (const block of msg.content) {
181
191
  if (block.type === "toolCall") {
182
- const malformed = isMalformedToolCallName(block.name);
192
+ const malformed = isMalformedToolCall(block);
183
193
  const queue = dropQueues.get(block.id);
184
194
  if (queue) queue.push(malformed);
185
195
  else dropQueues.set(block.id, [malformed]);
@@ -283,9 +293,10 @@ export function transformMessages<TApi extends Api>(
283
293
  duplicateToolCallIdSuffixPrefix = "_dup",
284
294
  targetCompat: Model<TApi>["compat"] = model.compat,
285
295
  ): Message[] {
286
- // Drop assistant `toolCall` blocks with empty/whitespace `name` (and their
287
- // matched `toolResult` messages) before anything else looks at the history.
288
- // Replays of these would 400 every provider — see `sanitizeMalformedToolCalls`.
296
+ // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
297
+ // (and their matched `toolResult` messages) before anything else looks at
298
+ // the history. Replays of these would 400 every provider — see
299
+ // `sanitizeMalformedToolCalls`.
289
300
  messages = sanitizeMalformedToolCalls(messages);
290
301
 
291
302
  // Build a map of original tool call IDs to normalized IDs
@@ -22,9 +22,31 @@
22
22
  */
23
23
 
24
24
  import type { AssistantMessage, TextContent, ThinkingContent, ToolCall } from "../types";
25
+ import {
26
+ clearStreamingPartialJson,
27
+ getStreamingPartialJson,
28
+ type StreamingPartialJsonCarrier,
29
+ setStreamingPartialJson,
30
+ } from "./block-symbols";
25
31
  import { AssistantMessageEventStream } from "./event-stream";
26
32
  import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "./stream-markup-healing";
27
33
 
34
+ type StreamingToolCall = ToolCall & StreamingPartialJsonCarrier;
35
+
36
+ function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
37
+ const block: StreamingToolCall = { ...source, arguments: source.arguments };
38
+ const partialJson = getStreamingPartialJson(source);
39
+ if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
40
+ return block;
41
+ }
42
+
43
+ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): void {
44
+ Object.assign(target, source);
45
+ const partialJson = getStreamingPartialJson(source);
46
+ if (partialJson === undefined) clearStreamingPartialJson(target);
47
+ else setStreamingPartialJson(target, partialJson);
48
+ }
49
+
28
50
  /**
29
51
  * Wrap a provider stream so leaked reasoning fences are healed into thinking
30
52
  * blocks live, for every provider. Returns a new stream that re-projects the
@@ -55,12 +77,14 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
55
77
  case "toolcall_start": {
56
78
  projector ??= new LeakedThinkingProjector(out, event.partial);
57
79
  const block = event.partial.content[event.contentIndex];
58
- projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block.name : "");
80
+ projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block : undefined);
59
81
  break;
60
82
  }
61
- case "toolcall_delta":
62
- projector?.toolDelta(event.contentIndex, event.delta);
83
+ case "toolcall_delta": {
84
+ const block = event.partial.content[event.contentIndex];
85
+ projector?.toolDelta(event.contentIndex, event.delta, block?.type === "toolCall" ? block : undefined);
63
86
  break;
87
+ }
64
88
  case "toolcall_end":
65
89
  projector?.toolEnd(event.contentIndex, event.toolCall);
66
90
  break;
@@ -111,7 +135,7 @@ class LeakedThinkingProjector {
111
135
  /** Latest non-undefined text signature seen, stamped onto held-back text flushed later. */
112
136
  #lastTextSignature: string | undefined;
113
137
  /** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
114
- #toolBlocks = new Map<number, { index: number }>();
138
+ #toolBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
115
139
 
116
140
  constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
117
141
  this.#out = out;
@@ -136,29 +160,39 @@ class LeakedThinkingProjector {
136
160
  }
137
161
 
138
162
  /** Forward a native tool call's start, releasing any held-back text first. */
139
- toolStart(srcIndex: number, name: string): void {
163
+ toolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
164
+ if (!source) return;
140
165
  this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
141
166
  this.#closeText();
142
167
  this.#closeThinking();
143
- const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
168
+ const block = cloneToolCall(source);
144
169
  this.#partial.content.push(block);
145
170
  const index = this.#partial.content.length - 1;
146
- this.#toolBlocks.set(srcIndex, { index });
171
+ this.#toolBlocks.set(srcIndex, { index, block });
147
172
  this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
148
173
  }
149
174
 
150
- toolDelta(srcIndex: number, delta: string): void {
151
- const entry = this.#toolBlocks.get(srcIndex);
175
+ toolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
176
+ let entry = this.#toolBlocks.get(srcIndex);
177
+ if (!entry && source) {
178
+ this.toolStart(srcIndex, source);
179
+ entry = this.#toolBlocks.get(srcIndex);
180
+ }
152
181
  if (!entry) return;
182
+ if (source) syncToolCall(entry.block, source);
153
183
  this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
154
184
  }
155
185
 
156
186
  toolEnd(srcIndex: number, toolCall: ToolCall): void {
157
187
  const entry = this.#toolBlocks.get(srcIndex);
158
188
  if (entry) {
159
- const block = this.#partial.content[entry.index] as ToolCall;
160
- Object.assign(block, toolCall);
161
- this.#out.push({ type: "toolcall_end", contentIndex: entry.index, toolCall: block, partial: this.#partial });
189
+ syncToolCall(entry.block, toolCall);
190
+ this.#out.push({
191
+ type: "toolcall_end",
192
+ contentIndex: entry.index,
193
+ toolCall: entry.block,
194
+ partial: this.#partial,
195
+ });
162
196
  this.#toolBlocks.delete(srcIndex);
163
197
  return;
164
198
  }
@@ -166,7 +200,7 @@ class LeakedThinkingProjector {
166
200
  this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
167
201
  this.#closeText();
168
202
  this.#closeThinking();
169
- const block: ToolCall = { ...toolCall };
203
+ const block = cloneToolCall(toolCall);
170
204
  this.#partial.content.push(block);
171
205
  const index = this.#partial.content.length - 1;
172
206
  this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
@@ -287,12 +287,17 @@ export class ThinkingLoopDetector {
287
287
  * stream that trips the tool-call reminder. Gemini occasionally narrates a long
288
288
  * chain of titled summaries ("Examining Result Handling", "Refining Result
289
289
  * Rendering", …) without ever calling a tool, burning the whole budget on
290
- * planning; at this many distinct titles it has almost certainly stalled. This
291
- * is the over-planning shape {@link ThinkingLoopDetector} misses those titles
292
- * are stripped before its similarity analysis precisely because their wording
293
- * keeps changing, so a genuinely-distinct planning runaway never trips it.
290
+ * planning. This is the over-planning shape {@link ThinkingLoopDetector} misses
291
+ * those titles are stripped before its similarity analysis precisely because their
292
+ * wording keeps changing, so a genuinely-distinct planning runaway never trips it.
293
+ *
294
+ * Set well above legitimate hard-problem depth: a capable model can emit ~10
295
+ * distinct, progressing hypotheses in a single reasoning block before acting (and
296
+ * a false trip is costly — the interrupt discards the whole reasoning turn). A
297
+ * real narration runaway burns dozens-to-hundreds of titles, so this still trips
298
+ * fast on the actual pathology.
294
299
  */
295
- export const GEMINI_HEADER_RUNAWAY_THRESHOLD = 10;
300
+ export const GEMINI_HEADER_RUNAWAY_THRESHOLD = 24;
296
301
 
297
302
  /**
298
303
  * True when a single trimmed line is a Gemini reasoning-summary title: a markdown