@oh-my-pi/pi-agent-core 15.12.3 → 15.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.12.4] - 2026-06-13
6
+ ### Fixed
7
+
8
+ - Fixed remote compaction input trimming to use unlimited context when `model.contextWindow` is unset
9
+
5
10
  ## [15.12.1] - 2026-06-12
6
11
  ### Breaking Changes
7
12
 
@@ -308,6 +308,7 @@ export declare class Agent {
308
308
  getInterruptMode(): "immediate" | "wait";
309
309
  setTools(t: AgentTool<any>[]): void;
310
310
  replaceMessages(ms: AgentMessage[]): void;
311
+ replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]): void;
311
312
  appendMessage(m: AgentMessage): void;
312
313
  popMessage(): AgentMessage | undefined;
313
314
  /**
@@ -324,6 +325,15 @@ export declare class Agent {
324
325
  clearFollowUpQueue(): void;
325
326
  clearAllQueues(): void;
326
327
  hasQueuedMessages(): boolean;
328
+ /** Non-consuming view of the pending steering queue (insertion order, newest
329
+ * last). The session layer derives its queued-message display/count from
330
+ * this live view instead of a mirror, so the agent-core queue stays the
331
+ * single source of truth. */
332
+ peekSteeringQueue(): readonly AgentMessage[];
333
+ /** Non-consuming view of the pending follow-up queue. See
334
+ * {@link peekSteeringQueue}. */
335
+ peekFollowUpQueue(): readonly AgentMessage[];
336
+ get isAborting(): boolean;
327
337
  /**
328
338
  * Remove and return the last steering message from the queue (LIFO).
329
339
  * Used by dequeue keybinding.
@@ -13,6 +13,15 @@
13
13
  */
14
14
  import type { FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
15
15
  export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
16
+ /**
17
+ * Hard ceiling on remote compaction HTTP requests. Unlike every provider
18
+ * stream (guarded by first-event/idle watchdogs in pi-ai), these are raw
19
+ * fetches awaiting one non-streamed JSON body — a connection silently dropped
20
+ * by a middlebox would otherwise hang the whole compaction pipeline forever
21
+ * (frozen "Auto context-full maintenance…" spinner, manual /compact queueing
22
+ * behind it). On timeout the caller falls back to local summarization.
23
+ */
24
+ export declare const REMOTE_COMPACTION_TIMEOUT_MS = 180000;
16
25
  export type OpenAiRemoteCompactionItem = {
17
26
  type: "compaction" | "compaction_summary";
18
27
  encrypted_content?: string;
@@ -56,7 +65,9 @@ export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Rec
56
65
  export declare function buildOpenAiNativeHistory(messages: Message[], model: Model, previousReplacementHistory?: Array<Record<string, unknown>>): Array<Record<string, unknown>>;
57
66
  export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: string, compactInput: Array<Record<string, unknown>>, instructions: string, signal?: AbortSignal, opts?: {
58
67
  fetch?: FetchImpl;
68
+ timeoutMs?: number;
59
69
  }): Promise<OpenAiRemoteCompactionResponse>;
60
70
  export declare function requestRemoteCompaction(endpoint: string, request: RemoteCompactionRequest, signal?: AbortSignal, opts?: {
61
71
  fetch?: FetchImpl;
72
+ timeoutMs?: number;
62
73
  }): 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": "15.12.3",
4
+ "version": "15.12.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,11 +35,11 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.12.3",
39
- "@oh-my-pi/pi-catalog": "15.12.3",
40
- "@oh-my-pi/pi-natives": "15.12.3",
41
- "@oh-my-pi/pi-utils": "15.12.3",
42
- "@oh-my-pi/snapcompact": "15.12.3",
38
+ "@oh-my-pi/pi-ai": "15.12.4",
39
+ "@oh-my-pi/pi-catalog": "15.12.4",
40
+ "@oh-my-pi/pi-natives": "15.12.4",
41
+ "@oh-my-pi/pi-utils": "15.12.4",
42
+ "@oh-my-pi/snapcompact": "15.12.4",
43
43
  "@opentelemetry/api": "^1.9.1"
44
44
  },
45
45
  "devDependencies": {
package/src/agent.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type ToolResultMessage,
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
26
+ import { logger } from "@oh-my-pi/pi-utils";
26
27
  import { abortReasonText, agentLoop, agentLoopContinue } from "./agent-loop";
27
28
  import type { AppendOnlyContextManager } from "./append-only-context";
28
29
  import type { HarmonyAuditEvent } from "./harmony-leak";
@@ -706,6 +707,11 @@ export class Agent {
706
707
  this.#state.messages = ms.slice();
707
708
  }
708
709
 
710
+ replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
711
+ this.#steeringQueue = steering.slice();
712
+ this.#followUpQueue = followUp.slice();
713
+ }
714
+
709
715
  appendMessage(m: AgentMessage) {
710
716
  this.#state.messages.push(m);
711
717
  }
@@ -751,6 +757,24 @@ export class Agent {
751
757
  return this.#steeringQueue.length > 0 || this.#followUpQueue.length > 0;
752
758
  }
753
759
 
760
+ /** Non-consuming view of the pending steering queue (insertion order, newest
761
+ * last). The session layer derives its queued-message display/count from
762
+ * this live view instead of a mirror, so the agent-core queue stays the
763
+ * single source of truth. */
764
+ peekSteeringQueue(): readonly AgentMessage[] {
765
+ return this.#steeringQueue;
766
+ }
767
+
768
+ /** Non-consuming view of the pending follow-up queue. See
769
+ * {@link peekSteeringQueue}. */
770
+ peekFollowUpQueue(): readonly AgentMessage[] {
771
+ return this.#followUpQueue;
772
+ }
773
+
774
+ get isAborting(): boolean {
775
+ return this.#abortController?.signal.aborted === true && this.#state.isStreaming;
776
+ }
777
+
754
778
  #dequeueSteeringMessages(): AgentMessage[] {
755
779
  if (this.#steeringMode === "one-at-a-time") {
756
780
  if (this.#steeringQueue.length > 0) {
@@ -1153,11 +1177,15 @@ export class Agent {
1153
1177
  const result = listener(e) as unknown;
1154
1178
  if (isPromise(result)) {
1155
1179
  result.catch(err => {
1156
- console.error("Agent listener rejected:", err instanceof Error ? err.message : err);
1180
+ logger.warn("Agent listener rejected", {
1181
+ error: err instanceof Error ? err.message : String(err),
1182
+ });
1157
1183
  });
1158
1184
  }
1159
1185
  } catch (err) {
1160
- console.error("Agent listener threw:", err instanceof Error ? err.message : err);
1186
+ logger.warn("Agent listener threw", {
1187
+ error: err instanceof Error ? err.message : String(err),
1188
+ });
1161
1189
  }
1162
1190
  }
1163
1191
  }
@@ -807,6 +807,7 @@ async function generateShortSummary(
807
807
  prompt: promptText,
808
808
  },
809
809
  signal,
810
+ { fetch: options?.fetch },
810
811
  );
811
812
  return remote.summary;
812
813
  }
@@ -1047,6 +1048,10 @@ export async function compact(
1047
1048
  );
1048
1049
  preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1049
1050
  } catch (err) {
1051
+ // A user/session abort is a cancellation, not a remote failure —
1052
+ // swallowing it here would downgrade Esc into "fall back to local
1053
+ // summarization" and keep compaction running on an aborted signal.
1054
+ if (signal?.aborted) throw err;
1050
1055
  logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1051
1056
  error: err instanceof Error ? err.message : String(err),
1052
1057
  model: model.id,
@@ -1114,6 +1119,7 @@ export async function compact(
1114
1119
  // Same propagation as summaryOptions above — generateShortSummary
1115
1120
  // resolves its own reasoning via resolveCompactionEffort.
1116
1121
  thinkingLevel: options?.thinkingLevel,
1122
+ fetch: summaryOptions.fetch,
1117
1123
  },
1118
1124
  );
1119
1125
 
@@ -35,6 +35,23 @@ import { logger } from "@oh-my-pi/pi-utils";
35
35
 
36
36
  export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
37
37
 
38
+ /**
39
+ * Hard ceiling on remote compaction HTTP requests. Unlike every provider
40
+ * stream (guarded by first-event/idle watchdogs in pi-ai), these are raw
41
+ * fetches awaiting one non-streamed JSON body — a connection silently dropped
42
+ * by a middlebox would otherwise hang the whole compaction pipeline forever
43
+ * (frozen "Auto context-full maintenance…" spinner, manual /compact queueing
44
+ * behind it). On timeout the caller falls back to local summarization.
45
+ */
46
+ export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
47
+
48
+ /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
49
+ function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
50
+ if (timeoutMs <= 0) return signal;
51
+ const timeout = AbortSignal.timeout(timeoutMs);
52
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
53
+ }
54
+
38
55
  export type OpenAiRemoteCompactionItem = {
39
56
  type: "compaction" | "compaction_summary";
40
57
  encrypted_content?: string;
@@ -147,14 +164,6 @@ export function withOpenAiRemoteCompactionPreserveData(
147
164
  // Input/output filtering for OpenAI compact endpoint
148
165
  // ============================================================================
149
166
 
150
- function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number {
151
- let chars = instructions.length;
152
- for (const item of input) {
153
- chars += JSON.stringify(item).length;
154
- }
155
- return Math.ceil(chars / 4);
156
- }
157
-
158
167
  function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolean {
159
168
  return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
160
169
  }
@@ -171,16 +180,29 @@ function trimOpenAiCompactInput(
171
180
  instructions: string,
172
181
  ): Array<Record<string, unknown>> {
173
182
  const trimmed = [...input];
174
- while (trimmed.length > 0 && estimateOpenAiCompactInputTokens(trimmed, instructions) > contextWindow) {
183
+ // Per-item serialized sizes are cached and decremented on removal.
184
+ // Re-stringifying the whole input per popped item was O(N²) in total chars
185
+ // — hundreds of MB of stringify churn on a 200k-token codex history,
186
+ // blocking the event loop for seconds (same class as the addOpenAiCallIds
187
+ // fix above).
188
+ const sizes = trimmed.map(item => JSON.stringify(item).length);
189
+ let chars = instructions.length;
190
+ for (const size of sizes) chars += size;
191
+ const removeAt = (index: number): void => {
192
+ chars -= sizes[index] ?? 0;
193
+ trimmed.splice(index, 1);
194
+ sizes.splice(index, 1);
195
+ };
196
+ while (trimmed.length > 0 && Math.ceil(chars / 4) > contextWindow) {
175
197
  const last = trimmed[trimmed.length - 1];
176
198
  if (last?.type === "function_call_output" || last?.type === "custom_tool_call_output") {
177
199
  const callId = typeof last.call_id === "string" ? last.call_id : undefined;
178
200
  const callType = last.type === "custom_tool_call_output" ? "custom_tool_call" : "function_call";
179
- trimmed.pop();
201
+ removeAt(trimmed.length - 1);
180
202
  if (callId) {
181
203
  const matchingCallIndex = trimmed.findLastIndex(item => item.type === callType && item.call_id === callId);
182
204
  if (matchingCallIndex >= 0) {
183
- trimmed.splice(matchingCallIndex, 1);
205
+ removeAt(matchingCallIndex);
184
206
  }
185
207
  }
186
208
  continue;
@@ -188,7 +210,7 @@ function trimOpenAiCompactInput(
188
210
  if (!last || !shouldTrimOpenAiCompactInputItem(last)) {
189
211
  break;
190
212
  }
191
- trimmed.pop();
213
+ removeAt(trimmed.length - 1);
192
214
  }
193
215
  return trimmed;
194
216
  }
@@ -429,12 +451,12 @@ export async function requestOpenAiRemoteCompaction(
429
451
  compactInput: Array<Record<string, unknown>>,
430
452
  instructions: string,
431
453
  signal?: AbortSignal,
432
- opts?: { fetch?: FetchImpl },
454
+ opts?: { fetch?: FetchImpl; timeoutMs?: number },
433
455
  ): Promise<OpenAiRemoteCompactionResponse> {
434
456
  const endpoint = resolveOpenAiCompactEndpoint(model);
435
457
  const request: OpenAiRemoteCompactionRequest = {
436
458
  model: model.id,
437
- input: trimOpenAiCompactInput(compactInput, model.contextWindow, instructions),
459
+ input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
438
460
  instructions,
439
461
  };
440
462
  const headers: Record<string, string> = {
@@ -457,7 +479,7 @@ export async function requestOpenAiRemoteCompaction(
457
479
  method: "POST",
458
480
  headers,
459
481
  body: JSON.stringify(request),
460
- signal,
482
+ signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
461
483
  });
462
484
 
463
485
  if (!response.ok) {
@@ -509,13 +531,13 @@ export async function requestRemoteCompaction(
509
531
  endpoint: string,
510
532
  request: RemoteCompactionRequest,
511
533
  signal?: AbortSignal,
512
- opts?: { fetch?: FetchImpl },
534
+ opts?: { fetch?: FetchImpl; timeoutMs?: number },
513
535
  ): Promise<RemoteCompactionResponse> {
514
536
  const response = await (opts?.fetch ?? fetch)(endpoint, {
515
537
  method: "POST",
516
538
  headers: { "content-type": "application/json" },
517
539
  body: JSON.stringify(request),
518
- signal,
540
+ signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
519
541
  });
520
542
 
521
543
  if (!response.ok) {