@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 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
@@ -45,4 +45,5 @@ export * from "./utils/openrouter-headers";
45
45
  export * from "./utils/retry";
46
46
  export * from "./utils/schema";
47
47
  export * from "./utils/thinking-loop";
48
+ export * from "./utils/tool-call-loop-guard";
48
49
  export * from "./utils/validation";
@@ -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.11",
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.11",
42
- "@oh-my-pi/pi-utils": "16.2.11",
43
- "@oh-my-pi/pi-wire": "16.2.11",
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 hasUsableNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
71
- return source !== undefined && source.name.trim().length > 0 && source.id.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`, and may even persist an invalid empty id into
217
- // replay history. So we do NOT start until the provider has emitted both a
218
- // non-empty name and a non-empty id; if either lands late, `nativeToolDelta`
219
- // retries the start on subsequent deltas.
216
+ // call until `toolcall_end`.
220
217
  nativeToolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
221
- if (this.#stopped || !hasUsableNativeToolCall(source) || this.#toolChannel === "inband") return;
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 && hasUsableNativeToolCall(source) && this.#toolChannel !== "inband") {
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
- if (hasUsableNativeToolCall(toolCall)) syncToolCall(entry.block, toolCall);
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/id were incomplete at start). Salvage only a real
261
- // native call whose identifier is now present; drop nameless/id-less ghosts
262
- // and anything the in-band channel already claimed.
263
- if (!hasUsableNativeToolCall(toolCall) || this.#toolChannel === "inband") return;
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
@@ -45,4 +45,5 @@ export * from "./utils/openrouter-headers";
45
45
  export * from "./utils/retry";
46
46
  export * from "./utils/schema";
47
47
  export * from "./utils/thinking-loop";
48
+ export * from "./utils/tool-call-loop-guard";
48
49
  export * from "./utils/validation";
@@ -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 { $env, parseJsonWithRepair, parseStreamingJson, sanitizeText } from "@oh-my-pi/pi-utils";
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
- state.currentToolCall[kStreamingPartialJson] = current + chunk;
2109
- state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall[kStreamingPartialJson]);
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,
@@ -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
- block.arguments = parseStreamingJson(accumulated);
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
- if (options.reasoningSummary !== null) {
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 signal = options?.signal;
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 (signal) {
147
- if (signal.aborted) {
148
- stream.fail(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted")));
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
- signal.addEventListener("abort", onAbort, { once: true });
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
- let sawTerminal = false;
181
- for await (const event of readSseJson<AssistantMessageEvent>(
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
- signal,
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 = signal?.aborted === true;
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 (signal) signal.removeEventListener("abort", onAbort);
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.name : "");
80
+ projector.toolStart(event.contentIndex, block?.type === "toolCall" ? block : undefined);
59
81
  break;
60
82
  }
61
- case "toolcall_delta":
62
- projector?.toolDelta(event.contentIndex, event.delta);
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, name: string): void {
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: ToolCall = { type: "toolCall", id: "", name, arguments: {} };
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
- const entry = this.#toolBlocks.get(srcIndex);
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
- const block = this.#partial.content[entry.index] as ToolCall;
160
- Object.assign(block, toolCall);
161
- this.#out.push({ type: "toolcall_end", contentIndex: entry.index, toolCall: block, partial: this.#partial });
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: ToolCall = { ...toolCall };
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
+ }