@oh-my-pi/pi-agent-core 17.1.3 → 17.1.5

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.5] - 2026-07-27
6
+
7
+ ### Fixed
8
+
9
+ - Fixed proxy-stream clients dropping finalized provider-only content blocks, including Anthropic native web-search history, by allowing `done` and `error` events to carry terminal assistant content while retaining delta-reconstructed content from older proxy servers that omit it ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)).
10
+
11
+ ## [17.1.4] - 2026-07-26
12
+
13
+ ### Changed
14
+
15
+ - Steering is now woken by an event instead of polled on a fixed interval while a tool batch runs. `AgentLoopConfig.waitForSteeringMessages` resolves when a steer is enqueued, so an interruption is observed as soon as it arrives rather than at the next tick, and idle batches stop burning wakeups. The interval timer remains for the IRC interrupt queue, which has no wake callback, and checks only IRC while the event watcher owns steering. Waits are raced against local abort, so a callback that does not observe its signal cannot hang batch teardown.
16
+
17
+ ### Fixed
18
+
19
+ - Fixed a Cursor tool result being lost when a custom `cursorOnToolResult` transformer was still pending as the turn closed. The provider dispatches decoded messages without awaiting them, so a `message_end` from the same chunk could drain the buffer before the transformer resolved, dropping the result and leaving its `toolCall` block to be stripped as dangling on replay. The entry is now reserved synchronously and patched in place once the transformer resolves, preserving buffer order.
20
+ - Fixed an async `cursorOnToolResult` transformer's rewrite being silently discarded when it resolved after the buffer drain. The reservation kept the call from dangling but the late patch mutated a detached entry, so the already-persisted message kept the pre-transform payload. The drain now awaits any transformer still in flight before persisting, matching the awaited exec-channel paths. A rejecting transformer is swallowed and the reserved payload stands in, so a failing hook cannot take the turn down or cost the result.
21
+ - Fixed Cursor tool results being dropped for hosts that pass neither `cursorExecHandlers` nor `cursorOnToolResult`. Both are optional, but the Cursor provider resolves native todo calls server-side and synthesizes exec blocks regardless, marking both as resolved so no placeholder result is emitted for them. The result buffer callback was only installed when one of the options was present, so a bare SDK host discarded the provider's paired result and every rebuilt transcript stripped the interaction. It is now installed unconditionally.
22
+ - Fixed an async `cursorOnToolResult` rewrite being lost when the provider errored mid-transform. The normal drain waits for a pending transformer, but the error path snapshotted the buffer without that await, so a transform still in flight patched an entry the catch path had already detached and the pre-transform payload was persisted. A provider error is exactly when a transform is most likely to be mid-flight.
23
+ - Reduced oversized OpenAI native compaction requests by replacing only trailing tool-output bodies that exceed the model context window, while preserving calls, assistant history, and reasoning.
24
+
5
25
  ## [17.1.2] - 2026-07-24
6
26
 
7
27
  ### Added
@@ -166,7 +166,16 @@ export interface AgentOptions {
166
166
  /** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
167
167
  getCursorTools?: () => AgentTool[];
168
168
  /**
169
- * Cursor tool result callback for exec tool responses.
169
+ * Optional rewrite of Cursor exec-channel tool results. May return a Promise.
170
+ *
171
+ * The Agent reserves the original result in its Cursor result buffer first,
172
+ * then awaits this hook and patches the reserved entry in place. That keeps
173
+ * the call paired even if `message_end` arrives while the Promise is still
174
+ * pending, and `#emitCursorSplitAssistantMessage` waits for any transformer
175
+ * still in flight before persisting, so a late rewrite is not lost. A
176
+ * rejecting transformer is swallowed and the reserved payload stands in.
177
+ * Hosts that only pass `cursorExecHandlers` (the coding-agent path) never
178
+ * hit this hook.
170
179
  */
171
180
  cursorOnToolResult?: CursorToolResultHandler;
172
181
  /** Current working directory used by local tool execution. */
@@ -26,6 +26,20 @@ export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompac
26
26
  * behind it). On timeout the caller falls back to local summarization.
27
27
  */
28
28
  export declare const REMOTE_COMPACTION_TIMEOUT_MS = 180000;
29
+ export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
30
+ export interface TrimRemoteCompactionInputResult {
31
+ input: Array<Record<string, unknown>>;
32
+ rewrittenOutputs: number;
33
+ estimatedTokensBefore: number;
34
+ estimatedTokensAfter: number;
35
+ }
36
+ /**
37
+ * Preserve the full native transcript unless trailing tool outputs alone push a
38
+ * remote compaction request beyond the model window. Replacing only those
39
+ * outputs keeps call/result pairing and all earlier assistant/reasoning history,
40
+ * matching Codex's recovery path for oversized tool turns.
41
+ */
42
+ export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, contextWindow: number | null | undefined, instructions: string, tools?: unknown[]): TrimRemoteCompactionInputResult;
29
43
  export type OpenAiRemoteCompactionItem = {
30
44
  type: "compaction" | "compaction_summary";
31
45
  encrypted_content?: string;
@@ -53,11 +53,13 @@ export type ProxyAssistantMessageEvent = {
53
53
  type: "done";
54
54
  reason: Extract<StopReason, "stop" | "length" | "toolUse">;
55
55
  usage: AssistantMessage["usage"];
56
+ content?: AssistantMessage["content"];
56
57
  } | {
57
58
  type: "error";
58
59
  reason: Extract<StopReason, "aborted" | "error">;
59
60
  errorMessage?: string;
60
61
  usage: AssistantMessage["usage"];
62
+ content?: AssistantMessage["content"];
61
63
  };
62
64
  export interface ProxyStreamOptions extends SimpleStreamOptions {
63
65
  /** Auth token for the proxy server */
@@ -1 +1,2 @@
1
1
  export declare function countTokens(text: string | string[]): number;
2
+ export declare function countTokensConservatively(text: string | string[]): number;
@@ -181,6 +181,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
181
181
  * messages are still delivered at the next injection boundary.
182
182
  */
183
183
  hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
184
+ /**
185
+ * Wakes the in-flight tool interrupt watcher when a steering message is queued.
186
+ * The callback must not consume the queue; the loop still calls
187
+ * {@link hasSteeringMessages} before aborting and injects through
188
+ * {@link getSteeringMessages}.
189
+ */
190
+ waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
184
191
  /**
185
192
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
186
193
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "17.1.3",
4
+ "version": "17.1.5",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "17.1.3",
39
- "@oh-my-pi/pi-catalog": "17.1.3",
40
- "@oh-my-pi/pi-natives": "17.1.3",
41
- "@oh-my-pi/pi-utils": "17.1.3",
42
- "@oh-my-pi/pi-wire": "17.1.3",
43
- "@oh-my-pi/snapcompact": "17.1.3",
38
+ "@oh-my-pi/pi-ai": "17.1.5",
39
+ "@oh-my-pi/pi-catalog": "17.1.5",
40
+ "@oh-my-pi/pi-natives": "17.1.5",
41
+ "@oh-my-pi/pi-utils": "17.1.5",
42
+ "@oh-my-pi/pi-wire": "17.1.5",
43
+ "@oh-my-pi/snapcompact": "17.1.5",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -2004,7 +2004,7 @@ async function executeToolCalls(
2004
2004
  // external + IRC aborts. Every other tool sees ONLY the external signal:
2005
2005
  // neither queued steering nor a peer IRC ever hard-kills a partially
2006
2006
  // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
2007
- // steeringSignal above, and the message injects at the next boundary.
2007
+ // `steeringSignal` above, and the message injects at the next boundary.
2008
2008
  const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
2009
2009
  const interruptibleSignal: AbortSignal = signal
2010
2010
  ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
@@ -2050,6 +2050,21 @@ async function executeToolCalls(
2050
2050
  };
2051
2051
  });
2052
2052
 
2053
+ const checkIrcInterrupts = async (): Promise<void> => {
2054
+ // IRC only fires once: a peer interrupt already recorded on interruptState
2055
+ // must not re-abort, and (unlike steering) never re-consumes a queue.
2056
+ if (!shouldInterruptImmediately || signal?.aborted || interruptState.triggered) return;
2057
+ if (hasIrcInterrupts && (await hasIrcInterrupts())) {
2058
+ // Peer IRC hard-aborts interruptible waits only; foreground tools keep
2059
+ // running (no partial side effects) but get the cooperative soft
2060
+ // signal so backgroundable work can step aside for the peer message.
2061
+ interruptState.triggered = true;
2062
+ interruptState.source = "irc";
2063
+ ircAbortController.abort();
2064
+ steeringSoftController.abort();
2065
+ }
2066
+ };
2067
+
2053
2068
  const checkSteering = async (): Promise<void> => {
2054
2069
  // `signal` (external/user abort) is checked separately from the internal
2055
2070
  // abort controllers: once the run is externally aborted it is unwinding
@@ -2087,18 +2102,7 @@ async function executeToolCalls(
2087
2102
  }
2088
2103
  return;
2089
2104
  }
2090
- // IRC only fires once: a peer interrupt already recorded on interruptState
2091
- // must not re-abort, and (unlike steering above) never re-consume a queue.
2092
- if (interruptState.triggered) return;
2093
- if (hasIrcInterrupts && (await hasIrcInterrupts())) {
2094
- // Peer IRC hard-aborts interruptible waits only; foreground tools keep
2095
- // running (no partial side effects) but get the cooperative soft
2096
- // signal so backgroundable work can step aside for the peer message.
2097
- interruptState.triggered = true;
2098
- interruptState.source = "irc";
2099
- ircAbortController.abort();
2100
- steeringSoftController.abort();
2101
- }
2105
+ await checkIrcInterrupts();
2102
2106
  };
2103
2107
 
2104
2108
  const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
@@ -2443,13 +2447,60 @@ async function executeToolCalls(
2443
2447
  // mode; checkSteering is idempotent (no-op once triggered).
2444
2448
  const watchSteeringWhileRunning =
2445
2449
  shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
2446
- const steeringWatchTimer = watchSteeringWhileRunning
2447
- ? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
2450
+ const eventDrivenSteeringWatch =
2451
+ watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
2452
+ const steeringWatchAbortController = new AbortController();
2453
+ const steeringWatchSignal = signal
2454
+ ? AbortSignal.any([signal, steeringWatchAbortController.signal])
2455
+ : steeringWatchAbortController.signal;
2456
+ // Race every wait against one local abort promise. The callback contract does
2457
+ // not require an implementation to observe the signal, and one that resolves
2458
+ // only on the next queue event would otherwise never settle once the batch
2459
+ // finishes, so awaiting it during teardown would hang a batch with no steer.
2460
+ const { promise: watchAborted, resolve: resolveWatchAbort } = Promise.withResolvers<void>();
2461
+ if (steeringWatchSignal.aborted) {
2462
+ resolveWatchAbort();
2463
+ } else {
2464
+ steeringWatchSignal.addEventListener("abort", () => resolveWatchAbort(), { once: true });
2465
+ }
2466
+ const watchAbortedFalse = watchAborted.then(() => false);
2467
+ const steeringWatchPromise = eventDrivenSteeringWatch
2468
+ ? (async (): Promise<void> => {
2469
+ while (!steeringWatchSignal.aborted) {
2470
+ // Subscribe before checking queue state. This closes the edge
2471
+ // race where a steer arrives after a check but before listener
2472
+ // registration: the subsequent check observes queued state,
2473
+ // while later arrivals resolve this already-installed wait.
2474
+ const steeringQueued = config.waitForSteeringMessages?.(steeringWatchSignal).then(
2475
+ () => true,
2476
+ () => false,
2477
+ );
2478
+ const steeringChecked = checkSteering().then(
2479
+ () => true,
2480
+ () => false,
2481
+ );
2482
+ if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
2483
+ if (steeringWatchSignal.aborted || interruptState.triggered) return;
2484
+ if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
2485
+ }
2486
+ })()
2448
2487
  : undefined;
2488
+ // IRC interrupt records have a separate session-owned queue and no wake
2489
+ // callback. Keep its established timer fallback when that queue is present;
2490
+ // system steering uses the event-driven path above and does not poll.
2491
+ const steeringWatchTimer =
2492
+ watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasIrcInterrupts !== undefined)
2493
+ ? setInterval(
2494
+ () => void (eventDrivenSteeringWatch ? checkIrcInterrupts() : checkSteering()),
2495
+ STEERING_INTERRUPT_POLL_MS,
2496
+ )
2497
+ : undefined;
2449
2498
  try {
2450
2499
  await Promise.allSettled(tasks);
2451
2500
  } finally {
2452
- if (steeringWatchTimer !== undefined) clearInterval(steeringWatchTimer);
2501
+ steeringWatchAbortController.abort();
2502
+ await steeringWatchPromise?.catch(() => undefined);
2503
+ clearInterval(steeringWatchTimer);
2453
2504
  }
2454
2505
  // Yield after batch tool execution to let GC and I/O catch up,
2455
2506
  // especially when tool results are large (e.g. bash output).
package/src/agent.ts CHANGED
@@ -276,7 +276,16 @@ export interface AgentOptions {
276
276
  getCursorTools?: () => AgentTool[];
277
277
 
278
278
  /**
279
- * Cursor tool result callback for exec tool responses.
279
+ * Optional rewrite of Cursor exec-channel tool results. May return a Promise.
280
+ *
281
+ * The Agent reserves the original result in its Cursor result buffer first,
282
+ * then awaits this hook and patches the reserved entry in place. That keeps
283
+ * the call paired even if `message_end` arrives while the Promise is still
284
+ * pending, and `#emitCursorSplitAssistantMessage` waits for any transformer
285
+ * still in flight before persisting, so a late rewrite is not lost. A
286
+ * rejecting transformer is swallowed and the reserved payload stands in.
287
+ * Hosts that only pass `cursorExecHandlers` (the coding-agent path) never
288
+ * hit this hook.
280
289
  */
281
290
  cursorOnToolResult?: CursorToolResultHandler;
282
291
 
@@ -330,6 +339,13 @@ export interface AgentPromptOptions {
330
339
  /** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */
331
340
  interface CursorToolResultEntry {
332
341
  toolResult: ToolResultMessage;
342
+ /**
343
+ * Set while an async `cursorOnToolResult` transformer is still running for
344
+ * this entry, and cleared once it settles. The drain awaits it so a
345
+ * transformer that rewrites the payload is not silently discarded when
346
+ * `message_end` lands in the same chunk as the tool result.
347
+ */
348
+ pending?: Promise<void>;
333
349
  }
334
350
 
335
351
  export class Agent {
@@ -353,6 +369,8 @@ export class Agent {
353
369
  #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
354
370
  #steeringQueue: AgentMessage[] = [];
355
371
  #followUpQueue: AgentMessage[] = [];
372
+ #steeringWaiters = new Set<() => void>();
373
+
356
374
  #steeringMode: "all" | "one-at-a-time";
357
375
  #followUpMode: "all" | "one-at-a-time";
358
376
  #interruptMode: "immediate" | "wait";
@@ -869,6 +887,7 @@ export class Agent {
869
887
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
870
888
  this.#steeringQueue = steering.slice();
871
889
  this.#followUpQueue = followUp.slice();
890
+ this.#notifySteeringWaiters();
872
891
  }
873
892
 
874
893
  appendMessage(m: AgentMessage) {
@@ -889,6 +908,7 @@ export class Agent {
889
908
  */
890
909
  steer(m: AgentMessage) {
891
910
  this.#steeringQueue.push(m);
911
+ this.#notifySteeringWaiters();
892
912
  }
893
913
 
894
914
  /**
@@ -901,6 +921,7 @@ export class Agent {
901
921
 
902
922
  clearSteeringQueue() {
903
923
  this.#steeringQueue = [];
924
+ this.#notifySteeringWaiters();
904
925
  }
905
926
 
906
927
  clearFollowUpQueue() {
@@ -910,6 +931,7 @@ export class Agent {
910
931
  clearAllQueues() {
911
932
  this.#steeringQueue = [];
912
933
  this.#followUpQueue = [];
934
+ this.#notifySteeringWaiters();
913
935
  }
914
936
 
915
937
  hasQueuedMessages(): boolean {
@@ -990,6 +1012,29 @@ export class Agent {
990
1012
  return this.#runningPrompt ?? Promise.resolve();
991
1013
  }
992
1014
 
1015
+ /**
1016
+ * Wait for a steering message without consuming the steering queue.
1017
+ *
1018
+ * The signal releases the waiter when the prompt ends, so an in-flight
1019
+ * tool watcher never survives the tool batch that owns it.
1020
+ */
1021
+ #waitForSteeringMessages(signal?: AbortSignal): Promise<void> {
1022
+ if (this.#steeringQueue.length > 0 || signal?.aborted) return Promise.resolve();
1023
+ const { promise, resolve } = Promise.withResolvers<void>();
1024
+ const onAbort = (): void => resolve();
1025
+ this.#steeringWaiters.add(resolve);
1026
+ signal?.addEventListener("abort", onAbort, { once: true });
1027
+ return promise.finally(() => {
1028
+ this.#steeringWaiters.delete(resolve);
1029
+ signal?.removeEventListener("abort", onAbort);
1030
+ });
1031
+ }
1032
+
1033
+ #notifySteeringWaiters(): void {
1034
+ const waiters = [...this.#steeringWaiters];
1035
+ for (const resolve of waiters) resolve();
1036
+ }
1037
+
993
1038
  reset() {
994
1039
  this.#state.messages.length = 0;
995
1040
  this.#state.isStreaming = false;
@@ -998,6 +1043,7 @@ export class Agent {
998
1043
  this.#state.error = undefined;
999
1044
  this.#steeringQueue = [];
1000
1045
  this.#followUpQueue = [];
1046
+ this.#notifySteeringWaiters();
1001
1047
  }
1002
1048
 
1003
1049
  /** Send a prompt with an AgentMessage */
@@ -1127,26 +1173,51 @@ export class Agent {
1127
1173
  tools: this.#state.tools,
1128
1174
  };
1129
1175
 
1130
- const cursorOnToolResult =
1131
- this.#cursorExecHandlers || this.#cursorOnToolResult
1132
- ? async (message: ToolResultMessage) => {
1133
- let finalMessage = message;
1134
- if (this.#cursorOnToolResult) {
1135
- try {
1136
- const updated = await this.#cursorOnToolResult(message);
1137
- if (updated) {
1138
- finalMessage = updated;
1139
- }
1140
- } catch {}
1141
- }
1142
- // Cursor executes tools server-side during streaming. We buffer
1143
- // each toolResult and emit them right after the assistant message
1144
- // closes (see `#emitCursorSplitAssistantMessage`), so replay
1145
- // receives (assistant with interleaved toolCall blocks) → results.
1146
- this.#cursorToolResultBuffer.push({ toolResult: finalMessage });
1147
- return finalMessage;
1148
- }
1149
- : undefined;
1176
+ // Installed unconditionally. Both `cursorExecHandlers` and
1177
+ // `cursorOnToolResult` are optional, but the Cursor provider resolves
1178
+ // native todo calls server-side and synthesizes exec blocks regardless,
1179
+ // marking both `kCursorExecResolved` — `agent-loop.ts` then emits no
1180
+ // placeholder result for them. The provider always offers a paired result
1181
+ // for those blocks (its todo fallback, and every `resolveExecHandler`
1182
+ // exit including the no-handler one), but only through this sink: without
1183
+ // it the result is dropped on the floor, the assistant block is left
1184
+ // unpaired, and `buildSessionContext` strips the whole interaction on
1185
+ // replay. A non-Cursor provider never calls this, so the closure costs
1186
+ // nothing.
1187
+ const cursorOnToolResult = async (message: ToolResultMessage) => {
1188
+ // Cursor executes tools server-side during streaming. We buffer each
1189
+ // toolResult and emit them right after the assistant message closes
1190
+ // (see `#emitCursorSplitAssistantMessage`), so replay receives
1191
+ // (assistant with interleaved toolCall blocks) → results.
1192
+ //
1193
+ // The entry is reserved SYNCHRONOUSLY, before awaiting the optional
1194
+ // transformer. The provider's data loop dispatches messages with
1195
+ // `void handleServerMessage(...)`, so a `message_end` decoded from the
1196
+ // same chunk can drain the buffer while a transformer is still pending
1197
+ // — pushing afterwards would drop the result and strip its toolCall
1198
+ // block as dangling on replay.
1199
+ //
1200
+ // The transformer's in-flight promise is recorded on the entry so the
1201
+ // drain can await it (`#emitCursorSplitAssistantMessage`). Without
1202
+ // that, a transformer resolving after the swap would patch a detached
1203
+ // object while the persisted result kept the original payload — the
1204
+ // rewrite silently lost.
1205
+ const entry: CursorToolResultEntry = { toolResult: message };
1206
+ this.#cursorToolResultBuffer.push(entry);
1207
+ const transform = this.#cursorOnToolResult;
1208
+ if (transform) {
1209
+ const pending = (async () => {
1210
+ try {
1211
+ const updated = await transform(message);
1212
+ if (updated) entry.toolResult = updated;
1213
+ } catch {}
1214
+ })();
1215
+ entry.pending = pending;
1216
+ await pending;
1217
+ entry.pending = undefined;
1218
+ }
1219
+ return entry.toolResult;
1220
+ };
1150
1221
 
1151
1222
  const getToolChoice = (): ToolChoiceDirective | undefined => {
1152
1223
  const queued = this.#getToolChoice?.();
@@ -1241,6 +1312,7 @@ export class Agent {
1241
1312
  }
1242
1313
  return { queued: true, source: "system" };
1243
1314
  },
1315
+ waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1244
1316
  hasIrcInterrupts: this.hasIrcInterrupts,
1245
1317
  getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
1246
1318
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
@@ -1277,7 +1349,7 @@ export class Agent {
1277
1349
  // Check if this is an assistant message with buffered Cursor tool results.
1278
1350
  // If so, split the message to emit tool results at the correct position.
1279
1351
  if (event.message.role === "assistant" && this.#cursorToolResultBuffer.length > 0) {
1280
- this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage);
1352
+ await this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage);
1281
1353
  continue; // Skip default emit - split method handles everything
1282
1354
  }
1283
1355
  this.#state.streamMessage = null;
@@ -1334,6 +1406,15 @@ export class Agent {
1334
1406
  const shouldEmitVisibleError = !stoppedForAbort;
1335
1407
  const assistantPartial = partial?.role === "assistant" ? partial : undefined;
1336
1408
  const hadAssistantStart = assistantPartial !== undefined;
1409
+ // Same contract as the normal drain in `#emitCursorSplitAssistantMessage`:
1410
+ // a transformer still in flight must be awaited before the payload is
1411
+ // snapshotted, or its rewrite patches an entry this catch path already
1412
+ // detached and the original is persisted instead. A provider error is
1413
+ // exactly when a transform is most likely to be mid-flight.
1414
+ const pendingTransforms = this.#cursorToolResultBuffer
1415
+ .filter(entry => entry.pending !== undefined)
1416
+ .map(entry => entry.pending);
1417
+ if (pendingTransforms.length > 0) await Promise.all(pendingTransforms);
1337
1418
  const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
1338
1419
  const retainedToolCallIds = new Set(completedToolCallIds);
1339
1420
  for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
@@ -1459,10 +1540,22 @@ export class Agent {
1459
1540
  * toolCall blocks; it also copied `preambleText` into every text block on
1460
1541
  * multi-text turns, producing duplicated text on replay.
1461
1542
  */
1462
- #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void {
1543
+ async #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): Promise<void> {
1544
+ // Snapshot and detach immediately so a still-pending `cursorOnToolResult`
1545
+ // cannot push into a drained buffer. Entries already reserved stay paired
1546
+ // with their toolCall.
1463
1547
  const buffer = this.#cursorToolResultBuffer;
1464
1548
  this.#cursorToolResultBuffer = [];
1465
1549
 
1550
+ // Await any transformer still running for a reserved entry before reading
1551
+ // its payload. The provider dispatches with `void handleServerMessage(…)`,
1552
+ // so a `message_end` from the same chunk can reach this point while a
1553
+ // transformer is mid-flight; without the await its rewrite would land on
1554
+ // the detached entry after the original was already appended and emitted.
1555
+ // Each `pending` swallows its own rejection, so this cannot throw.
1556
+ const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
1557
+ if (pending.length > 0) await Promise.all(pending);
1558
+
1466
1559
  this.#state.streamMessage = null;
1467
1560
  this.appendMessage(assistantMessage);
1468
1561
  this.#emit({ type: "message_end", message: assistantMessage });
@@ -28,7 +28,7 @@ import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
28
28
  import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
29
29
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
30
30
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
31
- import { logger, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
31
+ import { isRecord, logger, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
32
32
  import * as snapcompact from "@oh-my-pi/snapcompact";
33
33
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
34
34
  import { ThinkingLevel } from "../thinking";
@@ -51,6 +51,7 @@ import {
51
51
  requestOpenAiRemoteCompaction,
52
52
  requestRemoteCompaction,
53
53
  shouldUseOpenAiRemoteCompaction,
54
+ trimRemoteCompactionInputToContextWindow,
54
55
  withOpenAiRemoteCompactionPreserveData,
55
56
  } from "./openai";
56
57
  import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
@@ -1290,7 +1291,7 @@ function buildOpenAiResponsesCompactionInput(
1290
1291
  messages: Message[],
1291
1292
  model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1292
1293
  previousReplacementHistory: Array<Record<string, unknown>> | undefined,
1293
- ): unknown[] {
1294
+ ): Array<Record<string, unknown>> {
1294
1295
  const input = buildResponsesInput({
1295
1296
  model,
1296
1297
  context: { messages },
@@ -1300,7 +1301,14 @@ function buildOpenAiResponsesCompactionInput(
1300
1301
  includeThinkingSignatures: true,
1301
1302
  repairOrphanOutputs: true,
1302
1303
  });
1303
- return previousReplacementHistory ? [...previousReplacementHistory, ...input] : input;
1304
+ const nativeInput: Array<Record<string, unknown>> = [];
1305
+ for (const item of input) {
1306
+ if (!isRecord(item)) {
1307
+ throw new Error("OpenAI Responses compaction input contains a non-object item");
1308
+ }
1309
+ nativeInput.push(item);
1310
+ }
1311
+ return previousReplacementHistory ? [...previousReplacementHistory, ...nativeInput] : nativeInput;
1304
1312
  }
1305
1313
 
1306
1314
  /**
@@ -1415,20 +1423,33 @@ export async function compact(
1415
1423
  );
1416
1424
  if (remoteHistory.length > 0) {
1417
1425
  try {
1418
- const request = buildCompactionV2Request(
1419
- model,
1426
+ const instructions = summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT;
1427
+ const tools = summaryOptions.tools
1428
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1429
+ : undefined;
1430
+ const trimmed = trimRemoteCompactionInputToContextWindow(
1420
1431
  remoteHistory,
1421
- summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1422
- {
1423
- tools: summaryOptions.tools
1424
- ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1425
- : undefined,
1426
- reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1427
- sessionId: summaryOptions.sessionId,
1428
- promptCacheKey: summaryOptions.promptCacheKey,
1429
- retainedMessageBudget: settings.v2RetainedMessageBudget,
1430
- },
1432
+ model.contextWindow,
1433
+ instructions,
1434
+ tools,
1431
1435
  );
1436
+ if (trimmed.rewrittenOutputs > 0) {
1437
+ logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1438
+ model: model.id,
1439
+ provider: model.provider,
1440
+ rewrittenOutputs: trimmed.rewrittenOutputs,
1441
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
1442
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
1443
+ contextWindow: model.contextWindow,
1444
+ });
1445
+ }
1446
+ const request = buildCompactionV2Request(model, trimmed.input, instructions, {
1447
+ tools,
1448
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1449
+ sessionId: summaryOptions.sessionId,
1450
+ promptCacheKey: summaryOptions.promptCacheKey,
1451
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1452
+ });
1432
1453
  const remote = await withAuth(
1433
1454
  apiKey,
1434
1455
  key =>
@@ -44,7 +44,9 @@ import {
44
44
  OPENAI_HEADER_VALUES,
45
45
  OPENAI_HEADERS,
46
46
  } from "@oh-my-pi/pi-catalog/wire/codex";
47
- import { $env, logger, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
47
+ import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
48
+ import { countTokensConservatively } from "../tokenizer";
49
+ import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
48
50
 
49
51
  export * from "./compaction-v2-streaming";
50
52
 
@@ -66,6 +68,141 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
66
68
 
67
69
  const DEFAULT_AZURE_API_VERSION = "v1";
68
70
 
71
+ export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
72
+
73
+ const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
74
+ const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
75
+ const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
76
+
77
+ interface NormalizedEstimateValue {
78
+ value: unknown;
79
+ imageTokens: number;
80
+ }
81
+
82
+ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
83
+ if (Array.isArray(value)) {
84
+ const normalized: unknown[] = [];
85
+ let imageTokens = 0;
86
+ for (const item of value) {
87
+ const result = normalizeRemoteCompactionEstimateValue(item);
88
+ normalized.push(result.value);
89
+ imageTokens += result.imageTokens;
90
+ }
91
+ return { value: normalized, imageTokens };
92
+ }
93
+ if (!value || typeof value !== "object") return { value, imageTokens: 0 };
94
+
95
+ const record = value as Record<string, unknown>;
96
+ if (record.type === "input_image") {
97
+ return {
98
+ value: { ...record, image_url: "<image>" },
99
+ imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
100
+ };
101
+ }
102
+
103
+ const normalized: Record<string, unknown> = {};
104
+ let imageTokens = 0;
105
+ for (const [key, item] of Object.entries(record)) {
106
+ const result = normalizeRemoteCompactionEstimateValue(item);
107
+ normalized[key] = result.value;
108
+ imageTokens += result.imageTokens;
109
+ }
110
+ return { value: normalized, imageTokens };
111
+ }
112
+
113
+ export interface TrimRemoteCompactionInputResult {
114
+ input: Array<Record<string, unknown>>;
115
+ rewrittenOutputs: number;
116
+ estimatedTokensBefore: number;
117
+ estimatedTokensAfter: number;
118
+ }
119
+
120
+ function estimateRemoteCompactionInputTokens(
121
+ input: Array<Record<string, unknown>>,
122
+ instructions: string,
123
+ tools?: unknown[],
124
+ ): number {
125
+ const normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
126
+ const serialized = stringifyJson(normalized.value) ?? "";
127
+ return countTokensConservatively(serialized) + normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
128
+ }
129
+
130
+ function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
131
+ if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
132
+ return { ...item, output: CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE };
133
+ }
134
+ if (item.type === "tool_search_output") {
135
+ return { ...item, tools: [] };
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
141
+ if (item.type !== "message" || item.role !== "user" || !Array.isArray(item.content)) return false;
142
+
143
+ let hasLabel = false;
144
+ let hasImage = false;
145
+ for (const block of item.content) {
146
+ if (!isRecord(block)) continue;
147
+ if (block.type === "input_text" && block.text === TOOL_RESULT_IMAGE_ATTACHMENT_TEXT) hasLabel = true;
148
+ if (block.type === "input_image") hasImage = true;
149
+ }
150
+ return hasLabel && hasImage;
151
+ }
152
+
153
+ /**
154
+ * Preserve the full native transcript unless trailing tool outputs alone push a
155
+ * remote compaction request beyond the model window. Replacing only those
156
+ * outputs keeps call/result pairing and all earlier assistant/reasoning history,
157
+ * matching Codex's recovery path for oversized tool turns.
158
+ */
159
+ export function trimRemoteCompactionInputToContextWindow(
160
+ input: Array<Record<string, unknown>>,
161
+ contextWindow: number | null | undefined,
162
+ instructions: string,
163
+ tools?: unknown[],
164
+ ): TrimRemoteCompactionInputResult {
165
+ const estimatedTokensBefore = estimateRemoteCompactionInputTokens(input, instructions, tools);
166
+ if (!contextWindow || contextWindow <= 0 || estimatedTokensBefore <= contextWindow) {
167
+ return {
168
+ input,
169
+ rewrittenOutputs: 0,
170
+ estimatedTokensBefore,
171
+ estimatedTokensAfter: estimatedTokensBefore,
172
+ };
173
+ }
174
+
175
+ let rewrittenInput: Array<Record<string, unknown>> | undefined;
176
+ let estimatedTokensAfter = estimatedTokensBefore;
177
+ let rewrittenOutputs = 0;
178
+ for (let index = input.length - 1; index >= 0 && estimatedTokensAfter > contextWindow; index--) {
179
+ const item = input[index];
180
+ if (isToolResultImageAttachment(item)) continue;
181
+ const rewritten = rewriteToolOutputForContextWindow(item);
182
+ if (!rewritten) break;
183
+ rewrittenInput ??= input.slice();
184
+ rewrittenInput[index] = rewritten;
185
+ rewrittenOutputs++;
186
+ estimatedTokensAfter = estimateRemoteCompactionInputTokens(rewrittenInput, instructions, tools);
187
+ }
188
+
189
+ if (!rewrittenInput || estimatedTokensAfter > contextWindow) {
190
+ return {
191
+ input,
192
+ rewrittenOutputs: 0,
193
+ estimatedTokensBefore,
194
+ estimatedTokensAfter: estimatedTokensBefore,
195
+ };
196
+ }
197
+
198
+ return {
199
+ input: rewrittenInput,
200
+ rewrittenOutputs,
201
+ estimatedTokensBefore,
202
+ estimatedTokensAfter,
203
+ };
204
+ }
205
+
69
206
  /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
70
207
  function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
71
208
  if (timeoutMs <= 0) return signal;
@@ -583,7 +720,7 @@ export function buildOpenAiNativeHistory(
583
720
 
584
721
  if (hasImages && model.input.includes("image")) {
585
722
  const contentBlocks: Array<Record<string, unknown>> = [
586
- { type: "input_text", text: "Attached image(s) from tool result:" },
723
+ { type: "input_text", text: TOOL_RESULT_IMAGE_ATTACHMENT_TEXT },
587
724
  ];
588
725
  for (const block of message.content) {
589
726
  if (block.type !== "image") continue;
@@ -622,12 +759,23 @@ export async function requestOpenAiRemoteCompaction(
622
759
  ): Promise<OpenAiRemoteCompactionResponse> {
623
760
  const endpoint = resolveOpenAiCompactEndpoint(model);
624
761
  const requestModel = resolveOpenAiCompactModel(model);
762
+ const trimmed = trimRemoteCompactionInputToContextWindow(compactInput, model.contextWindow, instructions);
763
+ if (trimmed.rewrittenOutputs > 0) {
764
+ logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
765
+ model: model.id,
766
+ provider: model.provider,
767
+ rewrittenOutputs: trimmed.rewrittenOutputs,
768
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
769
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
770
+ contextWindow: model.contextWindow,
771
+ });
772
+ }
625
773
  const request: OpenAiRemoteCompactionRequest = {
626
774
  model: requestModel,
627
- // Send full history to the endpoint - don't trim locally.
628
- // The provider handles compression via the compaction endpoint.
629
- // Trimming before sending loses assistant messages and thinking blocks.
630
- input: compactInput,
775
+ // Preserve the native transcript. Only oversized trailing tool outputs are
776
+ // rewritten above, reducing the request without losing assistant turns,
777
+ // reasoning, or call/result pairing.
778
+ input: trimmed.input,
631
779
  instructions,
632
780
  };
633
781
  const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
@@ -0,0 +1 @@
1
+ Output exceeded the available model context and was truncated
package/src/proxy.ts CHANGED
@@ -56,12 +56,14 @@ export type ProxyAssistantMessageEvent =
56
56
  type: "done";
57
57
  reason: Extract<StopReason, "stop" | "length" | "toolUse">;
58
58
  usage: AssistantMessage["usage"];
59
+ content?: AssistantMessage["content"];
59
60
  }
60
61
  | {
61
62
  type: "error";
62
63
  reason: Extract<StopReason, "aborted" | "error">;
63
64
  errorMessage?: string;
64
65
  usage: AssistantMessage["usage"];
66
+ content?: AssistantMessage["content"];
65
67
  };
66
68
 
67
69
  export interface ProxyStreamOptions extends SimpleStreamOptions {
@@ -372,6 +374,7 @@ function processProxyEvent(
372
374
  case "done":
373
375
  partial.stopReason = proxyEvent.reason;
374
376
  partial.usage = proxyEvent.usage;
377
+ if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
375
378
  calculateCost(model, partial.usage);
376
379
  scrubPartialJson(partial);
377
380
  return { type: "done", reason: proxyEvent.reason, message: partial };
@@ -380,6 +383,7 @@ function processProxyEvent(
380
383
  partial.stopReason = proxyEvent.reason;
381
384
  partial.errorMessage = proxyEvent.errorMessage;
382
385
  partial.usage = proxyEvent.usage;
386
+ if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
383
387
  calculateCost(model, partial.usage);
384
388
  scrubPartialJson(partial);
385
389
  return { type: "error", reason: proxyEvent.reason, error: partial };
package/src/tokenizer.ts CHANGED
@@ -15,3 +15,13 @@ export function countTokens(text: string | string[]): number {
15
15
  return estimateTokens(text);
16
16
  }
17
17
  }
18
+
19
+ export function countTokensConservatively(text: string | string[]): number {
20
+ if (accurate) {
21
+ return countTokensNat(text);
22
+ } else if (Array.isArray(text)) {
23
+ return text.reduce((sum, value) => sum + Buffer.byteLength(value, "utf-8"), 0);
24
+ } else {
25
+ return Buffer.byteLength(text, "utf-8");
26
+ }
27
+ }
package/src/types.ts CHANGED
@@ -223,6 +223,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
223
223
  */
224
224
  hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
225
225
 
226
+ /**
227
+ * Wakes the in-flight tool interrupt watcher when a steering message is queued.
228
+ * The callback must not consume the queue; the loop still calls
229
+ * {@link hasSteeringMessages} before aborting and injects through
230
+ * {@link getSteeringMessages}.
231
+ */
232
+ waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
233
+
226
234
  /**
227
235
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
228
236
  *