@oh-my-pi/pi-agent-core 18.0.3 → 18.0.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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [18.0.4] - 2026-08-24
6
+
7
+ ### Changed
8
+
9
+ - Improved performance in append-only context mode by memoizing message serialization, keeping per-call sync overhead flat as conversations grow.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue where `onTurnEnd` was skipped for turns ended by a terminal tool result (such as a subagent's final `yield`).
14
+
3
15
  ## [18.0.0] - 2026-08-22
4
16
 
5
17
  ### Fixed
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.0.3",
4
+ "version": "18.0.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -35,16 +35,16 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "18.0.3",
39
- "@oh-my-pi/pi-catalog": "18.0.3",
40
- "@oh-my-pi/pi-natives": "18.0.3",
41
- "@oh-my-pi/pi-utils": "18.0.3",
42
- "@oh-my-pi/pi-wire": "18.0.3",
43
- "@oh-my-pi/snapcompact": "18.0.3",
38
+ "@oh-my-pi/pi-ai": "18.0.4",
39
+ "@oh-my-pi/pi-catalog": "18.0.4",
40
+ "@oh-my-pi/pi-natives": "18.0.4",
41
+ "@oh-my-pi/pi-utils": "18.0.4",
42
+ "@oh-my-pi/pi-wire": "18.0.4",
43
+ "@oh-my-pi/snapcompact": "18.0.4",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@oh-my-pi/omptype": "18.0.3",
47
+ "@oh-my-pi/omptype": "18.0.4",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -623,6 +623,15 @@ function buildAgentEndEvent(
623
623
  * Push a `turn_end` event and run the awaited per-turn hook when the run is
624
624
  * still healthy. The hook is skipped for externally aborted or errored turns so
625
625
  * a user interrupt does not hang on a background backlog wait.
626
+ *
627
+ * A {@link TERMINAL_TOOL_RESULT_ABORT_REASON} abort is the exception: it is a
628
+ * graceful yield (e.g. a subagent's final `yield` tool), not a user interrupt.
629
+ * The completed tool batch is persisted and the turn must still reach
630
+ * `onTurnEnd` so per-turn bookkeeping — notably advisor review of the yield
631
+ * delta (#9505) — runs exactly as it does for a plain end-of-turn message. The
632
+ * hook receives no signal in that case so downstream waits (advisor catch-up)
633
+ * behave identically to a normal final turn instead of short-circuiting on the
634
+ * spent abort.
626
635
  */
627
636
  async function emitTurnEnd(
628
637
  stream: EventStream<AgentEvent, AgentMessage[]>,
@@ -635,10 +644,16 @@ async function emitTurnEnd(
635
644
  runHookOnAbortedMessage = false,
636
645
  ): Promise<void> {
637
646
  stream.push({ type: "turn_end", message, toolResults });
647
+ const terminalYield = signal?.reason === TERMINAL_TOOL_RESULT_ABORT_REASON;
638
648
  const isAbortedOrError =
639
649
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
640
- if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
641
- await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
650
+ if ((signal?.aborted && !terminalYield) || (isAbortedOrError && !runHookOnAbortedMessage)) return;
651
+ await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
652
+ message,
653
+ toolResults,
654
+ willContinue: false,
655
+ ...context,
656
+ });
642
657
  }
643
658
 
644
659
  function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
@@ -16,7 +16,8 @@
16
16
 
17
17
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
18
18
  import { normalizeTools } from "./agent-loop";
19
- import type { AgentContext } from "./types";
19
+ import { messageEstimateVersion } from "./compaction/message-cache";
20
+ import type { AgentContext, AgentMessage } from "./types";
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // StablePrefix (formerly ImmutablePrefix)
@@ -177,6 +178,26 @@ export class AppendOnlyContextManager {
177
178
  * point on every subsequent turn.
178
179
  */
179
180
  #messageDigests: number[] = [];
181
+ /**
182
+ * Digests memoized by message object identity, validated by the message's
183
+ * estimate version ({@link messageEstimateVersion}). Synced message objects
184
+ * are stable between calls: converted fragments are cached per session
185
+ * message identity and handed back unchanged on every call, so an unchanged
186
+ * history re-hits this memo instead of re-serializing every previously-
187
+ * synced message on each LLM call.
188
+ *
189
+ * Owner-side rewrites (prune/shake/strip-images) mutate messages IN PLACE
190
+ * under stable identity — for assistant pass-through fragments the log
191
+ * aliases the very object being mutated — so a bare identity memo would
192
+ * serve pre-mutation bytes forever. Those owners MUST call
193
+ * `invalidateMessageCache`, which bumps the symbol-keyed version tag
194
+ * this memo validates before every hit; a version mismatch recomputes from
195
+ * actual bytes and the sync diverges exactly as if a fresh object had
196
+ * arrived. Mutating a synced message without that bump violates the
197
+ * cache-coherence contract shared with the tokenizer and convert caches
198
+ * (see `compaction/message-cache.ts`) and is unsupported.
199
+ */
200
+ #digestMemo = new WeakMap<object, { version: number; digest: number }>();
180
201
 
181
202
  build(context: AgentContext, options: BuildOptions): Context {
182
203
  this.prefix.build(context, options);
@@ -290,6 +311,9 @@ export class AppendOnlyContextManager {
290
311
  #messageDigest(msg: unknown): number {
291
312
  if (!msg || typeof msg !== "object") return 0;
292
313
  const m = msg as Record<string, unknown>;
314
+ const version = messageEstimateVersion(msg as AgentMessage);
315
+ const cached = this.#digestMemo.get(m);
316
+ if (cached !== undefined && cached.version === version) return cached.digest;
293
317
  const payload = JSON.stringify({
294
318
  r: m.role ?? null,
295
319
  c: m.content ?? null,
@@ -304,7 +328,9 @@ export class AppendOnlyContextManager {
304
328
  for (let j = 0; j < payload.length; j++) {
305
329
  hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
306
330
  }
307
- return hash >>> 0;
331
+ const hash32 = hash >>> 0;
332
+ this.#digestMemo.set(m, { version, digest: hash32 });
333
+ return hash32;
308
334
  }
309
335
  }
310
336