@oh-my-pi/pi-agent-core 16.1.21 → 16.1.23

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.23] - 2026-06-26
6
+
7
+ ### Changed
8
+
9
+ - Changed `AgentLoopConfig.onTurnEnd` and `Agent.setOnTurnEnd` callbacks to receive whether the loop will continue with another provider request.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed stale snapcompact archive frames leaking into context-full compaction after `compaction.strategy` was switched from `snapcompact` to `context-full`. Switching strategy left the latest compaction entry's `preserveData.snapcompact` in place, so context-full kept rebuilding context with old image frames attached — inflating context/token usage and making sessions appear to compact early (around ~60% apparent window use). The first context-full compaction after the switch now folds the prior archive's plaintext into the LLM summary input and strips `preserveData.snapcompact` from the new entry; legacy frame-only archives (no plaintext to migrate) are stripped outright. ([#3561](https://github.com/can1357/oh-my-pi/pull/3561) by [@serverinspector](https://github.com/serverinspector))
14
+
5
15
  ## [16.1.18] - 2026-06-25
6
16
 
7
17
  ### Fixed
@@ -2,7 +2,7 @@ import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Co
2
2
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
5
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types";
5
+ import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types";
6
6
  export declare class AgentBusyError extends Error {
7
7
  constructor(message?: string);
8
8
  }
@@ -341,7 +341,7 @@ export declare class Agent {
341
341
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
342
342
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
343
343
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
344
- setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void;
344
+ setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
345
345
  /**
346
346
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
347
347
  * completions, late LSP diagnostics) drained at each step boundary. Never
@@ -33,6 +33,7 @@ export interface CompactionSettings {
33
33
  strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
34
34
  thresholdPercent?: number;
35
35
  thresholdTokens?: number;
36
+ midTurnEnabled?: boolean;
36
37
  reserveTokens: number;
37
38
  keepRecentTokens: number;
38
39
  autoContinue?: boolean;
@@ -13,6 +13,14 @@ export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMe
13
13
  * (e.g. dropping late diagnostics a newer edit superseded).
14
14
  */
15
15
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
16
+ export interface AgentTurnEndContext {
17
+ /** Assistant/user message that just completed this turn boundary. */
18
+ message: AgentMessage;
19
+ /** Tool results produced by this turn, already paired with `message` in the live context. */
20
+ toolResults: ToolResultMessage[];
21
+ /** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
22
+ willContinue: boolean;
23
+ }
16
24
  /**
17
25
  * A soft tool requirement: the host wants `toolName` called before the loop
18
26
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -292,11 +300,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
292
300
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
293
301
  /**
294
302
  * Called after a turn ends and before the loop polls steering/asides for the
295
- * next iteration. Use this for awaited per-turn bookkeeping that must be
296
- * visible before the next model request (e.g. synchronizing an advisor's
297
- * backlog so advice produced during the wait is injected as an aside).
303
+ * next iteration. `context` carries the just-finished turn; `context.willContinue`
304
+ * is true when the current tool-loop batch is continuing without yielding to
305
+ * post-turn steering.
298
306
  */
299
- onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
307
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
300
308
  /**
301
309
  * Called once an assistant message is finalized from the model stream, before
302
310
  * it is appended to the context, emitted as `message_end`, or its tool calls
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": "16.1.21",
4
+ "version": "16.1.23",
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,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.1.21",
39
- "@oh-my-pi/pi-catalog": "16.1.21",
40
- "@oh-my-pi/pi-natives": "16.1.21",
41
- "@oh-my-pi/pi-utils": "16.1.21",
42
- "@oh-my-pi/pi-wire": "16.1.21",
43
- "@oh-my-pi/snapcompact": "16.1.21",
38
+ "@oh-my-pi/pi-ai": "16.1.23",
39
+ "@oh-my-pi/pi-catalog": "16.1.23",
40
+ "@oh-my-pi/pi-natives": "16.1.23",
41
+ "@oh-my-pi/pi-utils": "16.1.23",
42
+ "@oh-my-pi/pi-wire": "16.1.23",
43
+ "@oh-my-pi/snapcompact": "16.1.23",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  AgentMessage,
63
63
  AgentTool,
64
64
  AgentToolResult,
65
+ AgentTurnEndContext,
65
66
  AsideMessage,
66
67
  StreamFn,
67
68
  } from "./types";
@@ -406,12 +407,13 @@ async function emitTurnEnd(
406
407
  toolResults: ToolResultMessage[],
407
408
  config: AgentLoopConfig,
408
409
  signal?: AbortSignal,
410
+ context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
409
411
  ): Promise<void> {
410
412
  stream.push({ type: "turn_end", message, toolResults });
411
413
  const isAbortedOrError =
412
414
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
413
415
  if (signal?.aborted || isAbortedOrError) return;
414
- await config.onTurnEnd?.(currentContext.messages, signal);
416
+ await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
415
417
  }
416
418
 
417
419
  /**
@@ -903,7 +905,7 @@ async function runLoopBody(
903
905
  status: message.stopReason === "aborted" ? "aborted" : "error",
904
906
  });
905
907
  }
906
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
908
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
907
909
 
908
910
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
909
911
  stream.end(newMessages);
@@ -1032,7 +1034,9 @@ async function runLoopBody(
1032
1034
  hasMoreToolCalls = true;
1033
1035
  }
1034
1036
 
1035
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
1037
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1038
+ willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1039
+ });
1036
1040
 
1037
1041
  if (isDeadlineExceeded(config.deadline)) {
1038
1042
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
package/src/agent.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  AgentState,
45
45
  AgentTool,
46
46
  AgentToolContext,
47
+ AgentTurnEndContext,
47
48
  AsideMessage,
48
49
  StreamFn,
49
50
  ToolCallContext,
@@ -370,7 +371,7 @@ export class Agent {
370
371
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
371
372
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
372
373
  #onBeforeYield?: () => Promise<void> | void;
373
- #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
374
+ #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
374
375
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
375
376
  #telemetry?: AgentLoopConfig["telemetry"];
376
377
  #appendOnlyContext?: AppendOnlyContextManager;
@@ -726,7 +727,11 @@ export class Agent {
726
727
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void {
727
728
  this.#onBeforeYield = fn;
728
729
  }
729
- setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void {
730
+ setOnTurnEnd(
731
+ fn:
732
+ | ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void)
733
+ | undefined,
734
+ ): void {
730
735
  this.#onTurnEnd = fn;
731
736
  }
732
737
 
@@ -1142,7 +1147,7 @@ export class Agent {
1142
1147
  : undefined,
1143
1148
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1144
1149
  onHarmonyLeak: this.#onHarmonyLeak,
1145
- onTurnEnd: (messages, signal) => this.#onTurnEnd?.(messages, signal),
1150
+ onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1146
1151
  getToolChoice,
1147
1152
  getReasoning: () => this.#state.thinkingLevel,
1148
1153
  getDisableReasoning: () => this.#state.disableReasoning,
@@ -44,6 +44,7 @@ import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { typ
44
44
  import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
45
45
  import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
46
46
  import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" };
47
+ import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
47
48
 
48
49
  import {
49
50
  computeFileLists,
@@ -148,6 +149,7 @@ export interface CompactionSettings {
148
149
  strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
149
150
  thresholdPercent?: number;
150
151
  thresholdTokens?: number;
152
+ midTurnEnabled?: boolean;
151
153
  reserveTokens: number;
152
154
  keepRecentTokens: number;
153
155
  autoContinue?: boolean;
@@ -160,6 +162,7 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
160
162
  strategy: "context-full",
161
163
  thresholdPercent: -1,
162
164
  thresholdTokens: -1,
165
+ midTurnEnabled: true,
163
166
  reserveTokens: 16384,
164
167
  keepRecentTokens: 20000,
165
168
  autoContinue: true,
@@ -658,6 +661,27 @@ export interface SummaryOptions {
658
661
  fetch?: FetchImpl;
659
662
  }
660
663
 
664
+ function formatPreviousSnapcompactArchive(archiveText: string): string {
665
+ return prompt.render(snapcompactArchiveContextPrompt, { archiveText });
666
+ }
667
+
668
+ function mergePreviousSummaryWithSnapcompactArchive(
669
+ previousSummary: string | undefined,
670
+ archiveText: string | undefined,
671
+ ): string | undefined {
672
+ if (!archiveText) return previousSummary;
673
+ const archiveSummary = formatPreviousSnapcompactArchive(archiveText);
674
+ return previousSummary ? `${previousSummary}\n\n${archiveSummary}` : archiveSummary;
675
+ }
676
+
677
+ function createSnapcompactArchiveMigrationMessage(archiveText: string): Message {
678
+ return {
679
+ role: "user",
680
+ content: [{ type: "text", text: formatPreviousSnapcompactArchive(archiveText) }],
681
+ timestamp: Date.now(),
682
+ };
683
+ }
684
+
661
685
  export async function generateSummary(
662
686
  currentMessages: AgentMessage[],
663
687
  model: Model,
@@ -1101,10 +1125,27 @@ export async function compact(
1101
1125
  fetch: options?.fetch,
1102
1126
  };
1103
1127
 
1128
+ const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1129
+ const previousSnapcompactArchiveText = previousSnapcompactArchive
1130
+ ? snapcompact.archiveSourceText(previousSnapcompactArchive)
1131
+ : undefined;
1132
+ const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
1133
+ previousSummary,
1134
+ previousSnapcompactArchiveText,
1135
+ );
1136
+ const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
1137
+ ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1138
+ : undefined;
1139
+
1104
1140
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1105
1141
  if (settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1106
1142
  const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1107
- const remoteMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1143
+ const remoteMessages: AgentMessage[] = [
1144
+ ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1145
+ ...messagesToSummarize,
1146
+ ...turnPrefixMessages,
1147
+ ...recentMessages,
1148
+ ];
1108
1149
  const previousReplacementHistory =
1109
1150
  previousRemoteCompaction?.provider === model.provider
1110
1151
  ? previousRemoteCompaction.replacementHistory
@@ -1150,7 +1191,7 @@ export async function compact(
1150
1191
  if (isSplitTurn && turnPrefixMessages.length > 0) {
1151
1192
  // Generate both summaries in parallel
1152
1193
  const [historyResult, turnPrefixResult] = await Promise.all([
1153
- messagesToSummarize.length > 0
1194
+ messagesToSummarize.length > 0 || previousSummaryForCompaction
1154
1195
  ? generateSummary(
1155
1196
  messagesToSummarize,
1156
1197
  model,
@@ -1158,7 +1199,7 @@ export async function compact(
1158
1199
  apiKey,
1159
1200
  signal,
1160
1201
  customInstructions,
1161
- previousSummary,
1202
+ previousSummaryForCompaction,
1162
1203
  summaryOptions,
1163
1204
  )
1164
1205
  : Promise.resolve("No prior history."),
@@ -1175,12 +1216,12 @@ export async function compact(
1175
1216
  apiKey,
1176
1217
  signal,
1177
1218
  customInstructions,
1178
- previousSummary,
1219
+ previousSummaryForCompaction,
1179
1220
  summaryOptions,
1180
1221
  );
1181
- } else if (previousSummary) {
1222
+ } else if (previousSummaryForCompaction) {
1182
1223
  // No new messages to summarize, preserve previous summary
1183
- summary = previousSummary;
1224
+ summary = previousSummaryForCompaction;
1184
1225
  } else {
1185
1226
  // No messages and no previous summary
1186
1227
  summary = "No prior history.";
@@ -1214,13 +1255,21 @@ export async function compact(
1214
1255
  throw new Error("First kept entry has no ID - session may need migration");
1215
1256
  }
1216
1257
 
1258
+ // This LLM-summary path migrated any prior snapcompact frames into the summary
1259
+ // text above; strip the now-stale frame archive from preserveData so it cannot
1260
+ // re-attach to the rebuilt context. Only the legacy-frame case needs stripping —
1261
+ // when there was no previous archive, preserveData carries no frames to drop.
1262
+ const finalPreserveData = previousSnapcompactArchive
1263
+ ? snapcompact.stripPreservedArchive(preserveData)
1264
+ : preserveData;
1265
+
1217
1266
  return {
1218
1267
  summary,
1219
1268
  shortSummary,
1220
1269
  firstKeptEntryId,
1221
1270
  tokensBefore,
1222
1271
  details: { readFiles, modifiedFiles } as CompactionDetails,
1223
- preserveData,
1272
+ preserveData: finalPreserveData,
1224
1273
  };
1225
1274
  }
1226
1275
 
@@ -0,0 +1,3 @@
1
+ Previous snapcompact archive source text:
2
+
3
+ {{archiveText}}
package/src/types.ts CHANGED
@@ -37,6 +37,15 @@ export type StreamFn = (
37
37
  */
38
38
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
39
39
 
40
+ export interface AgentTurnEndContext {
41
+ /** Assistant/user message that just completed this turn boundary. */
42
+ message: AgentMessage;
43
+ /** Tool results produced by this turn, already paired with `message` in the live context. */
44
+ toolResults: ToolResultMessage[];
45
+ /** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
46
+ willContinue: boolean;
47
+ }
48
+
40
49
  /**
41
50
  * A soft tool requirement: the host wants `toolName` called before the loop
42
51
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -346,11 +355,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
346
355
  ) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
347
356
  /**
348
357
  * Called after a turn ends and before the loop polls steering/asides for the
349
- * next iteration. Use this for awaited per-turn bookkeeping that must be
350
- * visible before the next model request (e.g. synchronizing an advisor's
351
- * backlog so advice produced during the wait is injected as an aside).
358
+ * next iteration. `context` carries the just-finished turn; `context.willContinue`
359
+ * is true when the current tool-loop batch is continuing without yielding to
360
+ * post-turn steering.
352
361
  */
353
- onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
362
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
354
363
 
355
364
  /**
356
365
  * Called once an assistant message is finalized from the model stream, before