@oh-my-pi/pi-agent-core 18.6.0 → 18.6.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,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.3] - 2026-10-06
6
+
7
+ ### Breaking Changes
8
+
9
+ - `Agent.withdrawUndeliveredQueuedMessages()` replaces `withdrawLiveSteering()` and returns `{ steering, followUp }`: it also takes back queued input already dequeued for the next model call, which the aborted run then neither records nor reports in `agent_end` ([#14179](https://github.com/can1357/oh-my-pi/pull/14179) by [@andrebrait](https://github.com/andrebrait))
10
+
11
+ ### Added
12
+
13
+ - Added `Agent.setOnModelCallSystemPrompt`, called with the exact system prompt each model call is built from ([#14338](https://github.com/can1357/oh-my-pi/pull/14338) by [@will-bogusz](https://github.com/will-bogusz))
14
+
15
+ ### Changed
16
+
17
+ - `SessionInitEntry.systemPrompt` holds the system prompt blocks as sent; session files written earlier keep one joined string ([#14338](https://github.com/can1357/oh-my-pi/pull/14338) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - Image token estimates now use the catalog's `imageTokens()` formula with its OpenAI Responses wire rule; values are unchanged ([#14286](https://github.com/can1357/oh-my-pi/pull/14286) by [@will-bogusz](https://github.com/will-bogusz)).
19
+
20
+ ### Fixed
21
+
22
+ - Fixed the context count and auto-compaction trigger pricing every snapcompact archive frame at Opus's high-res rate, which overcounted Codex and Gemini sessions and could re-trigger compaction right after compacting ([#14291](https://github.com/can1357/oh-my-pi/pull/14291) by [@will-bogusz](https://github.com/will-bogusz))
23
+ - Fixed native (OpenAI) compaction being refused as over the context window in sessions with many screenshots. Images were counted as about 1,200 tokens when deciding to compact but 12,000 when checking whether the compaction request fits; both checks now estimate images from their actual dimensions, and a request is no longer refused when only the image estimate pushes it over the window ([#14260](https://github.com/can1357/oh-my-pi/pull/14260) by [@H4vC](https://github.com/H4vC)).
24
+ - Fixed OpenAI and Codex Remote Compaction V2 dropping your recent messages instead of keeping them next to the compaction summary ([#14247](https://github.com/can1357/oh-my-pi/pull/14247) by [@will-bogusz](https://github.com/will-bogusz))
25
+ - Fixed the failed V2 remote compaction warning claiming a V1 fallback on Codex, where V1 does not run ([#14245](https://github.com/can1357/oh-my-pi/pull/14245) by [@will-bogusz](https://github.com/will-bogusz))
26
+ - Fixed Anthropic native compaction being rejected with `Invalid signature in thinking block` (or silently dropping the summarized thinking) on models with preserved thinking ([#14251](https://github.com/can1357/oh-my-pi/pull/14251) by [@will-bogusz](https://github.com/will-bogusz))
27
+
28
+ ## [18.6.1] - 2026-10-04
29
+
30
+ ### Fixed
31
+
32
+ - Fixed native OpenAI context compaction for sessions containing many screenshots, preventing image-size estimates from incorrectly causing compaction requests to be rejected.
33
+
5
34
  ## [18.6.0] - 2026-10-03
6
35
 
7
36
  ### Fixed
@@ -390,8 +390,8 @@ export declare class Agent {
390
390
  get state(): AgentState;
391
391
  /**
392
392
  * Tokenizer for the active model. The instance is replaced whenever the
393
- * active model's encoding changes (see {@link setModel}), so callers must
394
- * not cache it across model switches.
393
+ * active model's encoding or snapcompact frame pricing changes (see
394
+ * {@link setModel}), so callers must not cache it across model switches.
395
395
  */
396
396
  get tokenizer(): Tokenizer;
397
397
  get appendOnlyContext(): AppendOnlyContextManager | undefined;
@@ -422,10 +422,15 @@ export declare class Agent {
422
422
  /** Register an independently removable hook that runs immediately before each model call. */
423
423
  addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
424
424
  /**
425
- * Take back live-steered messages ahead of an abort (Esc restores them to the editor):
426
- * the aborted run then neither records nor requeues them.
425
+ * Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
426
+ * it to the editor): steering live steering took for the in-flight response, then each batch
427
+ * dequeued for the next model call, oldest first. The aborted run then neither records it (or
428
+ * the context prepared for it) nor requeues it.
427
429
  */
428
- withdrawLiveSteering(): AgentMessage[];
430
+ withdrawUndeliveredQueuedMessages(): {
431
+ steering: AgentMessage[];
432
+ followUp: AgentMessage[];
433
+ };
429
434
  /** Steering live steering took for the streaming response; the transcript records it once
430
435
  * that response (or its tool batch) ends, which is when the model switches to it. */
431
436
  peekLiveSteeredMessages(): AgentMessage[];
@@ -434,6 +439,8 @@ export declare class Agent {
434
439
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
435
440
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
436
441
  setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
442
+ /** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
443
+ setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void;
437
444
  /**
438
445
  * Install or replace the host pre-model-call gate; pass `undefined` to
439
446
  * remove it. Gates are sampled when a run starts: installing the first
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Anthropic on-demand compaction (`compact-2026-09-04` beta).
3
3
  *
4
- * The request sends only the prefix to summarize, with the live conversation's
5
- * system prompt, tools and thinking settings. The returned signed block
6
- * replaces that prefix; the retained tail is replayed from session entries.
4
+ * The request sends only the prefix to summarize, as the live conversation
5
+ * sent it: same system prompt, tools, history bytes and thinking settings.
6
+ * The returned signed block replaces that prefix; the retained tail is
7
+ * replayed from session entries.
7
8
  */
8
- import type { AnthropicCompactionPayload, ApiKey, Effort, Message, Model, SimpleStreamOptions, Tool, Usage } from "@oh-my-pi/pi-ai";
9
+ import type { AnthropicCompactionFiles, AnthropicCompactionPayload, ApiKey, Context, Effort, Model, SimpleStreamOptions, Usage } from "@oh-my-pi/pi-ai";
9
10
  import { type InstrumentedChatSpanOptions } from "../telemetry.js";
10
11
  import type { AgentMessage } from "../types.js";
11
12
  export declare const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
@@ -17,8 +18,12 @@ export interface AnthropicCompactionPreserveData {
17
18
  signature?: string;
18
19
  /** Legacy threshold block state; replay-only. */
19
20
  encryptedContent?: string;
20
- /** Harness file metadata (`<files>` section) replayed after the native block. */
21
+ /** Harness file metadata (`<files>` section) replayed after the retained tail. */
21
22
  filesText?: string;
23
+ /** Earlier summaries' file metadata replayed inside the retained tail. */
24
+ retainedFiles?: AnthropicCompactionFiles[];
25
+ /** The retained tail replays unchanged (see `AnthropicCompactionPayload.exactTail`). */
26
+ exactTail?: true;
22
27
  /** Model that wrote the summary. */
23
28
  model?: string;
24
29
  /** Prompt tokens the compaction request processed, for display. */
@@ -51,10 +56,11 @@ export declare function findAnthropicCompactionCut(messages: readonly (AgentMess
51
56
  /** Instructions replace the API default; the request contains only summarized messages. */
52
57
  export declare function buildAnthropicCompactionInstructions(basePrompt: string, customInstructions: string | undefined, extraContext: string | undefined): string;
53
58
  export interface AnthropicNativeCompactionRequest {
54
- systemPrompt: string[];
55
- messages: Message[];
56
- tools?: Tool[];
59
+ /** The summarized prefix as the live turn would send it. */
60
+ context: Context;
57
61
  instructions: string;
62
+ /** Replayed file metadata due before this time ends the request (see `AnthropicCompactionRequest.filesDueBefore`). */
63
+ filesDueBefore?: number;
58
64
  maxTokens: number;
59
65
  reasoning?: Effort;
60
66
  }
@@ -26,6 +26,8 @@ export interface CompactionV2Usage {
26
26
  export interface CompactionV2Request {
27
27
  body: OpenAICodexCompactionBody;
28
28
  input: unknown[];
29
+ /** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
30
+ retainedUserItems: unknown[];
29
31
  retainedMessageBudget: number;
30
32
  sessionId?: string;
31
33
  promptCacheKey?: string;
@@ -56,12 +58,14 @@ export declare function buildCompactionV2Request(model: Model, input: unknown[],
56
58
  sessionId?: string;
57
59
  promptCacheKey?: string;
58
60
  retainedMessageBudget?: number;
61
+ retainedUserItems?: unknown[];
59
62
  }): CompactionV2Request;
60
63
  /** Wrap a body built by the normal Codex serializer for V2 compaction transport. */
61
64
  export declare function buildCompactionV2RequestFromBody(model: Model, body: OpenAICodexCompactionBody, options?: {
62
65
  sessionId?: string;
63
66
  promptCacheKey?: string;
64
67
  retainedMessageBudget?: number;
68
+ retainedUserItems?: unknown[];
65
69
  }): CompactionV2Request;
66
70
  /** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
67
71
  export declare function requestCompactionV2Streaming(model: Model, apiKey: string, request: CompactionV2Request, signal?: AbortSignal, options?: {
@@ -72,7 +76,7 @@ export declare function requestCompactionV2Streaming(model: Model, apiKey: strin
72
76
  codexCompaction?: CodexCompactionContext;
73
77
  preferWebsockets?: boolean;
74
78
  }): Promise<CompactionV2Response>;
75
- /** Build Codex-style V2 replacement history from prompt input plus compaction output. */
79
+ /** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
76
80
  export declare function buildCompactionV2ReplacementHistory(input: unknown[], compactionItem: Record<string, unknown>, retainedMessageBudget?: number): {
77
81
  replacementHistory: Array<Record<string, unknown>>;
78
82
  retainedImageCount: number;
@@ -177,6 +177,20 @@ export interface SummaryOptions {
177
177
  initiatorOverride?: MessageAttribution;
178
178
  metadata?: Record<string, unknown>;
179
179
  convertToLlm?: ConvertToLlm;
180
+ /**
181
+ * The provider context a live turn sends for `summarized` + `retained`, cut to
182
+ * `summarized`. Anthropic on-demand compaction sends it so retained signed
183
+ * thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
184
+ * `tools` and `convertToLlm`.
185
+ */
186
+ buildProviderContext?: (summarized: AgentMessage[], retained: AgentMessage[], signal?: AbortSignal) => Promise<Context>;
187
+ /**
188
+ * Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
189
+ * next to the compaction item. Defaults to `role === "user"`; hosts whose
190
+ * user-initiated turns also arrive as custom messages (e.g. skill invocations)
191
+ * widen it.
192
+ */
193
+ isUserAuthored?: (message: AgentMessage) => boolean;
180
194
  /**
181
195
  * Optional telemetry handle. When provided, every LLM call emitted during
182
196
  * compaction is wrapped in an OTEL chat span tagged with
@@ -88,8 +88,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
88
88
  }
89
89
  export interface SessionInitEntry extends SessionEntryBase {
90
90
  type: "session_init";
91
- /** Full system prompt sent to the model */
92
- systemPrompt: string;
91
+ /** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
92
+ systemPrompt: string[] | string;
93
93
  /** Initial task/user message */
94
94
  task: string;
95
95
  /** Tools available to the agent */
@@ -28,19 +28,31 @@ export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompac
28
28
  */
29
29
  export declare const REMOTE_COMPACTION_TIMEOUT_MS = 300000;
30
30
  export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
31
+ /**
32
+ * Tokens of stored Responses replacement history. Retained `input_image` parts
33
+ * count at their image estimate rather than as base64 text; everything else,
34
+ * opaque compaction state included, counts as serialized.
35
+ */
36
+ export declare function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number;
31
37
  export interface TrimRemoteCompactionInputResult {
32
38
  input: Array<Record<string, unknown>>;
33
39
  rewrittenOutputs: number;
34
40
  estimatedTokensBefore: number;
35
41
  estimatedTokensAfter: number;
36
- /** Whether `input` fits the model window; false means it must not be sent. */
42
+ /**
43
+ * Whether `input` may be sent; false means it must not be. True when the
44
+ * full estimate fits, or when only the image estimate pushes it over (image
45
+ * pricing is a guess; the provider's own accounting decides).
46
+ */
37
47
  fits: boolean;
38
48
  }
39
49
  /**
40
50
  * Preserve the full native transcript unless trailing tool outputs alone push a
41
51
  * remote compaction request beyond the model window. Replacing only those
42
52
  * outputs keeps call/result pairing and all earlier assistant/reasoning history,
43
- * matching Codex's recovery path for oversized tool turns.
53
+ * matching Codex's recovery path for oversized tool turns. A request whose text
54
+ * fits but whose image estimate does not is still sendable: refusing it on an
55
+ * image guess would skip native compaction the provider would have accepted.
44
56
  */
45
57
  export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, tokenizer: Tokenizer, contextWindow: number | null | undefined, instructions: string, tools?: unknown[]): TrimRemoteCompactionInputResult;
46
58
  /**
@@ -0,0 +1,29 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { type ImageSize } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
3
+ /**
4
+ * Dimension-based image token estimates shared by the local context counter
5
+ * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
6
+ * Both sides must price the same image the same way: when they disagreed
7
+ * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
8
+ * stayed under the local compaction trigger yet was refused as over-window by
9
+ * the remote probe.
10
+ *
11
+ * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
12
+ * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
13
+ * which is also a close upper estimate for other providers once omp has
14
+ * downscaled the image (≤1568px by default).
15
+ */
16
+ /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
17
+ export type ImageDetail = ImageContent["detail"];
18
+ export type { ImageSize };
19
+ /**
20
+ * Estimated input tokens for one image. Unknown dimensions (undecodable data,
21
+ * remote URLs, provider file ids) charge the detail level's full patch budget.
22
+ */
23
+ export declare function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number;
24
+ /** Pixel size read from the header of base64-encoded image data, or null. */
25
+ export declare function base64ImageSize(base64: string): ImageSize | null;
26
+ /** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
27
+ export declare function dataUrlImageSize(url: string): ImageSize | null;
28
+ /** {@link estimateImageTokens} for an in-memory image block. */
29
+ export declare function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number;
@@ -1,5 +1,6 @@
1
1
  import type { Model } from "@oh-my-pi/pi-ai";
2
2
  import * as natives from "@oh-my-pi/pi-natives";
3
+ import * as snapcompact from "@oh-my-pi/snapcompact";
3
4
  import type { AgentMessage } from "./types.js";
4
5
  /** Maps the catalog-resolved tokenizer family to its native implementation. */
5
6
  export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): natives.Encoding | null;
@@ -42,14 +43,15 @@ export interface TokenBudgetCheck {
42
43
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
43
44
  * is fixed at construction, so a cached count can never straddle two
44
45
  * encodings. An `Agent` owns one for its active model (swapping the instance
45
- * when the model's encoding changes); one-shot flows construct their own for
46
- * the model that will be billed. Known tokenizer families use exact native
47
- * counts; unknown models keep the fast byte estimate (or o200k when
48
- * `PI_TOKENIZER_ACCURATE=1`).
46
+ * when the model's encoding or snapcompact frame pricing changes); one-shot
47
+ * flows construct their own for the model that will be billed. Known
48
+ * tokenizer families use exact native counts; unknown models keep the fast
49
+ * byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
49
50
  */
50
51
  export declare class Tokenizer {
51
52
  #private;
52
- constructor(model?: Pick<Model, "tokenizer"> | null);
53
+ readonly frameBillingKey: string;
54
+ constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null);
53
55
  get encoding(): natives.Encoding | null;
54
56
  countTokens(text: string | string[], mode?: TokenCountMode): number;
55
57
  /**
@@ -70,7 +72,8 @@ export declare class Tokenizer {
70
72
  * Settled historical messages are counted once and reused until an owner
71
73
  * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
72
74
  * assistants bypass the memo entirely (see the message-cache settle-gate
73
- * invariant). Image blocks charge a fixed per-image estimate.
75
+ * invariant). Image blocks charge {@link estimateImageContentTokens}, the
76
+ * same dimension-based estimate the native remote-compaction probe applies.
74
77
  */
75
78
  countMessage(message: AgentMessage, options?: MessageCountOptions): number;
76
79
  /** Sum of {@link countMessage} over `messages`. */
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.6.0",
4
+ "version": "18.6.3",
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.6.0",
42
- "@oh-my-pi/pi-catalog": "18.6.0",
43
- "@oh-my-pi/pi-natives": "18.6.0",
44
- "@oh-my-pi/pi-utils": "18.6.0",
45
- "@oh-my-pi/pi-wire": "18.6.0",
46
- "@oh-my-pi/snapcompact": "18.6.0",
41
+ "@oh-my-pi/pi-ai": "18.6.3",
42
+ "@oh-my-pi/pi-catalog": "18.6.3",
43
+ "@oh-my-pi/pi-natives": "18.6.3",
44
+ "@oh-my-pi/pi-utils": "18.6.3",
45
+ "@oh-my-pi/pi-wire": "18.6.3",
46
+ "@oh-my-pi/snapcompact": "18.6.3",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.6.0",
50
+ "@oh-my-pi/omptype": "18.6.3",
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
@@ -26,6 +26,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
27
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
28
28
  import { logger } from "@oh-my-pi/pi-utils";
29
+ import * as snapcompact from "@oh-my-pi/snapcompact";
29
30
  import {
30
31
  abortReasonText,
31
32
  agentLoop,
@@ -386,6 +387,13 @@ interface CursorToolResultEntry {
386
387
 
387
388
  type QueuedMessageQueue = "steering" | "followUp";
388
389
 
390
+ interface LiveSteeredEntry {
391
+ message: AgentMessage;
392
+ controller: AbortController | undefined;
393
+ /** Context preparation committed for the batch, carried by its last adopted entry. Never requeued. */
394
+ additional?: readonly AgentMessage[];
395
+ }
396
+
389
397
  interface QueuedMessageClaim {
390
398
  messages: AgentMessage[];
391
399
  controller: AbortController;
@@ -420,16 +428,20 @@ export class Agent {
420
428
  * transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
421
429
  * {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
422
430
  * replacement must not drop it too; the run's end requeues whatever it did not record, and
423
- * {@link withdrawLiveSteering} takes it back ahead of an abort.
431
+ * {@link withdrawUndeliveredQueuedMessages} takes it back ahead of an abort.
424
432
  */
425
- #liveSteered: { message: AgentMessage; controller: AbortController | undefined }[] = [];
426
- /** Dequeued originals remain recoverable until their transcript events arrive. */
433
+ #liveSteered: LiveSteeredEntry[] = [];
434
+ /** Dequeued originals remain recoverable until their transcript events arrive. `additional` is
435
+ * the context preparation appended after them, which the transcript records with them. */
427
436
  #queuedMessageDeliveries = new Set<{
428
437
  queue: QueuedMessageQueue;
429
438
  controller: AbortController | undefined;
430
439
  messages: AgentMessage[];
431
440
  next: number;
441
+ additional: readonly AgentMessage[];
432
442
  }>();
443
+ /** Messages {@link withdrawUndeliveredQueuedMessages} took back, keyed to the run that must not record them. */
444
+ #withdrawnMessages = new WeakMap<AgentMessage, AbortController>();
433
445
  #steeringWaiters = new Set<() => void>();
434
446
  #queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
435
447
 
@@ -484,6 +496,7 @@ export class Agent {
484
496
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
485
497
  #onBeforeYield?: () => Promise<void> | void;
486
498
  #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
499
+ #onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
487
500
  #beforeModelCall?: AgentBeforeModelCall;
488
501
  #additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
489
502
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
@@ -831,19 +844,22 @@ export class Agent {
831
844
 
832
845
  /**
833
846
  * Tokenizer for the active model. The instance is replaced whenever the
834
- * active model's encoding changes (see {@link setModel}), so callers must
835
- * not cache it across model switches.
847
+ * active model's encoding or snapcompact frame pricing changes (see
848
+ * {@link setModel}), so callers must not cache it across model switches.
836
849
  */
837
850
  get tokenizer(): Tokenizer {
838
851
  return this.#tokenizer;
839
852
  }
840
853
 
841
854
  /**
842
- * Swap the tokenizer only when the encoding actually changes, so the warm
843
- * per-message memo survives same-encoding model switches.
855
+ * Swap the tokenizer only when the encoding or frame pricing actually
856
+ * changes, so the warm per-message memo survives same-pricing model switches.
844
857
  */
845
858
  #syncTokenizer(model: Model | null | undefined): void {
846
- if (tokenizerEncodingForModel(model) !== this.#tokenizer.encoding) {
859
+ if (
860
+ tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
861
+ snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
862
+ ) {
847
863
  this.#tokenizer = new Tokenizer(model);
848
864
  }
849
865
  }
@@ -967,7 +983,7 @@ export class Agent {
967
983
  if (messages.length === 0) return messages;
968
984
  const runController = this.#abortController;
969
985
  if (!prepare) {
970
- this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
986
+ this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
971
987
  return messages;
972
988
  }
973
989
 
@@ -986,7 +1002,13 @@ export class Agent {
986
1002
  throw new DOMException("Queued message preparation cancelled", "AbortError");
987
1003
  }
988
1004
  delete this.#queuedMessageClaims[queue];
989
- this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
1005
+ this.#queuedMessageDeliveries.add({
1006
+ queue,
1007
+ controller: runController,
1008
+ messages,
1009
+ next: 0,
1010
+ additional: additional ?? [],
1011
+ });
990
1012
  return additional?.length ? [...messages, ...additional] : messages;
991
1013
  } catch (error) {
992
1014
  if (signal.aborted) throw error;
@@ -1048,10 +1070,15 @@ export class Agent {
1048
1070
  const pending = delivery.messages.slice(delivery.next);
1049
1071
  const kept = pending.filter(message => !taken.includes(message));
1050
1072
  if (kept.length === pending.length) continue;
1073
+ let last: LiveSteeredEntry | undefined;
1051
1074
  for (const message of pending) {
1052
- if (taken.includes(message)) this.#liveSteered.push({ message, controller: delivery.controller });
1075
+ if (!taken.includes(message)) continue;
1076
+ last = { message, controller: delivery.controller };
1077
+ this.#liveSteered.push(last);
1053
1078
  }
1054
1079
  if (kept.length === 0) {
1080
+ // The delivery record goes away; keep its context withdrawable with the adopted batch.
1081
+ if (last && delivery.additional.length > 0) last.additional = delivery.additional;
1055
1082
  this.#queuedMessageDeliveries.delete(delivery);
1056
1083
  } else {
1057
1084
  delivery.messages = kept;
@@ -1061,13 +1088,29 @@ export class Agent {
1061
1088
  }
1062
1089
 
1063
1090
  /**
1064
- * Take back live-steered messages ahead of an abort (Esc restores them to the editor):
1065
- * the aborted run then neither records nor requeues them.
1091
+ * Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
1092
+ * it to the editor): steering live steering took for the in-flight response, then each batch
1093
+ * dequeued for the next model call, oldest first. The aborted run then neither records it (or
1094
+ * the context prepared for it) nor requeues it.
1066
1095
  */
1067
- withdrawLiveSteering(): AgentMessage[] {
1068
- const messages = this.peekLiveSteeredMessages();
1096
+ withdrawUndeliveredQueuedMessages(): { steering: AgentMessage[]; followUp: AgentMessage[] } {
1097
+ const withdrawn: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
1098
+ const suppress = (message: AgentMessage, controller: AbortController | undefined) => {
1099
+ if (controller) this.#withdrawnMessages.set(message, controller);
1100
+ };
1101
+ for (const { message, controller, additional } of this.#liveSteered) {
1102
+ withdrawn.steering.push(message);
1103
+ suppress(message, controller);
1104
+ for (const context of additional ?? []) suppress(context, controller);
1105
+ }
1069
1106
  this.#liveSteered = [];
1070
- return messages;
1107
+ for (const delivery of this.#queuedMessageDeliveries) {
1108
+ const pending = delivery.messages.slice(delivery.next);
1109
+ withdrawn[delivery.queue].push(...pending);
1110
+ for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
1111
+ }
1112
+ this.#queuedMessageDeliveries.clear();
1113
+ return withdrawn;
1071
1114
  }
1072
1115
 
1073
1116
  /** Steering live steering took for the streaming response; the transcript records it once
@@ -1101,6 +1144,11 @@ export class Agent {
1101
1144
  this.#onTurnEnd = fn;
1102
1145
  }
1103
1146
 
1147
+ /** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
1148
+ setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void {
1149
+ this.#onModelCallSystemPrompt = fn;
1150
+ }
1151
+
1104
1152
  /**
1105
1153
  * Install or replace the host pre-model-call gate; pass `undefined` to
1106
1154
  * remove it. Gates are sampled when a run starts: installing the first
@@ -1800,6 +1848,7 @@ export class Agent {
1800
1848
  await Bun.sleep(0);
1801
1849
  }
1802
1850
  context.systemPrompt = this.#state.systemPrompt;
1851
+ this.#onModelCallSystemPrompt?.(context.systemPrompt);
1803
1852
  context.tools = this.#toolsForModel(this.#state.model ?? model);
1804
1853
  },
1805
1854
  beforeModelCall:
@@ -1879,8 +1928,19 @@ export class Agent {
1879
1928
  ? agentLoop(messages, context, config, loopSignal, this.streamFn)
1880
1929
  : agentLoopContinue(context, config, loopSignal, this.streamFn);
1881
1930
 
1882
- for await (const event of stream) {
1931
+ for await (let event of stream) {
1883
1932
  if (this.#abortController !== loopAbortController) return;
1933
+ // Withdrawn input the loop already holds for this run never reaches the transcript.
1934
+ if (
1935
+ (event.type === "message_start" || event.type === "message_end") &&
1936
+ this.#withdrawnMessages.get(event.message) === loopAbortController
1937
+ ) {
1938
+ continue;
1939
+ }
1940
+ if (event.type === "agent_end") {
1941
+ const messages = event.messages.filter(m => this.#withdrawnMessages.get(m) !== loopAbortController);
1942
+ if (messages.length < event.messages.length) event = { ...event, messages };
1943
+ }
1884
1944
  if (event.type === "turn_start") turnOpen = true;
1885
1945
  if (event.type === "turn_end") turnOpen = false;
1886
1946
  // Update internal state based on events
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * Anthropic on-demand compaction (`compact-2026-09-04` beta).
3
3
  *
4
- * The request sends only the prefix to summarize, with the live conversation's
5
- * system prompt, tools and thinking settings. The returned signed block
6
- * replaces that prefix; the retained tail is replayed from session entries.
4
+ * The request sends only the prefix to summarize, as the live conversation
5
+ * sent it: same system prompt, tools, history bytes and thinking settings.
6
+ * The returned signed block replaces that prefix; the retained tail is
7
+ * replayed from session entries.
7
8
  */
8
9
 
9
10
  import type {
11
+ AnthropicCompactionFiles,
10
12
  AnthropicCompactionPayload,
11
13
  ApiKey,
14
+ Context,
12
15
  Effort,
13
- Message,
14
16
  Model,
15
17
  SimpleStreamOptions,
16
- Tool,
17
18
  Usage,
18
19
  } from "@oh-my-pi/pi-ai";
19
20
  import * as AIError from "@oh-my-pi/pi-ai/error";
21
+ import { resolveAnthropicCompactionEffort } from "@oh-my-pi/pi-ai/providers/anthropic";
20
22
  import { supportsAnthropicCompaction } from "@oh-my-pi/pi-ai/providers/anthropic-compaction";
21
23
  import { isRecord, prompt } from "@oh-my-pi/pi-utils";
22
24
  import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
@@ -33,8 +35,12 @@ export interface AnthropicCompactionPreserveData {
33
35
  signature?: string;
34
36
  /** Legacy threshold block state; replay-only. */
35
37
  encryptedContent?: string;
36
- /** Harness file metadata (`<files>` section) replayed after the native block. */
38
+ /** Harness file metadata (`<files>` section) replayed after the retained tail. */
37
39
  filesText?: string;
40
+ /** Earlier summaries' file metadata replayed inside the retained tail. */
41
+ retainedFiles?: AnthropicCompactionFiles[];
42
+ /** The retained tail replays unchanged (see `AnthropicCompactionPayload.exactTail`). */
43
+ exactTail?: true;
38
44
  /** Model that wrote the summary. */
39
45
  model?: string;
40
46
  /** Prompt tokens the compaction request processed, for display. */
@@ -76,6 +82,15 @@ export function getPreservedAnthropicCompactionData(
76
82
  ...(typeof candidate.filesText === "string" && candidate.filesText.length > 0
77
83
  ? { filesText: candidate.filesText }
78
84
  : {}),
85
+ ...(Array.isArray(candidate.retainedFiles) && candidate.retainedFiles.length > 0
86
+ ? {
87
+ retainedFiles: candidate.retainedFiles.filter(
88
+ (files): files is AnthropicCompactionFiles =>
89
+ isRecord(files) && typeof files.text === "string" && typeof files.after === "number",
90
+ ),
91
+ }
92
+ : {}),
93
+ ...(candidate.exactTail === true ? { exactTail: true as const } : {}),
79
94
  ...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
80
95
  ...(typeof candidate.usedTokens === "number" ? { usedTokens: candidate.usedTokens } : {}),
81
96
  };
@@ -109,6 +124,8 @@ export function getAnthropicCompactionPayload(
109
124
  ...(preserved.signature ? { signature: preserved.signature } : {}),
110
125
  ...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
111
126
  ...(preserved.filesText ? { filesText: preserved.filesText } : {}),
127
+ ...(preserved.retainedFiles?.length ? { retainedFiles: preserved.retainedFiles } : {}),
128
+ ...(preserved.exactTail ? { exactTail: true as const } : {}),
112
129
  };
113
130
  }
114
131
 
@@ -167,10 +184,11 @@ export function buildAnthropicCompactionInstructions(
167
184
  }
168
185
 
169
186
  export interface AnthropicNativeCompactionRequest {
170
- systemPrompt: string[];
171
- messages: Message[];
172
- tools?: Tool[];
187
+ /** The summarized prefix as the live turn would send it. */
188
+ context: Context;
173
189
  instructions: string;
190
+ /** Replayed file metadata due before this time ends the request (see `AnthropicCompactionRequest.filesDueBefore`). */
191
+ filesDueBefore?: number;
174
192
  maxTokens: number;
175
193
  reasoning?: Effort;
176
194
  }
@@ -214,12 +232,12 @@ export async function requestAnthropicNativeCompaction(
214
232
  ): Promise<AnthropicNativeCompactionResponse> {
215
233
  const response = await instrumentedCompleteSimple(
216
234
  model,
217
- { systemPrompt: request.systemPrompt, messages: request.messages, tools: request.tools },
235
+ request.context,
218
236
  {
219
237
  apiKey,
220
238
  signal,
221
239
  maxTokens: request.maxTokens,
222
- reasoning: request.reasoning,
240
+ reasoning: resolveAnthropicCompactionEffort(model, request.context.messages, request.reasoning),
223
241
  initiatorOverride: options.initiatorOverride,
224
242
  metadata: options.metadata,
225
243
  fetch: options.fetch,
@@ -227,7 +245,7 @@ export async function requestAnthropicNativeCompaction(
227
245
  promptCacheKey: options.promptCacheKey,
228
246
  providerSessionState: options.providerSessionState,
229
247
  maxInFlightRequests: options.maxInFlightRequests,
230
- anthropicCompaction: { instructions: request.instructions },
248
+ anthropicCompaction: { instructions: request.instructions, filesDueBefore: request.filesDueBefore },
231
249
  },
232
250
  {
233
251
  telemetry: options.telemetry,