@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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
type AnthropicCompactionFiles,
|
|
9
10
|
type Api,
|
|
10
11
|
type ApiKey,
|
|
11
12
|
type AssistantMessage,
|
|
@@ -63,10 +64,9 @@ import type { CompactionEntry, SessionEntry } from "./entries";
|
|
|
63
64
|
import { NativeCompactionError } from "./errors";
|
|
64
65
|
import {
|
|
65
66
|
type ConvertToLlm,
|
|
66
|
-
createBranchSummaryMessage,
|
|
67
67
|
createCompactionSummaryMessage,
|
|
68
|
-
createCustomMessage,
|
|
69
68
|
defaultConvertToLlm,
|
|
69
|
+
getMessageFromEntry,
|
|
70
70
|
} from "./messages";
|
|
71
71
|
import {
|
|
72
72
|
assertRemoteCompactionInputFits,
|
|
@@ -141,34 +141,6 @@ function extractFileOperations(
|
|
|
141
141
|
return fileOps;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// ============================================================================
|
|
145
|
-
// Message Extraction
|
|
146
|
-
// ============================================================================
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Extract AgentMessage from an entry if it produces one.
|
|
150
|
-
* Returns undefined for entries that don't contribute to LLM context.
|
|
151
|
-
*/
|
|
152
|
-
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
153
|
-
if (entry.type === "message") {
|
|
154
|
-
return entry.message;
|
|
155
|
-
}
|
|
156
|
-
if (entry.type === "custom_message") {
|
|
157
|
-
return createCustomMessage(
|
|
158
|
-
entry.customType,
|
|
159
|
-
entry.content,
|
|
160
|
-
entry.display,
|
|
161
|
-
entry.details,
|
|
162
|
-
entry.timestamp,
|
|
163
|
-
entry.attribution,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
if (entry.type === "branch_summary") {
|
|
167
|
-
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
|
168
|
-
}
|
|
169
|
-
return undefined;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
144
|
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
|
|
173
145
|
export interface CompactionResult<T = unknown> {
|
|
174
146
|
summary: string;
|
|
@@ -191,6 +163,14 @@ export interface CompactionSettings {
|
|
|
191
163
|
strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
|
|
192
164
|
thresholdPercent?: number;
|
|
193
165
|
thresholdTokens?: number;
|
|
166
|
+
/**
|
|
167
|
+
* Stands in for the context window when the percentage or reserve-based
|
|
168
|
+
* threshold is computed (when `> 0` and smaller than the window); a positive
|
|
169
|
+
* `thresholdTokens` still wins and is checked against the real window (at
|
|
170
|
+
* or past it, the window less its reserve applies). Set by per-model
|
|
171
|
+
* compaction limits; request and overflow budgets keep the real window.
|
|
172
|
+
*/
|
|
173
|
+
baseWindowTokens?: number;
|
|
194
174
|
midTurnEnabled?: boolean;
|
|
195
175
|
/**
|
|
196
176
|
* Tokens reserved below the context window for the next prompt + response.
|
|
@@ -386,27 +366,42 @@ export function compactionContextTokens(providerContextTokens: number, storedCon
|
|
|
386
366
|
return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
|
|
387
367
|
}
|
|
388
368
|
|
|
369
|
+
/**
|
|
370
|
+
* The prompt budget below `contextWindow`: the window less
|
|
371
|
+
* {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
|
|
372
|
+
* reaches the whole window even when the reserve resolves to 0.
|
|
373
|
+
*/
|
|
374
|
+
function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
375
|
+
return Math.max(0, Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)));
|
|
376
|
+
}
|
|
377
|
+
|
|
389
378
|
export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
390
|
-
// Fixed token limit takes priority over percentage
|
|
379
|
+
// Fixed token limit takes priority over percentage, and is checked against
|
|
380
|
+
// the real window: `baseWindowTokens` only rescales the policies below.
|
|
391
381
|
const thresholdTokens = settings.thresholdTokens;
|
|
392
382
|
if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
|
|
393
|
-
//
|
|
383
|
+
// A trigger at or past the window can never fire before the request
|
|
384
|
+
// overflows (a provider may cap the window below the configured point);
|
|
385
|
+
// fall back to the window's prompt budget, as the reserve policy does.
|
|
386
|
+
// A trigger below the window is exact, as the `f` model entries promise,
|
|
387
|
+
// even one inside the reserve: the user chose that headroom. The step at
|
|
388
|
+
// the window is inherent; a monotone fallback would sit at window - 1.
|
|
389
|
+
if (thresholdTokens >= contextWindow) return promptBudgetTokens(contextWindow, settings);
|
|
394
390
|
return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
|
|
395
391
|
}
|
|
392
|
+
const baseWindowTokens = settings.baseWindowTokens;
|
|
393
|
+
if (typeof baseWindowTokens === "number" && Number.isFinite(baseWindowTokens) && baseWindowTokens > 0) {
|
|
394
|
+
contextWindow = Math.min(contextWindow, baseWindowTokens);
|
|
395
|
+
}
|
|
396
396
|
|
|
397
397
|
// Percentage-based threshold. The default absolute reserve can exceed bundled
|
|
398
398
|
// small-context windows, or nearly consume a 16k-class window; in those
|
|
399
399
|
// known-impossible default configurations, fall back to the proportional
|
|
400
400
|
// reserve so threshold/recovery-band checks stay usable. Explicit valid
|
|
401
|
-
// configured reserves still define the usable prompt budget.
|
|
402
|
-
// contextWindow - 1 (matching the fixed-token clamp above) so the threshold
|
|
403
|
-
// never reaches the whole window even when the reserve resolves to 0.
|
|
401
|
+
// configured reserves still define the usable prompt budget.
|
|
404
402
|
const thresholdPercent = settings.thresholdPercent;
|
|
405
403
|
if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
|
|
406
|
-
return
|
|
407
|
-
0,
|
|
408
|
-
Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)),
|
|
409
|
-
);
|
|
404
|
+
return promptBudgetTokens(contextWindow, settings);
|
|
410
405
|
}
|
|
411
406
|
const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
|
|
412
407
|
return Math.floor(contextWindow * (clampedThresholdPercent / 100));
|
|
@@ -676,10 +671,28 @@ export interface SummaryOptions {
|
|
|
676
671
|
initiatorOverride?: MessageAttribution;
|
|
677
672
|
metadata?: Record<string, unknown>;
|
|
678
673
|
convertToLlm?: ConvertToLlm;
|
|
674
|
+
/**
|
|
675
|
+
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
676
|
+
* `summarized`. Anthropic on-demand compaction sends it so retained signed
|
|
677
|
+
* thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
|
|
678
|
+
* `tools` and `convertToLlm`.
|
|
679
|
+
*/
|
|
680
|
+
buildProviderContext?: (
|
|
681
|
+
summarized: AgentMessage[],
|
|
682
|
+
retained: AgentMessage[],
|
|
683
|
+
signal?: AbortSignal,
|
|
684
|
+
) => Promise<Context>;
|
|
685
|
+
/**
|
|
686
|
+
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
687
|
+
* next to the compaction item. Defaults to `role === "user"`; hosts whose
|
|
688
|
+
* user-initiated turns also arrive as custom messages (e.g. skill invocations)
|
|
689
|
+
* widen it.
|
|
690
|
+
*/
|
|
691
|
+
isUserAuthored?: (message: AgentMessage) => boolean;
|
|
679
692
|
/**
|
|
680
693
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
681
694
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
682
|
-
* `
|
|
695
|
+
* `zeta.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
|
|
683
696
|
* or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
|
|
684
697
|
*/
|
|
685
698
|
telemetry?: AgentTelemetry;
|
|
@@ -1042,7 +1055,7 @@ export interface HandoffOptions {
|
|
|
1042
1055
|
metadata?: Record<string, unknown>;
|
|
1043
1056
|
/**
|
|
1044
1057
|
* Optional telemetry handle. When provided, the handoff LLM call is
|
|
1045
|
-
* wrapped in an OTEL chat span tagged with `
|
|
1058
|
+
* wrapped in an OTEL chat span tagged with `zeta.gen_ai.oneshot.kind = "handoff"`.
|
|
1046
1059
|
*/
|
|
1047
1060
|
telemetry?: AgentTelemetry;
|
|
1048
1061
|
/**
|
|
@@ -1612,6 +1625,8 @@ export async function compact(
|
|
|
1612
1625
|
initiatorOverride: options?.initiatorOverride,
|
|
1613
1626
|
metadata: options?.metadata,
|
|
1614
1627
|
convertToLlm: options?.convertToLlm,
|
|
1628
|
+
buildProviderContext: options?.buildProviderContext,
|
|
1629
|
+
isUserAuthored: options?.isUserAuthored,
|
|
1615
1630
|
telemetry: options?.telemetry,
|
|
1616
1631
|
// Honor /model thinking selection on every fan-out summarizer.
|
|
1617
1632
|
// Without this propagation, generateSummary / generateTurnPrefixSummary
|
|
@@ -1675,7 +1690,17 @@ export async function compact(
|
|
|
1675
1690
|
previousRemoteCompaction?.provider === model.provider
|
|
1676
1691
|
? previousRemoteCompaction.replacementHistory
|
|
1677
1692
|
: undefined;
|
|
1678
|
-
const
|
|
1693
|
+
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1694
|
+
const messages = convertToLlm(remoteMessages);
|
|
1695
|
+
// Replacement history keeps what the user wrote. Summaries, archive
|
|
1696
|
+
// migrations, and custom/hook messages can serialize as user-role items
|
|
1697
|
+
// too, so pick the user's own messages before serialization erases that,
|
|
1698
|
+
// then serialize each one exactly as the request does.
|
|
1699
|
+
const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
|
|
1700
|
+
const userMessages = convertToLlm(
|
|
1701
|
+
[...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
|
|
1702
|
+
);
|
|
1703
|
+
const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
|
|
1679
1704
|
const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
|
|
1680
1705
|
let codexBody: OpenAICodexCompactionBody | undefined;
|
|
1681
1706
|
let remoteHistory: Array<Record<string, unknown>>;
|
|
@@ -1715,8 +1740,19 @@ export async function compact(
|
|
|
1715
1740
|
}
|
|
1716
1741
|
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1717
1742
|
codexBody.input = remoteHistory;
|
|
1743
|
+
for (const message of userMessages) {
|
|
1744
|
+
const userBody = await buildTransformedCodexRequestBody(
|
|
1745
|
+
model,
|
|
1746
|
+
{ messages: [message] },
|
|
1747
|
+
{ responsesLite: model.useResponsesLite },
|
|
1748
|
+
);
|
|
1749
|
+
retainedUserItems.push(...(userBody.input ?? []));
|
|
1750
|
+
}
|
|
1718
1751
|
} else {
|
|
1719
1752
|
remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
|
|
1753
|
+
for (const message of userMessages) {
|
|
1754
|
+
retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
|
|
1755
|
+
}
|
|
1720
1756
|
}
|
|
1721
1757
|
if (remoteHistory.length > 0) {
|
|
1722
1758
|
try {
|
|
@@ -1754,6 +1790,7 @@ export async function compact(
|
|
|
1754
1790
|
sessionId: summaryOptions.sessionId,
|
|
1755
1791
|
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1756
1792
|
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1793
|
+
retainedUserItems,
|
|
1757
1794
|
};
|
|
1758
1795
|
const request = codexBody
|
|
1759
1796
|
? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
|
|
@@ -1781,11 +1818,17 @@ export async function compact(
|
|
|
1781
1818
|
// summarization" and keep compaction running on an aborted signal.
|
|
1782
1819
|
if (signal?.aborted) throw err;
|
|
1783
1820
|
nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
model
|
|
1787
|
-
|
|
1788
|
-
|
|
1821
|
+
// Claim the V1 fallback only when the V1 block below will run.
|
|
1822
|
+
logger.warn(
|
|
1823
|
+
shouldUseOpenAiRemoteCompaction(model)
|
|
1824
|
+
? "OpenAI V2 remote compaction failed, falling back to V1 remote compaction"
|
|
1825
|
+
: "OpenAI V2 remote compaction failed",
|
|
1826
|
+
{
|
|
1827
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1828
|
+
model: model.id,
|
|
1829
|
+
provider: model.provider,
|
|
1830
|
+
},
|
|
1831
|
+
);
|
|
1789
1832
|
}
|
|
1790
1833
|
}
|
|
1791
1834
|
}
|
|
@@ -1825,7 +1868,7 @@ export async function compact(
|
|
|
1825
1868
|
),
|
|
1826
1869
|
{ signal },
|
|
1827
1870
|
);
|
|
1828
|
-
preserveData = withOpenAiRemoteCompactionPreserveData(
|
|
1871
|
+
preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
|
|
1829
1872
|
usedRemoteCompaction = true;
|
|
1830
1873
|
} catch (err) {
|
|
1831
1874
|
// A user/session abort is a cancellation, not a remote failure —
|
|
@@ -1848,32 +1891,40 @@ export async function compact(
|
|
|
1848
1891
|
let nativeSignature: string | undefined;
|
|
1849
1892
|
let nativeFirstKeptEntryId = firstKeptEntryId;
|
|
1850
1893
|
let nativeUsedTokens: number | undefined;
|
|
1894
|
+
let nativeRetainedFiles: AnthropicCompactionFiles[] | undefined;
|
|
1851
1895
|
if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
|
|
1852
1896
|
const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
|
|
1853
1897
|
// Lead with the previous summary as the live context renders it:
|
|
1854
1898
|
// natively when this provider wrote it, as text otherwise.
|
|
1855
1899
|
// A prior snapcompact archive is already merged into that summary
|
|
1856
|
-
// text.
|
|
1900
|
+
// text. Like the live context, the summary keeps its commit time and
|
|
1901
|
+
// predates its rewrite marker before all replayed messages, so
|
|
1857
1902
|
// retained thinking stays bound to the original prefix.
|
|
1858
1903
|
const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
|
|
1859
|
-
const previousSummaryAt =
|
|
1860
|
-
firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
|
|
1861
1904
|
const previousSummaryMessage = previousSummaryForCompaction
|
|
1862
|
-
? createCompactionSummaryMessage(
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
:
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1905
|
+
? createCompactionSummaryMessage(
|
|
1906
|
+
previousSummaryForCompaction,
|
|
1907
|
+
tokensBefore,
|
|
1908
|
+
preparation.previousSummaryTimestamp ?? new Date().toISOString(),
|
|
1909
|
+
{
|
|
1910
|
+
historyRewriteAt: firstReplayed !== undefined ? firstReplayed.timestamp - 1 : undefined,
|
|
1911
|
+
providerPayload:
|
|
1912
|
+
previousNative?.provider === model.provider
|
|
1913
|
+
? {
|
|
1914
|
+
type: "anthropicCompaction",
|
|
1915
|
+
provider: previousNative.provider,
|
|
1916
|
+
content: previousNative.content,
|
|
1917
|
+
...(previousNative.signature ? { signature: previousNative.signature } : {}),
|
|
1918
|
+
...(previousNative.encryptedContent
|
|
1919
|
+
? { encryptedContent: previousNative.encryptedContent }
|
|
1920
|
+
: {}),
|
|
1921
|
+
...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
|
|
1922
|
+
...(previousNative.retainedFiles ? { retainedFiles: previousNative.retainedFiles } : {}),
|
|
1923
|
+
...(previousNative.exactTail ? { exactTail: true as const } : {}),
|
|
1924
|
+
}
|
|
1925
|
+
: undefined,
|
|
1926
|
+
},
|
|
1927
|
+
)
|
|
1877
1928
|
: undefined;
|
|
1878
1929
|
const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
|
|
1879
1930
|
const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
|
|
@@ -1891,20 +1942,57 @@ export async function compact(
|
|
|
1891
1942
|
? firstKeptEntryId
|
|
1892
1943
|
: (recentEntryIds?.[safeCut - originalCut] ?? "");
|
|
1893
1944
|
for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
|
|
1894
|
-
const
|
|
1945
|
+
const summarizedMessages = [
|
|
1895
1946
|
...(previousSummaryMessage ? [previousSummaryMessage] : []),
|
|
1896
1947
|
...allMessages.slice(0, safeCut),
|
|
1897
|
-
]
|
|
1948
|
+
];
|
|
1949
|
+
// Earlier file metadata replays before the first message created after
|
|
1950
|
+
// its summary. Metadata due inside the new retained tail stays out of the
|
|
1951
|
+
// request, and the new summary keeps replaying it. So does metadata due
|
|
1952
|
+
// right at the cut when the first kept message is a user-side turn: live
|
|
1953
|
+
// requests sent it merged into that turn, and ending the request with it
|
|
1954
|
+
// would merge the summarized range into the kept turn instead, which
|
|
1955
|
+
// invalidates the kept thinking.
|
|
1956
|
+
const firstRetained = allMessages[safeCut];
|
|
1957
|
+
const filesDueBefore =
|
|
1958
|
+
firstRetained === undefined
|
|
1959
|
+
? undefined
|
|
1960
|
+
: firstRetained.role === "assistant"
|
|
1961
|
+
? firstRetained.timestamp
|
|
1962
|
+
: (allMessages.slice(0, safeCut).findLast(message => message.role !== "toolResult")?.timestamp ??
|
|
1963
|
+
Number.NEGATIVE_INFINITY);
|
|
1964
|
+
const previousPayload = previousSummaryMessage?.providerPayload;
|
|
1965
|
+
if (
|
|
1966
|
+
filesDueBefore !== undefined &&
|
|
1967
|
+
previousSummaryMessage !== undefined &&
|
|
1968
|
+
previousPayload?.type === "anthropicCompaction" &&
|
|
1969
|
+
previousPayload.exactTail
|
|
1970
|
+
) {
|
|
1971
|
+
const carried = [
|
|
1972
|
+
...(previousPayload.retainedFiles ?? []),
|
|
1973
|
+
...(previousPayload.filesText
|
|
1974
|
+
? [{ text: previousPayload.filesText, after: previousSummaryMessage.timestamp }]
|
|
1975
|
+
: []),
|
|
1976
|
+
].filter(files => files.after >= filesDueBefore);
|
|
1977
|
+
if (carried.length > 0) nativeRetainedFiles = carried;
|
|
1978
|
+
}
|
|
1898
1979
|
try {
|
|
1980
|
+
// The live turn's own system prompt, wire tools and transformed
|
|
1981
|
+
// history when the host can build them: kept thinking remains valid
|
|
1982
|
+
// only when the summarized prefix matches what was sent byte for byte.
|
|
1983
|
+
const context = summaryOptions.buildProviderContext
|
|
1984
|
+
? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
|
|
1985
|
+
: {
|
|
1986
|
+
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1987
|
+
messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
|
|
1988
|
+
tools: summaryOptions.tools,
|
|
1989
|
+
};
|
|
1899
1990
|
const remote = await requestAnthropicNativeCompaction(
|
|
1900
1991
|
model,
|
|
1901
1992
|
apiKey,
|
|
1902
1993
|
{
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1906
|
-
messages,
|
|
1907
|
-
tools: summaryOptions.tools,
|
|
1994
|
+
context,
|
|
1995
|
+
filesDueBefore,
|
|
1908
1996
|
instructions: buildAnthropicCompactionInstructions(
|
|
1909
1997
|
summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
|
|
1910
1998
|
customInstructions,
|
|
@@ -2026,6 +2114,8 @@ export async function compact(
|
|
|
2026
2114
|
content: nativeSummary,
|
|
2027
2115
|
...(nativeSignature ? { signature: nativeSignature } : {}),
|
|
2028
2116
|
...(filesText ? { filesText } : {}),
|
|
2117
|
+
...(nativeRetainedFiles ? { retainedFiles: nativeRetainedFiles } : {}),
|
|
2118
|
+
exactTail: true,
|
|
2029
2119
|
model: model.id,
|
|
2030
2120
|
usedTokens: nativeUsedTokens,
|
|
2031
2121
|
});
|
|
@@ -101,8 +101,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
|
101
101
|
|
|
102
102
|
export interface SessionInitEntry extends SessionEntryBase {
|
|
103
103
|
type: "session_init";
|
|
104
|
-
/**
|
|
105
|
-
systemPrompt: string;
|
|
104
|
+
/** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
|
|
105
|
+
systemPrompt: string[] | string;
|
|
106
106
|
/** Initial task/user message */
|
|
107
107
|
task: string;
|
|
108
108
|
/** Tools available to the agent */
|
|
@@ -48,7 +48,7 @@ export function registerMessageCacheInvalidator(invalidate: (message: AgentMessa
|
|
|
48
48
|
* the tag onto derived clones — harmless, because estimate memos key on message
|
|
49
49
|
* *identity* and a fresh clone starts with no memo entries anywhere.
|
|
50
50
|
*/
|
|
51
|
-
const kEstimateVersion = Symbol("
|
|
51
|
+
const kEstimateVersion = Symbol("zeta.messageEstimateVersion");
|
|
52
52
|
|
|
53
53
|
interface VersionedMessage {
|
|
54
54
|
[kEstimateVersion]?: number;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "@linxiraos/pi-ai";
|
|
9
9
|
import { prompt } from "@linxiraos/pi-utils";
|
|
10
10
|
import type { AgentMessage } from "../types";
|
|
11
|
+
import type { SessionEntry } from "./entries";
|
|
11
12
|
import branchSummaryContextPrompt from "./prompts/branch-summary-context.md" with { type: "text" };
|
|
12
13
|
import compactionSummaryContextPrompt from "./prompts/compaction-summary-context.md" with { type: "text" };
|
|
13
14
|
import handoffSummaryContextPrompt from "./prompts/handoff-summary-context.md" with { type: "text" };
|
|
@@ -291,3 +292,27 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
|
|
|
291
292
|
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
|
292
293
|
return messages.map(convertMessageToLlm).filter(message => message !== undefined);
|
|
293
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The context message a session entry contributes, or `undefined` for entries
|
|
298
|
+
* that don't reach the LLM (compaction markers, labels, model changes, ...).
|
|
299
|
+
*/
|
|
300
|
+
export function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
301
|
+
if (entry.type === "message") {
|
|
302
|
+
return entry.message;
|
|
303
|
+
}
|
|
304
|
+
if (entry.type === "custom_message") {
|
|
305
|
+
return createCustomMessage(
|
|
306
|
+
entry.customType,
|
|
307
|
+
entry.content,
|
|
308
|
+
entry.display,
|
|
309
|
+
entry.details,
|
|
310
|
+
entry.timestamp,
|
|
311
|
+
entry.attribution,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (entry.type === "branch_summary") {
|
|
315
|
+
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
package/src/compaction/openai.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getCodexAttestationHeader } from "@linxiraos/pi-ai/providers/openai-cod
|
|
|
20
20
|
import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
|
|
21
21
|
import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
|
|
22
22
|
import { createOpenAICodexCompatibilityMetadata } from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
23
|
+
import { applySessionHeader } from "@linxiraos/pi-ai/providers/inference-headers";
|
|
23
24
|
import {
|
|
24
25
|
encodeResponsesToolResultOutput,
|
|
25
26
|
hoistInterleavedResponsesToolBatchMessages,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
OPENAI_HEADERS,
|
|
54
55
|
} from "@linxiraos/pi-catalog/wire/codex";
|
|
55
56
|
import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
57
|
+
import { dataUrlImageSize, estimateImageTokens } from "../image-tokens";
|
|
56
58
|
import { Tokenizer } from "../tokenizer";
|
|
57
59
|
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
58
60
|
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
@@ -80,7 +82,8 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
|
|
|
80
82
|
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
81
83
|
|
|
82
84
|
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
83
|
-
|
|
85
|
+
/** Per-`input_image` record estimates; trim re-probes share untouched items by reference. */
|
|
86
|
+
const remoteImageTokenCache = new WeakMap<Record<string, unknown>, number>();
|
|
84
87
|
const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
|
|
85
88
|
|
|
86
89
|
interface NormalizedEstimateValue {
|
|
@@ -88,12 +91,12 @@ interface NormalizedEstimateValue {
|
|
|
88
91
|
imageTokens: number;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
|
|
94
|
+
function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
|
|
92
95
|
if (Array.isArray(value)) {
|
|
93
96
|
const normalized: unknown[] = [];
|
|
94
97
|
let imageTokens = 0;
|
|
95
98
|
for (const item of value) {
|
|
96
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
99
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
97
100
|
normalized.push(result.value);
|
|
98
101
|
imageTokens += result.imageTokens;
|
|
99
102
|
}
|
|
@@ -103,10 +106,16 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
103
106
|
|
|
104
107
|
const record = value as Record<string, unknown>;
|
|
105
108
|
if (record.type === "input_image") {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
let imageTokens = remoteImageTokenCache.get(record);
|
|
110
|
+
if (imageTokens === undefined) {
|
|
111
|
+
const detail = record.detail;
|
|
112
|
+
imageTokens = estimateImageTokens(
|
|
113
|
+
typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
|
|
114
|
+
detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
|
|
115
|
+
);
|
|
116
|
+
remoteImageTokenCache.set(record, imageTokens);
|
|
117
|
+
}
|
|
118
|
+
return { value: { ...record, image_url: "<image>" }, imageTokens };
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
const normalized: Record<string, unknown> = {};
|
|
@@ -115,20 +124,35 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
115
124
|
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
116
125
|
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
117
126
|
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
118
|
-
if (key === "encrypted_content" && typeof item === "string") continue;
|
|
119
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
127
|
+
if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
|
|
128
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
120
129
|
normalized[key] = result.value;
|
|
121
130
|
imageTokens += result.imageTokens;
|
|
122
131
|
}
|
|
123
132
|
return { value: normalized, imageTokens };
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
137
|
+
* count at their image estimate rather than as base64 text; everything else,
|
|
138
|
+
* opaque compaction state included, counts as serialized.
|
|
139
|
+
*/
|
|
140
|
+
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
141
|
+
const normalized = normalizeRemoteCompactionEstimateValue(items, false);
|
|
142
|
+
const serialized = stringifyJson(normalized.value);
|
|
143
|
+
return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
export interface TrimRemoteCompactionInputResult {
|
|
127
147
|
input: Array<Record<string, unknown>>;
|
|
128
148
|
rewrittenOutputs: number;
|
|
129
149
|
estimatedTokensBefore: number;
|
|
130
150
|
estimatedTokensAfter: number;
|
|
131
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Whether `input` may be sent; false means it must not be. True when the
|
|
153
|
+
* full estimate fits, or when only the image estimate pushes it over (image
|
|
154
|
+
* pricing is a guess; the provider's own accounting decides).
|
|
155
|
+
*/
|
|
132
156
|
fits: boolean;
|
|
133
157
|
}
|
|
134
158
|
|
|
@@ -138,15 +162,17 @@ interface RemoteCompactionBudgetProbe {
|
|
|
138
162
|
tokens: number;
|
|
139
163
|
/** Whether the request fits the window. Always true when no window is known. */
|
|
140
164
|
fits: boolean;
|
|
165
|
+
/** Whether the request minus its image estimate fits the window. */
|
|
166
|
+
textFits: boolean;
|
|
141
167
|
}
|
|
142
168
|
|
|
143
169
|
/**
|
|
144
|
-
* Cheap-first sizing of a remote-compaction request. Images
|
|
145
|
-
*
|
|
146
|
-
* tokenizer; opaque `encrypted_content` payloads are excluded. The
|
|
147
|
-
* transcript is then probed with {@link Tokenizer.checkTokenBudget},
|
|
148
|
-
* pays for an exact count when the byte bound cannot already prove
|
|
149
|
-
* fits.
|
|
170
|
+
* Cheap-first sizing of a remote-compaction request. Images (priced from their
|
|
171
|
+
* dimensions) and the request frame come off the budget rather than through
|
|
172
|
+
* the tokenizer; opaque `encrypted_content` payloads are excluded. The
|
|
173
|
+
* serialized transcript is then probed with {@link Tokenizer.checkTokenBudget},
|
|
174
|
+
* which only pays for an exact count when the byte bound cannot already prove
|
|
175
|
+
* the request fits.
|
|
150
176
|
*/
|
|
151
177
|
function probeRemoteCompactionInputBudget(
|
|
152
178
|
input: Array<Record<string, unknown>>,
|
|
@@ -159,10 +185,15 @@ function probeRemoteCompactionInputBudget(
|
|
|
159
185
|
const serialized = stringifyJson(normalized.value) ?? "";
|
|
160
186
|
const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
161
187
|
if (!contextWindow || contextWindow <= 0) {
|
|
162
|
-
return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true };
|
|
188
|
+
return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true, textFits: true };
|
|
163
189
|
}
|
|
164
190
|
const budget = tokenizer.checkTokenBudget(serialized, Math.max(0, contextWindow - flatTokens));
|
|
165
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
tokens: budget.tokens + flatTokens,
|
|
193
|
+
fits: budget.fits,
|
|
194
|
+
// A failed check carries the exact text count (or its byte upper bound).
|
|
195
|
+
textFits: budget.fits || budget.tokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS <= contextWindow,
|
|
196
|
+
};
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
@@ -192,7 +223,9 @@ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
|
|
|
192
223
|
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
193
224
|
* remote compaction request beyond the model window. Replacing only those
|
|
194
225
|
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
195
|
-
* matching Codex's recovery path for oversized tool turns.
|
|
226
|
+
* matching Codex's recovery path for oversized tool turns. A request whose text
|
|
227
|
+
* fits but whose image estimate does not is still sendable: refusing it on an
|
|
228
|
+
* image guess would skip native compaction the provider would have accepted.
|
|
196
229
|
*/
|
|
197
230
|
export function trimRemoteCompactionInputToContextWindow(
|
|
198
231
|
input: Array<Record<string, unknown>>,
|
|
@@ -226,22 +259,25 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
226
259
|
after = probeRemoteCompactionInputBudget(rewrittenInput, tokenizer, instructions, tools, contextWindow);
|
|
227
260
|
}
|
|
228
261
|
|
|
229
|
-
|
|
262
|
+
// Rewrites that reach a full fit are kept. When they cannot, and the text
|
|
263
|
+
// already fit, the overflow is image-only: rewriting cannot shrink the image
|
|
264
|
+
// estimate, so send the untouched input rather than discard tool output.
|
|
265
|
+
if (rewrittenInput && (after.fits || (!before.textFits && after.textFits))) {
|
|
230
266
|
return {
|
|
231
|
-
input,
|
|
232
|
-
rewrittenOutputs
|
|
267
|
+
input: rewrittenInput,
|
|
268
|
+
rewrittenOutputs,
|
|
233
269
|
estimatedTokensBefore: before.tokens,
|
|
234
|
-
estimatedTokensAfter:
|
|
235
|
-
fits:
|
|
270
|
+
estimatedTokensAfter: after.tokens,
|
|
271
|
+
fits: true,
|
|
236
272
|
};
|
|
237
273
|
}
|
|
238
274
|
|
|
239
275
|
return {
|
|
240
|
-
input
|
|
241
|
-
rewrittenOutputs,
|
|
276
|
+
input,
|
|
277
|
+
rewrittenOutputs: 0,
|
|
242
278
|
estimatedTokensBefore: before.tokens,
|
|
243
|
-
estimatedTokensAfter:
|
|
244
|
-
fits:
|
|
279
|
+
estimatedTokensAfter: before.tokens,
|
|
280
|
+
fits: before.textFits,
|
|
245
281
|
};
|
|
246
282
|
}
|
|
247
283
|
|
|
@@ -827,6 +863,7 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
827
863
|
Authorization: `Bearer ${apiKey}`,
|
|
828
864
|
...model.headers,
|
|
829
865
|
};
|
|
866
|
+
applySessionHeader(headers, model.compat, opts?.sessionId);
|
|
830
867
|
|
|
831
868
|
// Codex endpoints require additional auth headers
|
|
832
869
|
if (isCodexResponses) {
|