@oh-my-pi/pi-agent-core 18.18.1 → 18.18.3

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.2] - 2026-11-08
6
+
7
+ ### Added
8
+
9
+ - Added `canReplayRemoteCompactionFrom()` to determine whether a model can replay provider-native compaction produced by another model.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed GenAI message-content capture configured in omp's own `.env` files so it continues to apply when those values are no longer exposed through the process environment.
14
+ - Fixed compaction preparation for remote OpenAI compaction that the active model cannot replay, ensuring the content is expanded and remains available for compaction.
15
+ - Fixed generic remote compaction requests to `/chat/completions` so gateway sessions retain the correct per-request model attribution.
16
+
5
17
  ## [18.17.0] - 2026-11-06
6
18
 
7
19
  ### Added
@@ -460,6 +460,8 @@ export declare class Agent {
460
460
  * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
461
461
  * (or a higher-level view) on notification. */
462
462
  onQueueChange(listener: () => void): () => void;
463
+ /** Notify a session when this agent parks or resumes at a safe process-pause boundary. */
464
+ onPauseChange(listener: (paused: boolean) => void): () => void;
463
465
  /** Register an independently removable hook that runs before queued messages are consumed. */
464
466
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
465
467
  /** Register an independently removable hook that runs immediately before each model call, with the
@@ -266,6 +266,8 @@ export interface SummaryOptions {
266
266
  tools?: Tool[];
267
267
  /** Optional fetch implementation threaded into remote compaction calls. */
268
268
  fetch?: FetchImpl;
269
+ /** Inspect or replace a generic remote-compaction payload before it is sent. */
270
+ onPayload?: SimpleStreamOptions["onPayload"];
269
271
  /**
270
272
  * Optional completion transport override for host-level request wrappers
271
273
  * (e.g. the coding-agent provider-concurrency limiter). When provided,
@@ -380,6 +382,13 @@ export interface CompactionPreparation {
380
382
  * Responses-family replay. Local summaries have no provider restriction.
381
383
  */
382
384
  export declare function canReplayRemoteCompaction(preserveData: Record<string, unknown> | undefined, activeModel: Model): boolean;
385
+ /**
386
+ * Whether `reader` can replay the provider-native compaction `compactor` would
387
+ * write. Only the OpenAI Responses lanes (the model's own API or its
388
+ * `remoteCompaction.api` override) store a provider-bound payload; the
389
+ * Anthropic lane and local methods leave a summary any model reads.
390
+ */
391
+ export declare function canReplayRemoteCompactionFrom(compactor: Model, reader: Model): boolean;
383
392
  /**
384
393
  * Whether compaction preparation may reuse a native boundary instead of
385
394
  * re-expanding its original messages. This is deliberately stricter than normal
@@ -14,7 +14,7 @@
14
14
  * summarization endpoints that accept `{ systemPrompt, prompt }` and reply
15
15
  * with `{ summary, shortSummary? }`.
16
16
  */
17
- import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
17
+ import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState, SimpleStreamOptions } from "@oh-my-pi/pi-ai/types";
18
18
  import { Tokenizer } from "../tokenizer.js";
19
19
  export * from "./compaction-v2-streaming.js";
20
20
  export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
@@ -153,14 +153,17 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
153
153
  * HTTP 400 `"'messages' is required"`, compaction silently fell back to
154
154
  * local summarization, and context grew unbounded.
155
155
  *
156
- * When `context.model` is provided the chat-completions body is tagged with
157
- * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
158
- * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
159
- * 401s force-refresh through the standard credential rotation policy.
156
+ * On chat-completions endpoints the model's request headers are resolved for
157
+ * each call, its wire id is sent (llama.cpp requires it), and `opts.apiKey` is
158
+ * forwarded as `Authorization: Bearer`. Custom summarizers receive no model
159
+ * credentials. `opts.onPayload` can inspect or replace either request body.
160
+ * Callers wrap this in `withAuth` so 401s force-refresh through the standard
161
+ * credential rotation policy.
160
162
  */
161
163
  export declare function requestRemoteCompaction(endpoint: string, request: RemoteCompactionRequest, signal?: AbortSignal, opts?: {
162
164
  fetch?: FetchImpl;
163
165
  timeoutMs?: number;
164
166
  model?: Model;
165
167
  apiKey?: string;
168
+ onPayload?: SimpleStreamOptions["onPayload"];
166
169
  }): Promise<RemoteCompactionResponse>;
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": "18.18.1",
4
+ "version": "18.18.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.18.1",
42
- "@oh-my-pi/pi-catalog": "18.18.1",
43
- "@oh-my-pi/pi-natives": "18.18.1",
44
- "@oh-my-pi/pi-utils": "18.18.1",
45
- "@oh-my-pi/pi-wire": "18.18.1",
46
- "@oh-my-pi/snapcompact": "18.18.1",
41
+ "@oh-my-pi/pi-ai": "18.18.3",
42
+ "@oh-my-pi/pi-catalog": "18.18.3",
43
+ "@oh-my-pi/pi-natives": "18.18.3",
44
+ "@oh-my-pi/pi-utils": "18.18.3",
45
+ "@oh-my-pi/pi-wire": "18.18.3",
46
+ "@oh-my-pi/snapcompact": "18.18.3",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.18.1",
50
+ "@oh-my-pi/omptype": "18.18.3",
51
51
  "@opentelemetry/context-async-hooks": "2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent.ts CHANGED
@@ -423,6 +423,7 @@ export class Agent {
423
423
  #tokenizer = new Tokenizer(this.#state.model);
424
424
  #listeners = new Set<(e: AgentEvent) => void>();
425
425
  #queueListeners = new Set<() => void>();
426
+ #pauseListeners = new Set<(paused: boolean) => void>();
426
427
  #abortController?: AbortController;
427
428
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
428
429
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
@@ -1014,6 +1015,12 @@ export class Agent {
1014
1015
  return () => this.#queueListeners.delete(listener);
1015
1016
  }
1016
1017
 
1018
+ /** Notify a session when this agent parks or resumes at a safe process-pause boundary. */
1019
+ onPauseChange(listener: (paused: boolean) => void): () => void {
1020
+ this.#pauseListeners.add(listener);
1021
+ return () => this.#pauseListeners.delete(listener);
1022
+ }
1023
+
1017
1024
  /** Register an independently removable hook that runs before queued messages are consumed. */
1018
1025
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
1019
1026
  const registration = (signal?: AbortSignal) => hook(signal);
@@ -1378,6 +1385,19 @@ export class Agent {
1378
1385
  }
1379
1386
  }
1380
1387
 
1388
+ /** Report a safe-boundary park/resume transition; a throwing observer never disrupts the loop. */
1389
+ #emitPauseChanged(paused: boolean): void {
1390
+ for (const listener of this.#pauseListeners) {
1391
+ try {
1392
+ listener(paused);
1393
+ } catch (err) {
1394
+ logger.warn("Agent pause listener threw", {
1395
+ error: err instanceof Error ? err.message : String(err),
1396
+ });
1397
+ }
1398
+ }
1399
+ }
1400
+
1381
1401
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
1382
1402
  this.#steeringQueue = steering.slice();
1383
1403
  this.#followUpQueue = followUp.slice();
@@ -2078,7 +2098,9 @@ export class Agent {
2078
2098
  onHarmonyLeak: this.#onHarmonyLeak,
2079
2099
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
2080
2100
  onPauseChange: paused => {
2101
+ if (this.#isPaused === paused) return;
2081
2102
  this.#isPaused = paused;
2103
+ this.#emitPauseChanged(paused);
2082
2104
  },
2083
2105
  getToolChoice,
2084
2106
  softToolRequirementState: this.#softToolRequirementState,
@@ -757,6 +757,8 @@ export interface SummaryOptions {
757
757
  tools?: Tool[];
758
758
  /** Optional fetch implementation threaded into remote compaction calls. */
759
759
  fetch?: FetchImpl;
760
+ /** Inspect or replace a generic remote-compaction payload before it is sent. */
761
+ onPayload?: SimpleStreamOptions["onPayload"];
760
762
  /**
761
763
  * Optional completion transport override for host-level request wrappers
762
764
  * (e.g. the coding-agent provider-concurrency limiter). When provided,
@@ -1040,7 +1042,7 @@ async function summarizeConversationWindow(
1040
1042
  endpoint,
1041
1043
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1042
1044
  signal,
1043
- { fetch: options.fetch, model, apiKey: key },
1045
+ { fetch: options.fetch, model, apiKey: key, onPayload: options.onPayload },
1044
1046
  ),
1045
1047
  { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1046
1048
  );
@@ -1271,7 +1273,7 @@ async function generateShortSummary(
1271
1273
  endpoint,
1272
1274
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1273
1275
  signal,
1274
- { fetch: options?.fetch, model, apiKey: key },
1276
+ { fetch: options?.fetch, model, apiKey: key, onPayload: options?.onPayload },
1275
1277
  ),
1276
1278
  { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1277
1279
  );
@@ -1400,6 +1402,19 @@ export function canReplayRemoteCompaction(
1400
1402
  return !remote || (remote.provider === activeModel.provider && isOpenAiRemoteCompactionApi(activeModel.api));
1401
1403
  }
1402
1404
 
1405
+ /**
1406
+ * Whether `reader` can replay the provider-native compaction `compactor` would
1407
+ * write. Only the OpenAI Responses lanes (the model's own API or its
1408
+ * `remoteCompaction.api` override) store a provider-bound payload; the
1409
+ * Anthropic lane and local methods leave a summary any model reads.
1410
+ */
1411
+ export function canReplayRemoteCompactionFrom(compactor: Model, reader: Model): boolean {
1412
+ return (
1413
+ !isOpenAiRemoteCompactionApi(compactor.remoteCompaction?.api ?? compactor.api) ||
1414
+ (compactor.provider === reader.provider && isOpenAiRemoteCompactionApi(reader.api))
1415
+ );
1416
+ }
1417
+
1403
1418
  /**
1404
1419
  * Whether compaction preparation may reuse a native boundary instead of
1405
1420
  * re-expanding its original messages. This is deliberately stricter than normal
@@ -1463,7 +1478,12 @@ export function prepareCompaction(
1463
1478
  ): CompactionPreparation | undefined {
1464
1479
  const lastEntry = pathEntries[pathEntries.length - 1];
1465
1480
  // A speculative native record may leave uncovered messages before the record.
1466
- if (lastEntry?.type === "compaction" && !lastEntry.providerReplayThroughEntryId) {
1481
+ // A trailing boundary this preparation re-expands covers nothing yet.
1482
+ if (
1483
+ lastEntry?.type === "compaction" &&
1484
+ !lastEntry.providerReplayThroughEntryId &&
1485
+ (!activeModel || remotePreserveReusable(lastEntry.preserveData, activeModel, settings))
1486
+ ) {
1467
1487
  return undefined;
1468
1488
  }
1469
1489
 
@@ -1778,6 +1798,7 @@ export async function compact(
1778
1798
  codexCompaction: options?.codexCompaction,
1779
1799
  tools: options?.tools,
1780
1800
  fetch: options?.fetch,
1801
+ onPayload: options?.onPayload,
1781
1802
  completeImpl: options?.completeImpl,
1782
1803
  // The caller's opt-out must reach every summarization oneshot, otherwise
1783
1804
  // an outer retry loop multiplies with the inner one (see SummaryOptions).
@@ -39,6 +39,7 @@ import type {
39
39
  Message,
40
40
  Model,
41
41
  ProviderSessionState,
42
+ SimpleStreamOptions,
42
43
  } from "@oh-my-pi/pi-ai/types";
43
44
  import {
44
45
  dropMalformedOpenAIResponsesToolCalls,
@@ -59,7 +60,16 @@ import {
59
60
  OPENAI_HEADER_VALUES,
60
61
  OPENAI_HEADERS,
61
62
  } from "@oh-my-pi/pi-catalog/wire/codex";
62
- import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
63
+ import {
64
+ $env,
65
+ isRecord,
66
+ logger,
67
+ prompt,
68
+ ptree,
69
+ stringifyJson,
70
+ structuredCloneJSON,
71
+ untilAborted,
72
+ } from "@oh-my-pi/pi-utils";
63
73
  import { dataUrlImageSize } from "../image-tokens";
64
74
  import { Tokenizer } from "../tokenizer";
65
75
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
@@ -1058,16 +1068,24 @@ export async function requestOpenAiRemoteCompaction(
1058
1068
  * HTTP 400 `"'messages' is required"`, compaction silently fell back to
1059
1069
  * local summarization, and context grew unbounded.
1060
1070
  *
1061
- * When `context.model` is provided the chat-completions body is tagged with
1062
- * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
1063
- * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
1064
- * 401s force-refresh through the standard credential rotation policy.
1071
+ * On chat-completions endpoints the model's request headers are resolved for
1072
+ * each call, its wire id is sent (llama.cpp requires it), and `opts.apiKey` is
1073
+ * forwarded as `Authorization: Bearer`. Custom summarizers receive no model
1074
+ * credentials. `opts.onPayload` can inspect or replace either request body.
1075
+ * Callers wrap this in `withAuth` so 401s force-refresh through the standard
1076
+ * credential rotation policy.
1065
1077
  */
1066
1078
  export async function requestRemoteCompaction(
1067
1079
  endpoint: string,
1068
1080
  request: RemoteCompactionRequest,
1069
1081
  signal?: AbortSignal,
1070
- opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
1082
+ opts?: {
1083
+ fetch?: FetchImpl;
1084
+ timeoutMs?: number;
1085
+ model?: Model;
1086
+ apiKey?: string;
1087
+ onPayload?: SimpleStreamOptions["onPayload"];
1088
+ },
1071
1089
  ): Promise<RemoteCompactionResponse> {
1072
1090
  let endpointPath = endpoint;
1073
1091
  try {
@@ -1076,13 +1094,22 @@ export async function requestRemoteCompaction(
1076
1094
  // Keep the raw endpoint for relative/custom fetch implementations.
1077
1095
  }
1078
1096
  const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
1079
- const headers: Record<string, string> = { "content-type": "application/json" };
1097
+ const headers = new Headers({ "content-type": "application/json" });
1080
1098
  if (isChatCompletions) {
1081
- if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
1082
- if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
1099
+ if (opts?.apiKey) headers.set("Authorization", `Bearer ${opts.apiKey}`);
1100
+ const model = opts?.model;
1101
+ const resolveHeaders = model?.resolveHeaders;
1102
+ const configuredHeaders = resolveHeaders
1103
+ ? await untilAborted(signal, () => resolveHeaders(signal))
1104
+ : model?.headers;
1105
+ signal?.throwIfAborted();
1106
+ // `set` replaces case-insensitively, so a configured `authorization` overrides the default bearer.
1107
+ if (configuredHeaders) {
1108
+ for (const name in configuredHeaders) headers.set(name, configuredHeaders[name]!);
1109
+ }
1083
1110
  }
1084
1111
 
1085
- const body: Record<string, unknown> = isChatCompletions
1112
+ let body: unknown = isChatCompletions
1086
1113
  ? {
1087
1114
  model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
1088
1115
  messages: [
@@ -1093,6 +1120,8 @@ export async function requestRemoteCompaction(
1093
1120
  max_tokens: request.maxTokens,
1094
1121
  }
1095
1122
  : { systemPrompt: request.systemPrompt, prompt: request.prompt, maxTokens: request.maxTokens };
1123
+ const replacement = await opts?.onPayload?.(body, opts.model, signal);
1124
+ if (replacement !== undefined) body = replacement;
1096
1125
 
1097
1126
  const response = await (opts?.fetch ?? fetch)(endpoint, {
1098
1127
  method: "POST",
package/src/telemetry.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type ToolChoice,
40
40
  type Usage,
41
41
  } from "@oh-my-pi/pi-ai";
42
+ import { $otelEnv } from "@oh-my-pi/pi-utils";
42
43
  import {
43
44
  type Attributes,
44
45
  type AttributeValue,
@@ -453,7 +454,7 @@ export function resolveTelemetry(
453
454
  let contentCaptureEnvCache: ResolvedTelemetryContentCapture | undefined;
454
455
  function readContentCaptureEnv(): ResolvedTelemetryContentCapture {
455
456
  if (contentCaptureEnvCache !== undefined) return contentCaptureEnvCache;
456
- const raw = process.env[CONTENT_CAPTURE_ENV];
457
+ const raw = $otelEnv(CONTENT_CAPTURE_ENV);
457
458
  if (!raw) {
458
459
  contentCaptureEnvCache = "none";
459
460
  return "none";
package/src/tokenizer.ts CHANGED
@@ -3,16 +3,16 @@ import { renderDemotedThinking } from "@oh-my-pi/pi-ai/dialect/demotion";
3
3
  import { isFactoryDroidGeminiWire, keepsFactoryGeminiReasoning } from "@oh-my-pi/pi-ai/providers/factory-droid/gemini";
4
4
  import { ollamaChatDropsOwnThinking } from "@oh-my-pi/pi-ai/providers/ollama";
5
5
  import { completionsDropsOwnThinking, isLiteLLMThinkingSignature } from "@oh-my-pi/pi-ai/providers/openai-completions";
6
- import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
6
+ import {
7
+ getNativeResponsesHistoryItems,
8
+ getReplayableNativeResponsesHistoryItems,
9
+ replaysNativeResponsesTurn,
10
+ } from "@oh-my-pi/pi-ai/providers/openai-shared";
7
11
  import {
8
12
  dropsForeignReasoningPayload,
9
13
  isSameModelTurn,
10
14
  shouldDropTruncatedThinkingOnlyAssistant,
11
15
  } from "@oh-my-pi/pi-ai/providers/transform-messages";
12
- import {
13
- getOpenAIResponsesHistoryItems,
14
- sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
15
- } from "@oh-my-pi/pi-ai/utils";
16
16
  import { resolveThinkingReplayTokens, type ThinkingReplayTokens } from "@oh-my-pi/pi-catalog/compat/thinking-replay";
17
17
  import type { ModelTokenizer } from "@oh-my-pi/pi-catalog/types";
18
18
  import * as natives from "@oh-my-pi/pi-natives";
@@ -595,15 +595,19 @@ export class Tokenizer {
595
595
  * its reasoning, so those send no reasoning and no signature (0).
596
596
  */
597
597
  #replayedReasoningTokens(message: AssistantMessage): number | null | undefined {
598
- const reader = this.#reasoningReader;
599
- if (!reader || message.api !== reader.api || message.model !== reader.id) return undefined;
600
- const items = getOpenAIResponsesHistoryItems(message.providerPayload, reader.provider, message.provider);
601
- if (!items) return undefined;
602
- if (!items.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) return 0;
603
- const replay = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items);
604
- if (!replay?.some(item => item.type === "reasoning")) return 0;
605
- // Cold, only plaintext reasoning bound to no server state replays natively.
606
- if (!this.nativeHistoryReplay && !isColdReplayableResponsesTurn(replay)) return 0;
598
+ // A payload this reader does not own is not native history at all: the turn
599
+ // is rebuilt from its blocks, and its signatures are priced below.
600
+ if (!getNativeResponsesHistoryItems(message, this.#reasoningReader)) return undefined;
601
+ // The request decides whether the payload goes on the wire; `/shake` reads
602
+ // that same decision, so a kept turn and a billed turn always agree.
603
+ const replay = getReplayableNativeResponsesHistoryItems(message, this.#reasoningReader);
604
+ if (!replay || !replaysNativeResponsesTurn(replay, this.nativeHistoryReplay)) return 0;
605
+ // Then the amount: nothing reasoning-shaped survives to price, or the turn is
606
+ // dropped whole as truncated thinking, so it sends no reasoning and no
607
+ // signature either. An answer-only payload replays with neither.
608
+ if (!replay.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) {
609
+ return 0;
610
+ }
607
611
  return message.usage?.reasoningTokens ?? null;
608
612
  }
609
613