@oh-my-pi/pi-ai 16.2.8 → 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 +37 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/error/flags.d.ts +7 -0
- package/dist/types/error/format.d.ts +1 -1
- package/dist/types/registry/oauth/callback-server.d.ts +15 -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/error/flags.ts +15 -1
- package/src/error/format.ts +10 -1
- package/src/providers/ollama.ts +6 -0
- package/src/providers/transform-messages.ts +25 -14
- package/src/registry/oauth/callback-server.ts +47 -10
- package/src/utils/thinking-loop.ts +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
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
|
+
|
|
27
|
+
## [16.2.9] - 2026-06-30
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Added `OAuthCallbackFlowOptions.allowPortFallback` to allow disabling random-port fallback, enabling strict port enforcement and early configuration errors for OAuth flows with static redirect URIs.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Improved `OAuthCallbackFlow` port conflict error messages to include the busy port, configured redirect URI, and actionable remediation steps.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Fixed an issue where malformed tool-call JSON from local Ollama or llama.cpp models was incorrectly retried as generic 500 errors, now surfacing a clear recovery message.
|
|
40
|
+
- Fixed a race condition in OAuth callback flows where abort signals triggered before the callback listener was registered were ignored.
|
|
41
|
+
|
|
5
42
|
## [16.2.7] - 2026-06-30
|
|
6
43
|
|
|
7
44
|
### 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
|
+
}
|
|
@@ -23,6 +23,13 @@ export declare const Flag: {
|
|
|
23
23
|
export type Flag = (typeof Flag)[keyof typeof Flag];
|
|
24
24
|
export declare const STREAM_READ_ERROR_PATTERN: RegExp;
|
|
25
25
|
export declare const TRANSIENT_TRANSPORT_PATTERN: RegExp;
|
|
26
|
+
/**
|
|
27
|
+
* Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
|
|
28
|
+
* The model emitted invalid JSON in a tool call and the server returned HTTP 500
|
|
29
|
+
* with this exact text — replaying the same prompt yields the same malformed
|
|
30
|
+
* output, so callers strip {@link Flag.Transient} when this matches.
|
|
31
|
+
*/
|
|
32
|
+
export declare const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN: RegExp;
|
|
26
33
|
/** Whether an OAuth refresh error message means the grant is definitively dead. */
|
|
27
34
|
export declare function isOAuthExpiry(errorMessage: string): boolean;
|
|
28
35
|
export declare function create(...flags: number[]): number;
|
|
@@ -5,7 +5,7 @@ export interface FormatMessageOptions {
|
|
|
5
5
|
rawRequestDump?: RawHttpRequestDump;
|
|
6
6
|
/** Captured non-2xx response body, appended to the message when available. */
|
|
7
7
|
capturedErrorResponse?: CapturedHttpErrorResponse;
|
|
8
|
-
/** Provider id;
|
|
8
|
+
/** Provider id; gates provider-specific user-facing rewrites. */
|
|
9
9
|
provider?: string;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
@@ -9,6 +9,20 @@ export interface OAuthCallbackFlowOptions {
|
|
|
9
9
|
callbackHostname?: string;
|
|
10
10
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
11
11
|
redirectUri?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the flow may bind to a random port when {@link preferredPort} is
|
|
14
|
+
* unavailable. Defaults to `true` so historical AI-provider flows (which
|
|
15
|
+
* pick uncommon ports and tolerate any loopback callback) keep working.
|
|
16
|
+
*
|
|
17
|
+
* Set to `false` for providers that validate the redirect URI against a
|
|
18
|
+
* registered callback — silently advertising a random-port URI would be
|
|
19
|
+
* rejected by the authorization server, leaving the browser on an opaque
|
|
20
|
+
* 500 page and the local callback waiting until the 5-minute timeout fires.
|
|
21
|
+
* With fallback disabled, {@link OAuthCallbackFlow.login} throws a
|
|
22
|
+
* {@link AIError.ConfigurationError} immediately so the caller can surface
|
|
23
|
+
* an actionable message before opening the browser.
|
|
24
|
+
*/
|
|
25
|
+
allowPortFallback?: boolean;
|
|
12
26
|
}
|
|
13
27
|
/**
|
|
14
28
|
* Abstract base class for OAuth flows with local callback servers.
|
|
@@ -20,6 +34,7 @@ export declare abstract class OAuthCallbackFlow {
|
|
|
20
34
|
callbackPath: string;
|
|
21
35
|
callbackHostname: string;
|
|
22
36
|
redirectUri?: string;
|
|
37
|
+
allowPortFallback: boolean;
|
|
23
38
|
constructor(ctrl: OAuthController, preferredPortOrOptions: number | OAuthCallbackFlowOptions, callbackPath?: string);
|
|
24
39
|
/**
|
|
25
40
|
* Generate provider-specific authorization URL.
|
|
@@ -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;
|
package/src/error/flags.ts
CHANGED
|
@@ -94,6 +94,14 @@ const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
|
94
94
|
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
|
95
95
|
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
|
96
96
|
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
|
|
97
|
+
/**
|
|
98
|
+
* Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
|
|
99
|
+
* The model emitted invalid JSON in a tool call and the server returned HTTP 500
|
|
100
|
+
* with this exact text — replaying the same prompt yields the same malformed
|
|
101
|
+
* output, so callers strip {@link Flag.Transient} when this matches.
|
|
102
|
+
*/
|
|
103
|
+
export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
|
|
104
|
+
/failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
|
|
97
105
|
|
|
98
106
|
// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
|
|
99
107
|
// error, also surfaced in text). Treated as transient — a retry usually lands
|
|
@@ -441,7 +449,13 @@ export function classifyMessage(message: {
|
|
|
441
449
|
const currentStatus = message.errorStatus ?? statusFromId(existingId);
|
|
442
450
|
const textId = classifyText(message.errorMessage, currentStatus, message.api);
|
|
443
451
|
|
|
444
|
-
|
|
452
|
+
let kinds = ((existingId ?? 0) | textId) & KIND_MASK;
|
|
453
|
+
if (message.errorMessage && LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message.errorMessage)) {
|
|
454
|
+
// Deterministic local-model tool-call JSON parse failure: HTTP 500 is misleading
|
|
455
|
+
// because the same prompt reproduces the same malformed output, so the agent-level
|
|
456
|
+
// auto-retry would loop. Strip Transient so the recovery message surfaces immediately.
|
|
457
|
+
kinds &= ~Flag.Transient;
|
|
458
|
+
}
|
|
445
459
|
const id = kinds !== 0 ? create(kinds) : (statusFromId(textId) ?? statusFromId(existingId) ?? currentStatus ?? 0);
|
|
446
460
|
|
|
447
461
|
message.errorId = id;
|
package/src/error/format.ts
CHANGED
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
rewriteCopilotError,
|
|
6
6
|
} from "../utils/http-inspector";
|
|
7
7
|
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
|
8
|
+
import { LLAMA_CPP_TOOL_CALL_PARSE_PATTERN } from "./flags";
|
|
9
|
+
|
|
10
|
+
function rewriteOllamaToolCallJsonError(message: string): string {
|
|
11
|
+
if (!LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message)) return message;
|
|
12
|
+
return `Local Ollama model emitted malformed tool-call JSON and llama.cpp rejected it (HTTP 500). This is usually a deterministic model-output failure after context degradation, not a transient server outage; reload the model or reduce context, then retry.\n${message}`;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
/** Inputs that steer {@link formatMessage}'s formatter selection. */
|
|
10
16
|
export interface FormatMessageOptions {
|
|
@@ -12,7 +18,7 @@ export interface FormatMessageOptions {
|
|
|
12
18
|
rawRequestDump?: RawHttpRequestDump;
|
|
13
19
|
/** Captured non-2xx response body, appended to the message when available. */
|
|
14
20
|
capturedErrorResponse?: CapturedHttpErrorResponse;
|
|
15
|
-
/** Provider id;
|
|
21
|
+
/** Provider id; gates provider-specific user-facing rewrites. */
|
|
16
22
|
provider?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -32,5 +38,8 @@ export async function formatMessage(error: unknown, opts: FormatMessageOptions =
|
|
|
32
38
|
if (opts.provider === "github-copilot") {
|
|
33
39
|
message = rewriteCopilotError(message, error, opts.provider);
|
|
34
40
|
}
|
|
41
|
+
if (opts.provider === "ollama") {
|
|
42
|
+
message = rewriteOllamaToolCallJsonError(message);
|
|
43
|
+
}
|
|
35
44
|
return message;
|
|
36
45
|
}
|
package/src/providers/ollama.ts
CHANGED
|
@@ -323,6 +323,10 @@ function createChatBody(model: Model<"ollama-chat">, context: Context, options:
|
|
|
323
323
|
};
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
function shouldRetryOllamaResponse(response: Response, bodyText: string): boolean {
|
|
327
|
+
return response.status < 500 || !AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(bodyText);
|
|
328
|
+
}
|
|
329
|
+
|
|
326
330
|
async function captureHttpErrorResponse(response: Response): Promise<CapturedHttpErrorResponse> {
|
|
327
331
|
let bodyText: string | undefined;
|
|
328
332
|
let bodyJson: unknown;
|
|
@@ -598,6 +602,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
598
602
|
body: JSON.stringify(body),
|
|
599
603
|
signal: watchdog.signal,
|
|
600
604
|
defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
|
|
605
|
+
shouldRetryResponse: shouldRetryOllamaResponse,
|
|
601
606
|
fetch: options.fetch,
|
|
602
607
|
timeout: false,
|
|
603
608
|
});
|
|
@@ -747,6 +752,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
747
752
|
}
|
|
748
753
|
const result = await AIError.finalize(error, {
|
|
749
754
|
api: model.api,
|
|
755
|
+
provider: model.provider,
|
|
750
756
|
signal: options.signal,
|
|
751
757
|
rawRequestDump,
|
|
752
758
|
capturedErrorResponse,
|
|
@@ -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
|
|
@@ -26,6 +26,20 @@ export interface OAuthCallbackFlowOptions {
|
|
|
26
26
|
callbackHostname?: string;
|
|
27
27
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
28
28
|
redirectUri?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the flow may bind to a random port when {@link preferredPort} is
|
|
31
|
+
* unavailable. Defaults to `true` so historical AI-provider flows (which
|
|
32
|
+
* pick uncommon ports and tolerate any loopback callback) keep working.
|
|
33
|
+
*
|
|
34
|
+
* Set to `false` for providers that validate the redirect URI against a
|
|
35
|
+
* registered callback — silently advertising a random-port URI would be
|
|
36
|
+
* rejected by the authorization server, leaving the browser on an opaque
|
|
37
|
+
* 500 page and the local callback waiting until the 5-minute timeout fires.
|
|
38
|
+
* With fallback disabled, {@link OAuthCallbackFlow.login} throws a
|
|
39
|
+
* {@link AIError.ConfigurationError} immediately so the caller can surface
|
|
40
|
+
* an actionable message before opening the browser.
|
|
41
|
+
*/
|
|
42
|
+
allowPortFallback?: boolean;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
/**
|
|
@@ -37,6 +51,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
37
51
|
callbackPath: string;
|
|
38
52
|
callbackHostname: string;
|
|
39
53
|
redirectUri?: string;
|
|
54
|
+
allowPortFallback: boolean;
|
|
40
55
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
41
56
|
#callbackReject?: (error: string) => void;
|
|
42
57
|
|
|
@@ -50,6 +65,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
50
65
|
this.preferredPort = preferredPortOrOptions;
|
|
51
66
|
this.callbackPath = callbackPath;
|
|
52
67
|
this.callbackHostname = DEFAULT_HOSTNAME;
|
|
68
|
+
this.allowPortFallback = true;
|
|
53
69
|
return;
|
|
54
70
|
}
|
|
55
71
|
|
|
@@ -57,6 +73,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
57
73
|
this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
|
|
58
74
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
59
75
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
76
|
+
this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/**
|
|
@@ -87,18 +104,29 @@ export abstract class OAuthCallbackFlow {
|
|
|
87
104
|
.join("");
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
#loginCancelledError(): AIError.LoginCancelledError {
|
|
108
|
+
return new AIError.LoginCancelledError(`OAuth callback cancelled: ${this.ctrl.signal?.reason}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#throwIfCancelled(): void {
|
|
112
|
+
if (this.ctrl.signal?.aborted) throw this.#loginCancelledError();
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* Execute the OAuth login flow.
|
|
92
117
|
*/
|
|
93
118
|
async login(): Promise<OAuthCredentials> {
|
|
94
119
|
const state = this.generateState();
|
|
120
|
+
this.#throwIfCancelled();
|
|
95
121
|
|
|
96
122
|
// Start callback server first to get actual redirect URI
|
|
97
123
|
const { server, redirectUri } = await this.#startCallbackServer(state);
|
|
98
124
|
|
|
99
125
|
try {
|
|
126
|
+
this.#throwIfCancelled();
|
|
100
127
|
// Generate auth URL with the ACTUAL redirect URI (may differ from expected if port was busy)
|
|
101
128
|
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
129
|
+
this.#throwIfCancelled();
|
|
102
130
|
|
|
103
131
|
// Notify controller that auth is ready
|
|
104
132
|
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
@@ -106,6 +134,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
106
134
|
|
|
107
135
|
// Wait for callback or manual input
|
|
108
136
|
const { code } = await this.#waitForCallback(state);
|
|
137
|
+
this.#throwIfCancelled();
|
|
109
138
|
|
|
110
139
|
this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
|
|
111
140
|
|
|
@@ -126,10 +155,17 @@ export abstract class OAuthCallbackFlow {
|
|
|
126
155
|
}
|
|
127
156
|
const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
|
|
128
157
|
return { server, redirectUri };
|
|
129
|
-
} catch {
|
|
158
|
+
} catch (cause) {
|
|
130
159
|
if (this.redirectUri) {
|
|
131
160
|
throw new AIError.ConfigurationError(
|
|
132
|
-
`OAuth callback port ${this.preferredPort}
|
|
161
|
+
`OAuth callback port ${this.preferredPort} is in use, but oauth.redirectUri (${this.redirectUri}) requires this exact port. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or change oauth.redirectUri to point at an available port.`,
|
|
162
|
+
{ cause },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!this.allowPortFallback) {
|
|
166
|
+
throw new AIError.ConfigurationError(
|
|
167
|
+
`OAuth callback port ${this.preferredPort} is in use. The OAuth provider validates redirect URIs against its registered callback, so falling back to a random port would be rejected. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or set oauth.callbackPort/oauth.redirectUri to a port the provider has registered.`,
|
|
168
|
+
{ cause },
|
|
133
169
|
);
|
|
134
170
|
}
|
|
135
171
|
const server = this.#createServer(0, expectedState);
|
|
@@ -208,17 +244,18 @@ export abstract class OAuthCallbackFlow {
|
|
|
208
244
|
#waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
209
245
|
const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
|
|
210
246
|
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
247
|
+
if (signal.aborted) return Promise.reject(this.#loginCancelledError());
|
|
211
248
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
const callback = Promise.withResolvers<CallbackResult>();
|
|
250
|
+
this.#callbackResolve = callback.resolve;
|
|
251
|
+
this.#callbackReject = callback.reject;
|
|
215
252
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
});
|
|
253
|
+
signal.addEventListener("abort", () => {
|
|
254
|
+
this.#callbackResolve = undefined;
|
|
255
|
+
this.#callbackReject = undefined;
|
|
256
|
+
callback.reject(new AIError.LoginCancelledError(`OAuth callback cancelled: ${signal.reason}`));
|
|
221
257
|
});
|
|
258
|
+
const callbackPromise = callback.promise;
|
|
222
259
|
|
|
223
260
|
// Manual input race (if supported)
|
|
224
261
|
if (this.ctrl.onManualCodeInput) {
|
|
@@ -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
|