@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.
- package/CHANGELOG.md +169 -26
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/agent.d.ts +17 -4
- package/dist/types/append-only-context.d.ts +0 -4
- package/dist/types/compaction/anthropic.d.ts +108 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +2 -2
- package/dist/types/compaction/compaction.d.ts +33 -18
- package/dist/types/compaction/entries.d.ts +2 -0
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/compaction/openai.d.ts +9 -8
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +63 -0
- package/dist/types/thinking.d.ts +1 -1
- package/dist/types/types.d.ts +212 -3
- package/package.json +10 -10
- package/src/agent-loop.ts +582 -86
- package/src/agent.ts +183 -38
- package/src/append-only-context.ts +122 -1
- package/src/compaction/anthropic.ts +310 -0
- package/src/compaction/compaction-v2-streaming.ts +4 -3
- package/src/compaction/compaction.ts +299 -131
- package/src/compaction/entries.ts +2 -0
- package/src/compaction/index.ts +1 -0
- package/src/compaction/openai.ts +17 -10
- package/src/compaction/prompts/anthropic-compaction-instructions.md +17 -0
- package/src/index.ts +2 -0
- package/src/proxy.ts +49 -8
- package/src/speculative-execution.ts +953 -0
- package/src/telemetry.ts +1 -0
- package/src/thinking.ts +1 -1
- package/src/tokenizer.ts +11 -2
- package/src/types.ts +232 -4
|
@@ -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 (
|
|
48
|
-
export const V2_COMPACTION_TIMEOUT_MS =
|
|
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") ||
|