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

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 CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.11] - 2026-07-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed streaming UI glitches and resolved an issue where invalid empty tool call IDs were persisted in the chat history.
10
+
11
+ ## [16.2.10] - 2026-06-30
12
+
13
+ ### Added
14
+
15
+ - Added streaming support for keyed parameter argument deltas in XML-family in-band tool call scanners (Anthropic, DeepSeek, XML, Minimax)
16
+
17
+ ### Changed
18
+
19
+ - Improved native tool-call passthrough in `wrapInbandToolStream` to accurately mirror live streaming IDs, arguments, and partial JSON states from the underlying provider
20
+
21
+ ### Fixed
22
+
23
+ - Fixed a bug where tool calls with empty or missing IDs were not detected as malformed, causing API validation failures (e.g., 400 errors with Anthropic) on subsequent requests
24
+ - Raised Gemini header runaway threshold to prevent premature interruption of complex reasoning loops
25
+ - Fixed leaked ` ```thinking ` fences with nested language-tagged Markdown code blocks so inner fences remain inside structured thinking instead of leaking as visible reply text.
26
+
5
27
  ## [16.2.9] - 2026-06-30
6
28
 
7
29
  ### Added
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Close-matcher for a ` ```thinking ` block that respects nested Markdown code
3
+ * fences. A naive `indexOf("```")` closes the thinking section at the FIRST
4
+ * backtick fence inside the reasoning, so an inner ` ```rs … ``` ` code block
5
+ * leaks its body (and everything after) into the visible channel. This scanner
6
+ * tracks inner-fence nesting so only the real thinking closer ends the block.
7
+ *
8
+ * Distinguishing an inner opener from the closer: a fenced code block opener is
9
+ * ` ``` ` immediately followed by a language token (`rs`, `tool_code`, `c++` …)
10
+ * and a newline. The thinking closer is a bare ` ``` `, or ` ``` ` glued to the
11
+ * visible reply — its remainder is prose (contains whitespace/punctuation), not
12
+ * a language token. So a top-level fence whose info is a single language-token
13
+ * word opens a nested block; anything else closes the thinking, and the text
14
+ * after the fence run is the visible reply. This preserves the long-standing
15
+ * inline-close behavior (` ```Visible reply ` ends the block) while skipping
16
+ * language-tagged inner fences.
17
+ *
18
+ * Used by both the owned Gemini scanner (live ` ```thinking ` stream) and the
19
+ * generic `ThinkingInbandScanner` (leaked-idiom healing).
20
+ *
21
+ * Limitation: an *info-less* nested fence (a bare ` ``` ` opening a code block
22
+ * inside the reasoning) is indistinguishable from the thinking closer and ends
23
+ * the block. Models tag their fences with a language in practice, so this is
24
+ * strictly better than the previous first-` ``` ` behavior.
25
+ */
26
+ /** Result of feeding bytes to {@link FencedThinkingScanner}. */
27
+ export interface FencedThinkingResult {
28
+ /** Thinking text to emit for this feed (may be empty). */
29
+ readonly thinking: string;
30
+ /** True once the thinking closer has been consumed. */
31
+ readonly closed: boolean;
32
+ /** Bytes after the closing fence (visible reply); only meaningful when {@link closed}. */
33
+ readonly rest: string;
34
+ }
35
+ /**
36
+ * Stateful, line-oriented close-matcher for one ` ```thinking ` block. Owns the
37
+ * partial-line buffer so an ambiguous trailing fence is held until it resolves.
38
+ *
39
+ * Streaming stays character-level for ordinary content: a line is emitted as its
40
+ * bytes arrive, yet retained in the buffer until its newline so the complete
41
+ * line can be classified ({@link #emitted} tracks how many leading bytes are
42
+ * already emitted). A top-level fence candidate is held until its info
43
+ * disambiguates opener (language token) from closer (prose / bare).
44
+ */
45
+ export declare class FencedThinkingScanner {
46
+ #private;
47
+ /**
48
+ * Feed bytes and return thinking deltas plus close state. When `final`, the
49
+ * held tail resolves: a bare ` ``` ` or a ` ```<reply> ` fence closes the
50
+ * block (remainder becomes `rest`), otherwise it is unterminated thinking.
51
+ */
52
+ feed(text: string, final: boolean): FencedThinkingResult;
53
+ }
@@ -41,12 +41,17 @@ export declare class ThinkingLoopDetector {
41
41
  * stream that trips the tool-call reminder. Gemini occasionally narrates a long
42
42
  * chain of titled summaries ("Examining Result Handling", "Refining Result
43
43
  * Rendering", …) without ever calling a tool, burning the whole budget on
44
- * planning; at this many distinct titles it has almost certainly stalled. This
45
- * is the over-planning shape {@link ThinkingLoopDetector} misses those titles
46
- * are stripped before its similarity analysis precisely because their wording
47
- * keeps changing, so a genuinely-distinct planning runaway never trips it.
44
+ * planning. This is the over-planning shape {@link ThinkingLoopDetector} misses
45
+ * those titles are stripped before its similarity analysis precisely because their
46
+ * wording keeps changing, so a genuinely-distinct planning runaway never trips it.
47
+ *
48
+ * Set well above legitimate hard-problem depth: a capable model can emit ~10
49
+ * distinct, progressing hypotheses in a single reasoning block before acting (and
50
+ * a false trip is costly — the interrupt discards the whole reasoning turn). A
51
+ * real narration runaway burns dozens-to-hundreds of titles, so this still trips
52
+ * fast on the actual pathology.
48
53
  */
49
- export declare const GEMINI_HEADER_RUNAWAY_THRESHOLD = 10;
54
+ export declare const GEMINI_HEADER_RUNAWAY_THRESHOLD = 24;
50
55
  /**
51
56
  * True when a single trimmed line is a Gemini reasoning-summary title: a markdown
52
57
  * ATX heading (`## …`) or a whole-line bold / bold-italic run (`**Title**`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.2.9",
4
+ "version": "16.2.11",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.2.9",
42
- "@oh-my-pi/pi-utils": "16.2.9",
43
- "@oh-my-pi/pi-wire": "16.2.9",
41
+ "@oh-my-pi/pi-catalog": "16.2.11",
42
+ "@oh-my-pi/pi-utils": "16.2.11",
43
+ "@oh-my-pi/pi-wire": "16.2.11",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -132,7 +132,7 @@ export class AnthropicInbandScanner implements InbandScanner {
132
132
  progressed = this.#consumeInvoke(final, events);
133
133
  break;
134
134
  case "parameter":
135
- progressed = this.#consumeParameter(final);
135
+ progressed = this.#consumeParameter(final, events);
136
136
  break;
137
137
  case "thinking":
138
138
  progressed = this.#consumeThinking(final, events);
@@ -272,7 +272,7 @@ export class AnthropicInbandScanner implements InbandScanner {
272
272
  return true;
273
273
  }
274
274
 
275
- #consumeParameter(final: boolean): boolean {
275
+ #consumeParameter(final: boolean, events: InbandScanEvent[]): boolean {
276
276
  const tagStart = this.#buffer.indexOf("<");
277
277
  if (tagStart === -1) {
278
278
  if (final) {
@@ -280,14 +280,14 @@ export class AnthropicInbandScanner implements InbandScanner {
280
280
  this.#buffer = "";
281
281
  return false;
282
282
  }
283
- this.#appendParameterValue(this.#buffer);
283
+ this.#appendParameterValue(this.#buffer, events);
284
284
  this.#rawBlock += this.#buffer;
285
285
  this.#buffer = "";
286
286
  return false;
287
287
  }
288
288
  if (tagStart > 0) {
289
289
  const consumed = this.#buffer.slice(0, tagStart);
290
- this.#appendParameterValue(consumed);
290
+ this.#appendParameterValue(consumed, events);
291
291
  this.#rawBlock += consumed;
292
292
  this.#buffer = this.#buffer.slice(tagStart);
293
293
  return true;
@@ -307,7 +307,7 @@ export class AnthropicInbandScanner implements InbandScanner {
307
307
  return false;
308
308
  }
309
309
  const consumed = this.#buffer[0]!;
310
- this.#appendParameterValue(consumed);
310
+ this.#appendParameterValue(consumed, events);
311
311
  this.#rawBlock += consumed;
312
312
  this.#buffer = this.#buffer.slice(1);
313
313
  return true;
@@ -378,10 +378,22 @@ export class AnthropicInbandScanner implements InbandScanner {
378
378
  this.#state = "parameter";
379
379
  }
380
380
 
381
- #appendParameterValue(delta: string): void {
381
+ #appendParameterValue(delta: string, events: InbandScanEvent[]): void {
382
382
  if (delta.length === 0) return;
383
383
  const remaining = MAX_PARAMETER_VALUE_LENGTH - this.#paramValue.length;
384
- if (remaining > 0) this.#paramValue += delta.slice(0, remaining);
384
+ const accepted = remaining > 0 ? delta.slice(0, remaining) : "";
385
+ if (accepted.length > 0) {
386
+ this.#paramValue += accepted;
387
+ if (this.#started && this.#paramName.length > 0) {
388
+ events.push({
389
+ type: "toolArgDelta",
390
+ id: this.#id,
391
+ name: this.#name,
392
+ key: this.#paramName,
393
+ delta: accepted,
394
+ });
395
+ }
396
+ }
385
397
  if (delta.length > remaining) this.#paramTruncated = true;
386
398
  }
387
399
 
@@ -99,6 +99,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
99
99
  #dsmlArgs: Record<string, unknown> = {};
100
100
  #dsmlParamName = "";
101
101
  #dsmlParamIsString = true;
102
+ #dsmlParamRaw = "";
102
103
  #rawBlock = "";
103
104
  #stripLeadingWhitespace = false;
104
105
 
@@ -153,7 +154,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
153
154
  if (!this.#consumeDsmlInvoke(final, events)) break;
154
155
  continue;
155
156
  }
156
- if (!this.#consumeDsmlParam(final)) break;
157
+ if (!this.#consumeDsmlParam(final, events)) break;
157
158
  }
158
159
  if (final && this.#state === "thinking") this.#endThinking(events);
159
160
  if (final && this.#buffer.length === 0 && this.#rawBlock.length > 0) this.#rawBlock = "";
@@ -396,18 +397,23 @@ export class DeepSeekInbandScanner implements InbandScanner {
396
397
  return final;
397
398
  }
398
399
 
399
- #consumeDsmlParam(final: boolean): boolean {
400
+ #consumeDsmlParam(final: boolean, events: InbandScanEvent[]): boolean {
400
401
  const close = findEarliestToken(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
401
402
  if (!close) {
403
+ const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
404
+ const chunk = this.#buffer.slice(0, this.#buffer.length - hold);
405
+ this.#streamDsmlParam(chunk, events);
406
+ this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
402
407
  if (final) this.#resetDsmlTool();
403
408
  return false;
404
409
  }
405
- const rawValue = this.#buffer.slice(0, close.index);
406
- this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(rawValue, this.#dsmlParamIsString);
407
- this.#rawBlock += rawValue + close.token;
410
+ this.#streamDsmlParam(this.#buffer.slice(0, close.index), events);
411
+ this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(this.#dsmlParamRaw, this.#dsmlParamIsString);
412
+ this.#rawBlock += close.token;
408
413
  this.#buffer = this.#buffer.slice(close.index + close.token.length);
409
414
  this.#dsmlParamName = "";
410
415
  this.#dsmlParamIsString = true;
416
+ this.#dsmlParamRaw = "";
411
417
  this.#state = "dsmlInvoke";
412
418
  return true;
413
419
  }
@@ -418,6 +424,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
418
424
  events.push({ type: "toolStart", id: this.#id, name: this.#name });
419
425
  }
420
426
 
427
+ #streamDsmlParam(chunk: string, events: InbandScanEvent[]): void {
428
+ if (chunk.length === 0) return;
429
+ this.#dsmlParamRaw += chunk;
430
+ this.#rawBlock += chunk;
431
+ events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#dsmlParamName, delta: chunk });
432
+ }
433
+
421
434
  #emitThinking(delta: string, events: InbandScanEvent[]): void {
422
435
  if (delta.length === 0) return;
423
436
  if (this.#parseThinking) {
@@ -509,6 +522,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
509
522
  this.#dsmlArgs = {};
510
523
  this.#dsmlParamName = "";
511
524
  this.#dsmlParamIsString = true;
525
+ this.#dsmlParamRaw = "";
512
526
  this.#rawBlock = "";
513
527
  }
514
528
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Close-matcher for a ` ```thinking ` block that respects nested Markdown code
3
+ * fences. A naive `indexOf("```")` closes the thinking section at the FIRST
4
+ * backtick fence inside the reasoning, so an inner ` ```rs … ``` ` code block
5
+ * leaks its body (and everything after) into the visible channel. This scanner
6
+ * tracks inner-fence nesting so only the real thinking closer ends the block.
7
+ *
8
+ * Distinguishing an inner opener from the closer: a fenced code block opener is
9
+ * ` ``` ` immediately followed by a language token (`rs`, `tool_code`, `c++` …)
10
+ * and a newline. The thinking closer is a bare ` ``` `, or ` ``` ` glued to the
11
+ * visible reply — its remainder is prose (contains whitespace/punctuation), not
12
+ * a language token. So a top-level fence whose info is a single language-token
13
+ * word opens a nested block; anything else closes the thinking, and the text
14
+ * after the fence run is the visible reply. This preserves the long-standing
15
+ * inline-close behavior (` ```Visible reply ` ends the block) while skipping
16
+ * language-tagged inner fences.
17
+ *
18
+ * Used by both the owned Gemini scanner (live ` ```thinking ` stream) and the
19
+ * generic `ThinkingInbandScanner` (leaked-idiom healing).
20
+ *
21
+ * Limitation: an *info-less* nested fence (a bare ` ``` ` opening a code block
22
+ * inside the reasoning) is indistinguishable from the thinking closer and ends
23
+ * the block. Models tag their fences with a language in practice, so this is
24
+ * strictly better than the previous first-` ``` ` behavior.
25
+ */
26
+
27
+ /** A complete fence line: ≤3 lead spaces, a run of ≥3 backticks/tildes, then an info string. */
28
+ const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
29
+ /** ≤3 lead spaces then a (possibly partial) backtick run and whatever follows it. */
30
+ const BACKTICK_LEAD = /^ {0,3}(`*)([\s\S]*)$/;
31
+ /** A language-tag info string: one token, no whitespace (markers an inner fence opener carries). */
32
+ const LANG_TOKEN = /^[A-Za-z0-9_+#-]+$/;
33
+
34
+ /** Result of feeding bytes to {@link FencedThinkingScanner}. */
35
+ export interface FencedThinkingResult {
36
+ /** Thinking text to emit for this feed (may be empty). */
37
+ readonly thinking: string;
38
+ /** True once the thinking closer has been consumed. */
39
+ readonly closed: boolean;
40
+ /** Bytes after the closing fence (visible reply); only meaningful when {@link closed}. */
41
+ readonly rest: string;
42
+ }
43
+
44
+ /**
45
+ * Stateful, line-oriented close-matcher for one ` ```thinking ` block. Owns the
46
+ * partial-line buffer so an ambiguous trailing fence is held until it resolves.
47
+ *
48
+ * Streaming stays character-level for ordinary content: a line is emitted as its
49
+ * bytes arrive, yet retained in the buffer until its newline so the complete
50
+ * line can be classified ({@link #emitted} tracks how many leading bytes are
51
+ * already emitted). A top-level fence candidate is held until its info
52
+ * disambiguates opener (language token) from closer (prose / bare).
53
+ */
54
+ export class FencedThinkingScanner {
55
+ #buffer = "";
56
+ /** The fence run that opened the current nested code block, or "" at top level. */
57
+ #inner = "";
58
+ /** Bytes of the leading (incomplete) line already returned as thinking. */
59
+ #emitted = 0;
60
+
61
+ /**
62
+ * Feed bytes and return thinking deltas plus close state. When `final`, the
63
+ * held tail resolves: a bare ` ``` ` or a ` ```<reply> ` fence closes the
64
+ * block (remainder becomes `rest`), otherwise it is unterminated thinking.
65
+ */
66
+ feed(text: string, final: boolean): FencedThinkingResult {
67
+ this.#buffer += text;
68
+ let thinking = "";
69
+ for (;;) {
70
+ const nl = this.#buffer.indexOf("\n");
71
+ if (nl === -1) break;
72
+ const line = this.#buffer.slice(0, nl);
73
+ if (!this.#inner) {
74
+ const close = this.#closeRest(line);
75
+ if (close !== undefined) {
76
+ // Closer bytes are always held, so #emitted is 0 and nothing leaked.
77
+ const rest = close + this.#buffer.slice(nl); // keep the newline with the reply
78
+ this.#reset();
79
+ return { thinking, closed: true, rest };
80
+ }
81
+ }
82
+ // Content line (including an inner-fence open/close).
83
+ thinking += this.#buffer.slice(this.#emitted, nl + 1);
84
+ this.#updateInner(line);
85
+ this.#buffer = this.#buffer.slice(nl + 1);
86
+ this.#emitted = 0;
87
+ }
88
+
89
+ const tail = this.#buffer;
90
+ if (this.#inner) {
91
+ // Inside a nested block every byte is thinking content: emit eagerly,
92
+ // keeping it buffered until the newline classifies the line.
93
+ thinking += tail.slice(this.#emitted);
94
+ this.#emitted = tail.length;
95
+ return { thinking, closed: false, rest: "" };
96
+ }
97
+
98
+ if (final) {
99
+ const close = this.#closeRestFinal(tail);
100
+ if (close !== undefined) {
101
+ this.#reset();
102
+ return { thinking, closed: true, rest: close };
103
+ }
104
+ } else {
105
+ const close = this.#closeRestStreamingTail(tail);
106
+ if (close !== undefined) {
107
+ this.#reset();
108
+ return { thinking, closed: true, rest: close };
109
+ }
110
+ if (this.#mustHold(tail)) return { thinking, closed: false, rest: "" };
111
+ }
112
+ // Either final (flush the remainder) or a line that can no longer be a fence.
113
+ thinking += tail.slice(this.#emitted);
114
+ if (final) this.#reset();
115
+ else this.#emitted = tail.length;
116
+ return { thinking, closed: false, rest: "" };
117
+ }
118
+
119
+ /**
120
+ * Complete line close test. A bare backtick fence closes thinking; a
121
+ * language-token fence line opens an inner block; prose-like remainder is the
122
+ * inline visible reply.
123
+ */
124
+ #closeRest(line: string): string | undefined {
125
+ const m = BACKTICK_LEAD.exec(line);
126
+ if (!m || m[1]!.length < 3) return undefined;
127
+ const rest = m[2]!;
128
+ if (rest === "" || rest.trim() === "") return ""; // bare close (only whitespace)
129
+ if (LANG_TOKEN.test(rest)) return undefined; // language-tagged inner opener
130
+ return rest;
131
+ }
132
+
133
+ /** Final tail close test: EOF disambiguates any top-level backtick run as the closer. */
134
+ #closeRestFinal(tail: string): string | undefined {
135
+ const m = BACKTICK_LEAD.exec(tail);
136
+ if (!m || m[1]!.length < 3) return undefined;
137
+ const rest = m[2]!;
138
+ return rest.trim() === "" ? "" : rest;
139
+ }
140
+
141
+ /** Streaming tail close test: only a prose-like inline reply resolves the close. */
142
+ #closeRestStreamingTail(tail: string): string | undefined {
143
+ const m = BACKTICK_LEAD.exec(tail);
144
+ if (!m || m[1]!.length < 3) return undefined;
145
+ const rest = m[2]!;
146
+ if (rest === "" || rest.trim() === "" || LANG_TOKEN.test(rest)) return undefined;
147
+ return rest;
148
+ }
149
+
150
+ /** Whether a top-level trailing partial is still undecided and must be held. */
151
+ #mustHold(tail: string): boolean {
152
+ const m = BACKTICK_LEAD.exec(tail);
153
+ if (!m) return false;
154
+ const ticks = m[1]!.length;
155
+ const rest = m[2]!;
156
+ // A growing backtick run could still reach a fence. A complete run plus
157
+ // a language-token prefix is also undecided until a newline confirms an
158
+ // inner opener or a non-token character confirms an inline close.
159
+ if (rest === "" || rest.trim() === "") return ticks >= 1 || /^ {0,3}$/.test(tail);
160
+ return ticks >= 3 && LANG_TOKEN.test(rest);
161
+ }
162
+
163
+ #reset(): void {
164
+ this.#buffer = "";
165
+ this.#inner = "";
166
+ this.#emitted = 0;
167
+ }
168
+
169
+ /** Toggle nested-fence state for a completed content line. */
170
+ #updateInner(line: string): void {
171
+ const fence = FENCE_LINE.exec(line);
172
+ if (!fence) return;
173
+ const run = fence[1]!;
174
+ const info = fence[2]!.trim();
175
+ if (!this.#inner) {
176
+ // A top-level closer was already handled by #closeRest, so this opens a
177
+ // nested code block (tilde fence, or backtick fence with a language token).
178
+ this.#inner = run;
179
+ } else if (run[0] === this.#inner[0] && run.length >= this.#inner.length && info === "") {
180
+ // Closing fence: same char, at least as long, no info string.
181
+ this.#inner = "";
182
+ }
183
+ }
184
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Message, ToolCall } from "../types";
2
- import { mintToolCallId, partialSuffixOverlap, partialSuffixOverlapAny } from "./coercion";
2
+ import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
3
+ import { FencedThinkingScanner } from "./fenced-thinking";
3
4
  import dialectPrompt from "./gemini.md" with { type: "text" };
4
5
  import { assistantTranscriptParts, collectToolResultRun, joinUserBodies, messageContentText } from "./rendering";
5
6
  import type {
@@ -37,6 +38,8 @@ export class GeminiInbandScanner implements InbandScanner {
37
38
  #buffer = "";
38
39
  #state: State = "outside";
39
40
  #thinking = "";
41
+ /** Fence-aware close-matcher while {@link #state} is "thinking"; undefined otherwise. */
42
+ #fenced: FencedThinkingScanner | undefined;
40
43
  readonly #parseThinking: boolean;
41
44
 
42
45
  constructor(options: InbandScannerOptions = {}) {
@@ -55,21 +58,23 @@ export class GeminiInbandScanner implements InbandScanner {
55
58
 
56
59
  #consume(final: boolean): InbandScanEvent[] {
57
60
  const events: InbandScanEvent[] = [];
58
- while (this.#buffer.length > 0) {
59
- if (this.#state === "outside") {
60
- this.#consumeOutside(final, events);
61
- if (this.#state === "outside") break;
62
- continue;
63
- }
61
+ for (;;) {
64
62
  if (this.#state === "thinking") {
63
+ // Always run on final so the fenced scanner flushes its held tail even
64
+ // when #buffer is empty (a partial close held from the previous feed).
65
65
  this.#consumeThinking(final, events);
66
66
  if (this.#state === "thinking") break;
67
67
  continue;
68
68
  }
69
+ if (this.#buffer.length === 0) break;
70
+ if (this.#state === "outside") {
71
+ this.#consumeOutside(final, events);
72
+ if (this.#state === "outside") break;
73
+ continue;
74
+ }
69
75
  this.#consumeTool(final, events);
70
76
  if (this.#state === "tool") break;
71
77
  }
72
- if (final && this.#state === "thinking") this.#endThinking(events);
73
78
  return events;
74
79
  }
75
80
 
@@ -94,6 +99,7 @@ export class GeminiInbandScanner implements InbandScanner {
94
99
  if (isThink) {
95
100
  this.#buffer = this.#buffer.slice(start + THINK_OPEN.length);
96
101
  this.#thinking = "";
102
+ this.#fenced = new FencedThinkingScanner();
97
103
  events.push({ type: "thinkingStart" });
98
104
  this.#state = "thinking";
99
105
  return;
@@ -103,18 +109,13 @@ export class GeminiInbandScanner implements InbandScanner {
103
109
  }
104
110
 
105
111
  #consumeThinking(final: boolean, events: InbandScanEvent[]): void {
106
- const close = this.#buffer.indexOf(FENCE);
107
- if (close === -1) {
108
- const hold = final ? 0 : partialSuffixOverlap(this.#buffer, FENCE);
109
- this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
110
- this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
111
- if (final) this.#endThinking(events);
112
- return;
112
+ const result = this.#fenced!.feed(this.#buffer, final);
113
+ this.#buffer = result.closed ? result.rest : "";
114
+ this.#emitThinking(result.thinking, events);
115
+ if (result.closed || final) {
116
+ this.#endThinking(events);
117
+ this.#fenced = undefined;
113
118
  }
114
- this.#emitThinking(this.#buffer.slice(0, close), events);
115
- this.#buffer = this.#buffer.slice(close + FENCE.length);
116
- this.#endThinking(events);
117
- this.#state = "outside";
118
119
  }
119
120
 
120
121
  #emitThinking(delta: string, events: InbandScanEvent[]): void {
@@ -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 hasUsableNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
71
+ return source !== undefined && source.name.trim().length > 0 && source.id.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,36 @@ 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`, and may even persist an invalid empty id into
217
+ // replay history. So we do NOT start until the provider has emitted both a
218
+ // non-empty name and a non-empty id; if either lands late, `nativeToolDelta`
219
+ // retries the start on subsequent deltas.
220
+ nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
221
+ if (this.#stopped || !hasUsableNativeToolCall(source) || this.#toolChannel === "inband") return;
177
222
  this.#toolChannel = "native";
178
223
  this.#closeText();
179
224
  this.#closeThinking();
180
- const block: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
225
+ const block = cloneToolCall(source);
181
226
  this.#partial.content.push(block);
182
227
  const index = this.#partial.content.length - 1;
183
228
  this.#nativeBlocks.set(srcIndex, { index, block });
184
229
  if (this.#emitEvents) this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
185
230
  }
186
231
 
187
- nativeToolDelta(srcIndex: number, delta: string): void {
232
+ nativeToolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
188
233
  if (this.#stopped) return;
189
- const entry = this.#nativeBlocks.get(srcIndex);
234
+ let entry = this.#nativeBlocks.get(srcIndex);
235
+ if (!entry && hasUsableNativeToolCall(source) && this.#toolChannel !== "inband") {
236
+ this.nativeToolStart(srcIndex, source);
237
+ entry = this.#nativeBlocks.get(srcIndex);
238
+ }
190
239
  if (!entry) return;
240
+ if (source) syncToolCall(entry.block, source);
191
241
  if (this.#emitEvents)
192
242
  this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
193
243
  }
@@ -196,7 +246,7 @@ class InbandStreamProjector {
196
246
  if (this.#stopped) return;
197
247
  const entry = this.#nativeBlocks.get(srcIndex);
198
248
  if (entry) {
199
- Object.assign(entry.block, toolCall);
249
+ if (hasUsableNativeToolCall(toolCall)) syncToolCall(entry.block, toolCall);
200
250
  if (this.#emitEvents)
201
251
  this.#out.push({
202
252
  type: "toolcall_end",
@@ -207,14 +257,14 @@ class InbandStreamProjector {
207
257
  this.#nativeBlocks.delete(srcIndex);
208
258
  return;
209
259
  }
210
- // Never streamed (name was empty at start). Salvage a real call whose name
211
- // only arrived now; drop nameless ghosts and anything the in-band channel
212
- // already claimed.
213
- if (!toolCall.name || this.#toolChannel === "inband") return;
260
+ // Never streamed (name/id were incomplete at start). Salvage only a real
261
+ // native call whose identifier is now present; drop nameless/id-less ghosts
262
+ // and anything the in-band channel already claimed.
263
+ if (!hasUsableNativeToolCall(toolCall) || this.#toolChannel === "inband") return;
214
264
  this.#toolChannel = "native";
215
265
  this.#closeText();
216
266
  this.#closeThinking();
217
- const block: ToolCall = { ...toolCall };
267
+ const block = cloneToolCall(toolCall);
218
268
  this.#partial.content.push(block);
219
269
  const index = this.#partial.content.length - 1;
220
270
  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;
@@ -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
@@ -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