@oh-my-pi/pi-agent-core 17.2.3 → 17.2.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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.5] - 2026-08-03
6
+
7
+ ### Breaking Changes
8
+
9
+ - Tool examples embedded in tool descriptions now always render in Python call syntax, and the `exampleDialect` option has been removed from `AppendOnlyContextManager` build options.
10
+ - Updated `normalizeTools` to accept a `NormalizeToolsOptions` configuration object (`{ injectIntent, pruneDescriptions }`) instead of positional booleans.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed an issue where runs would fail with an error if an Anthropic stream was truncated after complete tool calls were streamed; the agent now recovers and executes those tool calls.
15
+ - Fixed an issue where artifact recovery reads could be incorrectly elided during compaction.
16
+
17
+ ## [17.2.4] - 2026-08-01
18
+
19
+ ### Fixed
20
+
21
+ - 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)).
22
+ - 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)).
23
+
5
24
  ## [17.2.2] - 2026-07-31
6
25
 
7
26
  ### Fixed
@@ -80,7 +80,13 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
80
80
  readonly detailed: () => Promise<AgentLoopDetailedResult>;
81
81
  };
82
82
  export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
83
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, pruneDescriptions?: boolean): Context["tools"];
83
+ export interface NormalizeToolsOptions {
84
+ /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
85
+ injectIntent: boolean;
86
+ /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
87
+ pruneDescriptions?: boolean;
88
+ }
89
+ export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
84
90
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
85
91
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
86
92
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -101,13 +107,15 @@ export declare function abortReasonText(signal: AbortSignal | undefined): string
101
107
  * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
102
108
  * websocket close) was surfaced by the CLI as if the local tool had failed.
103
109
  *
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"`.
110
+ * `source` names the state that prevented execution — either an assistant-side
111
+ * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
112
+ * still-pending call to service queued steering/peer input (`interrupt_skipped`).
113
+ * `upstreamError` is the provider-reported message when the turn ended with
114
+ * `stopReason === "error"`.
107
115
  */
108
116
  export interface SyntheticToolResultDetails {
109
117
  __synthetic: true;
110
- source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
118
+ source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length" | "interrupt_skipped";
111
119
  executed: false;
112
120
  upstreamError?: string;
113
121
  }
@@ -14,7 +14,6 @@
14
14
  * message delta is a cache miss each turn.
15
15
  */
16
16
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
17
- import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
18
17
  import type { AgentContext } from "./types.js";
19
18
  /** Frozen system prompt + tool spec snapshot. */
20
19
  export interface StablePrefixSnapshot {
@@ -26,7 +25,6 @@ export interface StablePrefixSnapshot {
26
25
  export interface BuildOptions {
27
26
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
28
27
  intentTracing: boolean;
29
- exampleDialect?: Dialect;
30
28
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
31
29
  pruneToolDescriptions?: boolean;
32
30
  }
@@ -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. */
@@ -31,8 +31,13 @@ export interface ShakeConfig {
31
31
  }
32
32
  /** Auto-shake config: protects the live tail, conservative thresholds. */
33
33
  export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
34
- /** Manual `/shake`: aggressive — drops every eligible region across history. */
34
+ /**
35
+ * Manual `/shake`: aggressive — drops every eligible region across history,
36
+ * artifact recovery reads included (the user's full escape hatch).
37
+ */
35
38
  export declare const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig;
39
+ /** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
40
+ export declare const RESCUE_SHAKE_CONFIG: ShakeConfig;
36
41
  /** A located eligible region. */
37
42
  export interface ToolResultShakeRegion {
38
43
  kind: "toolResult";
@@ -14,4 +14,6 @@ export declare function collectToolCallsById(entries: readonly SessionEntry[]):
14
14
  */
15
15
  export declare function getReadToolPath({ toolResult, toolCall }: ProtectedToolContext): string | undefined;
16
16
  export declare function isSkillReadToolResult(context: ProtectedToolContext): boolean;
17
+ /** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
18
+ export declare function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean;
17
19
  export declare function isProtectedToolResult(toolResult: ToolResultMessage, toolCall: AgentToolCall | undefined, matchers: readonly ProtectedToolMatcher[]): boolean;
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.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.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.5",
39
+ "@oh-my-pi/pi-catalog": "17.2.5",
40
+ "@oh-my-pi/pi-natives": "17.2.5",
41
+ "@oh-my-pi/pi-utils": "17.2.5",
42
+ "@oh-my-pi/pi-wire": "17.2.5",
43
+ "@oh-my-pi/snapcompact": "17.2.5",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -42,7 +42,6 @@ import {
42
42
  recoverHarmonyToolCall,
43
43
  signalListLabel,
44
44
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
45
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
46
45
  import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
47
46
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
48
47
  import { agentPauseGate } from "./pause";
@@ -829,13 +828,16 @@ function injectIntentIntoSchema(
829
828
  };
830
829
  }
831
830
 
832
- export function normalizeTools(
833
- tools: AgentContext["tools"],
834
- injectIntent: boolean,
835
- exampleDialect?: Dialect,
836
- pruneDescriptions = false,
837
- ): Context["tools"] {
838
- injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
831
+ export interface NormalizeToolsOptions {
832
+ /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
833
+ injectIntent: boolean;
834
+ /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
835
+ pruneDescriptions?: boolean;
836
+ }
837
+
838
+ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"] {
839
+ const pruneDescriptions = options.pruneDescriptions === true;
840
+ const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
839
841
  return tools?.map(t => {
840
842
  const intentMode = resolveIntentMode(t.intent);
841
843
  const doInjectIntent = injectIntent && intentMode !== "omit";
@@ -853,9 +855,7 @@ export function normalizeTools(
853
855
  let parameters = toolWireSchema(t) as TSchema;
854
856
  if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
855
857
  const description = t.description ?? "";
856
- const examplesBlock = exampleDialect
857
- ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
858
- : "";
858
+ const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
859
859
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
860
860
  return { ...t, parameters, description: finalDescription };
861
861
  });
@@ -1498,21 +1498,22 @@ async function prepareProviderCall(
1498
1498
  const llmMessages = await config.convertToLlm(messages);
1499
1499
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1500
1500
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1501
- const exampleDialect = ownedDialect ?? preferredDialect(model.id);
1502
1501
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1503
1502
  let llmContext: Context;
1504
1503
  if (config.appendOnlyContext) {
1505
1504
  config.appendOnlyContext.syncMessages(normalizedMessages);
1506
1505
  llmContext = config.appendOnlyContext.build(context, {
1507
1506
  intentTracing: !!config.intentTracing,
1508
- exampleDialect,
1509
1507
  pruneToolDescriptions,
1510
1508
  });
1511
1509
  } else {
1512
1510
  llmContext = {
1513
1511
  systemPrompt: context.systemPrompt,
1514
1512
  messages: normalizedMessages,
1515
- tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
1513
+ tools: normalizeTools(context.tools, {
1514
+ injectIntent: !!config.intentTracing,
1515
+ pruneDescriptions: pruneToolDescriptions,
1516
+ }),
1516
1517
  };
1517
1518
  }
1518
1519
  if (config.transformProviderContext) {
@@ -1928,8 +1929,10 @@ function recoverTransientErrorToolTurn(
1928
1929
  if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
1929
1930
  }
1930
1931
  if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
1932
+ const errorText = `${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`;
1931
1933
  if (
1932
- !AIError.isStreamReadErrorText(`${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`) &&
1934
+ !AIError.isStreamReadErrorText(errorText) &&
1935
+ !AIError.isStreamEnvelopeErrorText(errorText) &&
1933
1936
  !AIError.isTransientStreamParseError(message.errorMessage) &&
1934
1937
  !AIError.isTransientStreamParseError(message.stopDetails?.explanation)
1935
1938
  )
@@ -2454,6 +2457,7 @@ async function executeToolCalls(
2454
2457
  let isError = false;
2455
2458
  let caughtError: unknown;
2456
2459
  let completedToolExecution = false;
2460
+ let executionStarted = false;
2457
2461
 
2458
2462
  await runInActiveSpan(toolSpan, async () => {
2459
2463
  try {
@@ -2487,6 +2491,7 @@ async function executeToolCalls(
2487
2491
  providerMetadata: toolCall.providerMetadata,
2488
2492
  })
2489
2493
  : undefined;
2494
+ executionStarted = true;
2490
2495
  const rawResult = await tool.execute(
2491
2496
  toolCall.id,
2492
2497
  executionArgs,
@@ -2557,12 +2562,12 @@ async function executeToolCalls(
2557
2562
  const interrupted = interruptState.triggered;
2558
2563
  const perToolAborted = record.signal.aborted;
2559
2564
  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.
2565
+ if (interrupted && abortedDuringExecution) {
2566
+ // This tool's own signal fired AND it failed to produce a result. The
2567
+ // execution may already have performed partial work before throwing on
2568
+ // abort, so preserve that distinction in the placeholder metadata.
2564
2569
  record.skipped = true;
2565
- emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2570
+ emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
2566
2571
  } else {
2567
2572
  // No interrupt on this signal, or the tool finished before the interrupt landed
2568
2573
  // (`completedToolExecution`) — even if the signal aborted around completion. Keep
@@ -2703,7 +2708,7 @@ async function executeToolCalls(
2703
2708
  toolName: record.toolCall.name,
2704
2709
  status: "skipped",
2705
2710
  });
2706
- emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2711
+ emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
2707
2712
  }
2708
2713
  }
2709
2714
 
@@ -2723,17 +2728,34 @@ async function executeToolCalls(
2723
2728
  * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
2724
2729
  * websocket close) was surfaced by the CLI as if the local tool had failed.
2725
2730
  *
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"`.
2731
+ * `source` names the state that prevented execution — either an assistant-side
2732
+ * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
2733
+ * still-pending call to service queued steering/peer input (`interrupt_skipped`).
2734
+ * `upstreamError` is the provider-reported message when the turn ended with
2735
+ * `stopReason === "error"`.
2729
2736
  */
2730
2737
  export interface SyntheticToolResultDetails {
2731
2738
  __synthetic: true;
2732
- source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
2739
+ source:
2740
+ | "assistant_stop_aborted"
2741
+ | "assistant_stop_error"
2742
+ | "assistant_stop_skipped"
2743
+ | "assistant_stop_length"
2744
+ | "interrupt_skipped";
2733
2745
  executed: false;
2734
2746
  upstreamError?: string;
2735
2747
  }
2736
2748
 
2749
+ /**
2750
+ * Metadata for an interrupt-aborted call that entered `tool.execute()` but
2751
+ * threw before returning a usable result. It may have performed partial work.
2752
+ */
2753
+ interface InterruptedToolResultDetails {
2754
+ __interrupted: true;
2755
+ source: "interrupt_skipped";
2756
+ execution: "started";
2757
+ }
2758
+
2737
2759
  /**
2738
2760
  * Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
2739
2761
  * a tool_result emitted for a tool call the assistant never invoked (see
@@ -2844,7 +2866,10 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
2844
2866
  };
2845
2867
  }
2846
2868
 
2847
- function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undefined): AgentToolResult<any> {
2869
+ function createSkippedToolResult(
2870
+ source: SteeringInterruptSource | "irc" | undefined,
2871
+ executionStarted: boolean,
2872
+ ): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
2848
2873
  let reason = "pending steering message";
2849
2874
  let blocker = "queued message";
2850
2875
  if (source === "user") {
@@ -2864,6 +2889,8 @@ function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undef
2864
2889
  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
2890
  },
2866
2891
  ],
2867
- details: {},
2892
+ details: executionStarted
2893
+ ? { __interrupted: true, source: "interrupt_skipped", execution: "started" }
2894
+ : { __synthetic: true, source: "interrupt_skipped", executed: false },
2868
2895
  };
2869
2896
  }
package/src/agent.ts CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
28
27
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
29
28
  import { logger } from "@oh-my-pi/pi-utils";
30
29
  import {
@@ -771,12 +770,10 @@ export class Agent {
771
770
  const messages = normalizeMessagesForProvider(llmMessages, model);
772
771
  const tools = ownedDialect
773
772
  ? []
774
- : (normalizeTools(
775
- this.#toolsForModel(model),
776
- this.#intentTracing,
777
- preferredDialect(model.id),
778
- this.#pruneToolDescriptions,
779
- ) ?? []);
773
+ : (normalizeTools(this.#toolsForModel(model), {
774
+ injectIntent: this.#intentTracing,
775
+ pruneDescriptions: this.#pruneToolDescriptions,
776
+ }) ?? []);
780
777
  let context: Context = { systemPrompt, messages, tools };
781
778
  if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
782
779
  return context;
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
18
- import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
19
18
  import { normalizeTools } from "./agent-loop";
20
19
  import type { AgentContext } from "./types";
21
20
 
@@ -34,7 +33,6 @@ export interface StablePrefixSnapshot {
34
33
  export interface BuildOptions {
35
34
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
36
35
  intentTracing: boolean;
37
- exampleDialect?: Dialect;
38
36
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
39
37
  pruneToolDescriptions?: boolean;
40
38
  }
@@ -317,7 +315,10 @@ export class AppendOnlyContextManager {
317
315
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
318
316
  const systemPrompt = [...context.systemPrompt];
319
317
  const tools =
320
- normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? [];
318
+ normalizeTools(context.tools, {
319
+ injectIntent: options.intentTracing,
320
+ pruneDescriptions: options.pruneToolDescriptions,
321
+ }) ?? [];
321
322
  return {
322
323
  systemPrompt,
323
324
  tools,
@@ -337,7 +338,6 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
337
338
  cw: t.customWireName,
338
339
  })),
339
340
  i: options.intentTracing,
340
- ex: options.exampleDialect,
341
341
  pd: options.pruneToolDescriptions,
342
342
  });
343
343
  let hash = 0;
@@ -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 },
@@ -18,6 +18,7 @@ import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./en
18
18
  import { invalidateMessageCache } from "./message-cache";
19
19
  import {
20
20
  collectToolCallsById,
21
+ isArtifactRecoveryToolResult,
21
22
  isProtectedToolResult,
22
23
  isSkillReadToolResult,
23
24
  type ProtectedToolMatcher,
@@ -46,11 +47,14 @@ export interface ShakeConfig {
46
47
  export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
47
48
  protectTokens: 16_000,
48
49
  minSavings: 4_000,
49
- protectedTools: ["skill", isSkillReadToolResult],
50
+ protectedTools: ["skill", isSkillReadToolResult, isArtifactRecoveryToolResult],
50
51
  fenceMinTokens: 400,
51
52
  };
52
53
 
53
- /** Manual `/shake`: aggressive — drops every eligible region across history. */
54
+ /**
55
+ * Manual `/shake`: aggressive — drops every eligible region across history,
56
+ * artifact recovery reads included (the user's full escape hatch).
57
+ */
54
58
  export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
55
59
  protectTokens: 0,
56
60
  minSavings: 0,
@@ -58,6 +62,12 @@ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
58
62
  fenceMinTokens: 400,
59
63
  };
60
64
 
65
+ /** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
66
+ export const RESCUE_SHAKE_CONFIG: ShakeConfig = {
67
+ ...AGGRESSIVE_SHAKE_CONFIG,
68
+ protectedTools: [...AGGRESSIVE_SHAKE_CONFIG.protectedTools, isArtifactRecoveryToolResult],
69
+ };
70
+
61
71
  /** Rough token cost of a placeholder line; used only for the savings gate. */
62
72
  const PLACEHOLDER_TOKEN_ESTIMATE = 16;
63
73
 
@@ -39,6 +39,16 @@ export function isSkillReadToolResult(context: ProtectedToolContext): boolean {
39
39
  return getReadToolPath(context)?.startsWith(SKILL_INTERNAL_URL_PREFIX) ?? false;
40
40
  }
41
41
 
42
+ const ARTIFACT_INTERNAL_URL_PREFIX = "artifact://";
43
+
44
+ /** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
45
+ export function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean {
46
+ if (getReadToolPath(context)?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX)) return true;
47
+ const meta = (context.toolResult.details as { meta?: { source?: { type?: string; value?: string } } } | undefined)
48
+ ?.meta;
49
+ return meta?.source?.type === "internal" && (meta.source.value?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX) ?? false);
50
+ }
51
+
42
52
  export function isProtectedToolResult(
43
53
  toolResult: ToolResultMessage,
44
54
  toolCall: AgentToolCall | undefined,