@oh-my-pi/pi-agent-core 18.1.17 → 18.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.18] - 2026-09-11
6
+
7
+ ### Added
8
+
9
+ - Anthropic server-side compaction as a `remote` compaction backend: model lines the beta supports (`compat.supportsServerCompaction`, rule-owned in the catalog: Opus 4.6+, Sonnet 4.6+, Fable/Mythos 5) on the official endpoint, resolved the way the provider routes requests, plus Anthropic-compatible routes with `remoteCompaction.enabled`, compact by re-issuing the live turn's own request — same system prompt, tools, and history, so it reads the prompt cache the last turn wrote — with the `compact_20260112` edit paused after the summary and the harness summary prompt as `instructions`. The instructions name where the retained tail begins so the summary covers only the history the rebuilt context drops. The API's summary is stored as the entry text and as `preserveData.anthropicCompaction`, replayed natively on later Anthropic requests and read as plain text by every other provider; the retained tail comes from session entries as with a local summary. Contexts below 55k tokens (the API trigger floor plus margin) keep summarizing locally, and a response without a summary is a native failure, like the OpenAI lanes. An aborted compaction response is the abort (a cancellation, never a native failure) and an error response keeps its HTTP status, so auth and timeout classification match the OpenAI lanes; the block's opaque `encrypted_content` is persisted as `preserveData.anthropicCompaction.encryptedContent` and replayed verbatim.
10
+
11
+ ### Fixed
12
+
13
+ - `compact()` now forwards the caller's `oneshotRetry` opt-out to every summarization oneshot; auto-compaction's outer retry loop no longer multiplies with the inner transient-failure retries.
14
+
5
15
  ## [18.1.17] - 2026-09-10
6
16
 
7
17
  ### Changed
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Anthropic server-side compaction (`compact-2026-01-12` beta).
3
+ *
4
+ * The compaction request is the live turn's own request shape — same system
5
+ * prompt, tools, and message history — plus the `compact_20260112` edit with
6
+ * `pause_after_compaction`. The API summarizes the prompt from the already
7
+ * cached prefix and stops; the summary arrives as a `compaction` block that
8
+ * the provider surfaces as an `anthropicCompaction` payload. The summary is
9
+ * plain text, so it doubles as the compaction entry's readable summary for
10
+ * every other provider, while the Anthropic provider replays it as a native
11
+ * block (the API drops everything that precedes it). The retained tail after
12
+ * the cut point is replayed from session entries exactly like a local summary.
13
+ */
14
+ import type { AnthropicCompactionPayload, ApiKey, Effort, Message, Model, SimpleStreamOptions, Tool, Usage } from "@oh-my-pi/pi-ai";
15
+ import { type InstrumentedChatSpanOptions } from "../telemetry.js";
16
+ export declare const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
17
+ /** The API rejects a `compact_20260112` trigger below this many input tokens. */
18
+ export declare const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50000;
19
+ /**
20
+ * Smallest context the native lane accepts. The trigger sits at the API
21
+ * floor, so a prompt that lands below it is answered instead of compacted;
22
+ * the margin over the floor absorbs the difference between the last reported
23
+ * context size and the compaction request's own input.
24
+ */
25
+ export declare const ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS = 55000;
26
+ /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
27
+ export interface AnthropicCompactionPreserveData {
28
+ provider: string;
29
+ content: string;
30
+ /** Opaque provider state the API attached to the block; replayed verbatim. */
31
+ encryptedContent?: string;
32
+ /** Harness file metadata (`<files>` section) replayed after the native block. */
33
+ filesText?: string;
34
+ /** Model that wrote the summary. */
35
+ model?: string;
36
+ /** Prompt tokens the compaction request processed, for display. */
37
+ usedTokens?: number;
38
+ }
39
+ /**
40
+ * Whether a model compacts through the Anthropic compaction beta. Model
41
+ * eligibility is catalog policy (`compat.supportsServerCompaction`, the
42
+ * lineage the beta documents); endpoint eligibility is resolved the way the
43
+ * provider routes requests, so a Foundry or `ANTHROPIC_BASE_URL` reroute of a
44
+ * first-party model is excluded unless the route opted in with
45
+ * `remoteCompaction.enabled`.
46
+ */
47
+ export declare function shouldUseAnthropicNativeCompaction(model: Model): model is Model<"anthropic-messages">;
48
+ export declare function getPreservedAnthropicCompactionData(preserveData: Record<string, unknown> | undefined): AnthropicCompactionPreserveData | undefined;
49
+ /** Set or strip the Anthropic compaction slot; a new compaction never inherits a stale summary. */
50
+ export declare function withAnthropicCompactionPreserveData(preserveData: Record<string, unknown> | undefined, compaction: AnthropicCompactionPreserveData | undefined): Record<string, unknown> | undefined;
51
+ /** Replay payload for a compaction summary the active model produced natively. */
52
+ export declare function getAnthropicCompactionPayload(preserveData: Record<string, unknown> | undefined): AnthropicCompactionPayload | undefined;
53
+ /**
54
+ * The retained tail as the model will see it, for the summarization
55
+ * instructions: how many of the conversation's final wire messages stay in
56
+ * context verbatim, and the role of the first. The compaction request carries
57
+ * the whole conversation so the prompt cache the live turn wrote is read, but
58
+ * the summary must cover only the history before that tail — the local
59
+ * summarizer never sees the tail, and the rebuilt context replays it after the
60
+ * summary. Counting mirrors the provider's message conversion (consecutive
61
+ * tool results collapse into one user message; developer messages are user
62
+ * messages). Structured for the prompt template, which renders the
63
+ * singular/plural wording; the description quotes no content: quoting the
64
+ * tail would hand the summarizer the very facts it must leave to the tail.
65
+ */
66
+ export interface RetainedTailScope {
67
+ count: number;
68
+ role: "assistant" | "user";
69
+ }
70
+ export declare function describeRetainedTail(messages: readonly Message[]): RetainedTailScope | undefined;
71
+ /**
72
+ * Summarization prompt sent as the edit's `instructions`, which replace the
73
+ * API default entirely. The template lays out the retained-tail boundary
74
+ * first, so the summary covers only the history the rebuilt context drops,
75
+ * then the caller's extra context, the same structure prompt as the local
76
+ * summarizer, the caller's focus, and the tool-abstention clause the API
77
+ * recommends when tools are defined (a summarization pass that calls a tool
78
+ * yields no summary).
79
+ */
80
+ export declare function buildAnthropicCompactionInstructions(basePrompt: string, customInstructions: string | undefined, extraContext: string | undefined, retainedTail: RetainedTailScope | undefined): string;
81
+ export interface AnthropicNativeCompactionRequest {
82
+ systemPrompt: string[];
83
+ messages: Message[];
84
+ tools?: Tool[];
85
+ instructions: string;
86
+ maxTokens: number;
87
+ reasoning?: Effort;
88
+ }
89
+ export interface AnthropicNativeCompactionResponse {
90
+ content: string;
91
+ encryptedContent?: string;
92
+ usage: Usage;
93
+ model: string;
94
+ }
95
+ export interface AnthropicNativeCompactionOptions extends Pick<SimpleStreamOptions, "initiatorOverride" | "metadata" | "fetch" | "sessionId" | "promptCacheKey" | "providerSessionState" | "maxInFlightRequests">, Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {
96
+ }
97
+ /**
98
+ * Run one compaction request and return the summary the API wrote, with the
99
+ * opaque `encrypted_content` the API attached for the replay. `completeSimple`
100
+ * resolves terminal failures as messages, so their classification is restored
101
+ * here: an aborted response is an `AbortError` (a cancellation, never a native
102
+ * failure) and an error response keeps its HTTP status, so auth and timeout
103
+ * handling downstream classify it the same way as the OpenAI lanes. A response
104
+ * without a summary is a native failure — the API answers the prompt instead
105
+ * when its input never reached the trigger, and returns an empty block when
106
+ * the model called a tool during summarization.
107
+ */
108
+ export declare function requestAnthropicNativeCompaction(model: Model<"anthropic-messages">, apiKey: ApiKey, request: AnthropicNativeCompactionRequest, signal: AbortSignal | undefined, options: AnthropicNativeCompactionOptions): Promise<AnthropicNativeCompactionResponse>;
@@ -65,7 +65,11 @@ export declare const DEFAULT_RESERVE_TOKENS = 16384;
65
65
  */
66
66
  export declare const MAX_SUMMARY_TOKENS = 16384;
67
67
  export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
68
- /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
68
+ /**
69
+ * Whether a compaction candidate preserves provider-native transport under the
70
+ * effective settings: an OpenAI Responses compact route (V1 or streamed V2) or
71
+ * the Anthropic compaction beta.
72
+ */
69
73
  export declare function shouldUseProviderNativeCompaction(model: Model, settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">): boolean;
70
74
  /**
71
75
  * Calculate total context tokens from usage.
@@ -290,6 +294,8 @@ export interface CompactionPreparation {
290
294
  tokensBefore: number;
291
295
  /** Summary from previous compaction, for iterative update */
292
296
  previousSummary?: string;
297
+ /** ISO timestamp of the previous compaction entry, for iterative update */
298
+ previousSummaryTimestamp?: string;
293
299
  /** Preserved opaque compaction payload from the previous compaction, if any. */
294
300
  previousPreserveData?: Record<string, unknown>;
295
301
  /** File operations extracted from messagesToSummarize */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Compaction and summarization utilities.
3
3
  */
4
+ export * from "./anthropic.js";
4
5
  export * from "./branch-summarization.js";
5
6
  export * from "./compaction.js";
6
7
  export * from "./entries.js";
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.1.17",
4
+ "version": "18.1.18",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -35,16 +35,16 @@
35
35
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "18.1.17",
39
- "@oh-my-pi/pi-catalog": "18.1.17",
40
- "@oh-my-pi/pi-natives": "18.1.17",
41
- "@oh-my-pi/pi-utils": "18.1.17",
42
- "@oh-my-pi/pi-wire": "18.1.17",
43
- "@oh-my-pi/snapcompact": "18.1.17",
38
+ "@oh-my-pi/pi-ai": "18.1.18",
39
+ "@oh-my-pi/pi-catalog": "18.1.18",
40
+ "@oh-my-pi/pi-natives": "18.1.18",
41
+ "@oh-my-pi/pi-utils": "18.1.18",
42
+ "@oh-my-pi/pi-wire": "18.1.18",
43
+ "@oh-my-pi/snapcompact": "18.1.18",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@oh-my-pi/omptype": "18.1.17",
47
+ "@oh-my-pi/omptype": "18.1.18",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "^1.3.14"
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Anthropic server-side compaction (`compact-2026-01-12` beta).
3
+ *
4
+ * The compaction request is the live turn's own request shape — same system
5
+ * prompt, tools, and message history — plus the `compact_20260112` edit with
6
+ * `pause_after_compaction`. The API summarizes the prompt from the already
7
+ * cached prefix and stops; the summary arrives as a `compaction` block that
8
+ * the provider surfaces as an `anthropicCompaction` payload. The summary is
9
+ * plain text, so it doubles as the compaction entry's readable summary for
10
+ * every other provider, while the Anthropic provider replays it as a native
11
+ * block (the API drops everything that precedes it). The retained tail after
12
+ * the cut point is replayed from session entries exactly like a local summary.
13
+ */
14
+
15
+ import type {
16
+ AnthropicCompactionPayload,
17
+ ApiKey,
18
+ AssistantMessage,
19
+ Effort,
20
+ Message,
21
+ Model,
22
+ SimpleStreamOptions,
23
+ Tool,
24
+ Usage,
25
+ } from "@oh-my-pi/pi-ai";
26
+ import * as AIError from "@oh-my-pi/pi-ai/error";
27
+ import { supportsAnthropicCompaction } from "@oh-my-pi/pi-ai/providers/anthropic";
28
+ import { isRecord, prompt } from "@oh-my-pi/pi-utils";
29
+ import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
30
+ import anthropicCompactionInstructionsPrompt from "./prompts/anthropic-compaction-instructions.md" with { type: "text" };
31
+
32
+ export const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
33
+
34
+ /** The API rejects a `compact_20260112` trigger below this many input tokens. */
35
+ export const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50_000;
36
+
37
+ /**
38
+ * Smallest context the native lane accepts. The trigger sits at the API
39
+ * floor, so a prompt that lands below it is answered instead of compacted;
40
+ * the margin over the floor absorbs the difference between the last reported
41
+ * context size and the compaction request's own input.
42
+ */
43
+ export const ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS = 55_000;
44
+
45
+ /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
46
+ export interface AnthropicCompactionPreserveData {
47
+ provider: string;
48
+ content: string;
49
+ /** Opaque provider state the API attached to the block; replayed verbatim. */
50
+ encryptedContent?: string;
51
+ /** Harness file metadata (`<files>` section) replayed after the native block. */
52
+ filesText?: string;
53
+ /** Model that wrote the summary. */
54
+ model?: string;
55
+ /** Prompt tokens the compaction request processed, for display. */
56
+ usedTokens?: number;
57
+ }
58
+
59
+ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
60
+ return model.api === "anthropic-messages";
61
+ }
62
+
63
+ /**
64
+ * Whether a model compacts through the Anthropic compaction beta. Model
65
+ * eligibility is catalog policy (`compat.supportsServerCompaction`, the
66
+ * lineage the beta documents); endpoint eligibility is resolved the way the
67
+ * provider routes requests, so a Foundry or `ANTHROPIC_BASE_URL` reroute of a
68
+ * first-party model is excluded unless the route opted in with
69
+ * `remoteCompaction.enabled`.
70
+ */
71
+ export function shouldUseAnthropicNativeCompaction(model: Model): model is Model<"anthropic-messages"> {
72
+ return isAnthropicMessagesModel(model) && supportsAnthropicCompaction(model);
73
+ }
74
+
75
+ export function getPreservedAnthropicCompactionData(
76
+ preserveData: Record<string, unknown> | undefined,
77
+ ): AnthropicCompactionPreserveData | undefined {
78
+ const candidate = preserveData?.[ANTHROPIC_COMPACTION_PRESERVE_KEY];
79
+ if (!isRecord(candidate)) return undefined;
80
+ if (typeof candidate.provider !== "string" || candidate.provider.length === 0) return undefined;
81
+ if (typeof candidate.content !== "string" || candidate.content.length === 0) return undefined;
82
+ return {
83
+ provider: candidate.provider,
84
+ content: candidate.content,
85
+ ...(typeof candidate.encryptedContent === "string" && candidate.encryptedContent.length > 0
86
+ ? { encryptedContent: candidate.encryptedContent }
87
+ : {}),
88
+ ...(typeof candidate.filesText === "string" && candidate.filesText.length > 0
89
+ ? { filesText: candidate.filesText }
90
+ : {}),
91
+ ...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
92
+ ...(typeof candidate.usedTokens === "number" ? { usedTokens: candidate.usedTokens } : {}),
93
+ };
94
+ }
95
+
96
+ /** Set or strip the Anthropic compaction slot; a new compaction never inherits a stale summary. */
97
+ export function withAnthropicCompactionPreserveData(
98
+ preserveData: Record<string, unknown> | undefined,
99
+ compaction: AnthropicCompactionPreserveData | undefined,
100
+ ): Record<string, unknown> | undefined {
101
+ if (compaction) {
102
+ return { ...preserveData, [ANTHROPIC_COMPACTION_PRESERVE_KEY]: compaction };
103
+ }
104
+ if (!preserveData || !(ANTHROPIC_COMPACTION_PRESERVE_KEY in preserveData)) {
105
+ return preserveData;
106
+ }
107
+ const { [ANTHROPIC_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
108
+ return Object.keys(rest).length > 0 ? rest : undefined;
109
+ }
110
+
111
+ /** Replay payload for a compaction summary the active model produced natively. */
112
+ export function getAnthropicCompactionPayload(
113
+ preserveData: Record<string, unknown> | undefined,
114
+ ): AnthropicCompactionPayload | undefined {
115
+ const preserved = getPreservedAnthropicCompactionData(preserveData);
116
+ if (!preserved) return undefined;
117
+ return {
118
+ type: "anthropicCompaction",
119
+ provider: preserved.provider,
120
+ content: preserved.content,
121
+ ...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
122
+ ...(preserved.filesText ? { filesText: preserved.filesText } : {}),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The retained tail as the model will see it, for the summarization
128
+ * instructions: how many of the conversation's final wire messages stay in
129
+ * context verbatim, and the role of the first. The compaction request carries
130
+ * the whole conversation so the prompt cache the live turn wrote is read, but
131
+ * the summary must cover only the history before that tail — the local
132
+ * summarizer never sees the tail, and the rebuilt context replays it after the
133
+ * summary. Counting mirrors the provider's message conversion (consecutive
134
+ * tool results collapse into one user message; developer messages are user
135
+ * messages). Structured for the prompt template, which renders the
136
+ * singular/plural wording; the description quotes no content: quoting the
137
+ * tail would hand the summarizer the very facts it must leave to the tail.
138
+ */
139
+ export interface RetainedTailScope {
140
+ count: number;
141
+ role: "assistant" | "user";
142
+ }
143
+
144
+ export function describeRetainedTail(messages: readonly Message[]): RetainedTailScope | undefined {
145
+ const first = messages[0];
146
+ if (!first) return undefined;
147
+ let count = 0;
148
+ let previousWasToolResult = false;
149
+ for (const message of messages) {
150
+ const isToolResult = message.role === "toolResult";
151
+ if (!(isToolResult && previousWasToolResult)) count += 1;
152
+ previousWasToolResult = isToolResult;
153
+ }
154
+ // Mirror the provider's trailing-assistant prefill: a tail ending in a
155
+ // live assistant turn gains a synthetic trailing user message on the
156
+ // wire, which stays verbatim too. Only blocks the converter emits count —
157
+ // blank text never serializes, and images, redacted thinking, and fallback
158
+ // markers need target context this scope lacks, so a turn of only those
159
+ // emits nothing and draws no pad. Without the pad in
160
+ // the scope, the summary could duplicate the tail head.
161
+ const last = messages[messages.length - 1];
162
+ if (last?.role === "assistant" && last.content.some(emitsWireBlock)) {
163
+ count += 1;
164
+ }
165
+ return { count, role: first.role === "assistant" ? "assistant" : "user" };
166
+ }
167
+
168
+ /**
169
+ * Whether an assistant content block reaches the wire. Server-tool blocks
170
+ * serialize unconditionally; blank text never does. Redacted thinking and
171
+ * fallback markers replay only for specific deployments, which the scope
172
+ * cannot see, so a turn of only those conservatively draws no pad.
173
+ */
174
+ function emitsWireBlock(block: AssistantMessage["content"][number]): boolean {
175
+ switch (block.type) {
176
+ case "text":
177
+ return block.text.trim().length > 0;
178
+ case "toolCall":
179
+ case "anthropicServerTool":
180
+ return true;
181
+ case "thinking":
182
+ return block.thinking.trim().length > 0 || (block.thinkingSignature ?? "").trim().length > 0;
183
+ default:
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Summarization prompt sent as the edit's `instructions`, which replace the
190
+ * API default entirely. The template lays out the retained-tail boundary
191
+ * first, so the summary covers only the history the rebuilt context drops,
192
+ * then the caller's extra context, the same structure prompt as the local
193
+ * summarizer, the caller's focus, and the tool-abstention clause the API
194
+ * recommends when tools are defined (a summarization pass that calls a tool
195
+ * yields no summary).
196
+ */
197
+ export function buildAnthropicCompactionInstructions(
198
+ basePrompt: string,
199
+ customInstructions: string | undefined,
200
+ extraContext: string | undefined,
201
+ retainedTail: RetainedTailScope | undefined,
202
+ ): string {
203
+ return prompt.render(anthropicCompactionInstructionsPrompt, {
204
+ basePrompt,
205
+ customInstructions,
206
+ extraContext,
207
+ retainedTail,
208
+ });
209
+ }
210
+
211
+ export interface AnthropicNativeCompactionRequest {
212
+ systemPrompt: string[];
213
+ messages: Message[];
214
+ tools?: Tool[];
215
+ instructions: string;
216
+ maxTokens: number;
217
+ reasoning?: Effort;
218
+ }
219
+
220
+ export interface AnthropicNativeCompactionResponse {
221
+ content: string;
222
+ encryptedContent?: string;
223
+ usage: Usage;
224
+ model: string;
225
+ }
226
+
227
+ export interface AnthropicNativeCompactionOptions
228
+ extends
229
+ Pick<
230
+ SimpleStreamOptions,
231
+ | "initiatorOverride"
232
+ | "metadata"
233
+ | "fetch"
234
+ | "sessionId"
235
+ | "promptCacheKey"
236
+ | "providerSessionState"
237
+ | "maxInFlightRequests"
238
+ >,
239
+ Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {}
240
+
241
+ /**
242
+ * Run one compaction request and return the summary the API wrote, with the
243
+ * opaque `encrypted_content` the API attached for the replay. `completeSimple`
244
+ * resolves terminal failures as messages, so their classification is restored
245
+ * here: an aborted response is an `AbortError` (a cancellation, never a native
246
+ * failure) and an error response keeps its HTTP status, so auth and timeout
247
+ * handling downstream classify it the same way as the OpenAI lanes. A response
248
+ * without a summary is a native failure — the API answers the prompt instead
249
+ * when its input never reached the trigger, and returns an empty block when
250
+ * the model called a tool during summarization.
251
+ */
252
+ export async function requestAnthropicNativeCompaction(
253
+ model: Model<"anthropic-messages">,
254
+ apiKey: ApiKey,
255
+ request: AnthropicNativeCompactionRequest,
256
+ signal: AbortSignal | undefined,
257
+ options: AnthropicNativeCompactionOptions,
258
+ ): Promise<AnthropicNativeCompactionResponse> {
259
+ const response = await instrumentedCompleteSimple(
260
+ model,
261
+ { systemPrompt: request.systemPrompt, messages: request.messages, tools: request.tools },
262
+ {
263
+ apiKey,
264
+ signal,
265
+ maxTokens: request.maxTokens,
266
+ reasoning: request.reasoning,
267
+ initiatorOverride: options.initiatorOverride,
268
+ metadata: options.metadata,
269
+ fetch: options.fetch,
270
+ sessionId: options.sessionId,
271
+ promptCacheKey: options.promptCacheKey,
272
+ providerSessionState: options.providerSessionState,
273
+ maxInFlightRequests: options.maxInFlightRequests,
274
+ anthropicCompaction: {
275
+ triggerInputTokens: ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS,
276
+ pauseAfterCompaction: true,
277
+ instructions: request.instructions,
278
+ },
279
+ },
280
+ {
281
+ telemetry: options.telemetry,
282
+ oneshotKind: "compaction_native",
283
+ completeImpl: options.completeImpl,
284
+ retry: options.retry,
285
+ },
286
+ );
287
+ if (response.stopReason === "aborted") {
288
+ throw new AIError.AbortError("Anthropic compaction aborted", { cause: signal?.reason });
289
+ }
290
+ if (response.stopReason === "error") {
291
+ const message = `Anthropic compaction failed: ${response.errorMessage ?? "unknown error"}`;
292
+ throw response.errorStatus === undefined
293
+ ? new Error(message)
294
+ : new AIError.ProviderHttpError(message, response.errorStatus);
295
+ }
296
+ const payload = response.providerPayload;
297
+ if (payload?.type !== "anthropicCompaction" || payload.content.length === 0) {
298
+ throw new Error(
299
+ response.stopDetails?.type === "compaction"
300
+ ? "Anthropic compaction returned no summary"
301
+ : "Anthropic compaction response carried no compaction block",
302
+ );
303
+ }
304
+ return {
305
+ content: payload.content,
306
+ encryptedContent: payload.encryptedContent,
307
+ usage: response.usage,
308
+ model: response.model,
309
+ };
310
+ }
@@ -42,6 +42,15 @@ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
42
42
  import { ThinkingLevel } from "../thinking";
43
43
  import { Tokenizer } from "../tokenizer";
44
44
  import type { AgentMessage } from "../types";
45
+ import {
46
+ ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS,
47
+ buildAnthropicCompactionInstructions,
48
+ describeRetainedTail,
49
+ getPreservedAnthropicCompactionData,
50
+ requestAnthropicNativeCompaction,
51
+ shouldUseAnthropicNativeCompaction,
52
+ withAnthropicCompactionPreserveData,
53
+ } from "./anthropic";
45
54
  import {
46
55
  buildCompactionV2Request,
47
56
  buildCompactionV2RequestFromBody,
@@ -53,7 +62,13 @@ import {
53
62
  } from "./compaction-v2-streaming";
54
63
  import type { CompactionEntry, SessionEntry } from "./entries";
55
64
  import { NativeCompactionError } from "./errors";
56
- import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
65
+ import {
66
+ type ConvertToLlm,
67
+ createBranchSummaryMessage,
68
+ createCompactionSummaryMessage,
69
+ createCustomMessage,
70
+ defaultConvertToLlm,
71
+ } from "./messages";
57
72
  import {
58
73
  buildOpenAiNativeHistory,
59
74
  getPreservedOpenAiRemoteCompactionData,
@@ -224,7 +239,11 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
224
239
  v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
225
240
  };
226
241
 
227
- /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
242
+ /**
243
+ * Whether a compaction candidate preserves provider-native transport under the
244
+ * effective settings: an OpenAI Responses compact route (V1 or streamed V2) or
245
+ * the Anthropic compaction beta.
246
+ */
228
247
  export function shouldUseProviderNativeCompaction(
229
248
  model: Model,
230
249
  settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
@@ -232,7 +251,8 @@ export function shouldUseProviderNativeCompaction(
232
251
  if (settings.remoteEnabled === false) return false;
233
252
  return (
234
253
  shouldUseOpenAiRemoteCompaction(model) ||
235
- (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model))
254
+ (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model)) ||
255
+ shouldUseAnthropicNativeCompaction(model)
236
256
  );
237
257
  }
238
258
 
@@ -1236,6 +1256,8 @@ export interface CompactionPreparation {
1236
1256
  tokensBefore: number;
1237
1257
  /** Summary from previous compaction, for iterative update */
1238
1258
  previousSummary?: string;
1259
+ /** ISO timestamp of the previous compaction entry, for iterative update */
1260
+ previousSummaryTimestamp?: string;
1239
1261
  /** Preserved opaque compaction payload from the previous compaction, if any. */
1240
1262
  previousPreserveData?: Record<string, unknown>;
1241
1263
  /** File operations extracted from messagesToSummarize */
@@ -1410,6 +1432,7 @@ export function prepareCompaction(
1410
1432
  isSplitTurn: cutPoint.isSplitTurn,
1411
1433
  tokensBefore,
1412
1434
  previousSummary: previousCompaction?.summary,
1435
+ previousSummaryTimestamp: previousCompaction?.timestamp,
1413
1436
  previousPreserveData: previousCompaction?.preserveData,
1414
1437
  fileOps,
1415
1438
  settings,
@@ -1566,6 +1589,9 @@ export async function compact(
1566
1589
  tools: options?.tools,
1567
1590
  fetch: options?.fetch,
1568
1591
  completeImpl: options?.completeImpl,
1592
+ // The caller's opt-out must reach every summarization oneshot, otherwise
1593
+ // an outer retry loop multiplies with the inner one (see SummaryOptions).
1594
+ oneshotRetry: options?.oneshotRetry,
1569
1595
  };
1570
1596
 
1571
1597
  const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
@@ -1580,7 +1606,10 @@ export async function compact(
1580
1606
  ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1581
1607
  : undefined;
1582
1608
 
1583
- let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1609
+ let preserveData = withAnthropicCompactionPreserveData(
1610
+ withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
1611
+ undefined,
1612
+ );
1584
1613
  const remoteMessages: AgentMessage[] = [
1585
1614
  ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1586
1615
  ...messagesToSummarize,
@@ -1765,6 +1794,112 @@ export async function compact(
1765
1794
  }
1766
1795
  }
1767
1796
 
1797
+ // Anthropic server-side compaction: the live turn's request shape plus the
1798
+ // compact edit, so the API summarizes from its cached prefix. The summary is
1799
+ // real text, persisted both as the entry summary and as the native replay
1800
+ // payload. A context below the API's trigger floor cannot compact remotely
1801
+ // and takes the local summarizer instead — an eligibility boundary, not a
1802
+ // failure.
1803
+ let nativeSummary: string | undefined;
1804
+ let nativeEncryptedContent: string | undefined;
1805
+ let nativeUsedTokens: number | undefined;
1806
+ if (
1807
+ !usedRemoteCompaction &&
1808
+ settings.remoteEnabled !== false &&
1809
+ shouldUseAnthropicNativeCompaction(model) &&
1810
+ tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
1811
+ ) {
1812
+ const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1813
+ // Lead with the previous summary exactly as the live context renders it:
1814
+ // natively when this provider wrote it, as text otherwise. The request
1815
+ // then shares the live turn's prefix byte-for-byte. A prior snapcompact
1816
+ // archive is already merged into that summary text, so the archive
1817
+ // migration message the OpenAI lanes carry is omitted here.
1818
+ // The rewrite marker must precede every message this request replays —
1819
+ // summarized history and retained tail alike — exactly like the live
1820
+ // context rebuild predates its tail. A previous compaction's commit
1821
+ // timestamp is newer than re-retained or re-summarized turns, so
1822
+ // reusing it would strip their bound thinking only in this request,
1823
+ // diverging from the cached live prefix (and possibly dropping below
1824
+ // the trigger). Manually built preparations with no input at all fall
1825
+ // back to the current time.
1826
+ const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1827
+ const previousSummaryAt =
1828
+ firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
1829
+ const previousSummaryMessage = previousSummaryForCompaction
1830
+ ? createCompactionSummaryMessage(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
1831
+ providerPayload:
1832
+ previousNative?.provider === model.provider
1833
+ ? {
1834
+ type: "anthropicCompaction",
1835
+ provider: previousNative.provider,
1836
+ content: previousNative.content,
1837
+ ...(previousNative.encryptedContent
1838
+ ? { encryptedContent: previousNative.encryptedContent }
1839
+ : {}),
1840
+ ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1841
+ }
1842
+ : undefined,
1843
+ })
1844
+ : undefined;
1845
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1846
+ const retainedTail = convertToLlm(recentMessages);
1847
+ const messages = [
1848
+ ...convertToLlm([
1849
+ ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1850
+ ...messagesToSummarize,
1851
+ ...turnPrefixMessages,
1852
+ ]),
1853
+ ...retainedTail,
1854
+ ];
1855
+ try {
1856
+ const remote = await requestAnthropicNativeCompaction(
1857
+ model,
1858
+ apiKey,
1859
+ {
1860
+ systemPrompt: summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT],
1861
+ messages,
1862
+ tools: summaryOptions.tools,
1863
+ instructions: buildAnthropicCompactionInstructions(
1864
+ summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1865
+ customInstructions,
1866
+ formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
1867
+ describeRetainedTail(retainedTail),
1868
+ ),
1869
+ maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
1870
+ reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1871
+ },
1872
+ signal,
1873
+ {
1874
+ initiatorOverride: summaryOptions.initiatorOverride,
1875
+ metadata: summaryOptions.metadata,
1876
+ fetch: summaryOptions.fetch,
1877
+ sessionId: summaryOptions.sessionId,
1878
+ promptCacheKey: summaryOptions.promptCacheKey,
1879
+ providerSessionState: summaryOptions.providerSessionState,
1880
+ completeImpl: summaryOptions.completeImpl,
1881
+ telemetry: summaryOptions.telemetry,
1882
+ retry: summaryOneshotRetry(summaryOptions),
1883
+ },
1884
+ );
1885
+ nativeSummary = remote.content;
1886
+ nativeEncryptedContent = remote.encryptedContent;
1887
+ nativeUsedTokens = calculatePromptTokens(remote.usage);
1888
+ usedRemoteCompaction = true;
1889
+ } catch (err) {
1890
+ // A user/session abort is a cancellation, not a remote failure —
1891
+ // swallowing it here would downgrade Esc into "fall back to local
1892
+ // summarization" and keep compaction running on an aborted signal.
1893
+ if (signal?.aborted) throw err;
1894
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1895
+ logger.warn("Anthropic server-side compaction failed", {
1896
+ error: err instanceof Error ? err.message : String(err),
1897
+ model: model.id,
1898
+ provider: model.provider,
1899
+ });
1900
+ }
1901
+ }
1902
+
1768
1903
  if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
1769
1904
  throw new NativeCompactionError(nativeCompactionError);
1770
1905
  }
@@ -1772,7 +1907,11 @@ export async function compact(
1772
1907
  // Generate summaries (can be parallel if both needed) and merge into one
1773
1908
  let summary: string;
1774
1909
 
1775
- if (usedRemoteCompaction) {
1910
+ if (nativeSummary !== undefined) {
1911
+ // The API wrote a real summary; it is the entry text. The replayed
1912
+ // block below stays verbatim so it matches the opaque state.
1913
+ summary = nativeSummary;
1914
+ } else if (usedRemoteCompaction) {
1776
1915
  // Remote compaction (V2 or V1) already compacted remotely; the durable
1777
1916
  // history lives in the provider replay payload (preserveData). Skip local
1778
1917
  // summarization so a successful remote compaction never pays for a second,
@@ -1831,6 +1970,22 @@ export async function compact(
1831
1970
  // Compute file lists and append to summary
1832
1971
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1833
1972
  summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
1973
+ if (nativeSummary !== undefined) {
1974
+ // The replayed block stays byte-identical to the API's summary so it
1975
+ // matches `encryptedContent`. The harness file lists above travel
1976
+ // separately: the converter replaces the summary message with the
1977
+ // block and skips its text, so they would otherwise be invisible to
1978
+ // this provider. Every other provider keeps reading the entry text.
1979
+ const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
1980
+ preserveData = withAnthropicCompactionPreserveData(preserveData, {
1981
+ provider: model.provider,
1982
+ content: nativeSummary,
1983
+ ...(nativeEncryptedContent ? { encryptedContent: nativeEncryptedContent } : {}),
1984
+ ...(filesText ? { filesText } : {}),
1985
+ model: model.id,
1986
+ usedTokens: nativeUsedTokens,
1987
+ });
1988
+ }
1834
1989
 
1835
1990
  if (!firstKeptEntryId) {
1836
1991
  throw new Error("First kept entry has no ID - session may need migration");
@@ -2,6 +2,7 @@
2
2
  * Compaction and summarization utilities.
3
3
  */
4
4
 
5
+ export * from "./anthropic";
5
6
  export * from "./branch-summarization";
6
7
  export * from "./compaction";
7
8
  export * from "./entries";
@@ -0,0 +1,17 @@
1
+ {{#if retainedTail}}
2
+ SCOPE: The conversation's final {{#when retainedTail.count "==" 1}}{{retainedTail.role}} message stays{{else}}{{retainedTail.count}} messages, starting with a {{retainedTail.role}} message, stay{{/when}} in context verbatim after your summary. Summarize ONLY the history before those messages. You MUST NOT restate anything from those final messages — the reader sees them right after the summary — and you MUST treat them as the most recent state when describing progress and next steps.
3
+ {{else}}
4
+ SCOPE: The conversation above is the transcript to summarize. The API replaces everything before your summary with it, so nothing you leave out survives into the next context window.
5
+ {{/if}}
6
+
7
+ {{#if extraContext}}
8
+ {{extraContext}}
9
+
10
+ {{/if}}
11
+ {{basePrompt}}
12
+ {{#if customInstructions}}
13
+
14
+ Additional focus: {{customInstructions}}
15
+ {{/if}}
16
+
17
+ You MUST NOT call any tools while writing the summary; respond with the summary text only.