@oh-my-pi/pi-agent-core 18.4.3 → 18.4.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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.4] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Added `Agent.replaceQueue()` to replace one pending queue without changing the other queue ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
10
+ - Added queued-message grouping so owned companion records and their user prompt are dequeued together in `one-at-a-time` mode ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
11
+ - Added `Agent.onQueueChange()`, a listener called whenever a steering/follow-up queue mutator (enqueue, dequeue on delivery, clear, or restore) runs, so hosts can observe queue changes without polling ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
12
+
13
+ ### Fixed
14
+
15
+ - Fixed GPT models on Amazon Bedrock's OpenAI routes (bedrock-runtime and bedrock-mantle `/openai/...`) falling back to a local summary instead of OpenAI's native remote compaction; set `remoteCompaction.enabled: false` to opt out ([#13311](https://github.com/can1357/oh-my-pi/pull/13311) by [@mustafaabidali](https://github.com/mustafaabidali)).
16
+ - Fixed native compaction on Amazon Bedrock's OpenAI routes skipping the provider's request setup, which sent Bedrock Mantle compaction to an unresolved `{region}` host and skipped configured headers and proxies; other providers' compaction requests are unchanged ([#13311](https://github.com/can1357/oh-my-pi/pull/13311) by [@mustafaabidali](https://github.com/mustafaabidali)).
17
+
5
18
  ## [18.4.3] - 2026-09-28
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -251,6 +251,10 @@ agent.followUp({
251
251
  Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer
252
252
  steering until the current turn completes.
253
253
 
254
+ Hosts can use `setQueuedMessageGrouping((previous, next) => boolean)` to keep adjacent companion
255
+ records and their prompt together in `one-at-a-time` mode. Without a grouping predicate, records
256
+ remain separate. `replaceQueue("steering" | "followUp", messages)` replaces only the selected queue.
257
+
254
258
  ## Custom Message Types
255
259
 
256
260
  Extend `AgentMessage` via declaration merging:
@@ -411,6 +411,12 @@ export declare class Agent {
411
411
  */
412
412
  buildSideRequestContext(llmMessages: Message[], systemPrompt?: string[]): Promise<Context>;
413
413
  subscribe(fn: (e: AgentEvent) => void): () => void;
414
+ /** Register a listener notified after any steering/follow-up queue mutator
415
+ * (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
416
+ * the queue itself has no concept of display filtering — so listeners
417
+ * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
418
+ * (or a higher-level view) on notification. */
419
+ onQueueChange(listener: () => void): () => void;
414
420
  /** Register an independently removable hook that runs before queued messages are consumed. */
415
421
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
416
422
  /** Register an independently removable hook that runs immediately before each model call. */
@@ -461,6 +467,16 @@ export declare class Agent {
461
467
  setTools(t: AgentTool<any>[]): void;
462
468
  replaceMessages(ms: AgentMessage[]): void;
463
469
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]): void;
470
+ /**
471
+ * Replace one pending queue without changing the other queue.
472
+ *
473
+ * The caller's snapshot comes from {@link peekSteeringQueue} /
474
+ * {@link peekFollowUpQueue}, which prepend the live claimed batch. Installing
475
+ * it while that claim is still live would commit a removed message anyway and
476
+ * deliver the surviving claimed prefix twice, so the claim and its pending
477
+ * delivery are dropped here exactly as {@link replaceQueues} does.
478
+ */
479
+ replaceQueue(queue: "steering" | "followUp", messages: readonly AgentMessage[]): void;
464
480
  appendMessage(m: AgentMessage): void;
465
481
  popMessage(): AgentMessage | undefined;
466
482
  /**
@@ -499,6 +515,12 @@ export declare class Agent {
499
515
  /** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
500
516
  getPendingToolResults(): readonly ToolResultMessage[];
501
517
  get isAborting(): boolean;
518
+ /**
519
+ * Join adjacent queued records into one delivery unit in one-at-a-time mode.
520
+ * The predicate must be synchronous and side-effect-free. By default each
521
+ * record is independent; all mode, queue inspection, and removal stay unchanged.
522
+ */
523
+ setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void;
502
524
  /**
503
525
  * Remove and return the last steering message from the queue (LIFO).
504
526
  * Used by dequeue keybinding.
@@ -0,0 +1,5 @@
1
+ import type { Model } from "@oh-my-pi/pi-ai";
2
+ /** Azure OpenAI base URL for compaction requests: env override, resource name, then the model's baseUrl. */
3
+ export declare function resolveAzureOpenAiBaseUrl(model: Model): string;
4
+ /** Append `api-version` unless the endpoint already carries one. */
5
+ export declare function appendAzureApiVersion(endpoint: string): string;
@@ -0,0 +1,18 @@
1
+ import type { FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
2
+ export interface BedrockCompactionRequest {
3
+ model: Model;
4
+ apiKey: string;
5
+ fetch: FetchImpl;
6
+ }
7
+ /**
8
+ * Shape a native compaction request for Amazon Bedrock's OpenAI routes
9
+ * (`isBedrockOpenAIUrl`) the way stream dispatch shapes a normal Bedrock turn:
10
+ * configured headers, transport fetch (proxy, CA, User-Agent, request
11
+ * recording), then the provider's model and request hooks. The bundled
12
+ * `bedrock-mantle` provider's hook fills in `{region}` and picks bearer or
13
+ * SigV4 auth; without it compaction targets an unresolved host.
14
+ *
15
+ * Only Bedrock requests come through here; other providers' compaction
16
+ * requests keep their own transport.
17
+ */
18
+ export declare function prepareBedrockCompactionRequest(model: Model, apiKey: string, fetch: FetchImpl | undefined, signal: AbortSignal | undefined): Promise<BedrockCompactionRequest>;
@@ -6,7 +6,7 @@
6
6
  * compaction output item, then install retained real user messages plus that
7
7
  * compaction item as replacement history.
8
8
  */
9
- import type { CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
9
+ import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
10
10
  import { type OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
11
11
  /** Retained-message budget Codex uses after streamed V2 compaction. */
12
12
  export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
@@ -42,6 +42,8 @@ export interface CompactionV2Response {
42
42
  export declare function getCompactionV2Endpoint(model: Model): string | undefined;
43
43
  /** Check whether a model can use streaming V2 compaction. */
44
44
  export declare function shouldUseCompactionV2Streaming(model: Model): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">;
45
+ /** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
46
+ export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
45
47
  /** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
46
48
  export declare function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number;
47
49
  /** Build a V2 streaming compaction request from Responses-native history. */
@@ -14,7 +14,7 @@
14
14
  * summarization endpoints that accept `{ systemPrompt, prompt }` and reply
15
15
  * with `{ summary, shortSummary? }`.
16
16
  */
17
- import type { Api, CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
17
+ import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState } 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";
@@ -83,7 +83,6 @@ export interface RemoteCompactionResponse {
83
83
  summary: string;
84
84
  shortSummary?: string;
85
85
  }
86
- export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
87
86
  export declare function shouldUseOpenAiRemoteCompaction(model: Model): boolean;
88
87
  export declare function getPreservedOpenAiRemoteCompactionData(preserveData: Record<string, unknown> | undefined): OpenAiRemoteCompactionPreserveData | undefined;
89
88
  export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Record<string, unknown> | undefined, remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined): Record<string, unknown> | undefined;
@@ -7,6 +7,8 @@ export interface ProtectedToolContext {
7
7
  }
8
8
  export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) => boolean);
9
9
  export declare function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall>;
10
+ /** Return the tool-result message carried by a session entry, if any. */
11
+ export declare function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined;
10
12
  /**
11
13
  * Extract the `path` argument from a paired `read` tool call, when the result
12
14
  * is a `read` result carrying a string path. Returns `undefined` otherwise.
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.4.3",
4
+ "version": "18.4.5",
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.4.3",
42
- "@oh-my-pi/pi-catalog": "18.4.3",
43
- "@oh-my-pi/pi-natives": "18.4.3",
44
- "@oh-my-pi/pi-utils": "18.4.3",
45
- "@oh-my-pi/pi-wire": "18.4.3",
46
- "@oh-my-pi/snapcompact": "18.4.3",
41
+ "@oh-my-pi/pi-ai": "18.4.5",
42
+ "@oh-my-pi/pi-catalog": "18.4.5",
43
+ "@oh-my-pi/pi-natives": "18.4.5",
44
+ "@oh-my-pi/pi-utils": "18.4.5",
45
+ "@oh-my-pi/pi-wire": "18.4.5",
46
+ "@oh-my-pi/snapcompact": "18.4.5",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.4.3",
50
+ "@oh-my-pi/omptype": "18.4.5",
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
@@ -406,6 +406,7 @@ export class Agent {
406
406
  };
407
407
  #tokenizer = new Tokenizer(this.#state.model);
408
408
  #listeners = new Set<(e: AgentEvent) => void>();
409
+ #queueListeners = new Set<() => void>();
409
410
  #abortController?: AbortController;
410
411
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
411
412
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
@@ -430,6 +431,7 @@ export class Agent {
430
431
  next: number;
431
432
  }>();
432
433
  #steeringWaiters = new Set<() => void>();
434
+ #queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
433
435
 
434
436
  #steeringMode: "all" | "one-at-a-time";
435
437
  #followUpMode: "all" | "one-at-a-time";
@@ -914,6 +916,16 @@ export class Agent {
914
916
  return () => this.#listeners.delete(fn);
915
917
  }
916
918
 
919
+ /** Register a listener notified after any steering/follow-up queue mutator
920
+ * (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
921
+ * the queue itself has no concept of display filtering — so listeners
922
+ * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
923
+ * (or a higher-level view) on notification. */
924
+ onQueueChange(listener: () => void): () => void {
925
+ this.#queueListeners.add(listener);
926
+ return () => this.#queueListeners.delete(listener);
927
+ }
928
+
917
929
  /** Register an independently removable hook that runs before queued messages are consumed. */
918
930
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
919
931
  const registration = (signal?: AbortSignal) => hook(signal);
@@ -1001,6 +1013,7 @@ export class Agent {
1001
1013
  } else {
1002
1014
  this.#followUpQueue = [...claim.messages, ...this.#followUpQueue];
1003
1015
  }
1016
+ this.#emitQueueChanged();
1004
1017
  }
1005
1018
  claim.controller.abort();
1006
1019
  }
@@ -1026,6 +1039,7 @@ export class Agent {
1026
1039
  this.#notifySteeringWaiters();
1027
1040
  }
1028
1041
  if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
1042
+ if (restored.steering.length > 0 || restored.followUp.length > 0) this.#emitQueueChanged();
1029
1043
  }
1030
1044
 
1031
1045
  /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
@@ -1191,12 +1205,51 @@ export class Agent {
1191
1205
  this.#state.messages = ms.slice();
1192
1206
  }
1193
1207
 
1208
+ /** Signal that the steering/follow-up queue contents may have changed. Every
1209
+ * queue mutator below calls this once after mutating, so external layers
1210
+ * (AgentSession's `queue_update` coalescing) have a single seam to observe
1211
+ * enqueue, dequeue-on-delivery, clear, and restore without emits scattered
1212
+ * across every call site that queues or dequeues a message. */
1213
+ #emitQueueChanged(): void {
1214
+ for (const listener of this.#queueListeners) {
1215
+ try {
1216
+ listener();
1217
+ } catch (err) {
1218
+ logger.warn("Agent queue listener threw", {
1219
+ error: err instanceof Error ? err.message : String(err),
1220
+ });
1221
+ }
1222
+ }
1223
+ }
1224
+
1194
1225
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
1195
1226
  this.#steeringQueue = steering.slice();
1196
1227
  this.#followUpQueue = followUp.slice();
1197
1228
  this.#cancelQueuedMessagePreparation("steering");
1198
1229
  this.#cancelQueuedMessagePreparation("followUp");
1199
1230
  this.#notifySteeringWaiters();
1231
+ this.#emitQueueChanged();
1232
+ }
1233
+
1234
+ /**
1235
+ * Replace one pending queue without changing the other queue.
1236
+ *
1237
+ * The caller's snapshot comes from {@link peekSteeringQueue} /
1238
+ * {@link peekFollowUpQueue}, which prepend the live claimed batch. Installing
1239
+ * it while that claim is still live would commit a removed message anyway and
1240
+ * deliver the surviving claimed prefix twice, so the claim and its pending
1241
+ * delivery are dropped here exactly as {@link replaceQueues} does.
1242
+ */
1243
+ replaceQueue(queue: "steering" | "followUp", messages: readonly AgentMessage[]): void {
1244
+ if (queue === "steering") {
1245
+ this.#steeringQueue = messages.slice();
1246
+ this.#cancelQueuedMessagePreparation("steering");
1247
+ this.#notifySteeringWaiters();
1248
+ } else {
1249
+ this.#followUpQueue = messages.slice();
1250
+ this.#cancelQueuedMessagePreparation("followUp");
1251
+ }
1252
+ this.#emitQueueChanged();
1200
1253
  }
1201
1254
 
1202
1255
  appendMessage(m: AgentMessage) {
@@ -1228,6 +1281,7 @@ export class Agent {
1228
1281
  steer(m: AgentMessage) {
1229
1282
  this.#steeringQueue.push(m);
1230
1283
  this.#notifySteeringWaiters();
1284
+ this.#emitQueueChanged();
1231
1285
  }
1232
1286
 
1233
1287
  /**
@@ -1236,17 +1290,20 @@ export class Agent {
1236
1290
  */
1237
1291
  followUp(m: AgentMessage) {
1238
1292
  this.#followUpQueue.push(m);
1293
+ this.#emitQueueChanged();
1239
1294
  }
1240
1295
 
1241
1296
  clearSteeringQueue() {
1242
1297
  this.#steeringQueue = [];
1243
1298
  this.#cancelQueuedMessagePreparation("steering");
1244
1299
  this.#notifySteeringWaiters();
1300
+ this.#emitQueueChanged();
1245
1301
  }
1246
1302
 
1247
1303
  clearFollowUpQueue() {
1248
1304
  this.#followUpQueue = [];
1249
1305
  this.#cancelQueuedMessagePreparation("followUp");
1306
+ this.#emitQueueChanged();
1250
1307
  }
1251
1308
 
1252
1309
  /**
@@ -1266,6 +1323,7 @@ export class Agent {
1266
1323
  this.#cancelQueuedMessagePreparation("followUp");
1267
1324
  this.#notifySteeringWaiters();
1268
1325
  this.clearDeferredToolDirectives();
1326
+ this.#emitQueueChanged();
1269
1327
  }
1270
1328
 
1271
1329
  hasQueuedMessages(): boolean {
@@ -1317,32 +1375,34 @@ export class Agent {
1317
1375
  return this.#abortController?.signal.aborted === true && this.#state.isStreaming;
1318
1376
  }
1319
1377
 
1378
+ /**
1379
+ * Join adjacent queued records into one delivery unit in one-at-a-time mode.
1380
+ * The predicate must be synchronous and side-effect-free. By default each
1381
+ * record is independent; all mode, queue inspection, and removal stay unchanged.
1382
+ */
1383
+ setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void {
1384
+ this.#queuedMessageGrouping = predicate;
1385
+ }
1386
+
1387
+ #dequeueMessages(queue: readonly AgentMessage[], mode: "all" | "one-at-a-time"): AgentMessage[] {
1388
+ if (mode === "all") return queue.slice();
1389
+ let count = Math.min(1, queue.length);
1390
+ while (count < queue.length && this.#queuedMessageGrouping?.(queue[count - 1], queue[count])) count++;
1391
+ return queue.slice(0, count);
1392
+ }
1393
+
1320
1394
  #dequeueSteeringMessages(): AgentMessage[] {
1321
- if (this.#steeringMode === "one-at-a-time") {
1322
- if (this.#steeringQueue.length > 0) {
1323
- const first = this.#steeringQueue[0];
1324
- this.#steeringQueue = this.#steeringQueue.slice(1);
1325
- return [first];
1326
- }
1327
- return [];
1328
- }
1329
- const steering = this.#steeringQueue.slice();
1330
- this.#steeringQueue = [];
1331
- return steering;
1395
+ const messages = this.#dequeueMessages(this.#steeringQueue, this.#steeringMode);
1396
+ this.#steeringQueue = this.#steeringQueue.slice(messages.length);
1397
+ if (messages.length > 0) this.#emitQueueChanged();
1398
+ return messages;
1332
1399
  }
1333
1400
 
1334
1401
  #dequeueFollowUpMessages(): AgentMessage[] {
1335
- if (this.#followUpMode === "one-at-a-time") {
1336
- if (this.#followUpQueue.length > 0) {
1337
- const first = this.#followUpQueue[0];
1338
- this.#followUpQueue = this.#followUpQueue.slice(1);
1339
- return [first];
1340
- }
1341
- return [];
1342
- }
1343
- const followUp = this.#followUpQueue.slice();
1344
- this.#followUpQueue = [];
1345
- return followUp;
1402
+ const messages = this.#dequeueMessages(this.#followUpQueue, this.#followUpMode);
1403
+ this.#followUpQueue = this.#followUpQueue.slice(messages.length);
1404
+ if (messages.length > 0) this.#emitQueueChanged();
1405
+ return messages;
1346
1406
  }
1347
1407
 
1348
1408
  /**
@@ -1351,7 +1411,9 @@ export class Agent {
1351
1411
  */
1352
1412
  popLastSteer(): AgentMessage | undefined {
1353
1413
  if (this.#steeringQueue.length === 0) this.#cancelQueuedMessagePreparation("steering", true);
1354
- return this.#steeringQueue.pop();
1414
+ const popped = this.#steeringQueue.pop();
1415
+ if (popped !== undefined) this.#emitQueueChanged();
1416
+ return popped;
1355
1417
  }
1356
1418
 
1357
1419
  /**
@@ -1360,7 +1422,9 @@ export class Agent {
1360
1422
  */
1361
1423
  popLastFollowUp(): AgentMessage | undefined {
1362
1424
  if (this.#followUpQueue.length === 0) this.#cancelQueuedMessagePreparation("followUp", true);
1363
- return this.#followUpQueue.pop();
1425
+ const popped = this.#followUpQueue.pop();
1426
+ if (popped !== undefined) this.#emitQueueChanged();
1427
+ return popped;
1364
1428
  }
1365
1429
 
1366
1430
  clearMessages() {
@@ -0,0 +1,25 @@
1
+ import type { Model } from "@oh-my-pi/pi-ai";
2
+ import { $env } from "@oh-my-pi/pi-utils";
3
+
4
+ const DEFAULT_AZURE_API_VERSION = "v1";
5
+
6
+ /** Azure OpenAI base URL for compaction requests: env override, resource name, then the model's baseUrl. */
7
+ export function resolveAzureOpenAiBaseUrl(model: Model): string {
8
+ const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
9
+ const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
10
+ const resolvedBaseUrl =
11
+ baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
12
+ if (!resolvedBaseUrl) {
13
+ throw new Error(
14
+ "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
15
+ );
16
+ }
17
+ return resolvedBaseUrl.replace(/\/+$/, "");
18
+ }
19
+
20
+ /** Append `api-version` unless the endpoint already carries one. */
21
+ export function appendAzureApiVersion(endpoint: string): string {
22
+ if (/[?&]api-version=/.test(endpoint)) return endpoint;
23
+ const separator = endpoint.includes("?") ? "&" : "?";
24
+ return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
25
+ }
@@ -0,0 +1,48 @@
1
+ import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry";
2
+ import type { FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
3
+ import { transportFetch } from "@oh-my-pi/pi-ai/utils/transport-fetch";
4
+ import { untilAborted } from "@oh-my-pi/pi-utils";
5
+
6
+ export interface BedrockCompactionRequest {
7
+ model: Model;
8
+ apiKey: string;
9
+ fetch: FetchImpl;
10
+ }
11
+
12
+ /**
13
+ * Shape a native compaction request for Amazon Bedrock's OpenAI routes
14
+ * (`isBedrockOpenAIUrl`) the way stream dispatch shapes a normal Bedrock turn:
15
+ * configured headers, transport fetch (proxy, CA, User-Agent, request
16
+ * recording), then the provider's model and request hooks. The bundled
17
+ * `bedrock-mantle` provider's hook fills in `{region}` and picks bearer or
18
+ * SigV4 auth; without it compaction targets an unresolved host.
19
+ *
20
+ * Only Bedrock requests come through here; other providers' compaction
21
+ * requests keep their own transport.
22
+ */
23
+ export async function prepareBedrockCompactionRequest(
24
+ model: Model,
25
+ apiKey: string,
26
+ fetch: FetchImpl | undefined,
27
+ signal: AbortSignal | undefined,
28
+ ): Promise<BedrockCompactionRequest> {
29
+ let resolvedModel = model;
30
+ const resolveHeaders = model.resolveHeaders;
31
+ if (resolveHeaders) {
32
+ const headers = await untilAborted(signal, () => resolveHeaders(signal));
33
+ signal?.throwIfAborted();
34
+ resolvedModel = { ...model, resolveHeaders: undefined, headers: headers ? { ...headers } : undefined };
35
+ }
36
+ const baseFetch = transportFetch(resolvedModel, fetch);
37
+ const provider = getProviderDefinition(resolvedModel.provider);
38
+ const providerModel = provider?.prepareModel?.(resolvedModel) ?? resolvedModel;
39
+ const prepared = provider?.prepareRequest?.(providerModel, { apiKey, fetch: baseFetch, signal }) ?? {
40
+ model: providerModel,
41
+ options: { apiKey, fetch: baseFetch },
42
+ };
43
+ return {
44
+ model: { ...prepared.model, headers: { ...prepared.model.headers, ...prepared.options.headers } },
45
+ apiKey: prepared.options.apiKey || apiKey,
46
+ fetch: prepared.options.fetch ?? baseFetch,
47
+ };
48
+ }
@@ -24,6 +24,7 @@ import {
24
24
  resolveOpenAIRequestSetup,
25
25
  } from "@oh-my-pi/pi-ai/providers/openai-shared";
26
26
  import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
27
+ import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
27
28
  import {
28
29
  applyCodexResidencyHeader,
29
30
  CODEX_BASE_URL,
@@ -32,7 +33,9 @@ import {
32
33
  OPENAI_HEADER_VALUES,
33
34
  OPENAI_HEADERS,
34
35
  } from "@oh-my-pi/pi-catalog/wire/codex";
35
- import { $env, isUnexpectedSocketCloseMessage, logger, stringifyJson } from "@oh-my-pi/pi-utils";
36
+ import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@oh-my-pi/pi-utils";
37
+ import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
38
+ import { prepareBedrockCompactionRequest } from "./bedrock";
36
39
 
37
40
  // ============================================================================
38
41
  // Types & Configuration
@@ -47,7 +50,6 @@ export const V2_COMPACTION_MAX_RETRIES = 2;
47
50
  /** Timeout for V2 streaming compaction (5 minutes, same as V1). */
48
51
  export const V2_COMPACTION_TIMEOUT_MS = 300_000;
49
52
 
50
- const DEFAULT_AZURE_API_VERSION = "v1";
51
53
  const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
52
54
  const COMPACTION_TRIGGER_ITEM = { type: "compaction_trigger" } as const;
53
55
  // OpenAI image metering depends on detail and dimensions; charge the common
@@ -96,7 +98,7 @@ export interface CompactionV2Response {
96
98
  /** Resolve the streaming Responses endpoint for a V2-capable model. */
97
99
  export function getCompactionV2Endpoint(model: Model): string | undefined {
98
100
  if (model.remoteCompaction?.enabled === false) return undefined;
99
- if (!isOpenAiV2CompatibleModel(model)) return undefined;
101
+ if (!isOpenAiRemoteCompactionApi(compactionV2Api(model))) return undefined;
100
102
 
101
103
  const configuredEndpoint = model.remoteCompaction?.v2Endpoint ?? model.remoteCompaction?.streamingEndpoint;
102
104
  if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
@@ -115,7 +117,15 @@ export function getCompactionV2Endpoint(model: Model): string | undefined {
115
117
  export function shouldUseCompactionV2Streaming(
116
118
  model: Model,
117
119
  ): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses"> {
118
- if (model.remoteCompaction?.v2StreamingEnabled !== true) return false;
120
+ const v2StreamingEnabled = model.remoteCompaction?.v2StreamingEnabled;
121
+ if (v2StreamingEnabled === false) return false;
122
+ // Amazon Bedrock's OpenAI routes accept `compaction_trigger` without an opt-in.
123
+ if (
124
+ v2StreamingEnabled !== true &&
125
+ !(compactionV2Api(model) === "openai-responses" && isBedrockOpenAIUrl(model.baseUrl))
126
+ ) {
127
+ return false;
128
+ }
119
129
  return getCompactionV2Endpoint(model) !== undefined;
120
130
  }
121
131
 
@@ -123,8 +133,8 @@ function compactionV2Api(model: Model): Api | undefined {
123
133
  return model.remoteCompaction?.api ?? model.api;
124
134
  }
125
135
 
126
- function isOpenAiV2CompatibleModel(model: Model): boolean {
127
- const api = compactionV2Api(model);
136
+ /** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
137
+ export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
128
138
  return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
129
139
  }
130
140
 
@@ -152,25 +162,6 @@ function resolveOpenAiCodexResponsesEndpoint(baseUrl: string | undefined): strin
152
162
  return `${normalizedBase}/codex/responses`;
153
163
  }
154
164
 
155
- function resolveAzureOpenAiBaseUrl(model: Model): string {
156
- const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
157
- const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
158
- const resolvedBaseUrl =
159
- baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
160
- if (!resolvedBaseUrl) {
161
- throw new Error(
162
- "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
163
- );
164
- }
165
- return resolvedBaseUrl.replace(/\/+$/, "");
166
- }
167
-
168
- function appendAzureApiVersion(endpoint: string): string {
169
- if (/[?&]api-version=/.test(endpoint)) return endpoint;
170
- const separator = endpoint.includes("?") ? "&" : "?";
171
- return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
172
- }
173
-
174
165
  function resolveCompactionV2Model(model: Model): string {
175
166
  const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
176
167
  if (compactionV2Api(model) !== "azure-openai-responses") return requestModel;
@@ -248,13 +239,6 @@ export function buildCompactionV2RequestFromBody(
248
239
  // Streaming Request Handler
249
240
  // ============================================================================
250
241
 
251
- /** Race the caller's signal against the V2 request timeout. */
252
- function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
253
- if (timeoutMs <= 0) return signal;
254
- const timeout = AbortSignal.timeout(timeoutMs);
255
- return signal ? AbortSignal.any([signal, timeout]) : timeout;
256
- }
257
-
258
242
  /** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
259
243
  export async function requestCompactionV2Streaming(
260
244
  model: Model,
@@ -270,12 +254,19 @@ export async function requestCompactionV2Streaming(
270
254
  preferWebsockets?: boolean;
271
255
  },
272
256
  ): Promise<CompactionV2Response> {
257
+ let fetchImpl: FetchImpl = options?.fetch ?? globalThis.fetch;
258
+ if (isBedrockOpenAIUrl(model.baseUrl)) {
259
+ ({
260
+ model,
261
+ apiKey,
262
+ fetch: fetchImpl,
263
+ } = await prepareBedrockCompactionRequest(model, apiKey, options?.fetch, signal));
264
+ }
273
265
  const endpoint = getCompactionV2Endpoint(model);
274
266
  if (!endpoint) {
275
267
  throw new Error(`Model ${model.id} does not support V2 streaming compaction`);
276
268
  }
277
269
 
278
- const fetchImpl = options?.fetch ?? globalThis.fetch;
279
270
  const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
280
271
  const isCodexResponses = compactionV2Api(model) === "openai-codex-responses" || model.provider === "openai-codex";
281
272
  const codexMetadata =
@@ -293,7 +284,7 @@ export async function requestCompactionV2Streaming(
293
284
  let lastError: Error | undefined;
294
285
 
295
286
  for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
296
- const timeoutSignal = withRequestTimeout(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
287
+ const timeoutSignal = ptree.combineSignals(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
297
288
  try {
298
289
  return await attemptCompactionV2Streaming(endpoint, apiKey, model, request, fetchImpl, timeoutSignal, {
299
290
  codexMetadata,
@@ -28,7 +28,6 @@ import {
28
28
  } from "@oh-my-pi/pi-ai/providers/openai-shared";
29
29
  import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
30
30
  import type {
31
- Api,
32
31
  AssistantMessage,
33
32
  CodexCompactionContext,
34
33
  FetchImpl,
@@ -43,6 +42,7 @@ import {
43
42
  stripOpenAIResponsesOutputOnlyStatusesForReplay,
44
43
  } from "@oh-my-pi/pi-ai/utils";
45
44
  import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
45
+ import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
46
46
  import {
47
47
  applyCodexResidencyHeader,
48
48
  CODEX_BASE_URL,
@@ -51,8 +51,11 @@ import {
51
51
  OPENAI_HEADER_VALUES,
52
52
  OPENAI_HEADERS,
53
53
  } from "@oh-my-pi/pi-catalog/wire/codex";
54
- import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
54
+ import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
55
55
  import { Tokenizer } from "../tokenizer";
56
+ import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
57
+ import { prepareBedrockCompactionRequest } from "./bedrock";
58
+ import { isOpenAiRemoteCompactionApi } from "./compaction-v2-streaming";
56
59
  import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
57
60
 
58
61
  export * from "./compaction-v2-streaming";
@@ -73,8 +76,6 @@ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
73
76
  */
74
77
  export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
75
78
 
76
- const DEFAULT_AZURE_API_VERSION = "v1";
77
-
78
79
  export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
79
80
 
80
81
  const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
@@ -262,13 +263,6 @@ export function assertRemoteCompactionInputFits(trimmed: TrimRemoteCompactionInp
262
263
  );
263
264
  }
264
265
 
265
- /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
266
- function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
267
- if (timeoutMs <= 0) return signal;
268
- const timeout = AbortSignal.timeout(timeoutMs);
269
- return signal ? AbortSignal.any([signal, timeout]) : timeout;
270
- }
271
-
272
266
  export type OpenAiRemoteCompactionItem = {
273
267
  type: "compaction" | "compaction_summary";
274
268
  encrypted_content?: string;
@@ -309,10 +303,6 @@ export interface RemoteCompactionResponse {
309
303
  // OpenAI provider gating + endpoint resolution
310
304
  // ============================================================================
311
305
 
312
- export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
313
- return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
314
- }
315
-
316
306
  export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
317
307
  if (model.remoteCompaction?.enabled === false) return false;
318
308
  const compactionApi = model.remoteCompaction?.api ?? model.api;
@@ -323,6 +313,8 @@ export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
323
313
  return (model.remoteCompaction?.endpoint?.trim().length ?? 0) > 0;
324
314
  }
325
315
  if (model.provider === "openai") return true;
316
+ // Amazon Bedrock's OpenAI routes serve `/responses/compact` without an opt-in.
317
+ if (compactionApi === "openai-responses" && isBedrockOpenAIUrl(model.baseUrl)) return true;
326
318
  if (model.remoteCompaction?.enabled !== true) return false;
327
319
  return isOpenAiRemoteCompactionApi(compactionApi);
328
320
  }
@@ -353,25 +345,6 @@ function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: str
353
345
  return appendAzureApiVersion(endpoint);
354
346
  }
355
347
 
356
- function resolveAzureOpenAiBaseUrl(model: Model): string {
357
- const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
358
- const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
359
- const resolvedBaseUrl =
360
- baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
361
- if (!resolvedBaseUrl) {
362
- throw new Error(
363
- "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
364
- );
365
- }
366
- return resolvedBaseUrl.replace(/\/+$/, "");
367
- }
368
-
369
- function appendAzureApiVersion(endpoint: string): string {
370
- if (/[?&]api-version=/.test(endpoint)) return endpoint;
371
- const separator = endpoint.includes("?") ? "&" : "?";
372
- return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
373
- }
374
-
375
348
  function resolveOpenAiCompactModel(model: Model): string {
376
349
  const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
377
350
  const compactionApi = model.remoteCompaction?.api ?? model.api;
@@ -805,6 +778,10 @@ export async function requestOpenAiRemoteCompaction(
805
778
  codexCompaction?: CodexCompactionContext;
806
779
  },
807
780
  ): Promise<OpenAiRemoteCompactionResponse> {
781
+ let fetchImpl: FetchImpl = opts?.fetch ?? fetch;
782
+ if (isBedrockOpenAIUrl(model.baseUrl)) {
783
+ ({ model, apiKey, fetch: fetchImpl } = await prepareBedrockCompactionRequest(model, apiKey, opts?.fetch, signal));
784
+ }
808
785
  const endpoint = resolveOpenAiCompactEndpoint(model);
809
786
  const requestModel = resolveOpenAiCompactModel(model);
810
787
  const trimmed = trimRemoteCompactionInputToContextWindow(
@@ -889,11 +866,11 @@ export async function requestOpenAiRemoteCompaction(
889
866
  }
890
867
  }
891
868
 
892
- const response = await (opts?.fetch ?? fetch)(endpoint, {
869
+ const response = await fetchImpl(endpoint, {
893
870
  method: "POST",
894
871
  headers,
895
872
  body: stringifyJson(request),
896
- signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
873
+ signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
897
874
  });
898
875
 
899
876
  if (!response.ok) {
@@ -992,7 +969,7 @@ export async function requestRemoteCompaction(
992
969
  method: "POST",
993
970
  headers,
994
971
  body: stringifyJson(body),
995
- signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
972
+ signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
996
973
  });
997
974
 
998
975
  if (!response.ok) {
@@ -9,6 +9,7 @@ import type { SessionEntry, SessionMessageEntry } from "./entries";
9
9
  import { invalidateMessageCache } from "./message-cache";
10
10
  import {
11
11
  collectToolCallsById,
12
+ getToolResultMessage,
12
13
  isProtectedToolResult,
13
14
  isSkillReadToolResult,
14
15
  type ProtectedToolMatcher,
@@ -122,13 +123,6 @@ function createPrunedNotice(tokens: number): string {
122
123
  */
123
124
  export const MIN_PRUNE_TOKENS = 50;
124
125
 
125
- function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
126
- if (entry.type !== "message") return undefined;
127
- const message = entry.message as AgentMessage;
128
- if (message.role !== "toolResult") return undefined;
129
- return message as ToolResultMessage;
130
- }
131
-
132
126
  function estimatePrunedSavings(tokens: number, notice: string): number {
133
127
  const noticeTokens = Math.ceil(notice.length / 4);
134
128
  return Math.max(0, tokens - noticeTokens);
@@ -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
+ getToolResultMessage,
21
22
  isArtifactRecoveryToolResult,
22
23
  isProtectedToolResult,
23
24
  isSkillReadToolResult,
@@ -109,13 +110,6 @@ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
109
110
  const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
110
111
  const CLOSING_XML = /^<\/([a-z_-]+)>$/;
111
112
 
112
- function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
113
- if (entry.type !== "message") return undefined;
114
- const message = entry.message as AgentMessage;
115
- if (message.role !== "toolResult") return undefined;
116
- return message as ToolResultMessage;
117
- }
118
-
119
113
  function toolResultText(
120
114
  message: ToolResultMessage,
121
115
  tokenizer: Tokenizer,
@@ -1,5 +1,5 @@
1
1
  import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
2
- import type { AgentToolCall } from "../types";
2
+ import type { AgentMessage, AgentToolCall } from "../types";
3
3
  import type { SessionEntry } from "./entries";
4
4
 
5
5
  export interface ProtectedToolContext {
@@ -24,6 +24,14 @@ export function collectToolCallsById(entries: readonly SessionEntry[]): Map<stri
24
24
  return toolCalls;
25
25
  }
26
26
 
27
+ /** Return the tool-result message carried by a session entry, if any. */
28
+ export function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
29
+ if (entry.type !== "message") return undefined;
30
+ const message = entry.message as AgentMessage;
31
+ if (message.role !== "toolResult") return undefined;
32
+ return message as ToolResultMessage;
33
+ }
34
+
27
35
  /**
28
36
  * Extract the `path` argument from a paired `read` tool call, when the result
29
37
  * is a `read` result carrying a string path. Returns `undefined` otherwise.
package/src/telemetry.ts CHANGED
@@ -722,7 +722,7 @@ export function startChatSpan(
722
722
  model,
723
723
  parent: options.parent,
724
724
  stepNumber: options.stepNumber,
725
- attributes: buildChatRequestAttributes(options.stepNumber, options.request, model.provider),
725
+ attributes: buildChatRequestAttributes(options.stepNumber, options.request, model),
726
726
  });
727
727
  if (span) {
728
728
  telemetry?.collector.beginChat(span, {
@@ -756,7 +756,7 @@ export interface ChatRequestSnapshot {
756
756
  readonly messages?: readonly Message[];
757
757
  }
758
758
 
759
- function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot, provider: string): Attributes {
759
+ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot, model: Model): Attributes {
760
760
  const attrs: Attributes = {
761
761
  [OmpGenAIAttr.AgentStepNumber]: stepNumber,
762
762
  [GenAIAttr.OutputType]: "text",
@@ -772,7 +772,8 @@ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnap
772
772
  if (request.stopSequences && request.stopSequences.length > 0) {
773
773
  attrs[GenAIAttr.RequestStopSequences] = [...request.stopSequences];
774
774
  }
775
- if (request.serviceTier && shouldSendServiceTier(request.serviceTier, provider)) {
775
+ // Record the tier only when it reaches the wire for this model (Codex drops unadvertised tiers).
776
+ if (request.serviceTier && shouldSendServiceTier(request.serviceTier, model)) {
776
777
  attrs[OpenAIAttr.RequestServiceTier] = request.serviceTier;
777
778
  }
778
779
  if (request.reasoningEffort) attrs[OmpGenAIAttr.RequestReasoningEffort] = request.reasoningEffort;