@oh-my-pi/pi-ai 16.2.11 → 16.2.13
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 +19 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/cursor.d.ts +2 -1
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- package/package.json +4 -4
- package/src/dialect/owned-stream.ts +10 -13
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +25 -3
- package/src/providers/devin.ts +11 -2
- package/src/providers/openai-codex/request-transformer.ts +7 -2
- package/src/providers/pi-native-client.ts +40 -12
- package/src/utils/leaked-thinking-stream.ts +47 -13
- package/src/utils/tool-call-loop-guard.ts +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.13] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed pre-5.4 OpenAI Codex models (`gpt-5.1-codex`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`) rejecting requests with `Unsupported parameter: 'reasoning.summary' is not supported with this model` by gating `reasoning.summary` behind the same gpt-5.4 wire floor as `reasoning.context: "all_turns"`.
|
|
10
|
+
|
|
11
|
+
## [16.2.12] - 2026-07-01
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Improved streaming performance for Cursor and Devin providers by optimizing mid-stream tool-call argument parsing to prevent UI stalls when handling large payloads.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed issues with tool call streaming where tool call IDs, partial JSON payloads, or late-arriving IDs could be lost, filtered, or incorrectly initialized.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- Fixed cross-turn tool-call loops going undetected by adding a guard for consecutive identical tool calls. (#3971)
|
|
23
|
+
|
|
5
24
|
## [16.2.11] - 2026-07-01
|
|
6
25
|
|
|
7
26
|
### Fixed
|
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 {
|
|
@@ -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.13",
|
|
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.13",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.2.13",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.2.13",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -67,8 +67,8 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi
|
|
|
67
67
|
else setStreamingPartialJson(target, partialJson);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function
|
|
71
|
-
return source !== undefined && source.name.trim().length > 0
|
|
70
|
+
function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
|
|
71
|
+
return source !== undefined && source.name.trim().length > 0;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function parseInbandToolMessage(
|
|
@@ -213,12 +213,9 @@ class InbandStreamProjector {
|
|
|
213
213
|
// emits native tool calls, the projected block must mirror the provider's live
|
|
214
214
|
// id / args / partial-json state rather than inventing `{ id: "", arguments:
|
|
215
215
|
// {} }` placeholders — otherwise the UI loses streaming args, can mis-key the
|
|
216
|
-
// call until `toolcall_end
|
|
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.
|
|
216
|
+
// call until `toolcall_end`.
|
|
220
217
|
nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
|
|
221
|
-
if (this.#stopped || !
|
|
218
|
+
if (this.#stopped || !hasNamedNativeToolCall(source) || this.#toolChannel === "inband") return;
|
|
222
219
|
this.#toolChannel = "native";
|
|
223
220
|
this.#closeText();
|
|
224
221
|
this.#closeThinking();
|
|
@@ -232,7 +229,7 @@ class InbandStreamProjector {
|
|
|
232
229
|
nativeToolDelta(srcIndex: number, delta: string, source: StreamingToolCall | undefined): void {
|
|
233
230
|
if (this.#stopped) return;
|
|
234
231
|
let entry = this.#nativeBlocks.get(srcIndex);
|
|
235
|
-
if (!entry &&
|
|
232
|
+
if (!entry && hasNamedNativeToolCall(source) && this.#toolChannel !== "inband") {
|
|
236
233
|
this.nativeToolStart(srcIndex, source);
|
|
237
234
|
entry = this.#nativeBlocks.get(srcIndex);
|
|
238
235
|
}
|
|
@@ -246,7 +243,7 @@ class InbandStreamProjector {
|
|
|
246
243
|
if (this.#stopped) return;
|
|
247
244
|
const entry = this.#nativeBlocks.get(srcIndex);
|
|
248
245
|
if (entry) {
|
|
249
|
-
|
|
246
|
+
syncToolCall(entry.block, toolCall);
|
|
250
247
|
if (this.#emitEvents)
|
|
251
248
|
this.#out.push({
|
|
252
249
|
type: "toolcall_end",
|
|
@@ -257,10 +254,10 @@ class InbandStreamProjector {
|
|
|
257
254
|
this.#nativeBlocks.delete(srcIndex);
|
|
258
255
|
return;
|
|
259
256
|
}
|
|
260
|
-
// Never streamed (name
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
if (!
|
|
257
|
+
// Never streamed (name was empty at start). Salvage a real call whose name
|
|
258
|
+
// only arrived now; drop nameless ghosts and anything the in-band channel
|
|
259
|
+
// already claimed.
|
|
260
|
+
if (!hasNamedNativeToolCall(toolCall) || this.#toolChannel === "inband") return;
|
|
264
261
|
this.#toolChannel = "native";
|
|
265
262
|
this.#closeText();
|
|
266
263
|
this.#closeThinking();
|
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),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
2
|
-
import { supportsAllTurnsReasoningContext } from "@oh-my-pi/pi-catalog/identity";
|
|
2
|
+
import { supportsAllTurnsReasoningContext, supportsCodexReasoningSummary } from "@oh-my-pi/pi-catalog/identity";
|
|
3
3
|
import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
4
4
|
import type { Api, Model } from "../../types";
|
|
5
5
|
|
|
@@ -85,7 +85,12 @@ function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): Re
|
|
|
85
85
|
effort:
|
|
86
86
|
options.reasoningEffort === "none" ? "none" : requireSupportedEffort(model, options.reasoningEffort as Effort),
|
|
87
87
|
};
|
|
88
|
-
|
|
88
|
+
// `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids
|
|
89
|
+
// (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
|
|
90
|
+
// "Unsupported parameter: 'reasoning.summary' is not supported with this model".
|
|
91
|
+
// Mirrors the all_turns gate: an explicit summary is suppressed on unsupported
|
|
92
|
+
// ids, letting the server skip the human-readable summary stream.
|
|
93
|
+
if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) {
|
|
89
94
|
config.summary = options.reasoningSummary ?? "detailed";
|
|
90
95
|
}
|
|
91
96
|
return config;
|
|
@@ -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
|
|
|
@@ -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 });
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
2
|
+
import type { AssistantMessage, ToolCall, ToolResultMessage } from "../types";
|
|
3
|
+
|
|
4
|
+
const LEGACY_INTENT_FIELD = "__intent";
|
|
5
|
+
const RESULT_SUMMARY_LIMIT = 200;
|
|
6
|
+
const ARGUMENT_SUMMARY_LIMIT = 400;
|
|
7
|
+
|
|
8
|
+
/** Runtime settings for cross-turn tool-call repetition detection. */
|
|
9
|
+
export interface ToolCallLoopGuardOptions {
|
|
10
|
+
readonly threshold: number;
|
|
11
|
+
readonly exemptTools: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A completed assistant turn plus the tool results it produced. */
|
|
15
|
+
export interface ToolCallLoopTurn {
|
|
16
|
+
readonly message: AssistantMessage;
|
|
17
|
+
readonly toolResults: readonly ToolResultMessage[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Details needed to steer the model away from a repeated tool call. */
|
|
21
|
+
export interface RepeatedToolCallDetection {
|
|
22
|
+
readonly kind: "repeated_tool_call";
|
|
23
|
+
readonly toolName: string;
|
|
24
|
+
readonly count: number;
|
|
25
|
+
readonly resultSummary: string;
|
|
26
|
+
readonly argumentsSummary: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalizeToolCallValue(value: unknown): unknown {
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
return value.map(item => canonicalizeToolCallValue(item));
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== "object") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const input = value as Record<string, unknown>;
|
|
38
|
+
const output: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(input).sort()) {
|
|
40
|
+
if (key === INTENT_FIELD || key === LEGACY_INTENT_FIELD) continue;
|
|
41
|
+
output[key] = canonicalizeToolCallValue(input[key]);
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeText(text: string, limit: number): string {
|
|
47
|
+
let summary = text.replace(/\s+/g, " ").trim();
|
|
48
|
+
if (summary.length > limit) {
|
|
49
|
+
summary = `${summary.slice(0, limit)}…`;
|
|
50
|
+
}
|
|
51
|
+
return summary;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summarizeToolResult(toolResults: readonly ToolResultMessage[], toolCallId: string): string {
|
|
55
|
+
const result = toolResults.find(candidate => candidate.toolCallId === toolCallId);
|
|
56
|
+
if (!result) return "";
|
|
57
|
+
|
|
58
|
+
const textParts: string[] = [];
|
|
59
|
+
for (const block of result.content) {
|
|
60
|
+
if (block.type === "text") {
|
|
61
|
+
textParts.push(block.text);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return summarizeText(textParts.join("\n"), RESULT_SUMMARY_LIMIT);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detects consecutive identical assistant tool calls across model turns. */
|
|
68
|
+
export class ToolCallLoopGuard {
|
|
69
|
+
#threshold: number;
|
|
70
|
+
#exemptTools: ReadonlySet<string>;
|
|
71
|
+
#lastHash: string | undefined;
|
|
72
|
+
#count = 0;
|
|
73
|
+
|
|
74
|
+
constructor(options: ToolCallLoopGuardOptions) {
|
|
75
|
+
this.#threshold = Math.max(1, Math.trunc(options.threshold));
|
|
76
|
+
this.#exemptTools = new Set(options.exemptTools);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Records one completed turn and returns the threshold hit, if any. */
|
|
80
|
+
recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null {
|
|
81
|
+
const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
|
|
82
|
+
if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) {
|
|
83
|
+
this.#lastHash = undefined;
|
|
84
|
+
this.#count = 0;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toolCall = toolCalls[0]!;
|
|
89
|
+
const canonicalArgs = JSON.stringify(canonicalizeToolCallValue(toolCall.arguments));
|
|
90
|
+
const hash = `${toolCall.name}:${canonicalArgs}`;
|
|
91
|
+
if (hash === this.#lastHash) {
|
|
92
|
+
this.#count++;
|
|
93
|
+
} else {
|
|
94
|
+
this.#lastHash = hash;
|
|
95
|
+
this.#count = 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.#count !== this.#threshold) return null;
|
|
99
|
+
return {
|
|
100
|
+
kind: "repeated_tool_call",
|
|
101
|
+
toolName: toolCall.name,
|
|
102
|
+
count: this.#count,
|
|
103
|
+
resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
|
|
104
|
+
argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|