@linxiraos/pi-agent-core 1.2.0 → 1.3.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.
@@ -34,11 +34,13 @@ import {
34
34
  } from "@linxiraos/pi-ai/providers/openai-codex-responses";
35
35
  import { convertTools } from "@linxiraos/pi-ai/providers/openai-responses";
36
36
  import { buildResponsesInput, resolveOpenAICompatPolicy } from "@linxiraos/pi-ai/providers/openai-shared";
37
+ import { isSurvivingAssistant } from "@linxiraos/pi-ai/providers/transform-messages";
37
38
  import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@linxiraos/pi-ai/utils";
39
+ import { resolveCatalogPolicy } from "@linxiraos/pi-catalog/compat/catalog-policy";
38
40
  import { preferredDialect } from "@linxiraos/pi-catalog/identity";
39
41
  import { clampThinkingLevelForModel } from "@linxiraos/pi-catalog/model-thinking";
40
- import * as snapcompact from "@linxiraos/pi-snapcompact";
41
42
  import { isRecord, logger, prompt } from "@linxiraos/pi-utils";
43
+ import * as snapcompact from "@linxiraos/pi-snapcompact";
42
44
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
43
45
  import { ThinkingLevel } from "../thinking";
44
46
  import { Tokenizer } from "../tokenizer";
@@ -152,6 +154,12 @@ export interface CompactionResult<T = unknown> {
152
154
  details?: T;
153
155
  /** Hook-provided data to persist alongside compaction entry. */
154
156
  preserveData?: Record<string, unknown>;
157
+ /**
158
+ * Last entry the provider-native replay history covers. Set when a remote
159
+ * compaction could only fit the history before the kept tail; later entries
160
+ * replay normally after the native history.
161
+ */
162
+ providerReplayThroughEntryId?: string;
155
163
  }
156
164
 
157
165
  // ============================================================================
@@ -369,9 +377,11 @@ export function compactionContextTokens(providerContextTokens: number, storedCon
369
377
  /**
370
378
  * The prompt budget below `contextWindow`: the window less
371
379
  * {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
372
- * reaches the whole window even when the reserve resolves to 0.
380
+ * reaches the whole window even when the reserve resolves to 0. Independent of
381
+ * any configured trigger; the default {@link resolveThresholdTokens} and
382
+ * length-stop recovery's "did the window run out" check both use it.
373
383
  */
374
- function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
384
+ export function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
375
385
  return Math.max(0, Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)));
376
386
  }
377
387
 
@@ -529,13 +539,23 @@ export function findCutPoint(
529
539
  // belonging to an assistant. Checking individual messages can either retain
530
540
  // the oversized older assistant or miss its boundary and retain all history.
531
541
  let accumulatedTokens = 0;
542
+ // The latest surviving assistant turn replays byte-for-byte, signatures of
543
+ // abandoned tool calls included, so it is counted as the latest.
544
+ let latestTurnIndex = -1;
545
+ for (let i = entries.length - 1; i >= 0 && latestTurnIndex < 0; i--) {
546
+ const message = getMessageFromEntry(entries[i]);
547
+ if (message?.role === "assistant" && isSurvivingAssistant(message)) latestTurnIndex = i;
548
+ }
532
549
  let cutPointIndex = cutPoints.length - 1;
533
550
  let cutIndex = cutPoints[cutPointIndex];
534
551
 
535
552
  for (let i = endIndex - 1; i >= startIndex; i--) {
536
553
  const entry = entries[i];
537
554
  const message = getMessageFromEntry(entry);
538
- if (message) accumulatedTokens += tokenizer.countMessage(message);
555
+ if (message) {
556
+ accumulatedTokens +=
557
+ i === latestTurnIndex ? tokenizer.countLatestTurn(message) : tokenizer.countMessage(message);
558
+ }
539
559
  if (i !== cutPoints[cutPointIndex]) continue;
540
560
  if (accumulatedTokens > keepRecentTokens) break;
541
561
  cutIndex = i;
@@ -673,14 +693,15 @@ export interface SummaryOptions {
673
693
  convertToLlm?: ConvertToLlm;
674
694
  /**
675
695
  * 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`.
696
+ * `summarized`, as read by the summarizer `model`. Anthropic on-demand
697
+ * compaction sends it so retained signed thinking keeps its prefix; without
698
+ * it the request uses `remoteSystemPrompt`, `tools` and `convertToLlm`.
679
699
  */
680
700
  buildProviderContext?: (
681
701
  summarized: AgentMessage[],
682
702
  retained: AgentMessage[],
683
- signal?: AbortSignal,
703
+ signal: AbortSignal | undefined,
704
+ model: Model,
684
705
  ) => Promise<Context>;
685
706
  /**
686
707
  * Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
@@ -850,13 +871,14 @@ function planSummaryWindows(
850
871
  messages: Message[],
851
872
  tokenizer: Tokenizer,
852
873
  dialect: Dialect | undefined,
874
+ dropThinking: boolean,
853
875
  budgetTokens: number,
854
876
  ): Message[][] {
855
877
  const windows: Message[][] = [];
856
878
  let current: Message[] = [];
857
879
  let currentTokens = 0;
858
880
  for (const message of messages) {
859
- const tokens = tokenizer.countTokens(serializeConversationForSummary([message], dialect));
881
+ const tokens = tokenizer.countTokens(serializeConversationForSummary([message], dialect, dropThinking));
860
882
  if (currentTokens > 0 && currentTokens + tokens > budgetTokens) {
861
883
  windows.push(current);
862
884
  current = [];
@@ -885,8 +907,9 @@ export async function generateSummary(
885
907
  // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
886
908
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
887
909
  const dialect = preferredDialect(model.id);
910
+ const dropThinking = resolveCatalogPolicy(model).classifierCotExtraction === true;
888
911
  const tokenizer = new Tokenizer(model);
889
- const wholeConversation = serializeConversationForSummary(llmMessages, dialect);
912
+ const wholeConversation = serializeConversationForSummary(llmMessages, dialect, dropThinking);
890
913
  const budgetTokens = summaryInputBudgetTokens(model, maxTokens);
891
914
  // A span that outgrew the summarizer's window is summarized as a fold: each
892
915
  // window updates the summary carried out of the previous one, which is the
@@ -897,12 +920,15 @@ export async function generateSummary(
897
920
  // common case and costs exactly the one call it always did.
898
921
  const pending: SummaryWindow[] = tokenizer.checkTokenBudget(wholeConversation, budgetTokens).fits
899
922
  ? [{ messages: llmMessages, budgetTokens, text: wholeConversation }]
900
- : planSummaryWindows(llmMessages, tokenizer, dialect, budgetTokens).map(messages => ({ messages, budgetTokens }));
923
+ : planSummaryWindows(llmMessages, tokenizer, dialect, dropThinking, budgetTokens).map(messages => ({
924
+ messages,
925
+ budgetTokens,
926
+ }));
901
927
 
902
928
  let carriedSummary = previousSummary;
903
929
  while (pending.length > 0) {
904
930
  const window = pending[0];
905
- const text = window.text ?? serializeConversationForSummary(window.messages, dialect);
931
+ const text = window.text ?? serializeConversationForSummary(window.messages, dialect, dropThinking);
906
932
  // A budget probe, not a raw count: a window whose bytes already fit needs
907
933
  // neither an exact count nor the clamp, and the bust path hands back the
908
934
  // exact count the proportional clamp needs as its denominator.
@@ -940,7 +966,7 @@ export async function generateSummary(
940
966
  pending.splice(
941
967
  0,
942
968
  1,
943
- ...planSummaryWindows(window.messages, tokenizer, dialect, halved).map(messages => ({
969
+ ...planSummaryWindows(window.messages, tokenizer, dialect, dropThinking, halved).map(messages => ({
944
970
  messages,
945
971
  budgetTokens: halved,
946
972
  })),
@@ -1190,7 +1216,11 @@ async function generateShortSummary(
1190
1216
  ): Promise<string> {
1191
1217
  const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
1192
1218
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
1193
- const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
1219
+ const conversationText = serializeConversationForSummary(
1220
+ llmMessages,
1221
+ preferredDialect(model.id),
1222
+ resolveCatalogPolicy(model).classifierCotExtraction === true,
1223
+ );
1194
1224
 
1195
1225
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
1196
1226
  if (historySummary) {
@@ -1267,6 +1297,8 @@ export interface CompactionPreparation {
1267
1297
  recentMessages: AgentMessage[];
1268
1298
  /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
1269
1299
  recentEntryIds?: string[];
1300
+ /** Last entry before the kept tail; a remote compaction that cannot fit the tail covers history through it. */
1301
+ lastSummarizedEntryId?: string;
1270
1302
  /** Whether this is a split turn (cut point in middle of turn) */
1271
1303
  isSplitTurn: boolean;
1272
1304
  tokensBefore: number;
@@ -1479,6 +1511,7 @@ export function prepareCompaction(
1479
1511
  turnPrefixMessages,
1480
1512
  recentMessages,
1481
1513
  recentEntryIds,
1514
+ lastSummarizedEntryId: compactionEntries[cutPoint.firstKeptEntryIndex - 1]?.id,
1482
1515
  isSplitTurn: cutPoint.isSplitTurn,
1483
1516
  tokensBefore,
1484
1517
  previousSummary: previousCompaction?.summary,
@@ -1586,6 +1619,13 @@ function formatRemoteCompactionSummary(inputTokens: number): string {
1586
1619
  );
1587
1620
  }
1588
1621
 
1622
+ /** A V2 remote-compaction request's history and the user items its replacement history retains. */
1623
+ interface RemoteCompactionV2Input {
1624
+ codexBody: OpenAICodexCompactionBody | undefined;
1625
+ remoteHistory: Array<Record<string, unknown>>;
1626
+ retainedUserItems: unknown[];
1627
+ }
1628
+
1589
1629
  /**
1590
1630
  * Generate summaries for compaction using prepared data.
1591
1631
  * Returns CompactionResult - SessionManager adds id/parentId when saving.
@@ -1607,6 +1647,7 @@ export async function compact(
1607
1647
  turnPrefixMessages,
1608
1648
  recentMessages,
1609
1649
  recentEntryIds,
1650
+ lastSummarizedEntryId,
1610
1651
  isSplitTurn,
1611
1652
  tokensBefore,
1612
1653
  previousSummary,
@@ -1647,12 +1688,28 @@ export async function compact(
1647
1688
  oneshotRetry: options?.oneshotRetry,
1648
1689
  };
1649
1690
 
1650
- const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1691
+ // An archive keeps the reasoning policy of the model it was rendered for;
1692
+ // Claude refuses inputs that reproduce reasoning (issue #6093), so a
1693
+ // summarizer whose classifier refuses quoted reasoning (the
1694
+ // `classifier-cot-extraction` catalog axis) reads the archive's no-reasoning view (scrubbed
1695
+ // while its tool-output spans are still marked), and a summary with no
1696
+ // archive behind it is scrubbed of `¶think:` sections.
1697
+ const withoutThinking = !snapcompact.readerIncludesThinking(model);
1698
+ const storedSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1699
+ const snapcompactView =
1700
+ storedSnapcompactArchive && withoutThinking
1701
+ ? snapcompact.viewWithoutThinking(previousSummary ?? "", storedSnapcompactArchive)
1702
+ : undefined;
1703
+ const previousSnapcompactArchive = snapcompactView?.archive ?? storedSnapcompactArchive;
1704
+ const previousSummaryForReader =
1705
+ withoutThinking && previousSummary !== undefined
1706
+ ? (snapcompactView?.summary ?? snapcompact.textWithoutThinking(previousSummary))
1707
+ : previousSummary;
1651
1708
  const previousSnapcompactArchiveText = previousSnapcompactArchive
1652
1709
  ? snapcompact.archiveSourceText(previousSnapcompactArchive)
1653
1710
  : undefined;
1654
1711
  const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
1655
- previousSummary,
1712
+ previousSummaryForReader,
1656
1713
  previousSnapcompactArchiveText,
1657
1714
  );
1658
1715
  const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
@@ -1663,23 +1720,58 @@ export async function compact(
1663
1720
  // A local summary has no native payload to carry it into the first remote
1664
1721
  // request. Encode it as history; do not resend opaque native placeholders.
1665
1722
  const previousSummaryMigrationMessage =
1666
- settings.remoteEnabled !== false && previousSummary && !previousNativeHistory
1667
- ? createCompactionSummaryMessage(previousSummary, tokensBefore, new Date().toISOString())
1723
+ settings.remoteEnabled !== false && previousSummaryForReader && !previousNativeHistory
1724
+ ? createCompactionSummaryMessage(previousSummaryForReader, tokensBefore, new Date().toISOString())
1668
1725
  : undefined;
1669
1726
 
1670
1727
  let preserveData = withAnthropicCompactionPreserveData(
1671
1728
  withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
1672
1729
  undefined,
1673
1730
  );
1674
- const remoteMessages: AgentMessage[] = [
1731
+ const remotePrefixMessages: AgentMessage[] = [
1675
1732
  ...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
1676
1733
  ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1677
1734
  ...messagesToSummarize,
1678
1735
  ...turnPrefixMessages,
1679
- ...recentMessages,
1680
1736
  ];
1737
+ const remoteMessages: AgentMessage[] = [...remotePrefixMessages, ...recentMessages];
1681
1738
  let usedRemoteCompaction = false;
1739
+ let remoteReplayThroughEntryId: string | undefined;
1682
1740
  let nativeCompactionError: unknown;
1741
+ /**
1742
+ * Remote compaction replaces the whole history, so its request carries the
1743
+ * kept tail too. A turn can leave that history over the window (a large
1744
+ * response or tool output after the last check), which no request can
1745
+ * send. Then compact the history before the tail instead; the tail replays
1746
+ * normally after the native history, as a speculative snapshot's later
1747
+ * turns do. While that fallback exists, the whole-history attempt must not
1748
+ * rewrite the tail's trailing tool outputs: the prefix keeps them intact.
1749
+ */
1750
+ const canCompactBeforeTail = lastSummarizedEntryId !== undefined && recentMessages.length > 0;
1751
+ const sendRemoteCompaction = async (
1752
+ sendWhole: () => Promise<void>,
1753
+ sendPrefix: () => Promise<boolean>,
1754
+ ): Promise<void> => {
1755
+ try {
1756
+ await sendWhole();
1757
+ } catch (err) {
1758
+ if (
1759
+ signal?.aborted ||
1760
+ !canCompactBeforeTail ||
1761
+ !AIError.is(AIError.classify(err), AIError.Flag.ContextOverflow)
1762
+ ) {
1763
+ throw err;
1764
+ }
1765
+ logger.info("Remote compaction history overflows the context window; compacting before the kept tail", {
1766
+ model: model.id,
1767
+ provider: model.provider,
1768
+ keptMessages: recentMessages.length,
1769
+ error: err instanceof Error ? err.message : String(err),
1770
+ });
1771
+ if (!(await sendPrefix())) throw err;
1772
+ remoteReplayThroughEntryId = lastSummarizedEntryId;
1773
+ }
1774
+ };
1683
1775
  if (
1684
1776
  settings.remoteEnabled !== false &&
1685
1777
  settings.remoteStreamingV2Enabled !== false &&
@@ -1691,126 +1783,147 @@ export async function compact(
1691
1783
  ? previousRemoteCompaction.replacementHistory
1692
1784
  : undefined;
1693
1785
  const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1694
- const messages = convertToLlm(remoteMessages);
1695
1786
  // Replacement history keeps what the user wrote. Summaries, archive
1696
1787
  // migrations, and custom/hook messages can serialize as user-role items
1697
1788
  // too, so pick the user's own messages before serialization erases that,
1698
1789
  // then serialize each one exactly as the request does.
1699
1790
  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 ?? [])];
1704
1791
  const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
1705
- let codexBody: OpenAICodexCompactionBody | undefined;
1706
- let remoteHistory: Array<Record<string, unknown>>;
1707
- if (isCodexResponsesModel(model)) {
1708
- const previousCodexInput: CodexInputItem[] = [];
1709
- for (const item of previousReplacementHistory ?? []) {
1710
- if (!isCodexInputItem(item)) {
1711
- throw new Error("Stored Codex V2 compaction history contains an invalid input item");
1712
- }
1713
- previousCodexInput.push(item);
1714
- }
1715
- codexBody = await buildTransformedCodexRequestBody(
1716
- model,
1717
- { systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
1718
- {
1719
- reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1720
- forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1721
- responsesLite: model.useResponsesLite,
1722
- sessionId: summaryOptions.sessionId,
1723
- promptCacheKey: summaryOptions.promptCacheKey,
1724
- providerSessionState: summaryOptions.providerSessionState,
1725
- codexCompaction: createOpenAICodexCompactionRequestContext({
1726
- context: summaryOptions.codexCompaction,
1727
- implementation: "responses_compaction_v2",
1728
- }),
1729
- },
1730
- undefined,
1731
- previousCodexInput,
1792
+ const buildV2Input = async (includeTail: boolean): Promise<RemoteCompactionV2Input> => {
1793
+ const messages = convertToLlm(includeTail ? remoteMessages : remotePrefixMessages);
1794
+ const userMessages = convertToLlm(
1795
+ [...messagesToSummarize, ...turnPrefixMessages, ...(includeTail ? recentMessages : [])].filter(
1796
+ isUserAuthored,
1797
+ ),
1732
1798
  );
1733
- const input = Array.isArray(codexBody.input) ? codexBody.input : [];
1734
- const nativeInput: Array<Record<string, unknown>> = [];
1735
- for (const item of input) {
1736
- if (!isRecord(item)) {
1737
- throw new Error("Codex V2 compaction input contains a non-object item");
1799
+ const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
1800
+ let codexBody: OpenAICodexCompactionBody | undefined;
1801
+ let remoteHistory: Array<Record<string, unknown>>;
1802
+ if (isCodexResponsesModel(model)) {
1803
+ const previousCodexInput: CodexInputItem[] = [];
1804
+ for (const item of previousReplacementHistory ?? []) {
1805
+ if (!isCodexInputItem(item)) {
1806
+ throw new Error("Stored Codex V2 compaction history contains an invalid input item");
1807
+ }
1808
+ previousCodexInput.push(item);
1738
1809
  }
1739
- nativeInput.push(item);
1740
- }
1741
- remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1742
- codexBody.input = remoteHistory;
1743
- for (const message of userMessages) {
1744
- const userBody = await buildTransformedCodexRequestBody(
1810
+ codexBody = await buildTransformedCodexRequestBody(
1745
1811
  model,
1746
- { messages: [message] },
1747
- { responsesLite: model.useResponsesLite },
1812
+ { systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
1813
+ {
1814
+ reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1815
+ forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1816
+ responsesLite: model.useResponsesLite,
1817
+ sessionId: summaryOptions.sessionId,
1818
+ promptCacheKey: summaryOptions.promptCacheKey,
1819
+ providerSessionState: summaryOptions.providerSessionState,
1820
+ codexCompaction: createOpenAICodexCompactionRequestContext({
1821
+ context: summaryOptions.codexCompaction,
1822
+ implementation: "responses_compaction_v2",
1823
+ }),
1824
+ },
1825
+ undefined,
1826
+ previousCodexInput,
1748
1827
  );
1749
- retainedUserItems.push(...(userBody.input ?? []));
1828
+ const input = Array.isArray(codexBody.input) ? codexBody.input : [];
1829
+ const nativeInput: Array<Record<string, unknown>> = [];
1830
+ for (const item of input) {
1831
+ if (!isRecord(item)) {
1832
+ throw new Error("Codex V2 compaction input contains a non-object item");
1833
+ }
1834
+ nativeInput.push(item);
1835
+ }
1836
+ remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1837
+ codexBody.input = remoteHistory;
1838
+ for (const message of userMessages) {
1839
+ const userBody = await buildTransformedCodexRequestBody(
1840
+ model,
1841
+ { messages: [message] },
1842
+ { responsesLite: model.useResponsesLite },
1843
+ );
1844
+ retainedUserItems.push(...(userBody.input ?? []));
1845
+ }
1846
+ } else {
1847
+ remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1848
+ for (const message of userMessages) {
1849
+ retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1850
+ }
1750
1851
  }
1751
- } else {
1752
- remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1753
- for (const message of userMessages) {
1754
- retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1852
+ return { codexBody, remoteHistory, retainedUserItems };
1853
+ };
1854
+ const sendV2 = async (
1855
+ { codexBody, remoteHistory, retainedUserItems }: RemoteCompactionV2Input,
1856
+ rewriteOutputs: boolean,
1857
+ ): Promise<void> => {
1858
+ const instructions = codexBody
1859
+ ? typeof codexBody.instructions === "string"
1860
+ ? codexBody.instructions
1861
+ : ""
1862
+ : remoteSystemPrompt.join("\n\n");
1863
+ const tools = codexBody
1864
+ ? Array.isArray(codexBody.tools)
1865
+ ? codexBody.tools
1866
+ : undefined
1867
+ : summaryOptions.tools
1868
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1869
+ : undefined;
1870
+ const trimmed = trimRemoteCompactionInputToContextWindow(
1871
+ remoteHistory,
1872
+ new Tokenizer(model),
1873
+ model.contextWindow,
1874
+ instructions,
1875
+ tools,
1876
+ { rewriteOutputs },
1877
+ );
1878
+ if (trimmed.rewrittenOutputs > 0) {
1879
+ logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1880
+ model: model.id,
1881
+ provider: model.provider,
1882
+ rewrittenOutputs: trimmed.rewrittenOutputs,
1883
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
1884
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
1885
+ contextWindow: model.contextWindow,
1886
+ });
1755
1887
  }
1756
- }
1757
- if (remoteHistory.length > 0) {
1758
- try {
1759
- const instructions = codexBody
1760
- ? typeof codexBody.instructions === "string"
1761
- ? codexBody.instructions
1762
- : ""
1763
- : remoteSystemPrompt.join("\n\n");
1764
- const tools = codexBody
1765
- ? Array.isArray(codexBody.tools)
1766
- ? codexBody.tools
1767
- : undefined
1768
- : summaryOptions.tools
1769
- ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1770
- : undefined;
1771
- const trimmed = trimRemoteCompactionInputToContextWindow(
1772
- remoteHistory,
1773
- new Tokenizer(model),
1774
- model.contextWindow,
1775
- instructions,
1776
- tools,
1777
- );
1778
- if (trimmed.rewrittenOutputs > 0) {
1779
- logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1780
- model: model.id,
1781
- provider: model.provider,
1782
- rewrittenOutputs: trimmed.rewrittenOutputs,
1783
- estimatedTokensBefore: trimmed.estimatedTokensBefore,
1784
- estimatedTokensAfter: trimmed.estimatedTokensAfter,
1785
- contextWindow: model.contextWindow,
1888
+ assertRemoteCompactionInputFits(trimmed, model);
1889
+ const requestOptions = {
1890
+ sessionId: summaryOptions.sessionId,
1891
+ promptCacheKey: summaryOptions.promptCacheKey,
1892
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1893
+ retainedUserItems,
1894
+ };
1895
+ const request = codexBody
1896
+ ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
1897
+ : buildCompactionV2Request(model, trimmed.input, instructions, {
1898
+ ...requestOptions,
1899
+ tools,
1900
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1786
1901
  });
1787
- }
1788
- assertRemoteCompactionInputFits(trimmed, model);
1789
- const requestOptions = {
1790
- sessionId: summaryOptions.sessionId,
1791
- promptCacheKey: summaryOptions.promptCacheKey,
1792
- retainedMessageBudget: settings.v2RetainedMessageBudget,
1793
- retainedUserItems,
1794
- };
1795
- const request = codexBody
1796
- ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
1797
- : buildCompactionV2Request(model, trimmed.input, instructions, {
1798
- ...requestOptions,
1799
- tools,
1800
- reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1801
- });
1802
- const remote = await withAuth(
1803
- apiKey,
1804
- key =>
1805
- requestCompactionV2Streaming(model, key, request, signal, {
1806
- fetch: summaryOptions.fetch,
1807
- providerSessionState: summaryOptions.providerSessionState,
1808
- preferWebsockets: summaryOptions.preferWebsockets,
1809
- codexCompaction: summaryOptions.codexCompaction,
1810
- }),
1811
- { signal },
1902
+ const remote = await withAuth(
1903
+ apiKey,
1904
+ key =>
1905
+ requestCompactionV2Streaming(model, key, request, signal, {
1906
+ fetch: summaryOptions.fetch,
1907
+ providerSessionState: summaryOptions.providerSessionState,
1908
+ preferWebsockets: summaryOptions.preferWebsockets,
1909
+ codexCompaction: summaryOptions.codexCompaction,
1910
+ }),
1911
+ { signal },
1912
+ );
1913
+ preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
1914
+ };
1915
+ const whole = await buildV2Input(true);
1916
+ if (whole.remoteHistory.length > 0) {
1917
+ try {
1918
+ await sendRemoteCompaction(
1919
+ () => sendV2(whole, !canCompactBeforeTail),
1920
+ async () => {
1921
+ const prefix = await buildV2Input(false);
1922
+ if (prefix.remoteHistory.length === 0) return false;
1923
+ await sendV2(prefix, true);
1924
+ return true;
1925
+ },
1812
1926
  );
1813
- preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
1814
1927
  usedRemoteCompaction = true;
1815
1928
  } catch (err) {
1816
1929
  // A user/session abort is a cancellation, not a remote failure —
@@ -1842,33 +1955,52 @@ export async function compact(
1842
1955
  : previousV2Compaction?.provider === model.provider
1843
1956
  ? previousV2Compaction.replacementHistory
1844
1957
  : undefined;
1845
- const remoteHistory = buildOpenAiNativeHistory(
1846
- (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1958
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1959
+ const supportsOriginalDetail = openAiCompatSupportsImageDetailOriginal(model);
1960
+ const sendV1 = async (remoteHistory: Array<Record<string, unknown>>, rewriteOutputs: boolean): Promise<void> => {
1961
+ const remote = await withAuth(
1962
+ apiKey,
1963
+ key =>
1964
+ requestOpenAiRemoteCompaction(
1965
+ model,
1966
+ key,
1967
+ remoteHistory,
1968
+ summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
1969
+ signal,
1970
+ {
1971
+ fetch: summaryOptions.fetch,
1972
+ sessionId: summaryOptions.sessionId,
1973
+ providerSessionState: summaryOptions.providerSessionState,
1974
+ codexCompaction: summaryOptions.codexCompaction,
1975
+ rewriteOutputs,
1976
+ },
1977
+ ),
1978
+ { signal },
1979
+ );
1980
+ preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
1981
+ };
1982
+ const whole = buildOpenAiNativeHistory(
1983
+ convertToLlm(remoteMessages),
1847
1984
  model,
1848
1985
  previousReplacementHistory,
1849
- openAiCompatSupportsImageDetailOriginal(model),
1986
+ supportsOriginalDetail,
1850
1987
  );
1851
- if (remoteHistory.length > 0) {
1988
+ if (whole.length > 0) {
1852
1989
  try {
1853
- const remote = await withAuth(
1854
- apiKey,
1855
- key =>
1856
- requestOpenAiRemoteCompaction(
1990
+ await sendRemoteCompaction(
1991
+ () => sendV1(whole, !canCompactBeforeTail),
1992
+ async () => {
1993
+ const prefix = buildOpenAiNativeHistory(
1994
+ convertToLlm(remotePrefixMessages),
1857
1995
  model,
1858
- key,
1859
- remoteHistory,
1860
- summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
1861
- signal,
1862
- {
1863
- fetch: summaryOptions.fetch,
1864
- sessionId: summaryOptions.sessionId,
1865
- providerSessionState: summaryOptions.providerSessionState,
1866
- codexCompaction: summaryOptions.codexCompaction,
1867
- },
1868
- ),
1869
- { signal },
1996
+ previousReplacementHistory,
1997
+ supportsOriginalDetail,
1998
+ );
1999
+ if (prefix.length === 0) return false;
2000
+ await sendV1(prefix, true);
2001
+ return true;
2002
+ },
1870
2003
  );
1871
- preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
1872
2004
  usedRemoteCompaction = true;
1873
2005
  } catch (err) {
1874
2006
  // A user/session abort is a cancellation, not a remote failure —
@@ -1981,7 +2113,7 @@ export async function compact(
1981
2113
  // history when the host can build them: kept thinking remains valid
1982
2114
  // only when the summarized prefix matches what was sent byte for byte.
1983
2115
  const context = summaryOptions.buildProviderContext
1984
- ? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
2116
+ ? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal, model)
1985
2117
  : {
1986
2118
  systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1987
2119
  messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
@@ -2140,6 +2272,7 @@ export async function compact(
2140
2272
  tokensBefore,
2141
2273
  details: { readFiles, modifiedFiles } as CompactionDetails,
2142
2274
  preserveData: finalPreserveData,
2275
+ ...(remoteReplayThroughEntryId ? { providerReplayThroughEntryId: remoteReplayThroughEntryId } : {}),
2143
2276
  };
2144
2277
  }
2145
2278
 
@@ -2157,7 +2290,11 @@ async function generateTurnPrefixSummary(
2157
2290
  const maxTokens = Math.min(Math.floor(0.5 * reserveTokens), MAX_SUMMARY_TOKENS); // Smaller budget for turn prefix
2158
2291
 
2159
2292
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
2160
- const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
2293
+ const conversationText = serializeConversationForSummary(
2294
+ llmMessages,
2295
+ preferredDialect(model.id),
2296
+ resolveCatalogPolicy(model).classifierCotExtraction === true,
2297
+ );
2161
2298
  const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
2162
2299
  const summarizationMessages = [
2163
2300
  {