@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
|
@@ -5,6 +5,12 @@ import type {
|
|
|
5
5
|
ThinkingContent,
|
|
6
6
|
ToolCall,
|
|
7
7
|
} from "../types";
|
|
8
|
+
import {
|
|
9
|
+
clearStreamingPartialJson,
|
|
10
|
+
getStreamingPartialJson,
|
|
11
|
+
type StreamingPartialJsonCarrier,
|
|
12
|
+
setStreamingPartialJson,
|
|
13
|
+
} from "../utils/block-symbols";
|
|
8
14
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
9
15
|
import { buildStringArgsResolver } from "./coercion";
|
|
10
16
|
import { createInbandScanner } from "./factory";
|
|
@@ -36,6 +42,35 @@ function firstTokenIndex(text: string, tokens: readonly string[]): number {
|
|
|
36
42
|
type OpenText = { index: number } | undefined;
|
|
37
43
|
type OpenThinking = { index: number; text: string } | undefined;
|
|
38
44
|
|
|
45
|
+
type StreamingToolCall = ToolCall & StreamingPartialJsonCarrier;
|
|
46
|
+
|
|
47
|
+
function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
|
|
48
|
+
const block: StreamingToolCall = {
|
|
49
|
+
type: "toolCall",
|
|
50
|
+
id: source.id,
|
|
51
|
+
name: source.name,
|
|
52
|
+
arguments: source.arguments,
|
|
53
|
+
...(source.rawBlock !== undefined ? { rawBlock: source.rawBlock } : {}),
|
|
54
|
+
};
|
|
55
|
+
const partialJson = getStreamingPartialJson(source);
|
|
56
|
+
if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
|
|
57
|
+
return block;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): void {
|
|
61
|
+
target.id = source.id;
|
|
62
|
+
target.name = source.name;
|
|
63
|
+
target.arguments = source.arguments;
|
|
64
|
+
target.rawBlock = source.rawBlock;
|
|
65
|
+
const partialJson = getStreamingPartialJson(source);
|
|
66
|
+
if (partialJson === undefined) clearStreamingPartialJson(target);
|
|
67
|
+
else setStreamingPartialJson(target, partialJson);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
|
|
71
|
+
return source !== undefined && source.name.trim().length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
export function parseInbandToolMessage(
|
|
40
75
|
message: AssistantMessage,
|
|
41
76
|
dialect: Dialect,
|
|
@@ -95,12 +130,18 @@ export function wrapInbandToolStream(
|
|
|
95
130
|
// projector ignores nameless "ghost" parts and de-conflicts with the
|
|
96
131
|
// in-band channel.
|
|
97
132
|
const src = event.partial.content[event.contentIndex];
|
|
98
|
-
projector?.nativeToolStart(event.contentIndex, src?.type === "toolCall" ? src
|
|
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,33 @@ 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`.
|
|
217
|
+
nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
|
|
218
|
+
if (this.#stopped || !hasNamedNativeToolCall(source) || this.#toolChannel === "inband") return;
|
|
177
219
|
this.#toolChannel = "native";
|
|
178
220
|
this.#closeText();
|
|
179
221
|
this.#closeThinking();
|
|
180
|
-
const block
|
|
222
|
+
const block = cloneToolCall(source);
|
|
181
223
|
this.#partial.content.push(block);
|
|
182
224
|
const index = this.#partial.content.length - 1;
|
|
183
225
|
this.#nativeBlocks.set(srcIndex, { index, block });
|
|
184
226
|
if (this.#emitEvents) this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
|
|
185
227
|
}
|
|
186
228
|
|
|
187
|
-
nativeToolDelta(srcIndex: number, delta: string): void {
|
|
229
|
+
nativeToolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
|
|
188
230
|
if (this.#stopped) return;
|
|
189
|
-
|
|
231
|
+
let entry = this.#nativeBlocks.get(srcIndex);
|
|
232
|
+
if (!entry && hasNamedNativeToolCall(source) && this.#toolChannel !== "inband") {
|
|
233
|
+
this.nativeToolStart(srcIndex, source);
|
|
234
|
+
entry = this.#nativeBlocks.get(srcIndex);
|
|
235
|
+
}
|
|
190
236
|
if (!entry) return;
|
|
237
|
+
if (source) syncToolCall(entry.block, source);
|
|
191
238
|
if (this.#emitEvents)
|
|
192
239
|
this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
|
|
193
240
|
}
|
|
@@ -196,7 +243,7 @@ class InbandStreamProjector {
|
|
|
196
243
|
if (this.#stopped) return;
|
|
197
244
|
const entry = this.#nativeBlocks.get(srcIndex);
|
|
198
245
|
if (entry) {
|
|
199
|
-
|
|
246
|
+
syncToolCall(entry.block, toolCall);
|
|
200
247
|
if (this.#emitEvents)
|
|
201
248
|
this.#out.push({
|
|
202
249
|
type: "toolcall_end",
|
|
@@ -210,11 +257,11 @@ class InbandStreamProjector {
|
|
|
210
257
|
// Never streamed (name was empty at start). Salvage a real call whose name
|
|
211
258
|
// only arrived now; drop nameless ghosts and anything the in-band channel
|
|
212
259
|
// already claimed.
|
|
213
|
-
if (!toolCall
|
|
260
|
+
if (!hasNamedNativeToolCall(toolCall) || this.#toolChannel === "inband") return;
|
|
214
261
|
this.#toolChannel = "native";
|
|
215
262
|
this.#closeText();
|
|
216
263
|
this.#closeThinking();
|
|
217
|
-
const block
|
|
264
|
+
const block = cloneToolCall(toolCall);
|
|
218
265
|
this.#partial.content.push(block);
|
|
219
266
|
const index = this.#partial.content.length - 1;
|
|
220
267
|
if (this.#emitEvents) {
|
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/index.ts
CHANGED
package/src/providers/cursor.ts
CHANGED
|
@@ -102,7 +102,13 @@ import {
|
|
|
102
102
|
WriteSuccessSchema,
|
|
103
103
|
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
104
104
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
105
|
-
import {
|
|
105
|
+
import {
|
|
106
|
+
$env,
|
|
107
|
+
parseJsonWithRepair,
|
|
108
|
+
parseStreamingJson,
|
|
109
|
+
parseStreamingJsonThrottled,
|
|
110
|
+
sanitizeText,
|
|
111
|
+
} from "@oh-my-pi/pi-utils";
|
|
106
112
|
import * as AIError from "../error";
|
|
107
113
|
import type {
|
|
108
114
|
Api,
|
|
@@ -129,6 +135,7 @@ import {
|
|
|
129
135
|
clearStreamingPartialJson,
|
|
130
136
|
kStreamingBlockIndex,
|
|
131
137
|
kStreamingBlockKind,
|
|
138
|
+
kStreamingLastParseLen,
|
|
132
139
|
kStreamingPartialJson,
|
|
133
140
|
} from "../utils/block-symbols";
|
|
134
141
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
@@ -620,6 +627,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
620
627
|
export type ToolCallState = ToolCall & {
|
|
621
628
|
[kStreamingBlockIndex]: number;
|
|
622
629
|
[kStreamingPartialJson]?: string;
|
|
630
|
+
[kStreamingLastParseLen]?: number;
|
|
623
631
|
[kStreamingBlockKind]: "mcp" | "todo";
|
|
624
632
|
};
|
|
625
633
|
|
|
@@ -2105,8 +2113,16 @@ export function processInteractionUpdate(
|
|
|
2105
2113
|
if (chunk.length === 0) {
|
|
2106
2114
|
return;
|
|
2107
2115
|
}
|
|
2108
|
-
|
|
2109
|
-
state.currentToolCall
|
|
2116
|
+
const nextBuffer = current + chunk;
|
|
2117
|
+
state.currentToolCall[kStreamingPartialJson] = nextBuffer;
|
|
2118
|
+
// Throttle mid-stream parses to keep total parse work O(N) instead of O(N²)
|
|
2119
|
+
// in the argument-buffer length; the authoritative full parse runs in
|
|
2120
|
+
// `toolCallCompleted` (mcp branch) and the fallback end-of-stream path.
|
|
2121
|
+
const throttled = parseStreamingJsonThrottled(nextBuffer, state.currentToolCall[kStreamingLastParseLen] ?? 0);
|
|
2122
|
+
if (throttled) {
|
|
2123
|
+
state.currentToolCall.arguments = throttled.value;
|
|
2124
|
+
state.currentToolCall[kStreamingLastParseLen] = throttled.parsedLen;
|
|
2125
|
+
}
|
|
2110
2126
|
const idx = output.content.indexOf(state.currentToolCall);
|
|
2111
2127
|
stream.push({ type: "toolcall_delta", contentIndex: idx, delta: chunk, partial: output });
|
|
2112
2128
|
}
|
|
@@ -2114,6 +2130,12 @@ export function processInteractionUpdate(
|
|
|
2114
2130
|
if (state.currentToolCall) {
|
|
2115
2131
|
const toolCall = update.message.value.toolCall;
|
|
2116
2132
|
if (state.currentToolCall[kStreamingBlockKind] === "mcp") {
|
|
2133
|
+
// Authoritative full parse of the accumulated argument buffer; the delta
|
|
2134
|
+
// path throttles mid-stream parses, so `arguments` may lag the buffer.
|
|
2135
|
+
const partial = state.currentToolCall[kStreamingPartialJson];
|
|
2136
|
+
if (partial !== undefined) {
|
|
2137
|
+
state.currentToolCall.arguments = parseStreamingJson(partial);
|
|
2138
|
+
}
|
|
2117
2139
|
const decodedArgs = decodeMcpArgsMap(toolCall?.mcpToolCall?.args?.args);
|
|
2118
2140
|
state.currentToolCall.arguments = mergeCursorMcpToolCallArgs(
|
|
2119
2141
|
state.currentToolCall.arguments as Record<string, unknown> | undefined,
|
package/src/providers/devin.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
StopReason,
|
|
29
29
|
} from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb";
|
|
30
30
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
31
|
-
import { logger, parseStreamingJson } from "@oh-my-pi/pi-utils";
|
|
31
|
+
import { logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
|
|
32
32
|
import * as AIError from "../error";
|
|
33
33
|
import type {
|
|
34
34
|
Api,
|
|
@@ -105,6 +105,11 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
105
105
|
// accumulated per id (kept out of the content object so finalized tool calls stay clean).
|
|
106
106
|
const toolBlocks = new Map<string, ToolCall>();
|
|
107
107
|
const toolPartialJson = new Map<string, string>();
|
|
108
|
+
// Last-parsed argument-buffer length per tool-call id — bounds the
|
|
109
|
+
// mid-stream parse work to O(N) via `parseStreamingJsonThrottled`; the
|
|
110
|
+
// authoritative final parse still runs unconditionally in the toolcall_end
|
|
111
|
+
// loop below.
|
|
112
|
+
const toolLastParseLen = new Map<string, number>();
|
|
108
113
|
let activeToolCallId: string | undefined;
|
|
109
114
|
let latestStopReason = StopReason.UNSPECIFIED;
|
|
110
115
|
|
|
@@ -279,7 +284,11 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
279
284
|
: previousJson + tc.argumentsJson;
|
|
280
285
|
const delta = accumulated.slice(previousJson.length);
|
|
281
286
|
toolPartialJson.set(toolCallId, accumulated);
|
|
282
|
-
|
|
287
|
+
const throttled = parseStreamingJsonThrottled(accumulated, toolLastParseLen.get(toolCallId) ?? 0);
|
|
288
|
+
if (throttled) {
|
|
289
|
+
block.arguments = throttled.value;
|
|
290
|
+
toolLastParseLen.set(toolCallId, throttled.parsedLen);
|
|
291
|
+
}
|
|
283
292
|
stream.push({
|
|
284
293
|
type: "toolcall_delta",
|
|
285
294
|
contentIndex: output.content.indexOf(block),
|
|
@@ -26,7 +26,9 @@ import type {
|
|
|
26
26
|
Model,
|
|
27
27
|
SimpleStreamOptions,
|
|
28
28
|
} from "../types";
|
|
29
|
+
import { createAbortSourceTracker } from "../utils/abort";
|
|
29
30
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
31
|
+
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Fields that must not cross the wire — either non-serializable (functions,
|
|
@@ -47,6 +49,13 @@ const NON_WIRE_KEYS = new Set<keyof SimpleStreamOptions>([
|
|
|
47
49
|
"cursorOnToolResult",
|
|
48
50
|
"providerSessionState",
|
|
49
51
|
]);
|
|
52
|
+
const PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR = "pi-native stream stalled while waiting for the next event";
|
|
53
|
+
const PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR = "pi-native stream timed out while waiting for the first event";
|
|
54
|
+
|
|
55
|
+
function isPiNativeProgressEvent(event: unknown): boolean {
|
|
56
|
+
if (typeof event !== "object" || event === null || !("type" in event)) return true;
|
|
57
|
+
return event.type !== "start";
|
|
58
|
+
}
|
|
50
59
|
|
|
51
60
|
function buildWireOptions(options: SimpleStreamOptions | undefined): Record<string, unknown> {
|
|
52
61
|
if (!options) return {};
|
|
@@ -134,7 +143,8 @@ export function streamPiNative<TApi extends Api>(
|
|
|
134
143
|
const stream = new AssistantMessageEventStream();
|
|
135
144
|
|
|
136
145
|
void (async () => {
|
|
137
|
-
const
|
|
146
|
+
const callerSignal = options?.signal;
|
|
147
|
+
const abortTracker = createAbortSourceTracker(callerSignal);
|
|
138
148
|
// Abort propagation: cancel the response body when the caller's signal
|
|
139
149
|
// fires. Mirror `streamProxy`'s shape — explicit listener + finally
|
|
140
150
|
// cleanup — so we don't leak listeners on the long-running case.
|
|
@@ -143,12 +153,16 @@ export function streamPiNative<TApi extends Api>(
|
|
|
143
153
|
const body = response?.body;
|
|
144
154
|
if (body) body.cancel("Request aborted by caller").catch(() => {});
|
|
145
155
|
};
|
|
146
|
-
if (
|
|
147
|
-
if (
|
|
148
|
-
stream.fail(
|
|
156
|
+
if (callerSignal) {
|
|
157
|
+
if (callerSignal.aborted) {
|
|
158
|
+
stream.fail(
|
|
159
|
+
callerSignal.reason instanceof Error
|
|
160
|
+
? callerSignal.reason
|
|
161
|
+
: new Error(String(callerSignal.reason ?? "aborted")),
|
|
162
|
+
);
|
|
149
163
|
return;
|
|
150
164
|
}
|
|
151
|
-
|
|
165
|
+
callerSignal.addEventListener("abort", onAbort, { once: true });
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
try {
|
|
@@ -165,7 +179,7 @@ export function streamPiNative<TApi extends Api>(
|
|
|
165
179
|
stream: true,
|
|
166
180
|
});
|
|
167
181
|
|
|
168
|
-
response = await fetchImpl(url, { method: "POST", headers, body, signal });
|
|
182
|
+
response = await fetchImpl(url, { method: "POST", headers, body, signal: abortTracker.requestSignal });
|
|
169
183
|
if (!response.ok) {
|
|
170
184
|
stream.fail(await decodeGatewayError(response));
|
|
171
185
|
return;
|
|
@@ -177,11 +191,25 @@ export function streamPiNative<TApi extends Api>(
|
|
|
177
191
|
return;
|
|
178
192
|
}
|
|
179
193
|
|
|
180
|
-
|
|
181
|
-
|
|
194
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
195
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
196
|
+
const source = readSseJson<AssistantMessageEvent>(
|
|
182
197
|
response.body as ReadableStream<Uint8Array>,
|
|
183
|
-
|
|
184
|
-
)
|
|
198
|
+
abortTracker.requestSignal,
|
|
199
|
+
);
|
|
200
|
+
const watchedSource = iterateWithIdleTimeout(source, {
|
|
201
|
+
idleTimeoutMs,
|
|
202
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
203
|
+
errorMessage: PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR,
|
|
204
|
+
firstItemErrorMessage: PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
|
|
205
|
+
onIdle: () =>
|
|
206
|
+
abortTracker.abortLocally(new AIError.StreamTimeoutError(PI_NATIVE_STREAM_IDLE_TIMEOUT_ERROR)),
|
|
207
|
+
onFirstItemTimeout: () =>
|
|
208
|
+
abortTracker.abortLocally(new AIError.StreamTimeoutError(PI_NATIVE_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
|
|
209
|
+
isProgressItem: isPiNativeProgressEvent,
|
|
210
|
+
});
|
|
211
|
+
let sawTerminal = false;
|
|
212
|
+
for await (const event of watchedSource) {
|
|
185
213
|
if (event.type === "done" || event.type === "error") sawTerminal = true;
|
|
186
214
|
stream.push(event);
|
|
187
215
|
// `stream.push` resolves `.result()` on `done`/`error`; subsequent
|
|
@@ -195,7 +223,7 @@ export function streamPiNative<TApi extends Api>(
|
|
|
195
223
|
// so awaiters of `.result()` resolve instead of hanging forever.
|
|
196
224
|
// Matches the gateway's own defensive fallback in
|
|
197
225
|
// `pi-native-server.encodeStream`.
|
|
198
|
-
const aborted =
|
|
226
|
+
const aborted = abortTracker.wasCallerAbort();
|
|
199
227
|
const partial = makeSyntheticAssistant(model as Model<Api>);
|
|
200
228
|
if (aborted) {
|
|
201
229
|
partial.stopReason = "aborted";
|
|
@@ -210,7 +238,7 @@ export function streamPiNative<TApi extends Api>(
|
|
|
210
238
|
} catch (err) {
|
|
211
239
|
stream.fail(err);
|
|
212
240
|
} finally {
|
|
213
|
-
if (
|
|
241
|
+
if (callerSignal) callerSignal.removeEventListener("abort", onAbort);
|
|
214
242
|
}
|
|
215
243
|
})();
|
|
216
244
|
|
|
@@ -126,18 +126,20 @@ function deduplicateToolCallIds(
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Drop assistant `toolCall` blocks whose `name` is empty
|
|
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
|
|
@@ -22,9 +22,31 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { AssistantMessage, TextContent, ThinkingContent, ToolCall } from "../types";
|
|
25
|
+
import {
|
|
26
|
+
clearStreamingPartialJson,
|
|
27
|
+
getStreamingPartialJson,
|
|
28
|
+
type StreamingPartialJsonCarrier,
|
|
29
|
+
setStreamingPartialJson,
|
|
30
|
+
} from "./block-symbols";
|
|
25
31
|
import { AssistantMessageEventStream } from "./event-stream";
|
|
26
32
|
import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "./stream-markup-healing";
|
|
27
33
|
|
|
34
|
+
type StreamingToolCall = ToolCall & StreamingPartialJsonCarrier;
|
|
35
|
+
|
|
36
|
+
function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
|
|
37
|
+
const block: StreamingToolCall = { ...source, arguments: source.arguments };
|
|
38
|
+
const partialJson = getStreamingPartialJson(source);
|
|
39
|
+
if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
|
|
40
|
+
return block;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): void {
|
|
44
|
+
Object.assign(target, source);
|
|
45
|
+
const partialJson = getStreamingPartialJson(source);
|
|
46
|
+
if (partialJson === undefined) clearStreamingPartialJson(target);
|
|
47
|
+
else setStreamingPartialJson(target, partialJson);
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
/**
|
|
29
51
|
* Wrap a provider stream so leaked reasoning fences are healed into thinking
|
|
30
52
|
* blocks live, for every provider. Returns a new stream that re-projects the
|
|
@@ -55,12 +77,14 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
|
|
|
55
77
|
case "toolcall_start": {
|
|
56
78
|
projector ??= new LeakedThinkingProjector(out, event.partial);
|
|
57
79
|
const block = event.partial.content[event.contentIndex];
|
|
58
|
-
projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block
|
|
80
|
+
projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block : undefined);
|
|
59
81
|
break;
|
|
60
82
|
}
|
|
61
|
-
case "toolcall_delta":
|
|
62
|
-
|
|
83
|
+
case "toolcall_delta": {
|
|
84
|
+
const block = event.partial.content[event.contentIndex];
|
|
85
|
+
projector?.toolDelta(event.contentIndex, event.delta, block?.type === "toolCall" ? block : undefined);
|
|
63
86
|
break;
|
|
87
|
+
}
|
|
64
88
|
case "toolcall_end":
|
|
65
89
|
projector?.toolEnd(event.contentIndex, event.toolCall);
|
|
66
90
|
break;
|
|
@@ -111,7 +135,7 @@ class LeakedThinkingProjector {
|
|
|
111
135
|
/** Latest non-undefined text signature seen, stamped onto held-back text flushed later. */
|
|
112
136
|
#lastTextSignature: string | undefined;
|
|
113
137
|
/** Forwarded native tool calls, keyed by the inner stream's `contentIndex`. */
|
|
114
|
-
#toolBlocks = new Map<number, { index: number }>();
|
|
138
|
+
#toolBlocks = new Map<number, { index: number; block: StreamingToolCall }>();
|
|
115
139
|
|
|
116
140
|
constructor(out: AssistantMessageEventStream, seed: AssistantMessage) {
|
|
117
141
|
this.#out = out;
|
|
@@ -136,29 +160,39 @@ class LeakedThinkingProjector {
|
|
|
136
160
|
}
|
|
137
161
|
|
|
138
162
|
/** Forward a native tool call's start, releasing any held-back text first. */
|
|
139
|
-
toolStart(srcIndex: number,
|
|
163
|
+
toolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
|
|
164
|
+
if (!source) return;
|
|
140
165
|
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
141
166
|
this.#closeText();
|
|
142
167
|
this.#closeThinking();
|
|
143
|
-
const block
|
|
168
|
+
const block = cloneToolCall(source);
|
|
144
169
|
this.#partial.content.push(block);
|
|
145
170
|
const index = this.#partial.content.length - 1;
|
|
146
|
-
this.#toolBlocks.set(srcIndex, { index });
|
|
171
|
+
this.#toolBlocks.set(srcIndex, { index, block });
|
|
147
172
|
this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
|
|
148
173
|
}
|
|
149
174
|
|
|
150
|
-
toolDelta(srcIndex: number, delta: string): void {
|
|
151
|
-
|
|
175
|
+
toolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
|
|
176
|
+
let entry = this.#toolBlocks.get(srcIndex);
|
|
177
|
+
if (!entry && source) {
|
|
178
|
+
this.toolStart(srcIndex, source);
|
|
179
|
+
entry = this.#toolBlocks.get(srcIndex);
|
|
180
|
+
}
|
|
152
181
|
if (!entry) return;
|
|
182
|
+
if (source) syncToolCall(entry.block, source);
|
|
153
183
|
this.#out.push({ type: "toolcall_delta", contentIndex: entry.index, delta, partial: this.#partial });
|
|
154
184
|
}
|
|
155
185
|
|
|
156
186
|
toolEnd(srcIndex: number, toolCall: ToolCall): void {
|
|
157
187
|
const entry = this.#toolBlocks.get(srcIndex);
|
|
158
188
|
if (entry) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
syncToolCall(entry.block, toolCall);
|
|
190
|
+
this.#out.push({
|
|
191
|
+
type: "toolcall_end",
|
|
192
|
+
contentIndex: entry.index,
|
|
193
|
+
toolCall: entry.block,
|
|
194
|
+
partial: this.#partial,
|
|
195
|
+
});
|
|
162
196
|
this.#toolBlocks.delete(srcIndex);
|
|
163
197
|
return;
|
|
164
198
|
}
|
|
@@ -166,7 +200,7 @@ class LeakedThinkingProjector {
|
|
|
166
200
|
this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
|
|
167
201
|
this.#closeText();
|
|
168
202
|
this.#closeThinking();
|
|
169
|
-
const block
|
|
203
|
+
const block = cloneToolCall(toolCall);
|
|
170
204
|
this.#partial.content.push(block);
|
|
171
205
|
const index = this.#partial.content.length - 1;
|
|
172
206
|
this.#out.push({ type: "toolcall_start", contentIndex: index, partial: this.#partial });
|
|
@@ -287,12 +287,17 @@ export class ThinkingLoopDetector {
|
|
|
287
287
|
* stream that trips the tool-call reminder. Gemini occasionally narrates a long
|
|
288
288
|
* chain of titled summaries ("Examining Result Handling", "Refining Result
|
|
289
289
|
* Rendering", …) without ever calling a tool, burning the whole budget on
|
|
290
|
-
* planning
|
|
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
|