@oh-my-pi/pi-agent-core 17.2.3 → 17.2.4

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.4] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Codex V2 remote compaction bypassing the provider's live WebSocket transport before trying SSE ([#7198](https://github.com/can1357/oh-my-pi/issues/7198)).
10
+ - Tool calls skipped mid-batch to service queued steering/peer input now distinguish calls that never entered `tool.execute` (`SyntheticToolResultDetails`, `executed: false`) from in-flight calls that may have performed partial work (`execution: "started"`), allowing UI/telemetry consumers to render normal steering control flow without misreporting execution state ([#7199](https://github.com/can1357/oh-my-pi/issues/7199)).
11
+
5
12
  ## [17.2.2] - 2026-07-31
6
13
 
7
14
  ### Fixed
@@ -101,13 +101,15 @@ export declare function abortReasonText(signal: AbortSignal | undefined): string
101
101
  * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
102
102
  * websocket close) was surfaced by the CLI as if the local tool had failed.
103
103
  *
104
- * `source` names the assistant-side termination state that prevented
105
- * execution; `upstreamError` is the provider-reported message when the turn
106
- * ended with `stopReason === "error"`.
104
+ * `source` names the state that prevented execution — either an assistant-side
105
+ * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
106
+ * still-pending call to service queued steering/peer input (`interrupt_skipped`).
107
+ * `upstreamError` is the provider-reported message when the turn ended with
108
+ * `stopReason === "error"`.
107
109
  */
108
110
  export interface SyntheticToolResultDetails {
109
111
  __synthetic: true;
110
- source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
112
+ source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length" | "interrupt_skipped";
111
113
  executed: false;
112
114
  upstreamError?: string;
113
115
  }
@@ -68,6 +68,7 @@ export declare function requestCompactionV2Streaming(model: Model, apiKey: strin
68
68
  retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
69
69
  providerSessionState?: Map<string, ProviderSessionState>;
70
70
  codexCompaction?: CodexCompactionContext;
71
+ preferWebsockets?: boolean;
71
72
  }): Promise<CompactionV2Response>;
72
73
  /** Build Codex-style V2 replacement history from prompt input plus compaction output. */
73
74
  export declare function buildCompactionV2ReplacementHistory(input: unknown[], compactionItem: Record<string, unknown>, retainedMessageBudget?: number): {
@@ -200,6 +200,8 @@ export interface SummaryOptions {
200
200
  promptCacheKey?: string;
201
201
  /** Mutable provider state used to keep Codex compaction on the live session identity. */
202
202
  providerSessionState?: Map<string, ProviderSessionState>;
203
+ /** Whether Codex remote compaction should prefer the provider WebSocket transport. */
204
+ preferWebsockets?: boolean;
203
205
  /** Classification shared by every provider request in this logical compaction. */
204
206
  codexCompaction?: CodexCompactionContext;
205
207
  /** Provider-visible tools for remote compaction transports that replay native tool history. */
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.2.3",
4
+ "version": "17.2.4",
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.2.3",
39
- "@oh-my-pi/pi-catalog": "17.2.3",
40
- "@oh-my-pi/pi-natives": "17.2.3",
41
- "@oh-my-pi/pi-utils": "17.2.3",
42
- "@oh-my-pi/pi-wire": "17.2.3",
43
- "@oh-my-pi/snapcompact": "17.2.3",
38
+ "@oh-my-pi/pi-ai": "17.2.4",
39
+ "@oh-my-pi/pi-catalog": "17.2.4",
40
+ "@oh-my-pi/pi-natives": "17.2.4",
41
+ "@oh-my-pi/pi-utils": "17.2.4",
42
+ "@oh-my-pi/pi-wire": "17.2.4",
43
+ "@oh-my-pi/snapcompact": "17.2.4",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -2454,6 +2454,7 @@ async function executeToolCalls(
2454
2454
  let isError = false;
2455
2455
  let caughtError: unknown;
2456
2456
  let completedToolExecution = false;
2457
+ let executionStarted = false;
2457
2458
 
2458
2459
  await runInActiveSpan(toolSpan, async () => {
2459
2460
  try {
@@ -2487,6 +2488,7 @@ async function executeToolCalls(
2487
2488
  providerMetadata: toolCall.providerMetadata,
2488
2489
  })
2489
2490
  : undefined;
2491
+ executionStarted = true;
2490
2492
  const rawResult = await tool.execute(
2491
2493
  toolCall.id,
2492
2494
  executionArgs,
@@ -2557,12 +2559,12 @@ async function executeToolCalls(
2557
2559
  const interrupted = interruptState.triggered;
2558
2560
  const perToolAborted = record.signal.aborted;
2559
2561
  const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
2560
- if (interrupted && perToolAborted && isError && !completedToolExecution) {
2561
- // This tool's own signal fired AND it failed to produce a result: `tool.execute()`
2562
- // never returned (it threw on the abort), so it was genuinely cut off before
2563
- // producing usable output. Report it as skipped.
2562
+ if (interrupted && abortedDuringExecution) {
2563
+ // This tool's own signal fired AND it failed to produce a result. The
2564
+ // execution may already have performed partial work before throwing on
2565
+ // abort, so preserve that distinction in the placeholder metadata.
2564
2566
  record.skipped = true;
2565
- emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2567
+ emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
2566
2568
  } else {
2567
2569
  // No interrupt on this signal, or the tool finished before the interrupt landed
2568
2570
  // (`completedToolExecution`) — even if the signal aborted around completion. Keep
@@ -2703,7 +2705,7 @@ async function executeToolCalls(
2703
2705
  toolName: record.toolCall.name,
2704
2706
  status: "skipped",
2705
2707
  });
2706
- emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2708
+ emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
2707
2709
  }
2708
2710
  }
2709
2711
 
@@ -2723,17 +2725,34 @@ async function executeToolCalls(
2723
2725
  * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
2724
2726
  * websocket close) was surfaced by the CLI as if the local tool had failed.
2725
2727
  *
2726
- * `source` names the assistant-side termination state that prevented
2727
- * execution; `upstreamError` is the provider-reported message when the turn
2728
- * ended with `stopReason === "error"`.
2728
+ * `source` names the state that prevented execution — either an assistant-side
2729
+ * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
2730
+ * still-pending call to service queued steering/peer input (`interrupt_skipped`).
2731
+ * `upstreamError` is the provider-reported message when the turn ended with
2732
+ * `stopReason === "error"`.
2729
2733
  */
2730
2734
  export interface SyntheticToolResultDetails {
2731
2735
  __synthetic: true;
2732
- source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
2736
+ source:
2737
+ | "assistant_stop_aborted"
2738
+ | "assistant_stop_error"
2739
+ | "assistant_stop_skipped"
2740
+ | "assistant_stop_length"
2741
+ | "interrupt_skipped";
2733
2742
  executed: false;
2734
2743
  upstreamError?: string;
2735
2744
  }
2736
2745
 
2746
+ /**
2747
+ * Metadata for an interrupt-aborted call that entered `tool.execute()` but
2748
+ * threw before returning a usable result. It may have performed partial work.
2749
+ */
2750
+ interface InterruptedToolResultDetails {
2751
+ __interrupted: true;
2752
+ source: "interrupt_skipped";
2753
+ execution: "started";
2754
+ }
2755
+
2737
2756
  /**
2738
2757
  * Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
2739
2758
  * a tool_result emitted for a tool call the assistant never invoked (see
@@ -2844,7 +2863,10 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
2844
2863
  };
2845
2864
  }
2846
2865
 
2847
- function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undefined): AgentToolResult<any> {
2866
+ function createSkippedToolResult(
2867
+ source: SteeringInterruptSource | "irc" | undefined,
2868
+ executionStarted: boolean,
2869
+ ): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
2848
2870
  let reason = "pending steering message";
2849
2871
  let blocker = "queued message";
2850
2872
  if (source === "user") {
@@ -2864,6 +2886,8 @@ function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undef
2864
2886
  text: `Skipped due to ${reason}. Do not count this skipped result as completed work or verification. After the ${blocker} is handled on the next step, retry the skipped tool if it is still needed.`,
2865
2887
  },
2866
2888
  ],
2867
- details: {},
2889
+ details: executionStarted
2890
+ ? { __interrupted: true, source: "interrupt_skipped", execution: "started" }
2891
+ : { __synthetic: true, source: "interrupt_skipped", executed: false },
2868
2892
  };
2869
2893
  }
@@ -13,7 +13,9 @@ import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-c
13
13
  import {
14
14
  createOpenAICodexCompactionRequestContext,
15
15
  createOpenAICodexCompatibilityMetadata,
16
+ type OpenAICodexCompactionBody,
16
17
  type OpenAICodexCompatibilityMetadata,
18
+ openCodexCompactionEventStream,
17
19
  } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
18
20
  import {
19
21
  getOpenAIPromptCacheKey,
@@ -128,6 +130,14 @@ function isOpenAiV2CompatibleModel(model: Model): boolean {
128
130
  return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
129
131
  }
130
132
 
133
+ function shouldUseCodexProviderTransport(model: Model): model is Model<"openai-codex-responses"> {
134
+ return (
135
+ model.api === "openai-codex-responses" &&
136
+ model.remoteCompaction?.v2Endpoint === undefined &&
137
+ model.remoteCompaction?.streamingEndpoint === undefined
138
+ );
139
+ }
140
+
131
141
  function resolveOpenAiResponsesEndpoint(baseUrl: string | undefined): string {
132
142
  const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : "https://api.openai.com/v1";
133
143
  const normalizedBase = rawBase.replace(/\/+$/, "");
@@ -228,6 +238,7 @@ export async function requestCompactionV2Streaming(
228
238
  retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
229
239
  providerSessionState?: Map<string, ProviderSessionState>;
230
240
  codexCompaction?: CodexCompactionContext;
241
+ preferWebsockets?: boolean;
231
242
  },
232
243
  ): Promise<CompactionV2Response> {
233
244
  const endpoint = getCompactionV2Endpoint(model);
@@ -238,31 +249,29 @@ export async function requestCompactionV2Streaming(
238
249
  const fetchImpl = options?.fetch ?? globalThis.fetch;
239
250
  const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
240
251
  const isCodexResponses = compactionV2Api(model) === "openai-codex-responses" || model.provider === "openai-codex";
241
- const codexMetadata = isCodexResponses
242
- ? createOpenAICodexCompatibilityMetadata({
243
- sessionId: request.sessionId,
244
- providerSessionState: options?.providerSessionState,
245
- requestKind: "compaction",
246
- compaction: createOpenAICodexCompactionRequestContext({
247
- context: options?.codexCompaction,
248
- implementation: "responses_compaction_v2",
249
- }),
250
- })
251
- : undefined;
252
+ const codexMetadata =
253
+ isCodexResponses && !shouldUseCodexProviderTransport(model)
254
+ ? createOpenAICodexCompatibilityMetadata({
255
+ sessionId: request.sessionId,
256
+ providerSessionState: options?.providerSessionState,
257
+ requestKind: "compaction",
258
+ compaction: createOpenAICodexCompactionRequestContext({
259
+ context: options?.codexCompaction,
260
+ implementation: "responses_compaction_v2",
261
+ }),
262
+ })
263
+ : undefined;
252
264
  let lastError: Error | undefined;
253
265
 
254
266
  for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
255
267
  const timeoutSignal = withRequestTimeout(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
256
268
  try {
257
- return await attemptCompactionV2Streaming(
258
- endpoint,
259
- apiKey,
260
- model,
261
- request,
262
- fetchImpl,
263
- timeoutSignal,
269
+ return await attemptCompactionV2Streaming(endpoint, apiKey, model, request, fetchImpl, timeoutSignal, {
264
270
  codexMetadata,
265
- );
271
+ providerSessionState: options?.providerSessionState,
272
+ codexCompaction: options?.codexCompaction,
273
+ preferWebsockets: options?.preferWebsockets,
274
+ });
266
275
  } catch (err) {
267
276
  const error = err instanceof Error ? err : new Error(String(err));
268
277
  if (signal?.aborted) throw error;
@@ -292,15 +301,20 @@ async function attemptCompactionV2Streaming(
292
301
  model: Model,
293
302
  request: CompactionV2Request,
294
303
  fetchImpl: FetchImpl,
295
- signal?: AbortSignal,
296
- codexMetadata?: OpenAICodexCompatibilityMetadata,
304
+ signal: AbortSignal | undefined,
305
+ options: {
306
+ codexMetadata?: OpenAICodexCompatibilityMetadata;
307
+ providerSessionState?: Map<string, ProviderSessionState>;
308
+ codexCompaction?: CodexCompactionContext;
309
+ preferWebsockets?: boolean;
310
+ },
297
311
  ): Promise<CompactionV2Response> {
298
312
  // Faithful to Codex: append the compaction trigger as the final input item
299
313
  // of an otherwise-normal Responses request, then stream the result. `store`
300
314
  // stays false — compaction must never persist a server-side response object.
301
315
  const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
302
316
  const promptCacheKey = getOpenAIPromptCacheKey(cacheOptions);
303
- const body: Record<string, unknown> = {
317
+ const body: OpenAICodexCompactionBody = {
304
318
  model: request.model,
305
319
  input: [...request.input, COMPACTION_TRIGGER_ITEM],
306
320
  instructions: request.instructions,
@@ -318,8 +332,8 @@ async function attemptCompactionV2Streaming(
318
332
  ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
319
333
  ...(request.tools && request.tools.length > 0 ? { tools: request.tools, tool_choice: "auto" } : {}),
320
334
  };
321
- if (codexMetadata) {
322
- body.client_metadata = codexMetadata.clientMetadata;
335
+ if (options.codexMetadata) {
336
+ body.client_metadata = options.codexMetadata.clientMetadata;
323
337
  }
324
338
  // Responses Lite models take the same rewrite on the compaction stream:
325
339
  // instructions/tools ride as input items (codex-rs `compact_remote_v2`
@@ -327,9 +341,27 @@ async function attemptCompactionV2Streaming(
327
341
  if (model.useResponsesLite) {
328
342
  applyCodexResponsesLiteShape(body);
329
343
  }
344
+
345
+ if (shouldUseCodexProviderTransport(model)) {
346
+ const eventStream = await openCodexCompactionEventStream(model, body, {
347
+ apiKey,
348
+ signal,
349
+ fetch: fetchImpl,
350
+ sessionId: request.sessionId,
351
+ providerSessionState: options.providerSessionState,
352
+ preferWebsockets: options.preferWebsockets,
353
+ responsesLite: model.useResponsesLite,
354
+ codexCompaction: createOpenAICodexCompactionRequestContext({
355
+ context: options.codexCompaction,
356
+ implementation: "responses_compaction_v2",
357
+ }),
358
+ });
359
+ return collectCompactionV2Events(eventStream, request);
360
+ }
361
+
330
362
  const response = await fetchImpl(endpoint, {
331
363
  method: "POST",
332
- headers: buildCompactionV2Headers(model, apiKey, request, codexMetadata),
364
+ headers: buildCompactionV2Headers(model, apiKey, request, options.codexMetadata),
333
365
  body: stringifyJson(body),
334
366
  signal,
335
367
  });
@@ -400,6 +432,33 @@ function buildCompactionV2Headers(
400
432
  return headers;
401
433
  }
402
434
 
435
+ interface CompactionV2CollectionState {
436
+ outputItemCount: number;
437
+ compactionItems: Array<Record<string, unknown>>;
438
+ sawCompleted: boolean;
439
+ usage: CompactionV2Usage | undefined;
440
+ }
441
+
442
+ function createCompactionV2CollectionState(): CompactionV2CollectionState {
443
+ return {
444
+ outputItemCount: 0,
445
+ compactionItems: [],
446
+ sawCompleted: false,
447
+ usage: undefined,
448
+ };
449
+ }
450
+
451
+ async function collectCompactionV2Events(
452
+ events: AsyncIterable<Record<string, unknown>>,
453
+ request: CompactionV2Request,
454
+ ): Promise<CompactionV2Response> {
455
+ const state = createCompactionV2CollectionState();
456
+ for await (const event of events) {
457
+ handleCompactionV2Event(event, undefined, state);
458
+ }
459
+ return finishCompactionV2Collection(state, request);
460
+ }
461
+
403
462
  async function collectCompactionV2Output(
404
463
  response: Response,
405
464
  request: CompactionV2Request,
@@ -409,13 +468,7 @@ async function collectCompactionV2Output(
409
468
  throw new Error("No response body for V2 compaction streaming");
410
469
  }
411
470
 
412
- const state = {
413
- outputItemCount: 0,
414
- compactionItems: [] as Array<Record<string, unknown>>,
415
- sawCompleted: false,
416
- usage: undefined as CompactionV2Usage | undefined,
417
- };
418
-
471
+ const state = createCompactionV2CollectionState();
419
472
  try {
420
473
  const decoder = new TextDecoder();
421
474
  let buffer = "";
@@ -462,6 +515,13 @@ async function collectCompactionV2Output(
462
515
  reader.releaseLock();
463
516
  }
464
517
 
518
+ return finishCompactionV2Collection(state, request);
519
+ }
520
+
521
+ function finishCompactionV2Collection(
522
+ state: CompactionV2CollectionState,
523
+ request: CompactionV2Request,
524
+ ): CompactionV2Response {
465
525
  if (!state.sawCompleted) {
466
526
  throw new Error("V2 compaction stream closed before response.completed");
467
527
  }
@@ -477,7 +537,6 @@ async function collectCompactionV2Output(
477
537
  compactionItem,
478
538
  request.retainedMessageBudget,
479
539
  );
480
-
481
540
  return {
482
541
  compactionItem,
483
542
  replacementHistory,
@@ -490,12 +549,7 @@ async function collectCompactionV2Output(
490
549
  function handleCompactionV2SseEvent(
491
550
  data: string,
492
551
  eventName: string | undefined,
493
- state: {
494
- outputItemCount: number;
495
- compactionItems: Array<Record<string, unknown>>;
496
- sawCompleted: boolean;
497
- usage: CompactionV2Usage | undefined;
498
- },
552
+ state: CompactionV2CollectionState,
499
553
  ): void {
500
554
  if (data === "[DONE]") return;
501
555
  let event: Record<string, unknown>;
@@ -504,7 +558,14 @@ function handleCompactionV2SseEvent(
504
558
  } catch (err) {
505
559
  throw new Error(`V2 compaction stream parse failed: ${err instanceof Error ? err.message : String(err)}`);
506
560
  }
561
+ handleCompactionV2Event(event, eventName, state);
562
+ }
507
563
 
564
+ function handleCompactionV2Event(
565
+ event: Record<string, unknown>,
566
+ eventName: string | undefined,
567
+ state: CompactionV2CollectionState,
568
+ ): void {
508
569
  const type = typeof event.type === "string" ? event.type : eventName;
509
570
  if (type === "response.output_item.done") {
510
571
  state.outputItemCount++;
@@ -809,6 +809,8 @@ export interface SummaryOptions {
809
809
  promptCacheKey?: string;
810
810
  /** Mutable provider state used to keep Codex compaction on the live session identity. */
811
811
  providerSessionState?: Map<string, ProviderSessionState>;
812
+ /** Whether Codex remote compaction should prefer the provider WebSocket transport. */
813
+ preferWebsockets?: boolean;
812
814
  /** Classification shared by every provider request in this logical compaction. */
813
815
  codexCompaction?: CodexCompactionContext;
814
816
  /** Provider-visible tools for remote compaction transports that replay native tool history. */
@@ -1432,6 +1434,7 @@ export async function compact(
1432
1434
  sessionId: options?.sessionId,
1433
1435
  promptCacheKey: options?.promptCacheKey,
1434
1436
  providerSessionState: options?.providerSessionState,
1437
+ preferWebsockets: options?.preferWebsockets,
1435
1438
  codexCompaction: options?.codexCompaction,
1436
1439
  tools: options?.tools,
1437
1440
  fetch: options?.fetch,
@@ -1509,6 +1512,7 @@ export async function compact(
1509
1512
  requestCompactionV2Streaming(model, key, request, signal, {
1510
1513
  fetch: summaryOptions.fetch,
1511
1514
  providerSessionState: summaryOptions.providerSessionState,
1515
+ preferWebsockets: summaryOptions.preferWebsockets,
1512
1516
  codexCompaction: summaryOptions.codexCompaction,
1513
1517
  }),
1514
1518
  { signal },