@linxiraos/pi-agent-core 1.1.26 → 1.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -243
  2. package/README.md +1 -1
  3. package/dist/types/agent.d.ts +27 -7
  4. package/dist/types/compaction/anthropic.d.ts +14 -8
  5. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  6. package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
  7. package/dist/types/compaction/compaction.d.ts +24 -2
  8. package/dist/types/compaction/entries.d.ts +2 -2
  9. package/dist/types/compaction/messages.d.ts +6 -0
  10. package/dist/types/compaction/openai.d.ts +14 -2
  11. package/dist/types/compaction/pruning.d.ts +64 -16
  12. package/dist/types/compaction/tool-protection.d.ts +2 -1
  13. package/dist/types/compaction/utils.d.ts +5 -0
  14. package/dist/types/image-tokens.d.ts +29 -0
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/speculative-execution.d.ts +1 -1
  17. package/dist/types/telemetry.d.ts +70 -60
  18. package/dist/types/tokenizer.d.ts +9 -6
  19. package/dist/types/tool-arguments.d.ts +19 -0
  20. package/dist/types/types.d.ts +12 -1
  21. package/package.json +8 -8
  22. package/src/agent-loop.ts +191 -34
  23. package/src/agent.ts +126 -21
  24. package/src/append-only-context.ts +38 -29
  25. package/src/compaction/anthropic.ts +30 -12
  26. package/src/compaction/branch-summarization.ts +1 -1
  27. package/src/compaction/compaction-v2-streaming.ts +23 -16
  28. package/src/compaction/compaction.ts +163 -73
  29. package/src/compaction/entries.ts +2 -2
  30. package/src/compaction/message-cache.ts +1 -1
  31. package/src/compaction/messages.ts +25 -0
  32. package/src/compaction/openai.ts +65 -28
  33. package/src/compaction/pruning.ts +328 -96
  34. package/src/compaction/tool-protection.ts +8 -2
  35. package/src/compaction/utils.ts +9 -0
  36. package/src/image-tokens.ts +77 -0
  37. package/src/index.ts +2 -0
  38. package/src/prompts/dsml-tool-call-leak.md +19 -0
  39. package/src/speculative-execution.ts +5 -9
  40. package/src/telemetry.ts +100 -81
  41. package/src/tokenizer.ts +24 -17
  42. package/src/tool-arguments.ts +38 -0
  43. package/src/types.ts +12 -1
package/src/agent.ts CHANGED
@@ -26,6 +26,7 @@ import type { Dialect } from "@linxiraos/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@linxiraos/pi-ai/utils/harmony-leak";
27
27
  import { getBundledModel } from "@linxiraos/pi-catalog/models";
28
28
  import { logger } from "@linxiraos/pi-utils";
29
+ import * as snapcompact from "@linxiraos/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
 
@@ -441,6 +453,7 @@ export class Agent {
441
453
  #promptCacheKey?: string;
442
454
  #metadata?: Record<string, unknown>;
443
455
  #metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
456
+ #modelResolver?: (model: Model) => Model;
444
457
  #providerSessionState?: Map<string, ProviderSessionState>;
445
458
  #thinkingBudgets?: ThinkingBudgets;
446
459
  #temperature?: number;
@@ -484,6 +497,7 @@ export class Agent {
484
497
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
485
498
  #onBeforeYield?: () => Promise<void> | void;
486
499
  #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
500
+ #onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
487
501
  #beforeModelCall?: AgentBeforeModelCall;
488
502
  #additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
489
503
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
@@ -526,6 +540,11 @@ export class Agent {
526
540
  * are queued for the next boundary.
527
541
  */
528
542
  hasBackgroundCompletions?: AgentLoopConfig["hasBackgroundCompletions"];
543
+ /**
544
+ * Hook that peeks whether a passive aside is queued for the next boundary;
545
+ * ends an interruptible wait without interrupting other tools.
546
+ */
547
+ hasQueuedAsides?: AgentLoopConfig["hasQueuedAsides"];
529
548
 
530
549
  constructor(opts: AgentOptions = {}) {
531
550
  this.#state = { ...this.#state, ...opts.initialState };
@@ -831,19 +850,22 @@ export class Agent {
831
850
 
832
851
  /**
833
852
  * 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.
853
+ * active model's encoding or snapcompact frame pricing changes (see
854
+ * {@link setModel}), so callers must not cache it across model switches.
836
855
  */
837
856
  get tokenizer(): Tokenizer {
838
857
  return this.#tokenizer;
839
858
  }
840
859
 
841
860
  /**
842
- * Swap the tokenizer only when the encoding actually changes, so the warm
843
- * per-message memo survives same-encoding model switches.
861
+ * Swap the tokenizer only when the encoding or frame pricing actually
862
+ * changes, so the warm per-message memo survives same-pricing model switches.
844
863
  */
845
864
  #syncTokenizer(model: Model | null | undefined): void {
846
- if (tokenizerEncodingForModel(model) !== this.#tokenizer.encoding) {
865
+ if (
866
+ tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
867
+ snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
868
+ ) {
847
869
  this.#tokenizer = new Tokenizer(model);
848
870
  }
849
871
  }
@@ -917,7 +939,8 @@ export class Agent {
917
939
  }
918
940
 
919
941
  /** Register a listener notified after any steering/follow-up queue mutator
920
- * (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
942
+ * (enqueue, dequeue-on-delivery, clear, restore, live-steering adoption,
943
+ * withdrawal, and landing in the transcript) runs. Internal-only signal —
921
944
  * the queue itself has no concept of display filtering — so listeners
922
945
  * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
923
946
  * (or a higher-level view) on notification. */
@@ -967,7 +990,7 @@ export class Agent {
967
990
  if (messages.length === 0) return messages;
968
991
  const runController = this.#abortController;
969
992
  if (!prepare) {
970
- this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
993
+ this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
971
994
  return messages;
972
995
  }
973
996
 
@@ -986,7 +1009,13 @@ export class Agent {
986
1009
  throw new DOMException("Queued message preparation cancelled", "AbortError");
987
1010
  }
988
1011
  delete this.#queuedMessageClaims[queue];
989
- this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
1012
+ this.#queuedMessageDeliveries.add({
1013
+ queue,
1014
+ controller: runController,
1015
+ messages,
1016
+ next: 0,
1017
+ additional: additional ?? [],
1018
+ });
990
1019
  return additional?.length ? [...messages, ...additional] : messages;
991
1020
  } catch (error) {
992
1021
  if (signal.aborted) throw error;
@@ -1044,30 +1073,61 @@ export class Agent {
1044
1073
 
1045
1074
  /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
1046
1075
  #adoptLiveSteering(taken: readonly AgentMessage[]): void {
1076
+ let adopted = false;
1047
1077
  for (const delivery of this.#queuedMessageDeliveries) {
1048
1078
  const pending = delivery.messages.slice(delivery.next);
1049
1079
  const kept = pending.filter(message => !taken.includes(message));
1050
1080
  if (kept.length === pending.length) continue;
1081
+ let last: LiveSteeredEntry | undefined;
1082
+ adopted = true;
1051
1083
  for (const message of pending) {
1052
- if (taken.includes(message)) this.#liveSteered.push({ message, controller: delivery.controller });
1084
+ if (!taken.includes(message)) continue;
1085
+ last = { message, controller: delivery.controller };
1086
+ this.#liveSteered.push(last);
1053
1087
  }
1054
1088
  if (kept.length === 0) {
1089
+ // The delivery record goes away; keep its context withdrawable with the adopted batch.
1090
+ if (last && delivery.additional.length > 0) last.additional = delivery.additional;
1055
1091
  this.#queuedMessageDeliveries.delete(delivery);
1056
1092
  } else {
1057
1093
  delivery.messages = kept;
1058
1094
  delivery.next = 0;
1059
1095
  }
1060
1096
  }
1097
+ // The adopted messages move into #liveSteered, which queue snapshots
1098
+ // (peekLiveSteeredMessages) count as still pending; listeners must hear
1099
+ // about the swap or their queue view goes stale for the rest of the turn.
1100
+ if (adopted) this.#emitQueueChanged();
1061
1101
  }
1062
1102
 
1063
1103
  /**
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.
1104
+ * Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
1105
+ * it to the editor): steering live steering took for the in-flight response, then each batch
1106
+ * dequeued for the next model call, oldest first. The aborted run then neither records it (or
1107
+ * the context prepared for it) nor requeues it.
1066
1108
  */
1067
- withdrawLiveSteering(): AgentMessage[] {
1068
- const messages = this.peekLiveSteeredMessages();
1109
+ withdrawUndeliveredQueuedMessages(): { steering: AgentMessage[]; followUp: AgentMessage[] } {
1110
+ const withdrawn: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
1111
+ const suppress = (message: AgentMessage, controller: AbortController | undefined) => {
1112
+ if (controller) this.#withdrawnMessages.set(message, controller);
1113
+ };
1114
+ for (const { message, controller, additional } of this.#liveSteered) {
1115
+ withdrawn.steering.push(message);
1116
+ suppress(message, controller);
1117
+ for (const context of additional ?? []) suppress(context, controller);
1118
+ }
1119
+ // Every snapshot counts live-steered messages as pending, so clearing
1120
+ // them is a queue change even when the caller re-queues them right after.
1121
+ const hadLiveSteered = this.#liveSteered.length > 0;
1069
1122
  this.#liveSteered = [];
1070
- return messages;
1123
+ for (const delivery of this.#queuedMessageDeliveries) {
1124
+ const pending = delivery.messages.slice(delivery.next);
1125
+ withdrawn[delivery.queue].push(...pending);
1126
+ for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
1127
+ }
1128
+ this.#queuedMessageDeliveries.clear();
1129
+ if (hadLiveSteered) this.#emitQueueChanged();
1130
+ return withdrawn;
1071
1131
  }
1072
1132
 
1073
1133
  /** Steering live steering took for the streaming response; the transcript records it once
@@ -1101,6 +1161,11 @@ export class Agent {
1101
1161
  this.#onTurnEnd = fn;
1102
1162
  }
1103
1163
 
1164
+ /** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
1165
+ setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void {
1166
+ this.#onModelCallSystemPrompt = fn;
1167
+ }
1168
+
1104
1169
  /**
1105
1170
  * Install or replace the host pre-model-call gate; pass `undefined` to
1106
1171
  * remove it. Gates are sampled when a run starts: installing the first
@@ -1126,7 +1191,8 @@ export class Agent {
1126
1191
  /**
1127
1192
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
1128
1193
  * completions, late LSP diagnostics) drained at each step boundary. Never
1129
- * aborts in-flight tools. See `AgentLoopConfig.getAsideMessages`.
1194
+ * aborts foreground tools; an interruptible `wait` may end early through the
1195
+ * peek hooks. See `AgentLoopConfig.getAsideMessages`.
1130
1196
  */
1131
1197
  setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
1132
1198
  this.#asideMessageProvider = fn;
@@ -1159,8 +1225,20 @@ export class Agent {
1159
1225
  }
1160
1226
 
1161
1227
  setModel(model: Model) {
1162
- this.#state.model = model;
1163
- this.#syncTokenizer(model);
1228
+ // Sessions may start with no model selected (`initialState.model: undefined`).
1229
+ const resolved = this.#modelResolver && model ? this.#modelResolver(model) : model;
1230
+ this.#state.model = resolved;
1231
+ this.#syncTokenizer(resolved);
1232
+ }
1233
+
1234
+ /**
1235
+ * Route every model this agent adopts through `resolver` (e.g. to fit a
1236
+ * shared catalog row to this agent's own settings), starting with the
1237
+ * current one; `undefined` adopts models as given from now on.
1238
+ */
1239
+ setModelResolver(resolver: ((model: Model) => Model) | undefined): void {
1240
+ this.#modelResolver = resolver;
1241
+ if (resolver && this.#state.model) this.setModel(this.#state.model);
1164
1242
  }
1165
1243
 
1166
1244
  setThinkingLevel(l: Effort | undefined) {
@@ -1271,6 +1349,9 @@ export class Agent {
1271
1349
  const live = this.#liveSteered.findIndex(entry => entry.message === m);
1272
1350
  if (live >= 0) {
1273
1351
  this.#liveSteered.splice(live, 1);
1352
+ // The message landed in the transcript, so it stops counting as
1353
+ // queued pending input; snapshots shrink and listeners must hear it.
1354
+ this.#emitQueueChanged();
1274
1355
  return;
1275
1356
  }
1276
1357
  for (const delivery of this.#queuedMessageDeliveries) {
@@ -1800,6 +1881,7 @@ export class Agent {
1800
1881
  await Bun.sleep(0);
1801
1882
  }
1802
1883
  context.systemPrompt = this.#state.systemPrompt;
1884
+ this.#onModelCallSystemPrompt?.(context.systemPrompt);
1803
1885
  context.tools = this.#toolsForModel(this.#state.model ?? model);
1804
1886
  },
1805
1887
  beforeModelCall:
@@ -1864,6 +1946,7 @@ export class Agent {
1864
1946
  onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
1865
1947
  hasIrcInterrupts: this.hasIrcInterrupts,
1866
1948
  hasBackgroundCompletions: this.hasBackgroundCompletions,
1949
+ hasQueuedAsides: this.hasQueuedAsides,
1867
1950
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1868
1951
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1869
1952
  onBeforeYield: () => this.#onBeforeYield?.(),
@@ -1879,8 +1962,19 @@ export class Agent {
1879
1962
  ? agentLoop(messages, context, config, loopSignal, this.streamFn)
1880
1963
  : agentLoopContinue(context, config, loopSignal, this.streamFn);
1881
1964
 
1882
- for await (const event of stream) {
1965
+ for await (let event of stream) {
1883
1966
  if (this.#abortController !== loopAbortController) return;
1967
+ // Withdrawn input the loop already holds for this run never reaches the transcript.
1968
+ if (
1969
+ (event.type === "message_start" || event.type === "message_end") &&
1970
+ this.#withdrawnMessages.get(event.message) === loopAbortController
1971
+ ) {
1972
+ continue;
1973
+ }
1974
+ if (event.type === "agent_end") {
1975
+ const messages = event.messages.filter(m => this.#withdrawnMessages.get(m) !== loopAbortController);
1976
+ if (messages.length < event.messages.length) event = { ...event, messages };
1977
+ }
1884
1978
  if (event.type === "turn_start") turnOpen = true;
1885
1979
  if (event.type === "turn_end") turnOpen = false;
1886
1980
  // Update internal state based on events
@@ -2058,8 +2152,19 @@ export class Agent {
2058
2152
  turnOpen = false;
2059
2153
  this.#emit({ type: "agent_end", messages: agentEndMessages });
2060
2154
  } else {
2155
+ // Context hooks can reject on abort before provider streaming starts.
2156
+ // Publish the boundary just like a streaming abort so subscribers can
2157
+ // persist it and recover the interrupted turn after a reload.
2158
+ if (!turnOpen) this.#emit({ type: "turn_start" });
2159
+ if (!hadAssistantStart) {
2160
+ this.#state.streamMessage = errorMsg;
2161
+ this.#emit({ type: "message_start", message: errorMsg });
2162
+ }
2163
+ this.#state.streamMessage = null;
2061
2164
  this.appendMessage(errorMsg);
2062
2165
  this.#state.error = errorMessage;
2166
+ this.#emit({ type: "message_end", message: errorMsg });
2167
+ this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
2063
2168
  this.#emit({ type: "agent_end", messages: [errorMsg] });
2064
2169
  }
2065
2170
  } finally {
@@ -112,21 +112,23 @@ export class StablePrefix {
112
112
  * Build or rebuild from live context.
113
113
  * Returns `true` if the prefix actually changed (cache miss imminent).
114
114
  *
115
- * Steady-state fast path: when the live prompt reference is unchanged
116
- * AND every tool resolves to the same normalized parameters identity as
117
- * last build, the fingerprint cannot have changed, so the full snapshot
118
- * + stringify is skipped. Comparing resolved parameters (not the tool
115
+ * Steady-state fast path: when the live prompt has the same segments as
116
+ * last build AND every tool resolves to the same normalized parameters
117
+ * identity, the fingerprint cannot have changed, so the full snapshot +
118
+ * stringify is skipped. Comparing resolved parameters (not the tool
119
119
  * container) is load-bearing: tools like ReadTool expose `parameters` as
120
120
  * a getter over live settings (`skillful`, `memory.backend`), so the
121
121
  * schema can swap under stable tool references when a setting toggles.
122
122
  * Any other in-place mutation must go through `invalidate()`.
123
123
  */
124
- #lastPrompt: readonly string[] | undefined;
125
- // Joined prompt bytes snapshot: the prompt array is caller-owned and
124
+ // Segment-level prompt copy: the prompt array is caller-owned and
126
125
  // mutable in place (Agent.setSystemPrompt stores the caller's array;
127
126
  // anyone holding it can push/splice), so reference equality alone cannot
128
- // prove the bytes are unchanged. Compared by value on the fast path.
129
- #lastPromptText: string | undefined;
127
+ // prove the bytes are unchanged. Comparing the recorded segments against
128
+ // the live ones roots out that mutation without concatenating the whole
129
+ // prompt just to compare it — segments are immutable strings, so `!==`
130
+ // on two strings is a byte comparison.
131
+ #lastPromptSegments: readonly string[] | undefined;
130
132
  // Per-tool wire-identity snapshot: every field normalizeTools and
131
133
  // computeFingerprint read (name, description, resolved parameters,
132
134
  // strict, customFormat, customWireName, intent mode, examples
@@ -138,11 +140,17 @@ export class StablePrefix {
138
140
 
139
141
  build(context: AgentContext, options: BuildOptions): boolean {
140
142
  const prev = this.#snapshot;
141
- if (prev !== null && this.#fastPathHit(context, options)) {
143
+ // The per-tool keys are rendered once per build and thread from the
144
+ // fast-path check into the recorded key, so a miss does not re-render
145
+ // every live tool description a second time (`normalizeTools` reads
146
+ // the same getters inside takeSnapshot). `prev === null` skips the
147
+ // check entirely — there is nothing to compare against.
148
+ const toolKeys = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
149
+ if (prev !== null && this.#fastPathHit(context, options, toolKeys)) {
142
150
  return false;
143
151
  }
144
152
  const snapshot = takeSnapshot(context, options);
145
- this.#recordFastPathKey(context, options, snapshot.tools);
153
+ this.#recordFastPathKey(context, options, toolKeys);
146
154
  if (prev && prev.fingerprint === snapshot.fingerprint) {
147
155
  // Identity changed but bytes did not (e.g. equivalent rebuild):
148
156
  // keep serving the cached snapshot so downstream memo identity
@@ -154,47 +162,48 @@ export class StablePrefix {
154
162
  return true;
155
163
  }
156
164
 
157
- /** True when the cheap key matches: prompt + per-tool resolved identity. */
158
- #fastPathHit(context: AgentContext, options: BuildOptions): boolean {
165
+ /** True when the cheap key matches: prompt segments + per-tool identity. */
166
+ #fastPathHit(context: AgentContext, options: BuildOptions, toolKeys: readonly string[]): boolean {
159
167
  if (
160
168
  this.#lastIntentTracing !== options.intentTracing ||
161
169
  this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
162
170
  ) {
163
171
  return false;
164
172
  }
165
- // Prompt by reference first (steady state), then by joined bytes so
166
- // an in-place push/splice of the same array still misses.
167
- if (this.#lastPrompt !== context.systemPrompt) return false;
168
- if (this.#lastPromptText !== undefined) {
169
- const text = context.systemPrompt.join("\u0000");
170
- if (text !== this.#lastPromptText) return false;
173
+ // Prompt segments: the caller may push/splice the live array in place
174
+ // without any reference change, so compare the recorded segments
175
+ // rather than concatenating the whole prompt just to compare it.
176
+ const liveSegments = context.systemPrompt;
177
+ const cachedSegments = this.#lastPromptSegments;
178
+ if (cachedSegments === undefined || cachedSegments.length !== liveSegments.length) {
179
+ return false;
180
+ }
181
+ for (let i = 0; i < cachedSegments.length; i++) {
182
+ if (cachedSegments[i] !== liveSegments[i]) return false;
171
183
  }
172
- const tools = context.tools ?? [];
173
- if (this.#lastToolKey === undefined || this.#lastToolKey.length !== tools.length) {
184
+ const recorded = this.#lastToolKey;
185
+ if (recorded === undefined || recorded.length !== toolKeys.length) {
174
186
  return false;
175
187
  }
176
- for (let i = 0; i < tools.length; i++) {
177
- if (this.#lastToolKey[i] !== toolKeyForPrefix(tools[i]!)) {
188
+ for (let i = 0; i < toolKeys.length; i++) {
189
+ if (recorded[i] !== toolKeys[i]) {
178
190
  return false;
179
191
  }
180
192
  }
181
193
  return true;
182
194
  }
183
195
 
184
- #recordFastPathKey(context: AgentContext, options: BuildOptions, normalized: Tool[]): void {
185
- this.#lastPrompt = context.systemPrompt;
186
- this.#lastPromptText = context.systemPrompt.join("\u0000");
196
+ #recordFastPathKey(context: AgentContext, options: BuildOptions, renderedToolKeys: readonly string[]): void {
197
+ this.#lastPromptSegments = [...context.systemPrompt];
187
198
  this.#lastIntentTracing = options.intentTracing;
188
199
  this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
189
- this.#lastToolKey = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
190
- void normalized;
200
+ this.#lastToolKey = renderedToolKeys;
191
201
  }
192
202
 
193
203
  /** Force rebuild on the next `build()` call. */
194
204
  invalidate(): void {
195
205
  this.#snapshot = null;
196
- this.#lastPrompt = undefined;
197
- this.#lastPromptText = undefined;
206
+ this.#lastPromptSegments = undefined;
198
207
  this.#lastToolKey = undefined;
199
208
  this.#lastIntentTracing = undefined;
200
209
  this.#lastPruneToolDescriptions = undefined;
@@ -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 "@linxiraos/pi-ai";
19
20
  import * as AIError from "@linxiraos/pi-ai/error";
21
+ import { resolveAnthropicCompactionEffort } from "@linxiraos/pi-ai/providers/anthropic";
20
22
  import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic-compaction";
21
23
  import { isRecord, prompt } from "@linxiraos/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,
@@ -86,7 +86,7 @@ export interface GenerateBranchSummaryOptions {
86
86
  convertToLlm?: ConvertToLlm;
87
87
  /**
88
88
  * Optional telemetry handle. When provided, the branch summary LLM call is
89
- * wrapped in an OTEL chat span tagged with `omp.gen_ai.oneshot.kind = "branch_summary"`.
89
+ * wrapped in an OTEL chat span tagged with `zeta.gen_ai.oneshot.kind = "branch_summary"`.
90
90
  */
91
91
  telemetry?: AgentTelemetry;
92
92
  /**
@@ -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,
@@ -407,7 +412,16 @@ function buildCompactionV2Headers(
407
412
  : {
408
413
  "content-type": "application/json",
409
414
  ...resolveOpenAIRequestSetup(
410
- { provider: model.provider, id: model.id, baseUrl: model.baseUrl, headers: model.headers },
415
+ {
416
+ provider: model.provider,
417
+ id: model.id,
418
+ baseUrl: model.baseUrl,
419
+ headers: model.headers,
420
+ compat: {
421
+ sessionHeader:
422
+ model.compat && "sessionHeader" in model.compat ? model.compat.sessionHeader : undefined,
423
+ },
424
+ },
411
425
  { apiKey, messages: [], sessionId: request.sessionId ?? routingSessionId, promptCacheSessionId },
412
426
  ).headers,
413
427
  };
@@ -536,7 +550,7 @@ function finishCompactionV2Collection(
536
550
 
537
551
  const compactionItem = state.compactionItems[0];
538
552
  const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
539
- request.input,
553
+ request.retainedUserItems,
540
554
  compactionItem,
541
555
  request.retainedMessageBudget,
542
556
  );
@@ -665,15 +679,14 @@ function isRetryableCompactionError(error: Error): boolean {
665
679
  // Replacement History
666
680
  // ============================================================================
667
681
 
668
- /** Build Codex-style V2 replacement history from prompt input plus compaction output. */
682
+ /** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
669
683
  export function buildCompactionV2ReplacementHistory(
670
684
  input: unknown[],
671
685
  compactionItem: Record<string, unknown>,
672
686
  retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
673
687
  ): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
674
688
  const retained = input.filter(
675
- (item): item is Record<string, unknown> =>
676
- isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
689
+ (item): item is Record<string, unknown> => isRecord(item) && isRetainedUserMessageForCompactionV2(item),
677
690
  );
678
691
  const replacementHistory = truncateRetainedMessagesForCompactionV2(
679
692
  retained,
@@ -684,17 +697,11 @@ export function buildCompactionV2ReplacementHistory(
684
697
  return { replacementHistory, retainedImageCount };
685
698
  }
686
699
 
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);
700
+ function isRetainedUserMessageForCompactionV2(item: Record<string, unknown>): boolean {
701
+ // Responses input messages may omit `type`: omp serializes turns as
702
+ // `{ role, content }`, which the API reads as `type: "message"`.
703
+ const isMessage = item.type === "message" || (item.type === undefined && typeof item.role === "string");
704
+ return isMessage && item.role === "user" && !isContextualUserMessage(item);
698
705
  }
699
706
 
700
707
  function isContextualUserMessage(item: Record<string, unknown>): boolean {