@oh-my-pi/pi-agent-core 15.10.1 → 15.10.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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.3] - 2026-06-08
6
+
7
+ ### Added
8
+
9
+ - Added a non-interrupting "aside" message channel to the agent loop (`AgentLoopConfig.getAsideMessages` / `Agent.setAsideMessageProvider`). Asides are drained at each step boundary (after a tool batch, before the next model call) and at the yield check, so passive notifications (e.g. background-job completions, late LSP diagnostics) reach the model *between requests* without waiting for the agent to stop and without aborting in-flight tools the way steering does.
10
+
11
+ ### Changed
12
+
13
+ - Changed core custom and hook messages to convert to `developer` messages for provider context.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed the compaction spinner freezing (only repainting on a terminal resize) when compacting very large codex/OpenAI contexts. `buildOpenAiNativeHistory` re-collected the full known/custom tool-call id sets on every history-bearing message, rescanning the entire growing native history each time — O(N²) in history items — which blocked the event loop for seconds and starved the loader's animation timer and render scheduler. The sets are now maintained incrementally (linear), so building the compaction request no longer monopolizes the main thread.
18
+
19
+ ### Removed
20
+
21
+ - Removed the now-dead `<turn-aborted>` marker from the OpenAI compaction output user-message filter, since `transformMessages` no longer emits that note.
22
+ - Removed stale synthetic user-message tag filters from OpenAI remote compaction output preservation; developer messages are now dropped by role instead.
23
+ - Tool executions now receive the active turn `AbortSignal` unconditionally.
24
+
25
+
26
+ ## [15.10.2] - 2026-06-08
27
+
28
+ ### Fixed
29
+
30
+ - Fixed proxy stream silently returning a zero-token success response when the server disconnects without sending a `done` or `error` terminal SSE event. The stream now throws an error, surfacing the disconnect as an `error` event with `stopReason: "error"` and resolving `finalResultPromise`, instead of defaulting to `stopReason: "stop"` with empty content and leaving `stream.result()` callers hanging indefinitely.
31
+
5
32
  ## [15.10.1] - 2026-06-07
6
33
 
7
34
  ### Added
@@ -1,7 +1,7 @@
1
1
  import { type ApiKeyResolveContext, type AssistantMessage, type AssistantMessageEvent, type CursorExecHandlers, type CursorToolResultHandler, type Effort, type ImageContent, type Message, type Model, type ProviderSessionState, type ServiceTier, type SimpleStreamOptions, type ThinkingBudgets, type ToolChoice } from "@oh-my-pi/pi-ai";
2
2
  import type { AppendOnlyContextManager } from "./append-only-context";
3
3
  import type { HarmonyAuditEvent } from "./harmony-leak";
4
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, StreamFn, ToolCallContext } from "./types";
4
+ import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext } from "./types";
5
5
  export declare class AgentBusyError extends Error {
6
6
  constructor(message?: string);
7
7
  }
@@ -291,6 +291,12 @@ export declare class Agent {
291
291
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
292
292
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
293
293
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
294
+ /**
295
+ * Provide a source of non-interrupting "aside" messages (e.g. background-job
296
+ * completions, late LSP diagnostics) drained at each step boundary. Never
297
+ * aborts in-flight tools. See `AgentLoopConfig.getAsideMessages`.
298
+ */
299
+ setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
294
300
  emitExternalEvent(event: AgentEvent): void;
295
301
  setSystemPrompt(v: string[]): void;
296
302
  setModel(m: Model): void;
@@ -3,7 +3,7 @@
3
3
  * The server manages auth and proxies requests to LLM providers.
4
4
  */
5
5
  import { type AssistantMessage, type AssistantMessageEvent, type Context, EventStream, type Model, type SimpleStreamOptions, type StopReason } from "@oh-my-pi/pi-ai";
6
- declare class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
6
+ export declare class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
7
7
  constructor();
8
8
  }
9
9
  /**
@@ -81,4 +81,3 @@ export interface ProxyStreamOptions extends SimpleStreamOptions {
81
81
  * ```
82
82
  */
83
83
  export declare function streamProxy(model: Model, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream;
84
- export {};
@@ -5,6 +5,13 @@ import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
5
5
  import type { AgentTelemetryConfig } from "./telemetry";
6
6
  /** Stream function - can return sync or Promise for async config lookup */
7
7
  export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
8
+ /**
9
+ * An aside entry: a ready {@link AgentMessage}, or a sync thunk evaluated at
10
+ * injection time that returns the message to inject or `null` to skip it. Thunks
11
+ * let the producer make the final inject-or-drop decision against current state
12
+ * (e.g. dropping late diagnostics a newer edit superseded).
13
+ */
14
+ export type AsideMessage = AgentMessage | (() => AgentMessage | null);
8
15
  /**
9
16
  * Configuration for the agent loop.
10
17
  */
@@ -102,6 +109,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
102
109
  * continues with another turn.
103
110
  */
104
111
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
112
+ /**
113
+ * Returns non-interrupting "aside" messages to inject at a step boundary.
114
+ *
115
+ * Polled after each tool batch (before the next LLM call) AND at the yield
116
+ * check. Unlike steering, these NEVER abort in-flight tools — they are passive
117
+ * notifications (e.g. background-job completions, late LSP diagnostics) that
118
+ * should reach the model between requests without waiting for the agent to
119
+ * fully stop. Returned messages are appended to the context with normal
120
+ * message events and keep the loop running so the model can react.
121
+ */
122
+ getAsideMessages?: () => Promise<AsideMessage[]>;
105
123
  /**
106
124
  * Hook fired right before the loop would exit.
107
125
  *
@@ -352,8 +370,6 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
352
370
  loadMode?: "essential" | "discoverable";
353
371
  /** Short one-line summary used for tool discovery indexes. */
354
372
  summary?: string;
355
- /** If true, tool execution ignores abort signals (runs to completion) */
356
- nonAbortable?: boolean;
357
373
  /**
358
374
  * Concurrency mode for tool scheduling when multiple calls are in one turn.
359
375
  * - "shared": can run alongside other shared tools (default)
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.10.1",
4
+ "version": "15.10.3",
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,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.10.1",
39
- "@oh-my-pi/pi-natives": "15.10.1",
40
- "@oh-my-pi/pi-utils": "15.10.1",
38
+ "@oh-my-pi/pi-ai": "15.10.3",
39
+ "@oh-my-pi/pi-natives": "15.10.3",
40
+ "@oh-my-pi/pi-utils": "15.10.3",
41
41
  "@opentelemetry/api": "^1.9.1"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -49,6 +49,7 @@ import type {
49
49
  AgentMessage,
50
50
  AgentTool,
51
51
  AgentToolResult,
52
+ AsideMessage,
52
53
  StreamFn,
53
54
  } from "./types";
54
55
  import { yieldIfDue } from "./utils/yield";
@@ -465,6 +466,23 @@ function cloneAssistantMessageForToolCallCap(message: AssistantMessage): Assista
465
466
  };
466
467
  }
467
468
 
469
+ /**
470
+ * Resolve aside entries at the moment the loop is about to inject them. Each entry
471
+ * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
472
+ * producer can make the final inject-or-drop decision (return null) against
473
+ * up-to-the-injection state — e.g. dropping late diagnostics a newer edit
474
+ * superseded. Kept sync so it can never stall the loop.
475
+ */
476
+ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
477
+ if (!entries || entries.length === 0) return [];
478
+ const out: AgentMessage[] = [];
479
+ for (const entry of entries) {
480
+ const message = typeof entry === "function" ? entry() : entry;
481
+ if (message) out.push(message);
482
+ }
483
+ return out;
484
+ }
485
+
468
486
  async function runLoopBody(
469
487
  currentContext: AgentContext,
470
488
  newMessages: AgentMessage[],
@@ -647,15 +665,18 @@ async function runLoopBody(
647
665
 
648
666
  stream.push({ type: "turn_end", message, toolResults });
649
667
 
650
- pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
668
+ const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
669
+ const asides = resolveAsides(await config.getAsideMessages?.());
670
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
651
671
  }
652
672
 
653
- // Agent would stop here. Check for follow-up messages.
673
+ // Agent would stop here. Drain non-interrupting asides + follow-up messages.
654
674
  await config.onBeforeYield?.();
675
+ const asideMessages = resolveAsides(await config.getAsideMessages?.());
655
676
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
656
- if (followUpMessages.length > 0) {
657
- // Set as pending so inner loop processes them
658
- pendingMessages = followUpMessages;
677
+ if (asideMessages.length > 0 || followUpMessages.length > 0) {
678
+ // Set as pending so the inner loop processes them before stopping.
679
+ pendingMessages = [...asideMessages, ...followUpMessages];
659
680
  continue;
660
681
  }
661
682
 
@@ -1282,7 +1303,7 @@ async function executeToolCalls(
1282
1303
  const rawResult = await tool.execute(
1283
1304
  toolCall.id,
1284
1305
  transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
1285
- tool.nonAbortable ? undefined : toolSignal,
1306
+ toolSignal,
1286
1307
  partialResult => {
1287
1308
  stream.push({
1288
1309
  type: "tool_execution_update",
package/src/agent.ts CHANGED
@@ -33,6 +33,7 @@ import type {
33
33
  AgentState,
34
34
  AgentTool,
35
35
  AgentToolContext,
36
+ AsideMessage,
36
37
  StreamFn,
37
38
  ToolCallContext,
38
39
  } from "./types";
@@ -319,6 +320,7 @@ export class Agent {
319
320
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
320
321
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
321
322
  #onBeforeYield?: () => Promise<void> | void;
323
+ #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
322
324
  #telemetry?: AgentLoopConfig["telemetry"];
323
325
  #appendOnlyContext?: AppendOnlyContextManager;
324
326
 
@@ -629,6 +631,15 @@ export class Agent {
629
631
  this.#onBeforeYield = fn;
630
632
  }
631
633
 
634
+ /**
635
+ * Provide a source of non-interrupting "aside" messages (e.g. background-job
636
+ * completions, late LSP diagnostics) drained at each step boundary. Never
637
+ * aborts in-flight tools. See `AgentLoopConfig.getAsideMessages`.
638
+ */
639
+ setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
640
+ this.#asideMessageProvider = fn;
641
+ }
642
+
632
643
  emitExternalEvent(event: AgentEvent) {
633
644
  switch (event.type) {
634
645
  case "message_start":
@@ -999,6 +1010,7 @@ export class Agent {
999
1010
  return this.#dequeueSteeringMessages();
1000
1011
  },
1001
1012
  getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
1013
+ getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1002
1014
  onBeforeYield: () => this.#onBeforeYield?.(),
1003
1015
  telemetry: this.#telemetry,
1004
1016
  };
@@ -156,7 +156,7 @@ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
156
156
  ? [{ type: "text" as const, text: message.content }]
157
157
  : message.content;
158
158
  return {
159
- role: "user",
159
+ role: "developer",
160
160
  content,
161
161
  attribution: message.attribution,
162
162
  timestamp: message.timestamp,
@@ -158,38 +158,10 @@ function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolea
158
158
  return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
159
159
  }
160
160
 
161
- function shouldKeepOpenAiCompactOutputUserMessage(item: Record<string, unknown>): boolean {
162
- if (item.role !== "user") return false;
163
- const content = item.content;
164
- if (!Array.isArray(content) || content.length === 0) return false;
165
- const contextualFragmentPatterns = [
166
- [/^<system-reminder>[\s\S]*<\/system-reminder>$/i, /<system-reminder>/i],
167
- [/^#\s*AGENTS\.md instructions for\b[\s\S]*<\/INSTRUCTIONS>$/i, /# AGENTS.md instructions/],
168
- [/^<environment-context>[\s\S]*<\/environment-context>$/i, /<environment-context>/i],
169
- [/^<skill>[\s\S]*<\/skill>$/i, /<skill>/i],
170
- [/^<user-shell-command>[\s\S]*<\/user-shell-command>$/i, /<user-shell-command>/i],
171
- [/^<turn-aborted>[\s\S]*<\/turn-aborted>$/i, /<turn-aborted>/i],
172
- [/^<subagent-notification>[\s\S]*<\/subagent-notification>$/i, /<subagent-notification>/i],
173
- ] as const;
174
- return content.every(part => {
175
- if (!part || typeof part !== "object") return false;
176
- const candidate = part as { type?: unknown; text?: unknown };
177
- if (candidate.type === "input_image") return true;
178
- if (candidate.type !== "input_text" || typeof candidate.text !== "string") return false;
179
- const trimmed = candidate.text.trim();
180
- if (trimmed.length === 0) return false;
181
- return !contextualFragmentPatterns.some(([strictPattern, markerPattern]) => {
182
- return strictPattern.test(trimmed) || markerPattern.test(trimmed);
183
- });
184
- });
185
- }
186
-
187
161
  function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
188
162
  if (item.type === "compaction" || item.type === "compaction_summary") return true;
189
163
  if (item.type !== "message") return false;
190
- if (item.role === "developer") return false;
191
- if (item.role === "assistant") return true;
192
- return shouldKeepOpenAiCompactOutputUserMessage(item);
164
+ return item.role === "assistant" || item.role === "user";
193
165
  }
194
166
 
195
167
  function trimOpenAiCompactInput(
@@ -220,24 +192,27 @@ function trimOpenAiCompactInput(
220
192
  return trimmed;
221
193
  }
222
194
 
223
- function collectKnownOpenAiCallIds(items: Array<Record<string, unknown>>): Set<string> {
224
- const knownCallIds = new Set<string>();
195
+ // Register every tool-call id in `items` (and the subset using the custom-tool
196
+ // wire shape) into the running sets. The history builder maintains both sets
197
+ // incrementally as native history is appended, so this only scans the
198
+ // newly-added items (or, after a full-snapshot replace, the fresh input) rather
199
+ // than re-scanning the whole growing history per message — the latter was
200
+ // O(N²) and blocked the event loop for seconds while compacting large codex
201
+ // contexts (frozen spinner until the next forced render).
202
+ function addOpenAiCallIds(
203
+ items: Array<Record<string, unknown>>,
204
+ knownCallIds: Set<string>,
205
+ customCallIds: Set<string>,
206
+ ): void {
225
207
  for (const item of items) {
226
- if ((item.type === "function_call" || item.type === "custom_tool_call") && typeof item.call_id === "string") {
208
+ if (typeof item.call_id !== "string") continue;
209
+ if (item.type === "function_call") {
210
+ knownCallIds.add(item.call_id);
211
+ } else if (item.type === "custom_tool_call") {
227
212
  knownCallIds.add(item.call_id);
228
- }
229
- }
230
- return knownCallIds;
231
- }
232
-
233
- function collectCustomOpenAiCallIds(items: Array<Record<string, unknown>>): Set<string> {
234
- const customCallIds = new Set<string>();
235
- for (const item of items) {
236
- if (item.type === "custom_tool_call" && typeof item.call_id === "string") {
237
213
  customCallIds.add(item.call_id);
238
214
  }
239
215
  }
240
- return customCallIds;
241
216
  }
242
217
 
243
218
  // ============================================================================
@@ -265,16 +240,16 @@ export function buildOpenAiNativeHistory(
265
240
  const transformedMessages = transformMessages(messages, model, id => normalizeOpenAiCompactionToolCallId(id));
266
241
 
267
242
  let msgIndex = 0;
268
- let knownCallIds = collectKnownOpenAiCallIds(input);
269
- let customCallIds = collectCustomOpenAiCallIds(input);
243
+ const knownCallIds = new Set<string>();
244
+ const customCallIds = new Set<string>();
245
+ addOpenAiCallIds(input, knownCallIds, customCallIds);
270
246
  for (const message of transformedMessages) {
271
247
  if (message.role === "user" || message.role === "developer") {
272
248
  const providerPayload = (message as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
273
249
  const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
274
250
  if (historyItems) {
275
251
  input.push(...historyItems);
276
- knownCallIds = collectKnownOpenAiCallIds(input);
277
- customCallIds = collectCustomOpenAiCallIds(input);
252
+ addOpenAiCallIds(historyItems, knownCallIds, customCallIds);
278
253
  msgIndex++;
279
254
  continue;
280
255
  }
@@ -317,11 +292,13 @@ export function buildOpenAiNativeHistory(
317
292
  if (providerPayload) {
318
293
  if (providerPayload.dt) {
319
294
  input.push(...providerPayload.items);
295
+ addOpenAiCallIds(providerPayload.items, knownCallIds, customCallIds);
320
296
  } else {
321
297
  input.splice(0, input.length, ...providerPayload.items);
298
+ knownCallIds.clear();
299
+ customCallIds.clear();
300
+ addOpenAiCallIds(input, knownCallIds, customCallIds);
322
301
  }
323
- knownCallIds = collectKnownOpenAiCallIds(input);
324
- customCallIds = collectCustomOpenAiCallIds(input);
325
302
  msgIndex++;
326
303
  continue;
327
304
  }
package/src/proxy.ts CHANGED
@@ -16,8 +16,8 @@ import { calculateCost } from "@oh-my-pi/pi-ai/models";
16
16
  import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
17
17
  import { readSseJson } from "@oh-my-pi/pi-utils";
18
18
 
19
- // Create stream class matching ProxyMessageEventStream
20
- class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
19
+ // Event stream adapter for proxy SSE events
20
+ export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
21
21
  constructor() {
22
22
  super(
23
23
  event => event.type === "done" || event.type === "error",
@@ -167,9 +167,12 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
167
167
  }
168
168
  }
169
169
 
170
- if (options.signal?.aborted && !sawTerminalEvent) {
171
- const reason = options.signal.reason;
172
- throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
170
+ if (!sawTerminalEvent) {
171
+ if (options.signal?.aborted) {
172
+ const reason = options.signal.reason;
173
+ throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
174
+ }
175
+ throw new Error("Proxy stream ended without a terminal event (done or error)");
173
176
  }
174
177
 
175
178
  stream.end();
package/src/types.ts CHANGED
@@ -26,6 +26,14 @@ export type StreamFn = (
26
26
  ...args: Parameters<typeof streamSimple>
27
27
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
28
28
 
29
+ /**
30
+ * An aside entry: a ready {@link AgentMessage}, or a sync thunk evaluated at
31
+ * injection time that returns the message to inject or `null` to skip it. Thunks
32
+ * let the producer make the final inject-or-drop decision against current state
33
+ * (e.g. dropping late diagnostics a newer edit superseded).
34
+ */
35
+ export type AsideMessage = AgentMessage | (() => AgentMessage | null);
36
+
29
37
  /**
30
38
  * Configuration for the agent loop.
31
39
  */
@@ -132,6 +140,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
132
140
  * continues with another turn.
133
141
  */
134
142
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
143
+ /**
144
+ * Returns non-interrupting "aside" messages to inject at a step boundary.
145
+ *
146
+ * Polled after each tool batch (before the next LLM call) AND at the yield
147
+ * check. Unlike steering, these NEVER abort in-flight tools — they are passive
148
+ * notifications (e.g. background-job completions, late LSP diagnostics) that
149
+ * should reach the model between requests without waiting for the agent to
150
+ * fully stop. Returned messages are appended to the context with normal
151
+ * message events and keep the loop running so the model can react.
152
+ */
153
+ getAsideMessages?: () => Promise<AsideMessage[]>;
135
154
  /**
136
155
  * Hook fired right before the loop would exit.
137
156
  *
@@ -423,8 +442,6 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
423
442
  loadMode?: "essential" | "discoverable";
424
443
  /** Short one-line summary used for tool discovery indexes. */
425
444
  summary?: string;
426
- /** If true, tool execution ignores abort signals (runs to completion) */
427
- nonAbortable?: boolean;
428
445
  /**
429
446
  * Concurrency mode for tool scheduling when multiple calls are in one turn.
430
447
  * - "shared": can run alongside other shared tools (default)