@oh-my-pi/pi-ai 16.2.9 → 16.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/cursor.d.ts +2 -1
- package/dist/types/utils/thinking-loop.d.ts +10 -5
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- 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 +64 -17
- package/src/dialect/thinking.ts +22 -3
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +25 -3
- package/src/providers/devin.ts +11 -2
- package/src/providers/pi-native-client.ts +40 -12
- package/src/providers/transform-messages.ts +25 -14
- package/src/utils/leaked-thinking-stream.ts +47 -13
- package/src/utils/thinking-loop.ts +10 -5
- package/src/utils/tool-call-loop-guard.ts +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.12] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Improved streaming performance for Cursor and Devin providers by optimizing mid-stream tool-call argument parsing to prevent UI stalls when handling large payloads.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed issues with tool call streaming where tool call IDs, partial JSON payloads, or late-arriving IDs could be lost, filtered, or incorrectly initialized.
|
|
14
|
+
- Fixed an issue where stream healing for leaked thinking blocks could replace live tool-call blocks with empty-id placeholders, breaking streamed tool arguments on Anthropic-compatible streams.
|
|
15
|
+
- Fixed an issue where stalled auth-gateway SSE responses could hang indefinitely in pi-native streams by ensuring first-event and idle timeout watchdogs are properly honored.
|
|
16
|
+
- Fixed cross-turn tool-call loops going undetected by adding a guard for consecutive identical tool calls. (#3971)
|
|
17
|
+
|
|
18
|
+
## [16.2.11] - 2026-07-01
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed streaming UI glitches and resolved an issue where invalid empty tool call IDs were persisted in the chat history.
|
|
23
|
+
|
|
24
|
+
## [16.2.10] - 2026-06-30
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Added streaming support for keyed parameter argument deltas in XML-family in-band tool call scanners (Anthropic, DeepSeek, XML, Minimax)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Improved native tool-call passthrough in `wrapInbandToolStream` to accurately mirror live streaming IDs, arguments, and partial JSON states from the underlying provider
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- 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
|
|
37
|
+
- Raised Gemini header runaway threshold to prevent premature interruption of complex reasoning loops
|
|
38
|
+
- 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.
|
|
39
|
+
|
|
5
40
|
## [16.2.9] - 2026-06-30
|
|
6
41
|
|
|
7
42
|
### 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
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type JsonValue } from "@bufbuild/protobuf";
|
|
2
2
|
import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types";
|
|
3
|
-
import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingPartialJson } from "../utils/block-symbols";
|
|
3
|
+
import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
|
|
4
4
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
5
5
|
export declare const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
6
6
|
export declare const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
@@ -14,6 +14,7 @@ export declare const streamCursor: StreamFunction<"cursor-agent">;
|
|
|
14
14
|
export type ToolCallState = ToolCall & {
|
|
15
15
|
[kStreamingBlockIndex]: number;
|
|
16
16
|
[kStreamingPartialJson]?: string;
|
|
17
|
+
[kStreamingLastParseLen]?: number;
|
|
17
18
|
[kStreamingBlockKind]: "mcp" | "todo";
|
|
18
19
|
};
|
|
19
20
|
export interface BlockState {
|
|
@@ -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**`,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AssistantMessage, ToolResultMessage } from "../types";
|
|
2
|
+
/** Runtime settings for cross-turn tool-call repetition detection. */
|
|
3
|
+
export interface ToolCallLoopGuardOptions {
|
|
4
|
+
readonly threshold: number;
|
|
5
|
+
readonly exemptTools: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/** A completed assistant turn plus the tool results it produced. */
|
|
8
|
+
export interface ToolCallLoopTurn {
|
|
9
|
+
readonly message: AssistantMessage;
|
|
10
|
+
readonly toolResults: readonly ToolResultMessage[];
|
|
11
|
+
}
|
|
12
|
+
/** Details needed to steer the model away from a repeated tool call. */
|
|
13
|
+
export interface RepeatedToolCallDetection {
|
|
14
|
+
readonly kind: "repeated_tool_call";
|
|
15
|
+
readonly toolName: string;
|
|
16
|
+
readonly count: number;
|
|
17
|
+
readonly resultSummary: string;
|
|
18
|
+
readonly argumentsSummary: string;
|
|
19
|
+
}
|
|
20
|
+
/** Detects consecutive identical assistant tool calls across model turns. */
|
|
21
|
+
export declare class ToolCallLoopGuard {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(options: ToolCallLoopGuardOptions);
|
|
24
|
+
/** Records one completed turn and returns the threshold hit, if any. */
|
|
25
|
+
recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null;
|
|
26
|
+
}
|
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.12",
|
|
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.12",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.2.12",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.2.12",
|
|
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 {
|