@oh-my-pi/pi-agent-core 17.1.7 → 17.2.0

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.0] - 2026-07-30
6
+
7
+ ### Fixed
8
+
9
+ - Provider-native compaction failures now surface their transport error instead of silently switching to generic summarization; streaming V2 still falls back to native V1 when available.
10
+
5
11
  ## [17.1.7] - 2026-07-27
6
12
 
7
13
  ### Changed
@@ -53,6 +53,8 @@ export interface CompactionSettings {
53
53
  /** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
54
54
  export declare const DEFAULT_RESERVE_TOKENS = 16384;
55
55
  export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
56
+ /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
57
+ export declare function shouldUseProviderNativeCompaction(model: Model, settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">): boolean;
56
58
  /**
57
59
  * Calculate total context tokens from usage.
58
60
  * Uses the native totalTokens field when available, falls back to computing from components.
@@ -13,6 +13,18 @@ export declare class CompactionCancelledError extends Error {
13
13
  readonly name: "CompactionCancelledError";
14
14
  constructor(message?: string);
15
15
  }
16
+ /**
17
+ * A provider-native compaction request failed after every native protocol
18
+ * available for the selected model was exhausted.
19
+ *
20
+ * The cause stays attached so AI error classification can still recognize
21
+ * authentication failures. Non-auth failures remain distinguishable from
22
+ * ordinary summarization errors and must not fall through to another provider.
23
+ */
24
+ export declare class NativeCompactionError extends Error {
25
+ readonly name: "NativeCompactionError";
26
+ constructor(cause: unknown);
27
+ }
16
28
  /**
17
29
  * Outcome of a compaction attempt, surfaced by `CommandController.executeCompaction`
18
30
  * so callers (e.g. the plan-mode approval flow) can distinguish a deliberate abort
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.7",
4
+ "version": "17.2.0",
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.7",
39
- "@oh-my-pi/pi-catalog": "17.1.7",
40
- "@oh-my-pi/pi-natives": "17.1.7",
41
- "@oh-my-pi/pi-utils": "17.1.7",
42
- "@oh-my-pi/pi-wire": "17.1.7",
43
- "@oh-my-pi/snapcompact": "17.1.7",
38
+ "@oh-my-pi/pi-ai": "17.2.0",
39
+ "@oh-my-pi/pi-catalog": "17.2.0",
40
+ "@oh-my-pi/pi-natives": "17.2.0",
41
+ "@oh-my-pi/pi-utils": "17.2.0",
42
+ "@oh-my-pi/pi-wire": "17.2.0",
43
+ "@oh-my-pi/snapcompact": "17.2.0",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
11
- import { isTransientStatus, ProviderHttpError } from "@oh-my-pi/pi-ai/error";
11
+ import * as AIError from "@oh-my-pi/pi-ai/error";
12
12
  import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
13
13
  import {
14
14
  createOpenAICodexCompactionRequestContext,
@@ -21,6 +21,7 @@ import {
21
21
  parseAzureDeploymentNameMap,
22
22
  resolveOpenAIRequestSetup,
23
23
  } from "@oh-my-pi/pi-ai/providers/openai-shared";
24
+ import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
24
25
  import {
25
26
  CODEX_BASE_URL,
26
27
  getCodexAccountId,
@@ -334,18 +335,19 @@ async function attemptCompactionV2Streaming(
334
335
  });
335
336
 
336
337
  if (!response.ok) {
337
- const errorText = await response.text().catch(() => "");
338
+ const cause = await captureOpenAIHttpError(response);
338
339
  logger.warn("V2 remote compaction failed", {
339
340
  endpoint,
340
341
  status: response.status,
341
342
  statusText: response.statusText,
342
- errorText,
343
+ errorText: cause.captured.bodyText ?? "",
343
344
  });
344
- throw new ProviderHttpError(
345
+ throw new AIError.ProviderHttpError(
345
346
  `V2 remote compaction failed (${response.status} ${response.statusText})`,
346
347
  response.status,
347
348
  {
348
349
  headers: response.headers,
350
+ cause,
349
351
  },
350
352
  );
351
353
  }
@@ -560,6 +562,10 @@ function formatCompactionV2Failure(event: Record<string, unknown>, type: string)
560
562
  }
561
563
 
562
564
  function isRetryableCompactionError(error: Error): boolean {
565
+ // The gateway's synthetic auth_unavailable is an HTTP 503, but the
566
+ // captured response cause classifies it as auth. Let provider fallback run
567
+ // immediately instead of spending the transient retry budget.
568
+ if (AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)) return false;
563
569
  if (
564
570
  error.name === "AbortError" ||
565
571
  error.name === "TimeoutError" ||
@@ -567,8 +573,8 @@ function isRetryableCompactionError(error: Error): boolean {
567
573
  ) {
568
574
  return true;
569
575
  }
570
- if (error instanceof ProviderHttpError) {
571
- return isTransientStatus(error.status);
576
+ if (error instanceof AIError.ProviderHttpError) {
577
+ return AIError.isTransientStatus(error.status);
572
578
  }
573
579
  const message = error.message.toLowerCase();
574
580
  return (
@@ -22,7 +22,7 @@ import {
22
22
  type Usage,
23
23
  withAuth,
24
24
  } from "@oh-my-pi/pi-ai";
25
- import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
25
+ import * as AIError from "@oh-my-pi/pi-ai/error";
26
26
  import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
27
27
  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";
@@ -43,6 +43,7 @@ import {
43
43
  V2_RETAINED_MESSAGE_TOKEN_BUDGET,
44
44
  } from "./compaction-v2-streaming";
45
45
  import type { CompactionEntry, SessionEntry } from "./entries";
46
+ import { NativeCompactionError } from "./errors";
46
47
  import { isEstimateCacheable, readEstimateCache, writeEstimateCache } from "./message-cache";
47
48
  import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
48
49
  import {
@@ -202,6 +203,18 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
202
203
  v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
203
204
  };
204
205
 
206
+ /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
207
+ export function shouldUseProviderNativeCompaction(
208
+ model: Model,
209
+ settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
210
+ ): boolean {
211
+ if (settings.remoteEnabled === false) return false;
212
+ return (
213
+ shouldUseOpenAiRemoteCompaction(model) ||
214
+ (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model))
215
+ );
216
+ }
217
+
205
218
  // ============================================================================
206
219
  // Token calculation
207
220
  // ============================================================================
@@ -725,7 +738,9 @@ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined)
725
738
  */
726
739
  function createSummarizationError(prefix: string, response: AssistantMessage): Error {
727
740
  const text = `${prefix}: ${response.errorMessage || "Unknown error"}`;
728
- return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus);
741
+ return response.errorStatus === undefined
742
+ ? new Error(text)
743
+ : new AIError.ProviderHttpError(text, response.errorStatus);
729
744
  }
730
745
 
731
746
  function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean {
@@ -1332,6 +1347,17 @@ function buildCompactionV2Reasoning(
1332
1347
  return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" };
1333
1348
  }
1334
1349
 
1350
+ /**
1351
+ * Keep any non-auth native protocol failure ahead of authentication failures.
1352
+ * Downstream may retry compaction with another provider only when every native
1353
+ * protocol failed authentication, so a later auth error must not hide an
1354
+ * earlier transport or protocol failure.
1355
+ */
1356
+ function selectNativeCompactionError(previousError: unknown, nextError: unknown): unknown {
1357
+ if (previousError === undefined) return nextError;
1358
+ return AIError.is(AIError.classify(previousError), AIError.Flag.AuthFailed) ? nextError : previousError;
1359
+ }
1360
+
1335
1361
  /**
1336
1362
  * Generate summaries for compaction using prepared data.
1337
1363
  * Returns CompactionResult - SessionManager adds id/parentId when saving.
@@ -1406,6 +1432,7 @@ export async function compact(
1406
1432
  ...recentMessages,
1407
1433
  ];
1408
1434
  let usedRemoteCompaction = false;
1435
+ let nativeCompactionError: unknown;
1409
1436
  if (
1410
1437
  settings.remoteEnabled !== false &&
1411
1438
  settings.remoteStreamingV2Enabled !== false &&
@@ -1467,7 +1494,8 @@ export async function compact(
1467
1494
  // swallowing it here would downgrade Esc into "fall back to local
1468
1495
  // summarization" and keep compaction running on an aborted signal.
1469
1496
  if (signal?.aborted) throw err;
1470
- logger.warn("OpenAI V2 remote compaction failed, falling back to V1/local summarization", {
1497
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1498
+ logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", {
1471
1499
  error: err instanceof Error ? err.message : String(err),
1472
1500
  model: model.id,
1473
1501
  provider: model.provider,
@@ -1517,7 +1545,8 @@ export async function compact(
1517
1545
  // swallowing it here would downgrade Esc into "fall back to local
1518
1546
  // summarization" and keep compaction running on an aborted signal.
1519
1547
  if (signal?.aborted) throw err;
1520
- logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1548
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1549
+ logger.warn("OpenAI remote compaction failed", {
1521
1550
  error: err instanceof Error ? err.message : String(err),
1522
1551
  model: model.id,
1523
1552
  provider: model.provider,
@@ -1526,6 +1555,10 @@ export async function compact(
1526
1555
  }
1527
1556
  }
1528
1557
 
1558
+ if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
1559
+ throw new NativeCompactionError(nativeCompactionError);
1560
+ }
1561
+
1529
1562
  // Generate summaries (can be parallel if both needed) and merge into one
1530
1563
  let summary: string;
1531
1564
 
@@ -18,6 +18,22 @@ export class CompactionCancelledError extends Error {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * A provider-native compaction request failed after every native protocol
23
+ * available for the selected model was exhausted.
24
+ *
25
+ * The cause stays attached so AI error classification can still recognize
26
+ * authentication failures. Non-auth failures remain distinguishable from
27
+ * ordinary summarization errors and must not fall through to another provider.
28
+ */
29
+ export class NativeCompactionError extends Error {
30
+ readonly name = "NativeCompactionError" as const;
31
+
32
+ constructor(cause: unknown) {
33
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
34
+ }
35
+ }
36
+
21
37
  /**
22
38
  * Outcome of a compaction attempt, surfaced by `CommandController.executeCompaction`
23
39
  * so callers (e.g. the plan-mode approval flow) can distinguish a deliberate abort
@@ -38,6 +38,7 @@ import {
38
38
  getOpenAIResponsesHistoryPayload,
39
39
  normalizeResponsesToolCallId,
40
40
  } from "@oh-my-pi/pi-ai/utils";
41
+ import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
41
42
  import {
42
43
  CODEX_BASE_URL,
43
44
  getCodexAccountId,
@@ -840,18 +841,19 @@ export async function requestOpenAiRemoteCompaction(
840
841
  });
841
842
 
842
843
  if (!response.ok) {
843
- const errorText = await response.text().catch(() => "");
844
+ const cause = await captureOpenAIHttpError(response);
844
845
  logger.warn("OpenAI remote compaction failed", {
845
846
  endpoint,
846
847
  status: response.status,
847
848
  statusText: response.statusText,
848
- errorText,
849
+ errorText: cause.captured.bodyText ?? "",
849
850
  });
850
851
  throw new ProviderHttpError(
851
852
  `Remote compaction failed (${response.status} ${response.statusText})`,
852
853
  response.status,
853
854
  {
854
855
  headers: response.headers,
856
+ cause,
855
857
  },
856
858
  );
857
859
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
6
6
  import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
7
+ import { escapeHarmonyControlTokens } from "@oh-my-pi/pi-ai/utils/harmony-leak";
7
8
  import { formatGroupedPaths, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
8
9
  import type { AgentMessage } from "../types";
9
10
  import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
@@ -207,15 +208,13 @@ export function truncateToolResultForSummary(text: string): string {
207
208
  return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
208
209
  }
209
210
 
210
- const HARMONY_CONTROL_TOKEN_RE = /<\|(start|end|message|channel|constrain|return|call)\|>/g;
211
-
212
211
  /**
213
212
  * Serialize LLM messages as plain summary input without provider control tokens.
214
213
  */
215
214
  export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
216
215
  const conversation = serializeConversation(messages, dialect);
217
216
  if (dialect !== "harmony") return conversation;
218
- return conversation.replace(HARMONY_CONTROL_TOKEN_RE, "<\\|$1\\|>");
217
+ return escapeHarmonyControlTokens(conversation);
219
218
  }
220
219
 
221
220
  /**