@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -243
  2. package/README.md +1 -1
  3. package/dist/types/agent.d.ts +27 -7
  4. package/dist/types/compaction/anthropic.d.ts +14 -8
  5. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  6. package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
  7. package/dist/types/compaction/compaction.d.ts +24 -2
  8. package/dist/types/compaction/entries.d.ts +2 -2
  9. package/dist/types/compaction/messages.d.ts +6 -0
  10. package/dist/types/compaction/openai.d.ts +14 -2
  11. package/dist/types/compaction/pruning.d.ts +64 -16
  12. package/dist/types/compaction/tool-protection.d.ts +2 -1
  13. package/dist/types/compaction/utils.d.ts +5 -0
  14. package/dist/types/image-tokens.d.ts +29 -0
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/speculative-execution.d.ts +1 -1
  17. package/dist/types/telemetry.d.ts +70 -60
  18. package/dist/types/tokenizer.d.ts +9 -6
  19. package/dist/types/tool-arguments.d.ts +19 -0
  20. package/dist/types/types.d.ts +12 -1
  21. package/package.json +8 -8
  22. package/src/agent-loop.ts +191 -34
  23. package/src/agent.ts +126 -21
  24. package/src/append-only-context.ts +38 -29
  25. package/src/compaction/anthropic.ts +30 -12
  26. package/src/compaction/branch-summarization.ts +1 -1
  27. package/src/compaction/compaction-v2-streaming.ts +23 -16
  28. package/src/compaction/compaction.ts +163 -73
  29. package/src/compaction/entries.ts +2 -2
  30. package/src/compaction/message-cache.ts +1 -1
  31. package/src/compaction/messages.ts +25 -0
  32. package/src/compaction/openai.ts +65 -28
  33. package/src/compaction/pruning.ts +328 -96
  34. package/src/compaction/tool-protection.ts +8 -2
  35. package/src/compaction/utils.ts +9 -0
  36. package/src/image-tokens.ts +77 -0
  37. package/src/index.ts +2 -0
  38. package/src/prompts/dsml-tool-call-leak.md +19 -0
  39. package/src/speculative-execution.ts +5 -9
  40. package/src/telemetry.ts +100 -81
  41. package/src/tokenizer.ts +24 -17
  42. package/src/tool-arguments.ts +38 -0
  43. 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
- // Clamp to [1, contextWindow - 1] so there's always room
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. Cap at
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 Math.max(
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
- * `omp.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
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 `omp.gen_ai.oneshot.kind = "handoff"`.
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 messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages);
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
- logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", {
1785
- error: err instanceof Error ? err.message : String(err),
1786
- model: model.id,
1787
- provider: model.provider,
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(previousPreserveData, remote);
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. Predate the rewrite marker before all replayed messages so
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(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
1863
- providerPayload:
1864
- previousNative?.provider === model.provider
1865
- ? {
1866
- type: "anthropicCompaction",
1867
- provider: previousNative.provider,
1868
- content: previousNative.content,
1869
- ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1870
- ...(previousNative.encryptedContent
1871
- ? { encryptedContent: previousNative.encryptedContent }
1872
- : {}),
1873
- ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1874
- }
1875
- : undefined,
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 messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
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
- // The live prompt, not the local summarizer's synthetic system
1904
- // prompt: kept thinking remains valid only under identical controls.
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
- /** Full system prompt sent to the model */
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("omp.messageEstimateVersion");
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
+ }
@@ -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
- const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
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
- return {
107
- value: { ...record, image_url: "<image>" },
108
- imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
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
- /** Whether `input` fits the model window; false means it must not be sent. */
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 and the request
145
- * frame are charged flat, so they come off the budget rather than through the
146
- * tokenizer; opaque `encrypted_content` payloads are excluded. The serialized
147
- * transcript is then probed with {@link Tokenizer.checkTokenBudget}, which only
148
- * pays for an exact count when the byte bound cannot already prove the request
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 { tokens: budget.tokens + flatTokens, fits: budget.fits };
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
- if (!rewrittenInput || !after.fits) {
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: 0,
267
+ input: rewrittenInput,
268
+ rewrittenOutputs,
233
269
  estimatedTokensBefore: before.tokens,
234
- estimatedTokensAfter: before.tokens,
235
- fits: false,
270
+ estimatedTokensAfter: after.tokens,
271
+ fits: true,
236
272
  };
237
273
  }
238
274
 
239
275
  return {
240
- input: rewrittenInput,
241
- rewrittenOutputs,
276
+ input,
277
+ rewrittenOutputs: 0,
242
278
  estimatedTokensBefore: before.tokens,
243
- estimatedTokensAfter: after.tokens,
244
- fits: true,
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) {