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