@linxiraos/pi-agent-core 1.1.20 → 1.1.23

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 (41) hide show
  1. package/CHANGELOG.md +243 -2
  2. package/README.md +4 -3
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/dist/types/agent-loop.d.ts +14 -1
  5. package/dist/types/agent.d.ts +36 -0
  6. package/dist/types/compaction/anthropic.d.ts +22 -52
  7. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  8. package/dist/types/compaction/compaction.d.ts +4 -2
  9. package/dist/types/compaction/messages.d.ts +9 -0
  10. package/dist/types/compaction/openai.d.ts +11 -0
  11. package/dist/types/compaction/pruning.d.ts +9 -0
  12. package/dist/types/compaction/transcript-tokens.d.ts +25 -2
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/live-steering.d.ts +34 -0
  15. package/dist/types/output-budget.d.ts +54 -0
  16. package/dist/types/sent-tool-definitions.d.ts +17 -0
  17. package/dist/types/speculative-execution.d.ts +2 -1
  18. package/dist/types/telemetry.d.ts +103 -60
  19. package/dist/types/tool-context.d.ts +32 -0
  20. package/dist/types/types.d.ts +105 -7
  21. package/package.json +10 -10
  22. package/src/agent-loop.ts +386 -90
  23. package/src/agent.ts +175 -26
  24. package/src/compaction/anthropic.ts +62 -105
  25. package/src/compaction/branch-summarization.ts +1 -1
  26. package/src/compaction/compaction-v2-streaming.ts +13 -4
  27. package/src/compaction/compaction.ts +63 -51
  28. package/src/compaction/messages.ts +12 -2
  29. package/src/compaction/openai.ts +37 -9
  30. package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
  31. package/src/compaction/pruning.ts +1 -1
  32. package/src/compaction/transcript-tokens.ts +64 -3
  33. package/src/index.ts +6 -0
  34. package/src/live-steering.ts +89 -0
  35. package/src/output-budget.ts +142 -0
  36. package/src/sent-tool-definitions.ts +40 -0
  37. package/src/speculative-execution.ts +12 -0
  38. package/src/telemetry.ts +240 -117
  39. package/src/tokenizer.ts +45 -6
  40. package/src/tool-context.ts +55 -0
  41. package/src/types.ts +109 -7
@@ -9,13 +9,13 @@
9
9
 
10
10
  import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@linxiraos/pi-ai";
11
11
  import * as AIError from "@linxiraos/pi-ai/error";
12
+ import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
12
13
  import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
13
14
  import {
14
- createOpenAICodexCompactionRequestContext,
15
15
  createOpenAICodexCompatibilityMetadata,
16
+ openCodexCompactionEventStream,
16
17
  type OpenAICodexCompactionBody,
17
18
  type OpenAICodexCompatibilityMetadata,
18
- openCodexCompactionEventStream,
19
19
  } from "@linxiraos/pi-ai/providers/openai-codex-responses";
20
20
  import {
21
21
  getOpenAIPromptCacheKey,
@@ -597,6 +597,14 @@ function handleCompactionV2Event(
597
597
  if (type === "response.failed" || type === "response.incomplete") {
598
598
  throw new Error(formatCompactionV2Failure(event, type));
599
599
  }
600
+
601
+ // A standalone `error` event terminates the stream. Keep its status so a
602
+ // deterministic 4xx (e.g. context_too_large) is not retried as a dropped stream.
603
+ if (type === "error") {
604
+ const message = formatCompactionV2Failure(event, type);
605
+ const status = numberField(event, "status");
606
+ throw status === undefined ? new Error(message) : new AIError.ProviderHttpError(message, status);
607
+ }
600
608
  }
601
609
 
602
610
  function parseCompactionV2Usage(event: Record<string, unknown>): CompactionV2Usage | undefined {
@@ -629,8 +637,9 @@ function formatCompactionV2Failure(event: Record<string, unknown>, type: string)
629
637
  : response && isRecord(response.error)
630
638
  ? response.error
631
639
  : undefined;
632
- const message = error ? stringField(error, "message") : undefined;
633
- const code = error ? (stringField(error, "code") ?? stringField(error, "type")) : undefined;
640
+ // Responses `error` events carry code/message at the top level.
641
+ const message = stringField(error ?? event, "message");
642
+ const code = error ? (stringField(error, "code") ?? stringField(error, "type")) : stringField(event, "code");
634
643
  return `V2 compaction stream ${type}${code ? ` (${code})` : ""}${message ? `: ${message}` : ""}`;
635
644
  }
636
645
 
@@ -25,10 +25,10 @@ import {
25
25
  } from "@linxiraos/pi-ai";
26
26
  import type { Dialect } from "@linxiraos/pi-ai/dialect";
27
27
  import * as AIError from "@linxiraos/pi-ai/error";
28
+ import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
28
29
  import type { InputItem as CodexInputItem } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
29
30
  import {
30
31
  buildTransformedCodexRequestBody,
31
- createOpenAICodexCompactionRequestContext,
32
32
  type OpenAICodexCompactionBody,
33
33
  } from "@linxiraos/pi-ai/providers/openai-codex-responses";
34
34
  import { convertTools } from "@linxiraos/pi-ai/providers/openai-responses";
@@ -43,9 +43,8 @@ import { ThinkingLevel } from "../thinking";
43
43
  import { Tokenizer } from "../tokenizer";
44
44
  import type { AgentMessage } from "../types";
45
45
  import {
46
- ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS,
47
46
  buildAnthropicCompactionInstructions,
48
- describeRetainedTail,
47
+ findAnthropicCompactionCut,
49
48
  getPreservedAnthropicCompactionData,
50
49
  requestAnthropicNativeCompaction,
51
50
  shouldUseAnthropicNativeCompaction,
@@ -70,6 +69,7 @@ import {
70
69
  defaultConvertToLlm,
71
70
  } from "./messages";
72
71
  import {
72
+ assertRemoteCompactionInputFits,
73
73
  buildOpenAiNativeHistory,
74
74
  getPreservedOpenAiRemoteCompactionData,
75
75
  isOpenAiRemoteCompactionApi,
@@ -679,7 +679,7 @@ export interface SummaryOptions {
679
679
  /**
680
680
  * Optional telemetry handle. When provided, every LLM call emitted during
681
681
  * compaction is wrapped in an OTEL chat span tagged with
682
- * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
682
+ * `omp.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
683
683
  * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
684
684
  */
685
685
  telemetry?: AgentTelemetry;
@@ -1042,7 +1042,7 @@ export interface HandoffOptions {
1042
1042
  metadata?: Record<string, unknown>;
1043
1043
  /**
1044
1044
  * Optional telemetry handle. When provided, the handoff LLM call is
1045
- * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
1045
+ * wrapped in an OTEL chat span tagged with `omp.gen_ai.oneshot.kind = "handoff"`.
1046
1046
  */
1047
1047
  telemetry?: AgentTelemetry;
1048
1048
  /**
@@ -1252,6 +1252,8 @@ export interface CompactionPreparation {
1252
1252
  turnPrefixMessages: AgentMessage[];
1253
1253
  /** Messages kept in full after compaction (recent history) */
1254
1254
  recentMessages: AgentMessage[];
1255
+ /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
1256
+ recentEntryIds?: string[];
1255
1257
  /** Whether this is a split turn (cut point in middle of turn) */
1256
1258
  isSplitTurn: boolean;
1257
1259
  tokensBefore: number;
@@ -1380,16 +1382,22 @@ export function prepareCompaction(
1380
1382
  }
1381
1383
  }
1382
1384
  } else if (previousCompaction) {
1383
- // Local summaries exclude the retained tail, whose original entries precede
1384
- // the compaction record. Native replay already carries that tail. Only look
1385
- // backwards: advisor snapshots put all retained messages after the summary
1386
- // and may carry a keep ID from their previous, differently indexed snapshot.
1385
+ // Local and Anthropic summaries exclude the retained tail, whose
1386
+ // original entries precede the compaction record.
1387
1387
  for (let i = resetBoundaryIndex + 1; i < prevCompactionIndex; i++) {
1388
1388
  if (pathEntries[i].id === previousCompaction.firstKeptEntryId) {
1389
1389
  boundaryStart = i;
1390
1390
  break;
1391
1391
  }
1392
1392
  }
1393
+ if (previousCompaction.firstKeptEntryId === "" && previousCompaction.providerReplayThroughEntryId) {
1394
+ // An empty snapshot tail can still have turns appended during
1395
+ // background compaction; those start after the summarized snapshot.
1396
+ const snapshotIdx = pathEntries.findIndex(
1397
+ entry => entry.id === previousCompaction.providerReplayThroughEntryId,
1398
+ );
1399
+ if (snapshotIdx >= 0 && snapshotIdx < prevCompactionIndex) boundaryStart = snapshotIdx + 1;
1400
+ }
1393
1401
  }
1394
1402
 
1395
1403
  // Keep original IDs beside the converted messages so estimation, cutting,
@@ -1437,6 +1445,11 @@ export function prepareCompaction(
1437
1445
  return undefined;
1438
1446
  }
1439
1447
 
1448
+ const recentEntryIds: string[] = [];
1449
+ for (let i = cutPoint.firstKeptEntryIndex; i < compactionEntries.length; i++) {
1450
+ recentEntryIds.push(compactionEntries[i].id);
1451
+ }
1452
+
1440
1453
  // Extract file operations from messages and previous compaction
1441
1454
  const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
1442
1455
 
@@ -1452,6 +1465,7 @@ export function prepareCompaction(
1452
1465
  messagesToSummarize,
1453
1466
  turnPrefixMessages,
1454
1467
  recentMessages,
1468
+ recentEntryIds,
1455
1469
  isSplitTurn: cutPoint.isSplitTurn,
1456
1470
  tokensBefore,
1457
1471
  previousSummary: previousCompaction?.summary,
@@ -1579,6 +1593,7 @@ export async function compact(
1579
1593
  messagesToSummarize,
1580
1594
  turnPrefixMessages,
1581
1595
  recentMessages,
1596
+ recentEntryIds,
1582
1597
  isSplitTurn,
1583
1598
  tokensBefore,
1584
1599
  previousSummary,
@@ -1734,6 +1749,7 @@ export async function compact(
1734
1749
  contextWindow: model.contextWindow,
1735
1750
  });
1736
1751
  }
1752
+ assertRemoteCompactionInputFits(trimmed, model);
1737
1753
  const requestOptions = {
1738
1754
  sessionId: summaryOptions.sessionId,
1739
1755
  promptCacheKey: summaryOptions.promptCacheKey,
@@ -1826,35 +1842,19 @@ export async function compact(
1826
1842
  }
1827
1843
  }
1828
1844
 
1829
- // Anthropic server-side compaction: the live turn's request shape plus the
1830
- // compact edit, so the API summarizes from its cached prefix. The summary is
1831
- // real text, persisted both as the entry summary and as the native replay
1832
- // payload. A context below the API's trigger floor cannot compact remotely
1833
- // and takes the local summarizer instead — an eligibility boundary, not a
1834
- // failure.
1845
+ // On-demand compaction summarizes only the prefix; the tail is never sent
1846
+ // to this request and is replayed after the returned signed block.
1835
1847
  let nativeSummary: string | undefined;
1836
- let nativeEncryptedContent: string | undefined;
1848
+ let nativeSignature: string | undefined;
1849
+ let nativeFirstKeptEntryId = firstKeptEntryId;
1837
1850
  let nativeUsedTokens: number | undefined;
1838
- if (
1839
- !usedRemoteCompaction &&
1840
- settings.remoteEnabled !== false &&
1841
- shouldUseAnthropicNativeCompaction(model) &&
1842
- tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
1843
- ) {
1851
+ if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
1844
1852
  const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1845
- // Lead with the previous summary exactly as the live context renders it:
1846
- // natively when this provider wrote it, as text otherwise. The request
1847
- // then shares the live turn's prefix byte-for-byte. A prior snapcompact
1848
- // archive is already merged into that summary text, so the archive
1849
- // migration message the OpenAI lanes carry is omitted here.
1850
- // The rewrite marker must precede every message this request replays —
1851
- // summarized history and retained tail alike — exactly like the live
1852
- // context rebuild predates its tail. A previous compaction's commit
1853
- // timestamp is newer than re-retained or re-summarized turns, so
1854
- // reusing it would strip their bound thinking only in this request,
1855
- // diverging from the cached live prefix (and possibly dropping below
1856
- // the trigger). Manually built preparations with no input at all fall
1857
- // back to the current time.
1853
+ // Lead with the previous summary as the live context renders it:
1854
+ // natively when this provider wrote it, as text otherwise.
1855
+ // A prior snapcompact archive is already merged into that summary
1856
+ // text. Predate the rewrite marker before all replayed messages so
1857
+ // retained thinking stays bound to the original prefix.
1858
1858
  const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1859
1859
  const previousSummaryAt =
1860
1860
  firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
@@ -1866,6 +1866,7 @@ export async function compact(
1866
1866
  type: "anthropicCompaction",
1867
1867
  provider: previousNative.provider,
1868
1868
  content: previousNative.content,
1869
+ ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1869
1870
  ...(previousNative.encryptedContent
1870
1871
  ? { encryptedContent: previousNative.encryptedContent }
1871
1872
  : {}),
@@ -1874,29 +1875,40 @@ export async function compact(
1874
1875
  : undefined,
1875
1876
  })
1876
1877
  : undefined;
1877
- const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1878
- const retainedTail = convertToLlm(recentMessages);
1879
- const messages = [
1880
- ...convertToLlm([
1881
- ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1882
- ...messagesToSummarize,
1883
- ...turnPrefixMessages,
1884
- ]),
1885
- ...retainedTail,
1886
- ];
1878
+ const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1879
+ const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
1880
+ const nativeCut = findAnthropicCompactionCut(allMessages, originalCut);
1881
+ // Hand-built preparations lacking entry IDs cannot move their persisted
1882
+ // boundary into the tail. Summarizing all is still safe.
1883
+ const safeCut =
1884
+ nativeCut > originalCut && nativeCut < allMessages.length && !recentEntryIds?.[nativeCut - originalCut]
1885
+ ? allMessages.length
1886
+ : nativeCut;
1887
+ nativeFirstKeptEntryId =
1888
+ safeCut === allMessages.length
1889
+ ? ""
1890
+ : safeCut === originalCut
1891
+ ? firstKeptEntryId
1892
+ : (recentEntryIds?.[safeCut - originalCut] ?? "");
1893
+ for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
1894
+ const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
1895
+ ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1896
+ ...allMessages.slice(0, safeCut),
1897
+ ]);
1887
1898
  try {
1888
1899
  const remote = await requestAnthropicNativeCompaction(
1889
1900
  model,
1890
1901
  apiKey,
1891
1902
  {
1892
- systemPrompt: summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT],
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 ?? [],
1893
1906
  messages,
1894
1907
  tools: summaryOptions.tools,
1895
1908
  instructions: buildAnthropicCompactionInstructions(
1896
1909
  summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1897
1910
  customInstructions,
1898
1911
  formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
1899
- describeRetainedTail(retainedTail),
1900
1912
  ),
1901
1913
  maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
1902
1914
  reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
@@ -1915,7 +1927,7 @@ export async function compact(
1915
1927
  },
1916
1928
  );
1917
1929
  nativeSummary = remote.content;
1918
- nativeEncryptedContent = remote.encryptedContent;
1930
+ nativeSignature = remote.signature;
1919
1931
  nativeUsedTokens = calculatePromptTokens(remote.usage);
1920
1932
  usedRemoteCompaction = true;
1921
1933
  } catch (err) {
@@ -2004,7 +2016,7 @@ export async function compact(
2004
2016
  summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2005
2017
  if (nativeSummary !== undefined) {
2006
2018
  // The replayed block stays byte-identical to the API's summary so it
2007
- // matches `encryptedContent`. The harness file lists above travel
2019
+ // matches its signature. The harness file lists above travel
2008
2020
  // separately: the converter replaces the summary message with the
2009
2021
  // block and skips its text, so they would otherwise be invisible to
2010
2022
  // this provider. Every other provider keeps reading the entry text.
@@ -2012,7 +2024,7 @@ export async function compact(
2012
2024
  preserveData = withAnthropicCompactionPreserveData(preserveData, {
2013
2025
  provider: model.provider,
2014
2026
  content: nativeSummary,
2015
- ...(nativeEncryptedContent ? { encryptedContent: nativeEncryptedContent } : {}),
2027
+ ...(nativeSignature ? { signature: nativeSignature } : {}),
2016
2028
  ...(filesText ? { filesText } : {}),
2017
2029
  model: model.id,
2018
2030
  usedTokens: nativeUsedTokens,
@@ -2034,7 +2046,7 @@ export async function compact(
2034
2046
  return {
2035
2047
  summary,
2036
2048
  shortSummary,
2037
- firstKeptEntryId,
2049
+ firstKeptEntryId: nativeSummary !== undefined ? nativeFirstKeptEntryId : firstKeptEntryId,
2038
2050
  tokensBefore,
2039
2051
  details: { readFiles, modifiedFiles } as CompactionDetails,
2040
2052
  preserveData: finalPreserveData,
@@ -64,6 +64,13 @@ export interface CompactionSummaryMessage {
64
64
  images?: ImageContent[];
65
65
  /** Post-pass dead-end warning attached to this compaction (progress guard). */
66
66
  warning?: string;
67
+ /**
68
+ * Thinking-binding rewrite marker when it must differ from `timestamp`: a
69
+ * natively replayed summary predates it before the retained tail so that
70
+ * tail's bound thinking stays valid. `timestamp` remains the commit time,
71
+ * which is what invalidates the tail's pre-compaction usage reports.
72
+ */
73
+ historyRewriteAt?: number;
67
74
  timestamp: number;
68
75
  }
69
76
 
@@ -135,6 +142,8 @@ export interface CompactionSummaryMessageOptions {
135
142
  method?: string;
136
143
  /** Estimated context tokens after the rewrite, for display alongside `tokensBefore`. */
137
144
  tokensAfter?: number;
145
+ /** See {@link CompactionSummaryMessage.historyRewriteAt}. */
146
+ historyRewriteAt?: number;
138
147
  }
139
148
 
140
149
  export function createCompactionSummaryMessage(
@@ -143,7 +152,7 @@ export function createCompactionSummaryMessage(
143
152
  timestamp: string,
144
153
  options: CompactionSummaryMessageOptions = {},
145
154
  ): CompactionSummaryMessage {
146
- const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter } = options;
155
+ const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter, historyRewriteAt } = options;
147
156
  const imageBlocks =
148
157
  blocks?.filter((block): block is ImageContent => block.type === "image") ??
149
158
  (images && images.length > 0 ? images : undefined);
@@ -158,6 +167,7 @@ export function createCompactionSummaryMessage(
158
167
  blocks: blocks && blocks.length > 0 ? blocks : undefined,
159
168
  images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
160
169
  warning,
170
+ historyRewriteAt,
161
171
  timestamp: new Date(timestamp).getTime(),
162
172
  };
163
173
  }
@@ -246,7 +256,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
246
256
  ...(message.images ?? []),
247
257
  ],
248
258
  attribution: "agent",
249
- historyRewriteAt: message.timestamp,
259
+ historyRewriteAt: message.historyRewriteAt ?? message.timestamp,
250
260
  providerPayload: message.providerPayload,
251
261
  timestamp: message.timestamp,
252
262
  };
@@ -15,13 +15,11 @@
15
15
  * with `{ summary, shortSummary? }`.
16
16
  */
17
17
 
18
- import { ProviderHttpError } from "@linxiraos/pi-ai/error";
18
+ import { attach, create, Flag, ProviderHttpError } from "@linxiraos/pi-ai/error";
19
+ import { getCodexAttestationHeader } from "@linxiraos/pi-ai/providers/openai-codex-attestation";
20
+ import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
19
21
  import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
20
- import {
21
- createOpenAICodexCompactionRequestContext,
22
- createOpenAICodexCompatibilityMetadata,
23
- getCodexAttestationHeader,
24
- } from "@linxiraos/pi-ai/providers/openai-codex-responses";
22
+ import { createOpenAICodexCompatibilityMetadata } from "@linxiraos/pi-ai/providers/openai-codex-responses";
25
23
  import {
26
24
  encodeResponsesToolResultOutput,
27
25
  hoistInterleavedResponsesToolBatchMessages,
@@ -112,6 +110,10 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
112
110
  const normalized: Record<string, unknown> = {};
113
111
  let imageTokens = 0;
114
112
  for (const [key, item] of Object.entries(record)) {
113
+ // Opaque encrypted reasoning/compaction state: its local base64 size far
114
+ // exceeds what the provider bills, so it stays out of the fit estimate
115
+ // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
116
+ if (key === "encrypted_content" && typeof item === "string") continue;
115
117
  const result = normalizeRemoteCompactionEstimateValue(item);
116
118
  normalized[key] = result.value;
117
119
  imageTokens += result.imageTokens;
@@ -124,6 +126,8 @@ export interface TrimRemoteCompactionInputResult {
124
126
  rewrittenOutputs: number;
125
127
  estimatedTokensBefore: number;
126
128
  estimatedTokensAfter: number;
129
+ /** Whether `input` fits the model window; false means it must not be sent. */
130
+ fits: boolean;
127
131
  }
128
132
 
129
133
  /** Verdict for one remote-compaction request measured against the model window. */
@@ -137,9 +141,10 @@ interface RemoteCompactionBudgetProbe {
137
141
  /**
138
142
  * Cheap-first sizing of a remote-compaction request. Images and the request
139
143
  * frame are charged flat, so they come off the budget rather than through the
140
- * tokenizer; the serialized transcript is then probed with
141
- * {@link Tokenizer.checkTokenBudget}, which only pays for an exact count when
142
- * the byte bound cannot already prove the request fits.
144
+ * tokenizer; opaque `encrypted_content` payloads are excluded. The serialized
145
+ * transcript is then probed with {@link Tokenizer.checkTokenBudget}, which only
146
+ * pays for an exact count when the byte bound cannot already prove the request
147
+ * fits.
143
148
  */
144
149
  function probeRemoteCompactionInputBudget(
145
150
  input: Array<Record<string, unknown>>,
@@ -201,6 +206,7 @@ export function trimRemoteCompactionInputToContextWindow(
201
206
  rewrittenOutputs: 0,
202
207
  estimatedTokensBefore: before.tokens,
203
208
  estimatedTokensAfter: before.tokens,
209
+ fits: true,
204
210
  };
205
211
  }
206
212
 
@@ -224,6 +230,7 @@ export function trimRemoteCompactionInputToContextWindow(
224
230
  rewrittenOutputs: 0,
225
231
  estimatedTokensBefore: before.tokens,
226
232
  estimatedTokensAfter: before.tokens,
233
+ fits: false,
227
234
  };
228
235
  }
229
236
 
@@ -232,9 +239,29 @@ export function trimRemoteCompactionInputToContextWindow(
232
239
  rewrittenOutputs,
233
240
  estimatedTokensBefore: before.tokens,
234
241
  estimatedTokensAfter: after.tokens,
242
+ fits: true,
235
243
  };
236
244
  }
237
245
 
246
+ /**
247
+ * Refuse a native compaction request whose prepared input cannot fit the model
248
+ * window, before any network I/O. Re-expanded history behind an unreadable
249
+ * native boundary can exceed the window even when live context does not.
250
+ *
251
+ * @throws Error flagged `ContextOverflow` when `trimmed.fits` is false, so
252
+ * compaction callers skip retries and advance to the next method.
253
+ */
254
+ export function assertRemoteCompactionInputFits(trimmed: TrimRemoteCompactionInputResult, model: Model): void {
255
+ if (trimmed.fits) return;
256
+ throw attach(
257
+ new Error(
258
+ `Remote compaction input exceeds the context window of ${model.provider}/${model.id}: ` +
259
+ `estimated ${trimmed.estimatedTokensAfter} tokens > ${model.contextWindow}`,
260
+ ),
261
+ create(Flag.ContextOverflow),
262
+ );
263
+ }
264
+
238
265
  /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
239
266
  function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
240
267
  if (timeoutMs <= 0) return signal;
@@ -796,6 +823,7 @@ export async function requestOpenAiRemoteCompaction(
796
823
  contextWindow: model.contextWindow,
797
824
  });
798
825
  }
826
+ assertRemoteCompactionInputFits(trimmed, model);
799
827
  const request: OpenAiRemoteCompactionRequest = {
800
828
  model: requestModel,
801
829
  // Preserve the native transcript. Only oversized trailing tool outputs are
@@ -1,8 +1,4 @@
1
- {{#if retainedTail}}
2
- SCOPE: The conversation's final {{#when retainedTail.count "==" 1}}{{retainedTail.role}} message stays{{else}}{{retainedTail.count}} messages, starting with a {{retainedTail.role}} message, stay{{/when}} in context verbatim after your summary. Summarize ONLY the history before those messages. You MUST NOT restate anything from those final messages — the reader sees them right after the summary — and you MUST treat them as the most recent state when describing progress and next steps.
3
- {{else}}
4
- SCOPE: The conversation above is the transcript to summarize. The API replaces everything before your summary with it, so nothing you leave out survives into the next context window.
5
- {{/if}}
1
+ The conversation above is the complete history to summarize. The API replaces every message in this request with your summary; no message you leave out survives.
6
2
 
7
3
  {{#if extraContext}}
8
4
  {{extraContext}}
@@ -14,4 +10,4 @@ SCOPE: The conversation above is the transcript to summarize. The API replaces e
14
10
  Additional focus: {{customInstructions}}
15
11
  {{/if}}
16
12
 
17
- You MUST NOT call any tools while writing the summary; respond with the summary text only.
13
+ You MUST NOT call any tools while writing the summary; respond with the summary text only.
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
120
120
  * own rules: useless already drops no-savings candidates, superseded prunes for
121
121
  * correctness regardless of size.
122
122
  */
123
- const MIN_PRUNE_TOKENS = 50;
123
+ export const MIN_PRUNE_TOKENS = 50;
124
124
 
125
125
  function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
126
126
  if (entry.type !== "message") return undefined;
@@ -18,7 +18,7 @@
18
18
  * - `hasContextTokenUsage(usage)`: the report must carry usable context numbers.
19
19
  */
20
20
 
21
- import type { AssistantMessage } from "@linxiraos/pi-ai";
21
+ import type { AssistantMessage, Message } from "@linxiraos/pi-ai";
22
22
  import type { MessageCountOptions, Tokenizer } from "../tokenizer";
23
23
  import type { AgentMessage } from "../types";
24
24
  import { calculateContextTokens, hasContextTokenUsage } from "./compaction";
@@ -47,6 +47,31 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
47
47
  return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
48
48
  }
49
49
 
50
+ /**
51
+ * Newest `prunedAt` in `messages`, or `-Infinity` when nothing was pruned.
52
+ *
53
+ * A pruned tool result was rewritten in place, so every usage report made at
54
+ * or before this time still counts the removed bytes.
55
+ */
56
+ function newestPrunedAt(messages: readonly AgentMessage[]): number {
57
+ let rewriteAt = Number.NEGATIVE_INFINITY;
58
+ for (const message of messages) {
59
+ if (message.role === "toolResult" && message.prunedAt !== undefined) {
60
+ rewriteAt = Math.max(rewriteAt, message.prunedAt);
61
+ }
62
+ }
63
+ return rewriteAt;
64
+ }
65
+
66
+ /** Options for {@link findTranscriptUsageAnchor}. */
67
+ export interface TranscriptUsageAnchorOptions {
68
+ /**
69
+ * Also treat usage reported at or before the newest `prunedAt` as stale,
70
+ * the same rule {@link findRequestUsageAnchor} applies to request contexts.
71
+ */
72
+ skipPrunedAnchors?: boolean;
73
+ }
74
+
50
75
  /**
51
76
  * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
52
77
  * transcript, or `undefined` when none qualifies (fresh context, or every
@@ -58,15 +83,47 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
58
83
  export function findTranscriptUsageAnchor(
59
84
  messages: readonly AgentMessage[],
60
85
  fromIndex = 0,
86
+ options?: TranscriptUsageAnchorOptions,
61
87
  ): TranscriptUsageAnchor | undefined {
88
+ const rewriteAt = options?.skipPrunedAnchors === true ? newestPrunedAt(messages) : Number.NEGATIVE_INFINITY;
62
89
  for (let index = messages.length - 1; index >= fromIndex; index--) {
63
90
  const message = messages[index];
64
- if (!isTranscriptUsageAnchor(message)) continue;
91
+ if (!isTranscriptUsageAnchor(message) || message.timestamp <= rewriteAt) continue;
65
92
  return { index, message, tokens: calculateContextTokens(message.usage) };
66
93
  }
67
94
  return undefined;
68
95
  }
69
96
 
97
+ /**
98
+ * Newest assistant turn in a provider request's `messages` whose usage still
99
+ * describes the prefix it sits on, or `undefined` when none does.
100
+ *
101
+ * Request contexts carry no compaction index, so staleness is read from the
102
+ * rewrite markers themselves: a compaction/branch summary or pruned tool result
103
+ * (`prunedAt`) replaced text that every report made at or before the rewrite
104
+ * already counted. A summary's rewrite time is its `timestamp` (commit time);
105
+ * its `historyRewriteAt` may be predated before a natively replayed retained
106
+ * tail so that tail's bound thinking survives, but the tail's usage still
107
+ * counted the summarized prefix.
108
+ */
109
+ export function findRequestUsageAnchor(messages: readonly Message[]): TranscriptUsageAnchor | undefined {
110
+ let rewriteAt = Number.NEGATIVE_INFINITY;
111
+ let anchorIndex = -1;
112
+ let anchor: AssistantMessage | undefined;
113
+ for (let index = 0; index < messages.length; index++) {
114
+ const message = messages[index];
115
+ if (message.role === "user" && message.historyRewriteAt !== undefined) {
116
+ rewriteAt = Math.max(rewriteAt, message.historyRewriteAt, message.timestamp);
117
+ } else if (message.role === "toolResult" && message.prunedAt !== undefined) {
118
+ rewriteAt = Math.max(rewriteAt, message.prunedAt);
119
+ } else if (isTranscriptUsageAnchor(message) && message.timestamp > rewriteAt) {
120
+ anchorIndex = index;
121
+ anchor = message;
122
+ }
123
+ }
124
+ return anchor && { index: anchorIndex, message: anchor, tokens: calculateContextTokens(anchor.usage) };
125
+ }
126
+
70
127
  /** Options for {@link estimateTranscriptTokens}. */
71
128
  export interface TranscriptTokenOptions {
72
129
  /**
@@ -75,6 +132,8 @@ export interface TranscriptTokenOptions {
75
132
  * accounting is governed separately by {@link countFromIndex}.
76
133
  */
77
134
  anchorFromIndex?: number;
135
+ /** Forwarded to {@link findTranscriptUsageAnchor}. */
136
+ skipPrunedAnchors?: boolean;
78
137
  /**
79
138
  * First message whose content is counted locally when no anchor is found.
80
139
  * Defaults to 0 (count the whole transcript), which is what a floor
@@ -102,7 +161,9 @@ export function estimateTranscriptTokens(
102
161
  ): number {
103
162
  const estimateOptions: MessageCountOptions | undefined =
104
163
  options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
105
- const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0);
164
+ const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0, {
165
+ skipPrunedAnchors: options?.skipPrunedAnchors,
166
+ });
106
167
  let total = anchor?.tokens ?? 0;
107
168
  for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
108
169
  total += tokenizer.countMessage(messages[index], estimateOptions);
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./agent-loop";
6
6
  export * from "./append-only-context";
7
7
  // Compaction
8
8
  export * from "./compaction";
9
+ // Output cap sized to the remaining context window
10
+ export * from "./output-budget";
9
11
  // Process-global pause gate
10
12
  export * from "./pause";
11
13
  // Proxy utilities
@@ -14,12 +16,16 @@ export * from "./proxy";
14
16
  export * from "./replay-policy";
15
17
  // Run-level telemetry collector + aggregators
16
18
  export * from "./run-collector";
19
+ // Tool definitions remembered for Anthropic inactive-tool re-declaration
20
+ export * from "./sent-tool-definitions";
17
21
  // Speculative execution coordinator
18
22
  export * from "./speculative-execution";
19
23
  // Telemetry
20
24
  export * from "./telemetry";
21
25
  // Thinking selectors
22
26
  export * from "./thinking";
27
+ // Tool-context augmentation
28
+ export * from "./tool-context";
23
29
  // Tokenizer choice
24
30
  export * from "./tokenizer";
25
31
  // Types