@oh-my-pi/pi-agent-core 18.6.1 → 18.7.0

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.7.0] - 2026-10-06
6
+
7
+ ### Fixed
8
+
9
+ - Fixed interrupted runs so assistant message boundaries are emitted reliably, allowing subscribers to persist and recover the interrupted turn.
10
+
11
+ ## [18.6.3] - 2026-10-06
12
+
13
+ ### Breaking Changes
14
+
15
+ - `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))
16
+
17
+ ### Added
18
+
19
+ - 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))
20
+
21
+ ### Changed
22
+
23
+ - `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))
24
+ - 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)).
25
+
26
+ ### Fixed
27
+
28
+ - 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))
29
+ - 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)).
30
+ - 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))
31
+ - 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))
32
+ - 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))
33
+
5
34
  ## [18.6.1] - 2026-10-04
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,6 +28,12 @@ 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;
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { type ImageSize } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
2
3
  /**
3
4
  * Dimension-based image token estimates shared by the local context counter
4
5
  * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
@@ -7,16 +8,14 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
7
8
  * stayed under the local compaction trigger yet was refused as over-window by
8
9
  * the remote probe.
9
10
  *
10
- * Follows OpenAI's patch-based rule (32px patches, a per-detail pixel limit
11
- * and patch budget, x1.2 multiplier), which is also a close upper estimate for
12
- * other providers once omp has downscaled the image (≤1568px by default).
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).
13
15
  */
14
16
  /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
15
17
  export type ImageDetail = ImageContent["detail"];
16
- export interface ImageSize {
17
- width: number;
18
- height: number;
19
- }
18
+ export type { ImageSize };
20
19
  /**
21
20
  * Estimated input tokens for one image. Unknown dimensions (undecodable data,
22
21
  * remote URLs, provider file ids) charge the detail level's full patch budget.
@@ -520,8 +520,8 @@ export interface InstrumentedChatSpanOptions {
520
520
  /**
521
521
  * Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
522
522
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
523
- * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
524
- * outside this package to add new kinds without bumping the helper.
523
+ * `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
524
+ * to allow callers outside this package to add new kinds without bumping the helper.
525
525
  */
526
526
  readonly oneshotKind?: string;
527
527
  /** Extra span attributes applied verbatim. */
@@ -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
  /**
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.1",
4
+ "version": "18.7.0",
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.1",
42
- "@oh-my-pi/pi-catalog": "18.6.1",
43
- "@oh-my-pi/pi-natives": "18.6.1",
44
- "@oh-my-pi/pi-utils": "18.6.1",
45
- "@oh-my-pi/pi-wire": "18.6.1",
46
- "@oh-my-pi/snapcompact": "18.6.1",
41
+ "@oh-my-pi/pi-ai": "18.7.0",
42
+ "@oh-my-pi/pi-catalog": "18.7.0",
43
+ "@oh-my-pi/pi-natives": "18.7.0",
44
+ "@oh-my-pi/pi-utils": "18.7.0",
45
+ "@oh-my-pi/pi-wire": "18.7.0",
46
+ "@oh-my-pi/snapcompact": "18.7.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.6.1",
50
+ "@oh-my-pi/omptype": "18.7.0",
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
@@ -2058,8 +2118,19 @@ export class Agent {
2058
2118
  turnOpen = false;
2059
2119
  this.#emit({ type: "agent_end", messages: agentEndMessages });
2060
2120
  } else {
2121
+ // Context hooks can reject on abort before provider streaming starts.
2122
+ // Publish the boundary just like a streaming abort so subscribers can
2123
+ // persist it and recover the interrupted turn after a reload.
2124
+ if (!turnOpen) this.#emit({ type: "turn_start" });
2125
+ if (!hadAssistantStart) {
2126
+ this.#state.streamMessage = errorMsg;
2127
+ this.#emit({ type: "message_start", message: errorMsg });
2128
+ }
2129
+ this.#state.streamMessage = null;
2061
2130
  this.appendMessage(errorMsg);
2062
2131
  this.#state.error = errorMessage;
2132
+ this.#emit({ type: "message_end", message: errorMsg });
2133
+ this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
2063
2134
  this.#emit({ type: "agent_end", messages: [errorMsg] });
2064
2135
  }
2065
2136
  } finally {
@@ -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,
@@ -77,6 +77,8 @@ export interface CompactionV2Usage {
77
77
  export interface CompactionV2Request {
78
78
  body: OpenAICodexCompactionBody;
79
79
  input: unknown[];
80
+ /** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
81
+ retainedUserItems: unknown[];
80
82
  retainedMessageBudget: number;
81
83
  sessionId?: string;
82
84
  promptCacheKey?: string;
@@ -190,6 +192,7 @@ export function buildCompactionV2Request(
190
192
  sessionId?: string;
191
193
  promptCacheKey?: string;
192
194
  retainedMessageBudget?: number;
195
+ retainedUserItems?: unknown[];
193
196
  },
194
197
  ): CompactionV2Request {
195
198
  const cacheOptions = { sessionId: options?.sessionId, promptCacheKey: options?.promptCacheKey };
@@ -223,12 +226,14 @@ export function buildCompactionV2RequestFromBody(
223
226
  sessionId?: string;
224
227
  promptCacheKey?: string;
225
228
  retainedMessageBudget?: number;
229
+ retainedUserItems?: unknown[];
226
230
  },
227
231
  ): CompactionV2Request {
228
232
  const input = Array.isArray(body.input) ? body.input : [];
229
233
  return {
230
234
  body: { ...body, model: resolveCompactionV2Model(model), input },
231
235
  input,
236
+ retainedUserItems: options?.retainedUserItems ?? input,
232
237
  retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
233
238
  sessionId: options?.sessionId,
234
239
  promptCacheKey: options?.promptCacheKey,
@@ -536,7 +541,7 @@ function finishCompactionV2Collection(
536
541
 
537
542
  const compactionItem = state.compactionItems[0];
538
543
  const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
539
- request.input,
544
+ request.retainedUserItems,
540
545
  compactionItem,
541
546
  request.retainedMessageBudget,
542
547
  );
@@ -665,15 +670,14 @@ function isRetryableCompactionError(error: Error): boolean {
665
670
  // Replacement History
666
671
  // ============================================================================
667
672
 
668
- /** Build Codex-style V2 replacement history from prompt input plus compaction output. */
673
+ /** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
669
674
  export function buildCompactionV2ReplacementHistory(
670
675
  input: unknown[],
671
676
  compactionItem: Record<string, unknown>,
672
677
  retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
673
678
  ): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
674
679
  const retained = input.filter(
675
- (item): item is Record<string, unknown> =>
676
- isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
680
+ (item): item is Record<string, unknown> => isRecord(item) && isRetainedUserMessageForCompactionV2(item),
677
681
  );
678
682
  const replacementHistory = truncateRetainedMessagesForCompactionV2(
679
683
  retained,
@@ -684,17 +688,11 @@ export function buildCompactionV2ReplacementHistory(
684
688
  return { replacementHistory, retainedImageCount };
685
689
  }
686
690
 
687
- function isRetainedForCompactionV2(item: Record<string, unknown>): boolean {
688
- if (item.type !== "message") return false;
689
- const role = stringField(item, "role");
690
- return role === "user" || role === "developer" || role === "system";
691
- }
692
-
693
- function shouldKeepCompactionV2HistoryItem(item: Record<string, unknown>): boolean {
694
- if (item.type !== "message") return item.type === "compaction";
695
- const role = stringField(item, "role");
696
- if (role !== "user") return false;
697
- return !isContextualUserMessage(item);
691
+ function isRetainedUserMessageForCompactionV2(item: Record<string, unknown>): boolean {
692
+ // Responses input messages may omit `type`: omp serializes turns as
693
+ // `{ role, content }`, which the API reads as `type: "message"`.
694
+ const isMessage = item.type === "message" || (item.type === undefined && typeof item.role === "string");
695
+ return isMessage && item.role === "user" && !isContextualUserMessage(item);
698
696
  }
699
697
 
700
698
  function isContextualUserMessage(item: Record<string, unknown>): boolean {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ type AnthropicCompactionFiles,
9
10
  type Api,
10
11
  type ApiKey,
11
12
  type AssistantMessage,
@@ -676,6 +677,24 @@ export interface SummaryOptions {
676
677
  initiatorOverride?: MessageAttribution;
677
678
  metadata?: Record<string, unknown>;
678
679
  convertToLlm?: ConvertToLlm;
680
+ /**
681
+ * The provider context a live turn sends for `summarized` + `retained`, cut to
682
+ * `summarized`. Anthropic on-demand compaction sends it so retained signed
683
+ * thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
684
+ * `tools` and `convertToLlm`.
685
+ */
686
+ buildProviderContext?: (
687
+ summarized: AgentMessage[],
688
+ retained: AgentMessage[],
689
+ signal?: AbortSignal,
690
+ ) => Promise<Context>;
691
+ /**
692
+ * Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
693
+ * next to the compaction item. Defaults to `role === "user"`; hosts whose
694
+ * user-initiated turns also arrive as custom messages (e.g. skill invocations)
695
+ * widen it.
696
+ */
697
+ isUserAuthored?: (message: AgentMessage) => boolean;
679
698
  /**
680
699
  * Optional telemetry handle. When provided, every LLM call emitted during
681
700
  * compaction is wrapped in an OTEL chat span tagged with
@@ -1612,6 +1631,8 @@ export async function compact(
1612
1631
  initiatorOverride: options?.initiatorOverride,
1613
1632
  metadata: options?.metadata,
1614
1633
  convertToLlm: options?.convertToLlm,
1634
+ buildProviderContext: options?.buildProviderContext,
1635
+ isUserAuthored: options?.isUserAuthored,
1615
1636
  telemetry: options?.telemetry,
1616
1637
  // Honor /model thinking selection on every fan-out summarizer.
1617
1638
  // Without this propagation, generateSummary / generateTurnPrefixSummary
@@ -1675,7 +1696,17 @@ export async function compact(
1675
1696
  previousRemoteCompaction?.provider === model.provider
1676
1697
  ? previousRemoteCompaction.replacementHistory
1677
1698
  : undefined;
1678
- const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages);
1699
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1700
+ const messages = convertToLlm(remoteMessages);
1701
+ // Replacement history keeps what the user wrote. Summaries, archive
1702
+ // migrations, and custom/hook messages can serialize as user-role items
1703
+ // too, so pick the user's own messages before serialization erases that,
1704
+ // then serialize each one exactly as the request does.
1705
+ const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
1706
+ const userMessages = convertToLlm(
1707
+ [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
1708
+ );
1709
+ const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
1679
1710
  const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
1680
1711
  let codexBody: OpenAICodexCompactionBody | undefined;
1681
1712
  let remoteHistory: Array<Record<string, unknown>>;
@@ -1715,8 +1746,19 @@ export async function compact(
1715
1746
  }
1716
1747
  remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1717
1748
  codexBody.input = remoteHistory;
1749
+ for (const message of userMessages) {
1750
+ const userBody = await buildTransformedCodexRequestBody(
1751
+ model,
1752
+ { messages: [message] },
1753
+ { responsesLite: model.useResponsesLite },
1754
+ );
1755
+ retainedUserItems.push(...(userBody.input ?? []));
1756
+ }
1718
1757
  } else {
1719
1758
  remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1759
+ for (const message of userMessages) {
1760
+ retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1761
+ }
1720
1762
  }
1721
1763
  if (remoteHistory.length > 0) {
1722
1764
  try {
@@ -1754,6 +1796,7 @@ export async function compact(
1754
1796
  sessionId: summaryOptions.sessionId,
1755
1797
  promptCacheKey: summaryOptions.promptCacheKey,
1756
1798
  retainedMessageBudget: settings.v2RetainedMessageBudget,
1799
+ retainedUserItems,
1757
1800
  };
1758
1801
  const request = codexBody
1759
1802
  ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
@@ -1781,11 +1824,17 @@ export async function compact(
1781
1824
  // summarization" and keep compaction running on an aborted signal.
1782
1825
  if (signal?.aborted) throw err;
1783
1826
  nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1784
- logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", {
1785
- error: err instanceof Error ? err.message : String(err),
1786
- model: model.id,
1787
- provider: model.provider,
1788
- });
1827
+ // Claim the V1 fallback only when the V1 block below will run.
1828
+ logger.warn(
1829
+ shouldUseOpenAiRemoteCompaction(model)
1830
+ ? "OpenAI V2 remote compaction failed, falling back to V1 remote compaction"
1831
+ : "OpenAI V2 remote compaction failed",
1832
+ {
1833
+ error: err instanceof Error ? err.message : String(err),
1834
+ model: model.id,
1835
+ provider: model.provider,
1836
+ },
1837
+ );
1789
1838
  }
1790
1839
  }
1791
1840
  }
@@ -1848,32 +1897,40 @@ export async function compact(
1848
1897
  let nativeSignature: string | undefined;
1849
1898
  let nativeFirstKeptEntryId = firstKeptEntryId;
1850
1899
  let nativeUsedTokens: number | undefined;
1900
+ let nativeRetainedFiles: AnthropicCompactionFiles[] | undefined;
1851
1901
  if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
1852
1902
  const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1853
1903
  // Lead with the previous summary as the live context renders it:
1854
1904
  // natively when this provider wrote it, as text otherwise.
1855
1905
  // A prior snapcompact archive is already merged into that summary
1856
- // text. Predate the rewrite marker before all replayed messages so
1906
+ // text. Like the live context, the summary keeps its commit time and
1907
+ // predates its rewrite marker before all replayed messages, so
1857
1908
  // retained thinking stays bound to the original prefix.
1858
1909
  const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1859
- const previousSummaryAt =
1860
- firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
1861
1910
  const previousSummaryMessage = previousSummaryForCompaction
1862
- ? createCompactionSummaryMessage(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
1863
- providerPayload:
1864
- previousNative?.provider === model.provider
1865
- ? {
1866
- type: "anthropicCompaction",
1867
- provider: previousNative.provider,
1868
- content: previousNative.content,
1869
- ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1870
- ...(previousNative.encryptedContent
1871
- ? { encryptedContent: previousNative.encryptedContent }
1872
- : {}),
1873
- ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1874
- }
1875
- : undefined,
1876
- })
1911
+ ? createCompactionSummaryMessage(
1912
+ previousSummaryForCompaction,
1913
+ tokensBefore,
1914
+ preparation.previousSummaryTimestamp ?? new Date().toISOString(),
1915
+ {
1916
+ historyRewriteAt: firstReplayed !== undefined ? firstReplayed.timestamp - 1 : undefined,
1917
+ providerPayload:
1918
+ previousNative?.provider === model.provider
1919
+ ? {
1920
+ type: "anthropicCompaction",
1921
+ provider: previousNative.provider,
1922
+ content: previousNative.content,
1923
+ ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1924
+ ...(previousNative.encryptedContent
1925
+ ? { encryptedContent: previousNative.encryptedContent }
1926
+ : {}),
1927
+ ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1928
+ ...(previousNative.retainedFiles ? { retainedFiles: previousNative.retainedFiles } : {}),
1929
+ ...(previousNative.exactTail ? { exactTail: true as const } : {}),
1930
+ }
1931
+ : undefined,
1932
+ },
1933
+ )
1877
1934
  : undefined;
1878
1935
  const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1879
1936
  const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
@@ -1891,20 +1948,57 @@ export async function compact(
1891
1948
  ? firstKeptEntryId
1892
1949
  : (recentEntryIds?.[safeCut - originalCut] ?? "");
1893
1950
  for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
1894
- const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
1951
+ const summarizedMessages = [
1895
1952
  ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1896
1953
  ...allMessages.slice(0, safeCut),
1897
- ]);
1954
+ ];
1955
+ // Earlier file metadata replays before the first message created after
1956
+ // its summary. Metadata due inside the new retained tail stays out of the
1957
+ // request, and the new summary keeps replaying it. So does metadata due
1958
+ // right at the cut when the first kept message is a user-side turn: live
1959
+ // requests sent it merged into that turn, and ending the request with it
1960
+ // would merge the summarized range into the kept turn instead, which
1961
+ // invalidates the kept thinking.
1962
+ const firstRetained = allMessages[safeCut];
1963
+ const filesDueBefore =
1964
+ firstRetained === undefined
1965
+ ? undefined
1966
+ : firstRetained.role === "assistant"
1967
+ ? firstRetained.timestamp
1968
+ : (allMessages.slice(0, safeCut).findLast(message => message.role !== "toolResult")?.timestamp ??
1969
+ Number.NEGATIVE_INFINITY);
1970
+ const previousPayload = previousSummaryMessage?.providerPayload;
1971
+ if (
1972
+ filesDueBefore !== undefined &&
1973
+ previousSummaryMessage !== undefined &&
1974
+ previousPayload?.type === "anthropicCompaction" &&
1975
+ previousPayload.exactTail
1976
+ ) {
1977
+ const carried = [
1978
+ ...(previousPayload.retainedFiles ?? []),
1979
+ ...(previousPayload.filesText
1980
+ ? [{ text: previousPayload.filesText, after: previousSummaryMessage.timestamp }]
1981
+ : []),
1982
+ ].filter(files => files.after >= filesDueBefore);
1983
+ if (carried.length > 0) nativeRetainedFiles = carried;
1984
+ }
1898
1985
  try {
1986
+ // The live turn's own system prompt, wire tools and transformed
1987
+ // history when the host can build them: kept thinking remains valid
1988
+ // only when the summarized prefix matches what was sent byte for byte.
1989
+ const context = summaryOptions.buildProviderContext
1990
+ ? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
1991
+ : {
1992
+ systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1993
+ messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
1994
+ tools: summaryOptions.tools,
1995
+ };
1899
1996
  const remote = await requestAnthropicNativeCompaction(
1900
1997
  model,
1901
1998
  apiKey,
1902
1999
  {
1903
- // The live prompt, not the local summarizer's synthetic system
1904
- // prompt: kept thinking remains valid only under identical controls.
1905
- systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1906
- messages,
1907
- tools: summaryOptions.tools,
2000
+ context,
2001
+ filesDueBefore,
1908
2002
  instructions: buildAnthropicCompactionInstructions(
1909
2003
  summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1910
2004
  customInstructions,
@@ -2026,6 +2120,8 @@ export async function compact(
2026
2120
  content: nativeSummary,
2027
2121
  ...(nativeSignature ? { signature: nativeSignature } : {}),
2028
2122
  ...(filesText ? { filesText } : {}),
2123
+ ...(nativeRetainedFiles ? { retainedFiles: nativeRetainedFiles } : {}),
2124
+ exactTail: true,
2029
2125
  model: model.id,
2030
2126
  usedTokens: nativeUsedTokens,
2031
2127
  });
@@ -101,8 +101,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
101
101
 
102
102
  export interface SessionInitEntry extends SessionEntryBase {
103
103
  type: "session_init";
104
- /** Full system prompt sent to the model */
105
- systemPrompt: string;
104
+ /** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
105
+ systemPrompt: string[] | string;
106
106
  /** Initial task/user message */
107
107
  task: string;
108
108
  /** Tools available to the agent */
@@ -90,12 +90,12 @@ interface NormalizedEstimateValue {
90
90
  imageTokens: number;
91
91
  }
92
92
 
93
- function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
93
+ function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
94
94
  if (Array.isArray(value)) {
95
95
  const normalized: unknown[] = [];
96
96
  let imageTokens = 0;
97
97
  for (const item of value) {
98
- const result = normalizeRemoteCompactionEstimateValue(item);
98
+ const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
99
99
  normalized.push(result.value);
100
100
  imageTokens += result.imageTokens;
101
101
  }
@@ -123,14 +123,25 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
123
123
  // Opaque encrypted reasoning/compaction state: its local base64 size far
124
124
  // exceeds what the provider bills, so it stays out of the fit estimate
125
125
  // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
126
- if (key === "encrypted_content" && typeof item === "string") continue;
127
- const result = normalizeRemoteCompactionEstimateValue(item);
126
+ if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
127
+ const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
128
128
  normalized[key] = result.value;
129
129
  imageTokens += result.imageTokens;
130
130
  }
131
131
  return { value: normalized, imageTokens };
132
132
  }
133
133
 
134
+ /**
135
+ * Tokens of stored Responses replacement history. Retained `input_image` parts
136
+ * count at their image estimate rather than as base64 text; everything else,
137
+ * opaque compaction state included, counts as serialized.
138
+ */
139
+ export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
140
+ const normalized = normalizeRemoteCompactionEstimateValue(items, false);
141
+ const serialized = stringifyJson(normalized.value);
142
+ return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
143
+ }
144
+
134
145
  export interface TrimRemoteCompactionInputResult {
135
146
  input: Array<Record<string, unknown>>;
136
147
  rewrittenOutputs: number;
@@ -1,4 +1,10 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import {
3
+ type ImageSize,
4
+ type ImageTokenization,
5
+ imageTokens,
6
+ resolveImageTokenization,
7
+ } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
2
8
  import { parseImageMetadata } from "@oh-my-pi/pi-utils";
3
9
 
4
10
  /**
@@ -9,63 +15,30 @@ import { parseImageMetadata } from "@oh-my-pi/pi-utils";
9
15
  * stayed under the local compaction trigger yet was refused as over-window by
10
16
  * the remote probe.
11
17
  *
12
- * Follows OpenAI's patch-based rule (32px patches, a per-detail pixel limit
13
- * and patch budget, x1.2 multiplier), which is also a close upper estimate for
14
- * other providers once omp has downscaled the image (≤1568px by default).
18
+ * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
19
+ * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
20
+ * which is also a close upper estimate for other providers once omp has
21
+ * downscaled the image (≤1568px by default).
15
22
  */
16
23
 
17
24
  /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
18
25
  export type ImageDetail = ImageContent["detail"];
19
26
 
20
- export interface ImageSize {
21
- width: number;
22
- height: number;
23
- }
24
-
25
- const PATCH_PX = 32;
26
- const PATCH_TOKEN_MULTIPLIER = 1.2;
27
-
28
- interface DetailSizing {
29
- /** Longest side after the pixel-dimension fit. */
30
- maxDimension: number;
31
- /** Resizing patch budget, when the detail level defines one. */
32
- patchBudget?: number;
33
- /** Patches charged when the image size is unknown. */
34
- unknownPatches: number;
35
- }
36
-
37
- const LOW_SIZING: DetailSizing = { maxDimension: 512, unknownPatches: 16 * 16 };
38
- const HIGH_SIZING: DetailSizing = { maxDimension: 2048, patchBudget: 2_500, unknownPatches: 2_500 };
39
- // `auto` resolves to `original` sizing on current models, so it shares the
40
- // larger budget rather than risk undercounting.
41
- const ORIGINAL_SIZING: DetailSizing = { maxDimension: 6_000, patchBudget: 10_000, unknownPatches: 10_000 };
27
+ export type { ImageSize };
42
28
 
43
- function sizingFor(detail: ImageDetail): DetailSizing {
44
- if (detail === "low") return LOW_SIZING;
45
- if (detail === "high") return HIGH_SIZING;
46
- return ORIGINAL_SIZING;
47
- }
29
+ // Larger than every detail level's pixel limit, so an unknown size charges
30
+ // the level's full patch budget.
31
+ const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
48
32
 
49
- function countPatches(size: ImageSize, sizing: DetailSizing): number {
50
- let { width, height } = size;
51
- const longest = Math.max(width, height);
52
- if (longest > sizing.maxDimension) {
53
- const scale = sizing.maxDimension / longest;
54
- width = Math.max(1, Math.round(width * scale));
55
- height = Math.max(1, Math.round(height * scale));
56
- }
57
- const patches = Math.ceil(width / PATCH_PX) * Math.ceil(height / PATCH_PX);
58
- const budget = sizing.patchBudget;
59
- if (budget === undefined || patches <= budget) return patches;
33
+ let wireRule: ImageTokenization | undefined;
60
34
 
61
- const shrink = Math.sqrt((PATCH_PX * PATCH_PX * budget) / (width * height));
62
- const scaledW = (width * shrink) / PATCH_PX;
63
- const scaledH = (height * shrink) / PATCH_PX;
64
- const adjusted = shrink * Math.min(Math.floor(scaledW) / scaledW, Math.floor(scaledH) / scaledH);
65
- const resizedW = Math.floor(width * adjusted);
66
- const resizedH = Math.floor(height * adjusted);
67
- if (resizedW <= 0 || resizedH <= 0) return budget;
68
- return Math.min(budget, Math.ceil(resizedW / PATCH_PX) * Math.ceil(resizedH / PATCH_PX));
35
+ /** The OpenAI Responses wire's image rule, resolved from the catalog once. */
36
+ function openAiWireRule(): ImageTokenization {
37
+ if (wireRule) return wireRule;
38
+ const rule = resolveImageTokenization({ api: "openai-responses" });
39
+ if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
40
+ wireRule = rule;
41
+ return rule;
69
42
  }
70
43
 
71
44
  /**
@@ -73,9 +46,7 @@ function countPatches(size: ImageSize, sizing: DetailSizing): number {
73
46
  * remote URLs, provider file ids) charge the detail level's full patch budget.
74
47
  */
75
48
  export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
76
- const sizing = sizingFor(detail);
77
- const patches = size && size.width > 0 && size.height > 0 ? countPatches(size, sizing) : sizing.unknownPatches;
78
- return Math.ceil(patches * PATCH_TOKEN_MULTIPLIER);
49
+ return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
79
50
  }
80
51
 
81
52
  // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
package/src/telemetry.ts CHANGED
@@ -1790,8 +1790,8 @@ export interface InstrumentedChatSpanOptions {
1790
1790
  /**
1791
1791
  * Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
1792
1792
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
1793
- * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
1794
- * outside this package to add new kinds without bumping the helper.
1793
+ * `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
1794
+ * to allow callers outside this package to add new kinds without bumping the helper.
1795
1795
  */
1796
1796
  readonly oneshotKind?: string;
1797
1797
  /** Extra span attributes applied verbatim. */
package/src/tokenizer.ts CHANGED
@@ -5,7 +5,7 @@ import { materializeString, stringifyJson } from "@oh-my-pi/pi-utils";
5
5
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
6
6
  import * as snapcompact from "@oh-my-pi/snapcompact";
7
7
  import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
8
- import { estimateImageContentTokens } from "./image-tokens";
8
+ import { base64ImageSize, estimateImageContentTokens } from "./image-tokens";
9
9
  import type { AgentMessage } from "./types";
10
10
 
11
11
  const testEnv = Bun.env.NODE_ENV === "test";
@@ -149,13 +149,15 @@ interface MessageEstimate {
149
149
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
150
150
  * is fixed at construction, so a cached count can never straddle two
151
151
  * encodings. An `Agent` owns one for its active model (swapping the instance
152
- * when the model's encoding changes); one-shot flows construct their own for
153
- * the model that will be billed. Known tokenizer families use exact native
154
- * counts; unknown models keep the fast byte estimate (or o200k when
155
- * `PI_TOKENIZER_ACCURATE=1`).
152
+ * when the model's encoding or snapcompact frame pricing changes); one-shot
153
+ * flows construct their own for the model that will be billed. Known
154
+ * tokenizer families use exact native counts; unknown models keep the fast
155
+ * byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
156
156
  */
157
157
  export class Tokenizer {
158
158
  readonly #encoding: natives.Encoding | null;
159
+ readonly #frameBilling: snapcompact.FrameBilling;
160
+ readonly frameBillingKey: string;
159
161
 
160
162
  /** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
161
163
  readonly #nativeCounts = new LRUCache<string, number>({
@@ -174,8 +176,10 @@ export class Tokenizer {
174
176
  */
175
177
  #estimates = new WeakMap<AgentMessage, MessageEstimate>();
176
178
 
177
- constructor(model?: Pick<Model, "tokenizer"> | null) {
179
+ constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
178
180
  this.#encoding = tokenizerEncodingForModel(model);
181
+ this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
182
+ this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
179
183
  }
180
184
 
181
185
  get encoding(): natives.Encoding | null {
@@ -332,11 +336,12 @@ export class Tokenizer {
332
336
  if (message.blocks) {
333
337
  for (const block of message.blocks) {
334
338
  if (block.type === "text") fragments.push(block.text);
335
- else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
339
+ else extra += this.#frameTokens(block.data);
336
340
  }
337
341
  } else if (message.images) {
338
- // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
339
- extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
342
+ for (const image of message.images) {
343
+ extra += this.#frameTokens(image.data);
344
+ }
340
345
  }
341
346
  }
342
347
  break;
@@ -348,4 +353,10 @@ export class Tokenizer {
348
353
  if (fragments.length === 0) return extra;
349
354
  return extra + this.countTokens(fragments);
350
355
  }
356
+
357
+ /** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
358
+ #frameTokens(data: string): number {
359
+ const size = base64ImageSize(data);
360
+ return size ? snapcompact.frameTokens(this.#frameBilling, size) : snapcompact.FRAME_TOKEN_ESTIMATE;
361
+ }
351
362
  }