@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
|
@@ -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,10 +62,17 @@ import {
|
|
|
53
62
|
} from "./compaction-v2-streaming";
|
|
54
63
|
import type { CompactionEntry, SessionEntry } from "./entries";
|
|
55
64
|
import { NativeCompactionError } from "./errors";
|
|
56
|
-
import {
|
|
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,
|
|
75
|
+
isOpenAiRemoteCompactionApi,
|
|
60
76
|
requestOpenAiRemoteCompaction,
|
|
61
77
|
requestRemoteCompaction,
|
|
62
78
|
shouldUseOpenAiRemoteCompaction,
|
|
@@ -224,7 +240,11 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
|
224
240
|
v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
225
241
|
};
|
|
226
242
|
|
|
227
|
-
/**
|
|
243
|
+
/**
|
|
244
|
+
* Whether a compaction candidate preserves provider-native transport under the
|
|
245
|
+
* effective settings: an OpenAI Responses compact route (V1 or streamed V2) or
|
|
246
|
+
* the Anthropic compaction beta.
|
|
247
|
+
*/
|
|
228
248
|
export function shouldUseProviderNativeCompaction(
|
|
229
249
|
model: Model,
|
|
230
250
|
settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
|
|
@@ -232,7 +252,8 @@ export function shouldUseProviderNativeCompaction(
|
|
|
232
252
|
if (settings.remoteEnabled === false) return false;
|
|
233
253
|
return (
|
|
234
254
|
shouldUseOpenAiRemoteCompaction(model) ||
|
|
235
|
-
(settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model))
|
|
255
|
+
(settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model)) ||
|
|
256
|
+
shouldUseAnthropicNativeCompaction(model)
|
|
236
257
|
);
|
|
237
258
|
}
|
|
238
259
|
|
|
@@ -395,22 +416,6 @@ export function resolveThresholdTokens(contextWindow: number, settings: Compacti
|
|
|
395
416
|
// Cut point detection
|
|
396
417
|
// ============================================================================
|
|
397
418
|
|
|
398
|
-
function estimateEntriesTokens(
|
|
399
|
-
entries: SessionEntry[],
|
|
400
|
-
tokenizer: Tokenizer,
|
|
401
|
-
startIndex: number,
|
|
402
|
-
endIndex: number,
|
|
403
|
-
): number {
|
|
404
|
-
let total = 0;
|
|
405
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
406
|
-
const msg = getMessageFromEntry(entries[i]);
|
|
407
|
-
if (msg) {
|
|
408
|
-
total += tokenizer.countMessage(msg);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
return total;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
419
|
/**
|
|
415
420
|
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
|
|
416
421
|
* Never cut at tool results (they must follow their tool call).
|
|
@@ -455,6 +460,23 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
|
|
|
455
460
|
return cutPoints;
|
|
456
461
|
}
|
|
457
462
|
|
|
463
|
+
/**
|
|
464
|
+
* True for entries that begin a conversational turn (a user request, a
|
|
465
|
+
* bash-execution card, a branch summary, or a custom user-role message).
|
|
466
|
+
* Compaction cut alignment, turn discovery, and the collapsed display
|
|
467
|
+
* transcript's orphan-head trim all share this boundary definition.
|
|
468
|
+
*/
|
|
469
|
+
export function isTurnStartEntry(entry: SessionEntry): boolean {
|
|
470
|
+
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (entry.type === "message") {
|
|
474
|
+
const role = entry.message.role as string;
|
|
475
|
+
return role === "user" || role === "bashExecution";
|
|
476
|
+
}
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
458
480
|
/**
|
|
459
481
|
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
|
460
482
|
* Returns -1 if no turn start found before the index.
|
|
@@ -462,17 +484,9 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
|
|
|
462
484
|
*/
|
|
463
485
|
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
|
464
486
|
for (let i = entryIndex; i >= startIndex; i--) {
|
|
465
|
-
|
|
466
|
-
// branch_summary and custom_message are user-role messages, can start a turn
|
|
467
|
-
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
|
487
|
+
if (isTurnStartEntry(entries[i])) {
|
|
468
488
|
return i;
|
|
469
489
|
}
|
|
470
|
-
if (entry.type === "message") {
|
|
471
|
-
const role = entry.message.role as string;
|
|
472
|
-
if (role === "user" || role === "bashExecution") {
|
|
473
|
-
return i;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
490
|
}
|
|
477
491
|
return -1;
|
|
478
492
|
}
|
|
@@ -487,10 +501,11 @@ export interface CutPointResult {
|
|
|
487
501
|
}
|
|
488
502
|
|
|
489
503
|
/**
|
|
490
|
-
* Find the
|
|
504
|
+
* Find the oldest complete recent-history suffix that fits `keepRecentTokens`.
|
|
491
505
|
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
506
|
+
* Walk backwards by valid cut points, measuring whole assistant/tool groups.
|
|
507
|
+
* Keep the newest group even when it alone exceeds the budget; never retain
|
|
508
|
+
* an additional older group that would push an otherwise fitting suffix over.
|
|
494
509
|
*
|
|
495
510
|
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
|
496
511
|
* assistant message with tool calls, its tool results come after and will be kept.
|
|
@@ -515,55 +530,45 @@ export function findCutPoint(
|
|
|
515
530
|
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
|
516
531
|
}
|
|
517
532
|
|
|
518
|
-
//
|
|
533
|
+
// Evaluate the budget only at valid boundaries, after counting all results
|
|
534
|
+
// belonging to an assistant. Checking individual messages can either retain
|
|
535
|
+
// the oversized older assistant or miss its boundary and retain all history.
|
|
519
536
|
let accumulatedTokens = 0;
|
|
520
|
-
let
|
|
537
|
+
let cutPointIndex = cutPoints.length - 1;
|
|
538
|
+
let cutIndex = cutPoints[cutPointIndex];
|
|
521
539
|
|
|
522
540
|
for (let i = endIndex - 1; i >= startIndex; i--) {
|
|
523
541
|
const entry = entries[i];
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
// Check if we've exceeded the budget
|
|
531
|
-
if (accumulatedTokens >= keepRecentTokens) {
|
|
532
|
-
// Find the closest valid cut point at or after this entry
|
|
533
|
-
for (let c = 0; c < cutPoints.length; c++) {
|
|
534
|
-
if (cutPoints[c] >= i) {
|
|
535
|
-
cutIndex = cutPoints[c];
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
break;
|
|
540
|
-
}
|
|
542
|
+
const message = getMessageFromEntry(entry);
|
|
543
|
+
if (message) accumulatedTokens += tokenizer.countMessage(message);
|
|
544
|
+
if (i !== cutPoints[cutPointIndex]) continue;
|
|
545
|
+
if (accumulatedTokens > keepRecentTokens) break;
|
|
546
|
+
cutIndex = i;
|
|
547
|
+
cutPointIndex--;
|
|
541
548
|
}
|
|
542
549
|
|
|
543
|
-
|
|
550
|
+
const isTurnStart = isTurnStartEntry(entries[cutIndex]);
|
|
551
|
+
const turnStartIndex = isTurnStart ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
|
552
|
+
|
|
553
|
+
// Scan backwards from cutIndex to include any non-message entries (settings changes, etc.)
|
|
544
554
|
while (cutIndex > startIndex) {
|
|
545
555
|
const prevEntry = entries[cutIndex - 1];
|
|
546
|
-
// Stop at session header or
|
|
547
|
-
if (prevEntry.type === "compaction") {
|
|
556
|
+
// Stop at session header, compaction, or reset boundaries
|
|
557
|
+
if (prevEntry.type === "compaction" || prevEntry.type === "reset_boundary") {
|
|
548
558
|
break;
|
|
549
559
|
}
|
|
550
|
-
if (prevEntry
|
|
551
|
-
// Stop if we hit any message
|
|
560
|
+
if (getMessageFromEntry(prevEntry)) {
|
|
561
|
+
// Stop if we hit any entry that contributes a message
|
|
552
562
|
break;
|
|
553
563
|
}
|
|
554
|
-
// Include this non-message entry (
|
|
564
|
+
// Include this non-message entry (settings change, label, etc.)
|
|
555
565
|
cutIndex--;
|
|
556
566
|
}
|
|
557
567
|
|
|
558
|
-
// Determine if this is a split turn
|
|
559
|
-
const cutEntry = entries[cutIndex];
|
|
560
|
-
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
|
561
|
-
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
|
562
|
-
|
|
563
568
|
return {
|
|
564
569
|
firstKeptEntryIndex: cutIndex,
|
|
565
570
|
turnStartIndex,
|
|
566
|
-
isSplitTurn: !
|
|
571
|
+
isSplitTurn: !isTurnStart && turnStartIndex !== -1,
|
|
567
572
|
};
|
|
568
573
|
}
|
|
569
574
|
|
|
@@ -1252,6 +1257,8 @@ export interface CompactionPreparation {
|
|
|
1252
1257
|
tokensBefore: number;
|
|
1253
1258
|
/** Summary from previous compaction, for iterative update */
|
|
1254
1259
|
previousSummary?: string;
|
|
1260
|
+
/** ISO timestamp of the previous compaction entry, for iterative update */
|
|
1261
|
+
previousSummaryTimestamp?: string;
|
|
1255
1262
|
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
1256
1263
|
previousPreserveData?: Record<string, unknown>;
|
|
1257
1264
|
/** File operations extracted from messagesToSummarize */
|
|
@@ -1261,19 +1268,27 @@ export interface CompactionPreparation {
|
|
|
1261
1268
|
}
|
|
1262
1269
|
|
|
1263
1270
|
/**
|
|
1264
|
-
* Whether
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1271
|
+
* Whether the active model's normal encoder can consume stored native history.
|
|
1272
|
+
* Creating future compactions is a separate policy: disabling it does not disable
|
|
1273
|
+
* Responses-family replay. Local summaries have no provider restriction.
|
|
1274
|
+
*/
|
|
1275
|
+
export function canReplayRemoteCompaction(
|
|
1276
|
+
preserveData: Record<string, unknown> | undefined,
|
|
1277
|
+
activeModel: Model,
|
|
1278
|
+
): boolean {
|
|
1279
|
+
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1280
|
+
return !remote || (remote.provider === activeModel.provider && isOpenAiRemoteCompactionApi(activeModel.api));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Whether compaction preparation may reuse a native boundary instead of
|
|
1285
|
+
* re-expanding its original messages. This is deliberately stricter than normal
|
|
1286
|
+
* replay: the active model must both read the payload and remain eligible for
|
|
1287
|
+
* native compaction under the current settings. Otherwise local summarization
|
|
1288
|
+
* needs the originals, not an opaque placeholder.
|
|
1272
1289
|
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
* let the active model replay it, so keying reuse on "any candidate shares the
|
|
1276
|
-
* provider" left a provider-switched session permanently context-less (#6343).
|
|
1290
|
+
* Main-session preparation is judged against the active model, not any role
|
|
1291
|
+
* candidate, so a provider switch cannot strand the original history (#6343).
|
|
1277
1292
|
*/
|
|
1278
1293
|
export function remotePreserveReusable(
|
|
1279
1294
|
preserveData: Record<string, unknown> | undefined,
|
|
@@ -1282,15 +1297,16 @@ export function remotePreserveReusable(
|
|
|
1282
1297
|
): boolean {
|
|
1283
1298
|
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1284
1299
|
if (!remote) return true;
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1300
|
+
return (
|
|
1301
|
+
remote.provider === activeModel.provider &&
|
|
1302
|
+
isOpenAiRemoteCompactionApi(activeModel.api) &&
|
|
1303
|
+
shouldUseProviderNativeCompaction(activeModel, settings)
|
|
1304
|
+
);
|
|
1289
1305
|
}
|
|
1290
1306
|
|
|
1291
1307
|
/**
|
|
1292
|
-
* Index of the newest compaction
|
|
1293
|
-
* `-1` when none can.
|
|
1308
|
+
* Index of the newest compaction boundary reusable under preparation policy,
|
|
1309
|
+
* or `-1` when none can be reused (see {@link remotePreserveReusable}).
|
|
1294
1310
|
*
|
|
1295
1311
|
* A provider-native remote compaction (V2 or V1) stores an opaque replay payload
|
|
1296
1312
|
* and only a placeholder summary, so for any OTHER provider that entry
|
|
@@ -1325,22 +1341,18 @@ export function prepareCompaction(
|
|
|
1325
1341
|
activeModel?: Model,
|
|
1326
1342
|
tokenizer: Tokenizer = new Tokenizer(activeModel),
|
|
1327
1343
|
): CompactionPreparation | undefined {
|
|
1328
|
-
|
|
1344
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
1345
|
+
// A speculative native record may leave uncovered messages before the record.
|
|
1346
|
+
if (lastEntry?.type === "compaction" && !lastEntry.providerReplayThroughEntryId) {
|
|
1329
1347
|
return undefined;
|
|
1330
1348
|
}
|
|
1331
1349
|
|
|
1332
1350
|
let prevCompactionIndex = findReadableCompactionIndex(pathEntries, settings, activeModel);
|
|
1333
1351
|
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
// must not resurrect the dropped pre-clear turns into its summary — matching
|
|
1337
|
-
// how buildSessionContext starts the model-context rebuild after the boundary.
|
|
1338
|
-
// A boundary after the last reusable compaction supersedes it: the pre-reset
|
|
1339
|
-
// summary was cleared too, so drop the previous-compaction reuse and start
|
|
1340
|
-
// fresh after the boundary. A boundary at or before that compaction is already
|
|
1341
|
-
// superseded by it, so only scan newer entries.
|
|
1352
|
+
// A newer reset clears the previous summary. An older reset bounds both
|
|
1353
|
+
// the local retained tail and the native snapshot-to-commit interval.
|
|
1342
1354
|
let resetBoundaryIndex = -1;
|
|
1343
|
-
for (let i = pathEntries.length - 1; i
|
|
1355
|
+
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
|
1344
1356
|
if (pathEntries[i].type === "reset_boundary") {
|
|
1345
1357
|
resetBoundaryIndex = i;
|
|
1346
1358
|
break;
|
|
@@ -1349,14 +1361,54 @@ export function prepareCompaction(
|
|
|
1349
1361
|
if (resetBoundaryIndex > prevCompactionIndex) {
|
|
1350
1362
|
prevCompactionIndex = -1;
|
|
1351
1363
|
}
|
|
1352
|
-
const
|
|
1353
|
-
|
|
1364
|
+
const previousCompaction =
|
|
1365
|
+
prevCompactionIndex >= 0 ? (pathEntries[prevCompactionIndex] as CompactionEntry) : undefined;
|
|
1366
|
+
let boundaryStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
|
|
1367
|
+
if (
|
|
1368
|
+
previousCompaction &&
|
|
1369
|
+
(getCompactionV2PreserveData(previousCompaction.preserveData) ||
|
|
1370
|
+
getPreservedOpenAiRemoteCompactionData(previousCompaction.preserveData))
|
|
1371
|
+
) {
|
|
1372
|
+
if (previousCompaction.providerReplayThroughEntryId) {
|
|
1373
|
+
const replayThroughIndex = pathEntries.findIndex(
|
|
1374
|
+
entry => entry.id === previousCompaction.providerReplayThroughEntryId,
|
|
1375
|
+
);
|
|
1376
|
+
if (replayThroughIndex >= 0 && replayThroughIndex < prevCompactionIndex) {
|
|
1377
|
+
// Native replay covers the snapshot, not messages appended while the
|
|
1378
|
+
// request was running. Include that interval in the next preparation.
|
|
1379
|
+
boundaryStart = Math.max(replayThroughIndex, resetBoundaryIndex) + 1;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
} else if (previousCompaction) {
|
|
1383
|
+
// Local summaries exclude the retained tail, whose original entries precede
|
|
1384
|
+
// the compaction record. Native replay already carries that tail. Only look
|
|
1385
|
+
// backwards: advisor snapshots put all retained messages after the summary
|
|
1386
|
+
// and may carry a keep ID from their previous, differently indexed snapshot.
|
|
1387
|
+
for (let i = resetBoundaryIndex + 1; i < prevCompactionIndex; i++) {
|
|
1388
|
+
if (pathEntries[i].id === previousCompaction.firstKeptEntryId) {
|
|
1389
|
+
boundaryStart = i;
|
|
1390
|
+
break;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Keep original IDs beside the converted messages so estimation, cutting,
|
|
1396
|
+
// and all three output regions share one sequence without journal metadata.
|
|
1397
|
+
const compactionEntries: SessionEntry[] = [];
|
|
1398
|
+
const compactionMessages: AgentMessage[] = [];
|
|
1399
|
+
for (let i = boundaryStart; i < pathEntries.length; i++) {
|
|
1400
|
+
const entry = pathEntries[i];
|
|
1401
|
+
const message = getMessageFromEntry(entry);
|
|
1402
|
+
if (!message) continue;
|
|
1403
|
+
compactionEntries.push(entry);
|
|
1404
|
+
compactionMessages.push(message);
|
|
1405
|
+
}
|
|
1354
1406
|
|
|
1355
1407
|
const lastUsage = getLastAssistantUsage(pathEntries);
|
|
1356
1408
|
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
|
1357
1409
|
let keepRecentTokens = settings.keepRecentTokens;
|
|
1358
1410
|
if (lastUsage) {
|
|
1359
|
-
const estimatedTokens =
|
|
1411
|
+
const estimatedTokens = tokenizer.countMessages(compactionMessages);
|
|
1360
1412
|
const promptTokens = calculatePromptTokens(lastUsage);
|
|
1361
1413
|
const ratio = estimatedTokens > 0 ? promptTokens / estimatedTokens : 0;
|
|
1362
1414
|
if (Number.isFinite(ratio) && ratio > 1) {
|
|
@@ -1364,10 +1416,10 @@ export function prepareCompaction(
|
|
|
1364
1416
|
}
|
|
1365
1417
|
}
|
|
1366
1418
|
|
|
1367
|
-
const cutPoint = findCutPoint(
|
|
1419
|
+
const cutPoint = findCutPoint(compactionEntries, tokenizer, 0, compactionEntries.length, keepRecentTokens);
|
|
1368
1420
|
|
|
1369
1421
|
// Get ID of first kept entry
|
|
1370
|
-
const firstKeptEntry =
|
|
1422
|
+
const firstKeptEntry = compactionEntries[cutPoint.firstKeptEntryIndex];
|
|
1371
1423
|
if (!firstKeptEntry?.id) {
|
|
1372
1424
|
return undefined; // Session needs migration
|
|
1373
1425
|
}
|
|
@@ -1375,42 +1427,16 @@ export function prepareCompaction(
|
|
|
1375
1427
|
|
|
1376
1428
|
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
|
1377
1429
|
|
|
1378
|
-
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
// Messages for turn prefix summary (if splitting a turn)
|
|
1386
|
-
const turnPrefixMessages: AgentMessage[] = [];
|
|
1387
|
-
if (cutPoint.isSplitTurn) {
|
|
1388
|
-
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
|
1389
|
-
const msg = getMessageFromEntry(pathEntries[i]);
|
|
1390
|
-
if (msg) turnPrefixMessages.push(msg);
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// Messages kept after compaction (recent history)
|
|
1395
|
-
const recentMessages: AgentMessage[] = [];
|
|
1396
|
-
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
|
|
1397
|
-
const msg = getMessageFromEntry(pathEntries[i]);
|
|
1398
|
-
if (msg) recentMessages.push(msg);
|
|
1399
|
-
}
|
|
1430
|
+
const messagesToSummarize = compactionMessages.slice(0, historyEnd);
|
|
1431
|
+
const turnPrefixMessages = cutPoint.isSplitTurn
|
|
1432
|
+
? compactionMessages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex)
|
|
1433
|
+
: [];
|
|
1434
|
+
const recentMessages = compactionMessages.slice(cutPoint.firstKeptEntryIndex);
|
|
1400
1435
|
// Nothing to summarize means compaction would be a no-op.
|
|
1401
1436
|
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
|
|
1402
1437
|
return undefined;
|
|
1403
1438
|
}
|
|
1404
1439
|
|
|
1405
|
-
// Get previous summary and preserved data for iterative updates
|
|
1406
|
-
let previousSummary: string | undefined;
|
|
1407
|
-
let previousPreserveData: Record<string, unknown> | undefined;
|
|
1408
|
-
if (prevCompactionIndex >= 0) {
|
|
1409
|
-
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
|
1410
|
-
previousSummary = prevCompaction.summary;
|
|
1411
|
-
previousPreserveData = prevCompaction.preserveData;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
1440
|
// Extract file operations from messages and previous compaction
|
|
1415
1441
|
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
|
1416
1442
|
|
|
@@ -1428,8 +1454,9 @@ export function prepareCompaction(
|
|
|
1428
1454
|
recentMessages,
|
|
1429
1455
|
isSplitTurn: cutPoint.isSplitTurn,
|
|
1430
1456
|
tokensBefore,
|
|
1431
|
-
previousSummary,
|
|
1432
|
-
|
|
1457
|
+
previousSummary: previousCompaction?.summary,
|
|
1458
|
+
previousSummaryTimestamp: previousCompaction?.timestamp,
|
|
1459
|
+
previousPreserveData: previousCompaction?.preserveData,
|
|
1433
1460
|
fileOps,
|
|
1434
1461
|
settings,
|
|
1435
1462
|
};
|
|
@@ -1585,6 +1612,9 @@ export async function compact(
|
|
|
1585
1612
|
tools: options?.tools,
|
|
1586
1613
|
fetch: options?.fetch,
|
|
1587
1614
|
completeImpl: options?.completeImpl,
|
|
1615
|
+
// The caller's opt-out must reach every summarization oneshot, otherwise
|
|
1616
|
+
// an outer retry loop multiplies with the inner one (see SummaryOptions).
|
|
1617
|
+
oneshotRetry: options?.oneshotRetry,
|
|
1588
1618
|
};
|
|
1589
1619
|
|
|
1590
1620
|
const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
|
|
@@ -1598,9 +1628,21 @@ export async function compact(
|
|
|
1598
1628
|
const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
|
|
1599
1629
|
? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
|
|
1600
1630
|
: undefined;
|
|
1601
|
-
|
|
1602
|
-
|
|
1631
|
+
const previousNativeHistory =
|
|
1632
|
+
getCompactionV2PreserveData(previousPreserveData) ?? getPreservedOpenAiRemoteCompactionData(previousPreserveData);
|
|
1633
|
+
// A local summary has no native payload to carry it into the first remote
|
|
1634
|
+
// request. Encode it as history; do not resend opaque native placeholders.
|
|
1635
|
+
const previousSummaryMigrationMessage =
|
|
1636
|
+
settings.remoteEnabled !== false && previousSummary && !previousNativeHistory
|
|
1637
|
+
? createCompactionSummaryMessage(previousSummary, tokensBefore, new Date().toISOString())
|
|
1638
|
+
: undefined;
|
|
1639
|
+
|
|
1640
|
+
let preserveData = withAnthropicCompactionPreserveData(
|
|
1641
|
+
withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
|
|
1642
|
+
undefined,
|
|
1643
|
+
);
|
|
1603
1644
|
const remoteMessages: AgentMessage[] = [
|
|
1645
|
+
...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
|
|
1604
1646
|
...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
|
|
1605
1647
|
...messagesToSummarize,
|
|
1606
1648
|
...turnPrefixMessages,
|
|
@@ -1784,6 +1826,112 @@ export async function compact(
|
|
|
1784
1826
|
}
|
|
1785
1827
|
}
|
|
1786
1828
|
|
|
1829
|
+
// Anthropic server-side compaction: the live turn's request shape plus the
|
|
1830
|
+
// compact edit, so the API summarizes from its cached prefix. The summary is
|
|
1831
|
+
// real text, persisted both as the entry summary and as the native replay
|
|
1832
|
+
// payload. A context below the API's trigger floor cannot compact remotely
|
|
1833
|
+
// and takes the local summarizer instead — an eligibility boundary, not a
|
|
1834
|
+
// failure.
|
|
1835
|
+
let nativeSummary: string | undefined;
|
|
1836
|
+
let nativeEncryptedContent: string | undefined;
|
|
1837
|
+
let nativeUsedTokens: number | undefined;
|
|
1838
|
+
if (
|
|
1839
|
+
!usedRemoteCompaction &&
|
|
1840
|
+
settings.remoteEnabled !== false &&
|
|
1841
|
+
shouldUseAnthropicNativeCompaction(model) &&
|
|
1842
|
+
tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
|
|
1843
|
+
) {
|
|
1844
|
+
const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
|
|
1845
|
+
// Lead with the previous summary exactly as the live context renders it:
|
|
1846
|
+
// natively when this provider wrote it, as text otherwise. The request
|
|
1847
|
+
// then shares the live turn's prefix byte-for-byte. A prior snapcompact
|
|
1848
|
+
// archive is already merged into that summary text, so the archive
|
|
1849
|
+
// migration message the OpenAI lanes carry is omitted here.
|
|
1850
|
+
// The rewrite marker must precede every message this request replays —
|
|
1851
|
+
// summarized history and retained tail alike — exactly like the live
|
|
1852
|
+
// context rebuild predates its tail. A previous compaction's commit
|
|
1853
|
+
// timestamp is newer than re-retained or re-summarized turns, so
|
|
1854
|
+
// reusing it would strip their bound thinking only in this request,
|
|
1855
|
+
// diverging from the cached live prefix (and possibly dropping below
|
|
1856
|
+
// the trigger). Manually built preparations with no input at all fall
|
|
1857
|
+
// back to the current time.
|
|
1858
|
+
const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
|
|
1859
|
+
const previousSummaryAt =
|
|
1860
|
+
firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
|
|
1861
|
+
const previousSummaryMessage = previousSummaryForCompaction
|
|
1862
|
+
? createCompactionSummaryMessage(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
|
|
1863
|
+
providerPayload:
|
|
1864
|
+
previousNative?.provider === model.provider
|
|
1865
|
+
? {
|
|
1866
|
+
type: "anthropicCompaction",
|
|
1867
|
+
provider: previousNative.provider,
|
|
1868
|
+
content: previousNative.content,
|
|
1869
|
+
...(previousNative.encryptedContent
|
|
1870
|
+
? { encryptedContent: previousNative.encryptedContent }
|
|
1871
|
+
: {}),
|
|
1872
|
+
...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
|
|
1873
|
+
}
|
|
1874
|
+
: undefined,
|
|
1875
|
+
})
|
|
1876
|
+
: undefined;
|
|
1877
|
+
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1878
|
+
const retainedTail = convertToLlm(recentMessages);
|
|
1879
|
+
const messages = [
|
|
1880
|
+
...convertToLlm([
|
|
1881
|
+
...(previousSummaryMessage ? [previousSummaryMessage] : []),
|
|
1882
|
+
...messagesToSummarize,
|
|
1883
|
+
...turnPrefixMessages,
|
|
1884
|
+
]),
|
|
1885
|
+
...retainedTail,
|
|
1886
|
+
];
|
|
1887
|
+
try {
|
|
1888
|
+
const remote = await requestAnthropicNativeCompaction(
|
|
1889
|
+
model,
|
|
1890
|
+
apiKey,
|
|
1891
|
+
{
|
|
1892
|
+
systemPrompt: summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT],
|
|
1893
|
+
messages,
|
|
1894
|
+
tools: summaryOptions.tools,
|
|
1895
|
+
instructions: buildAnthropicCompactionInstructions(
|
|
1896
|
+
summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
|
|
1897
|
+
customInstructions,
|
|
1898
|
+
formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
|
|
1899
|
+
describeRetainedTail(retainedTail),
|
|
1900
|
+
),
|
|
1901
|
+
maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
|
|
1902
|
+
reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
|
|
1903
|
+
},
|
|
1904
|
+
signal,
|
|
1905
|
+
{
|
|
1906
|
+
initiatorOverride: summaryOptions.initiatorOverride,
|
|
1907
|
+
metadata: summaryOptions.metadata,
|
|
1908
|
+
fetch: summaryOptions.fetch,
|
|
1909
|
+
sessionId: summaryOptions.sessionId,
|
|
1910
|
+
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1911
|
+
providerSessionState: summaryOptions.providerSessionState,
|
|
1912
|
+
completeImpl: summaryOptions.completeImpl,
|
|
1913
|
+
telemetry: summaryOptions.telemetry,
|
|
1914
|
+
retry: summaryOneshotRetry(summaryOptions),
|
|
1915
|
+
},
|
|
1916
|
+
);
|
|
1917
|
+
nativeSummary = remote.content;
|
|
1918
|
+
nativeEncryptedContent = remote.encryptedContent;
|
|
1919
|
+
nativeUsedTokens = calculatePromptTokens(remote.usage);
|
|
1920
|
+
usedRemoteCompaction = true;
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
// A user/session abort is a cancellation, not a remote failure —
|
|
1923
|
+
// swallowing it here would downgrade Esc into "fall back to local
|
|
1924
|
+
// summarization" and keep compaction running on an aborted signal.
|
|
1925
|
+
if (signal?.aborted) throw err;
|
|
1926
|
+
nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
|
|
1927
|
+
logger.warn("Anthropic server-side compaction failed", {
|
|
1928
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1929
|
+
model: model.id,
|
|
1930
|
+
provider: model.provider,
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1787
1935
|
if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
|
|
1788
1936
|
throw new NativeCompactionError(nativeCompactionError);
|
|
1789
1937
|
}
|
|
@@ -1791,7 +1939,11 @@ export async function compact(
|
|
|
1791
1939
|
// Generate summaries (can be parallel if both needed) and merge into one
|
|
1792
1940
|
let summary: string;
|
|
1793
1941
|
|
|
1794
|
-
if (
|
|
1942
|
+
if (nativeSummary !== undefined) {
|
|
1943
|
+
// The API wrote a real summary; it is the entry text. The replayed
|
|
1944
|
+
// block below stays verbatim so it matches the opaque state.
|
|
1945
|
+
summary = nativeSummary;
|
|
1946
|
+
} else if (usedRemoteCompaction) {
|
|
1795
1947
|
// Remote compaction (V2 or V1) already compacted remotely; the durable
|
|
1796
1948
|
// history lives in the provider replay payload (preserveData). Skip local
|
|
1797
1949
|
// summarization so a successful remote compaction never pays for a second,
|
|
@@ -1850,6 +2002,22 @@ export async function compact(
|
|
|
1850
2002
|
// Compute file lists and append to summary
|
|
1851
2003
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1852
2004
|
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
2005
|
+
if (nativeSummary !== undefined) {
|
|
2006
|
+
// The replayed block stays byte-identical to the API's summary so it
|
|
2007
|
+
// matches `encryptedContent`. The harness file lists above travel
|
|
2008
|
+
// separately: the converter replaces the summary message with the
|
|
2009
|
+
// block and skips its text, so they would otherwise be invisible to
|
|
2010
|
+
// this provider. Every other provider keeps reading the entry text.
|
|
2011
|
+
const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
|
|
2012
|
+
preserveData = withAnthropicCompactionPreserveData(preserveData, {
|
|
2013
|
+
provider: model.provider,
|
|
2014
|
+
content: nativeSummary,
|
|
2015
|
+
...(nativeEncryptedContent ? { encryptedContent: nativeEncryptedContent } : {}),
|
|
2016
|
+
...(filesText ? { filesText } : {}),
|
|
2017
|
+
model: model.id,
|
|
2018
|
+
usedTokens: nativeUsedTokens,
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
1853
2021
|
|
|
1854
2022
|
if (!firstKeptEntryId) {
|
|
1855
2023
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
@@ -37,6 +37,8 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
|
37
37
|
shortSummary?: string;
|
|
38
38
|
firstKeptEntryId: string;
|
|
39
39
|
tokensBefore: number;
|
|
40
|
+
/** Last entry covered by native replay; later entries may precede the compaction record. */
|
|
41
|
+
providerReplayThroughEntryId?: string;
|
|
40
42
|
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
41
43
|
details?: T;
|
|
42
44
|
/** Hook-provided data to persist across compaction */
|