@linxiraos/pi-agent-core 1.1.14 → 1.1.16

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.
@@ -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 "@linxiraos/pi-ai";
26
+ import * as AIError from "@linxiraos/pi-ai/error";
27
+ import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic";
28
+ import { isRecord, prompt } from "@linxiraos/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
+ }
@@ -32,7 +32,7 @@ import {
32
32
  OPENAI_HEADER_VALUES,
33
33
  OPENAI_HEADERS,
34
34
  } from "@linxiraos/pi-catalog/wire/codex";
35
- import { $env, logger, stringifyJson } from "@linxiraos/pi-utils";
35
+ import { $env, isUnexpectedSocketCloseMessage, logger, stringifyJson } from "@linxiraos/pi-utils";
36
36
 
37
37
  // ============================================================================
38
38
  // Types & Configuration
@@ -44,8 +44,8 @@ export const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
44
44
  /** Max retries for V2 streaming compaction on transient stream errors. */
45
45
  export const V2_COMPACTION_MAX_RETRIES = 2;
46
46
 
47
- /** Timeout for V2 streaming compaction (3 minutes, same as V1). */
48
- export const V2_COMPACTION_TIMEOUT_MS = 180_000;
47
+ /** Timeout for V2 streaming compaction (5 minutes, same as V1). */
48
+ export const V2_COMPACTION_TIMEOUT_MS = 300_000;
49
49
 
50
50
  const DEFAULT_AZURE_API_VERSION = "v1";
51
51
  const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
@@ -651,6 +651,7 @@ function isRetryableCompactionError(error: Error): boolean {
651
651
  }
652
652
  const message = error.message.toLowerCase();
653
653
  return (
654
+ isUnexpectedSocketCloseMessage(message) ||
654
655
  message.includes("stream closed before response.completed") ||
655
656
  message.includes("stream parse failed") ||
656
657
  message.includes("server_error") ||