@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 +22 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/utils/thinking-loop.d.ts +10 -5
- package/package.json +4 -4
- package/src/dialect/anthropic.ts +19 -7
- package/src/dialect/deepseek.ts +19 -5
- package/src/dialect/fenced-thinking.ts +184 -0
- package/src/dialect/gemini.ts +20 -19
- package/src/dialect/owned-stream.ts +70 -20
- package/src/dialect/thinking.ts +22 -3
- package/src/providers/transform-messages.ts +25 -14
- package/src/utils/thinking-loop.ts +10 -5
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 =
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.2.
|
|
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
|
},
|
package/src/dialect/anthropic.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/dialect/deepseek.ts
CHANGED
|
@@ -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
|
-
|
|
406
|
-
this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(
|
|
407
|
-
this.#rawBlock +=
|
|
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
|
+
}
|
package/src/dialect/gemini.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, ToolCall } from "../types";
|
|
2
|
-
import { mintToolCallId,
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this.#
|
|
111
|
-
|
|
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
|
|
133
|
+
projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src : undefined);
|
|
99
134
|
break;
|
|
100
135
|
}
|
|
101
|
-
case "toolcall_delta":
|
|
102
|
-
|
|
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:
|
|
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. `
|
|
171
|
-
// stream's partial
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
//
|
|
212
|
-
// already claimed.
|
|
213
|
-
if (!toolCall
|
|
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
|
|
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) {
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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": "{}" }`
|
|
134
|
-
* GLM-5.2 + thinking on long turns, #3458)
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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" &&
|
|
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 =
|
|
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 `
|
|
287
|
-
// matched `toolResult` messages) before anything else looks at
|
|
288
|
-
// Replays of these would 400 every provider — see
|
|
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
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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 =
|
|
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
|