@linxiraos/pi-agent-core 1.0.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 (71) hide show
  1. package/CHANGELOG.md +1263 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +137 -0
  4. package/dist/types/agent.d.ts +465 -0
  5. package/dist/types/append-only-context.d.ts +131 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
  8. package/dist/types/compaction/compaction.d.ts +307 -0
  9. package/dist/types/compaction/entries.d.ts +111 -0
  10. package/dist/types/compaction/errors.d.ts +38 -0
  11. package/dist/types/compaction/index.d.ts +13 -0
  12. package/dist/types/compaction/message-cache.d.ts +23 -0
  13. package/dist/types/compaction/messages.d.ts +79 -0
  14. package/dist/types/compaction/openai.d.ts +117 -0
  15. package/dist/types/compaction/pruning.d.ts +105 -0
  16. package/dist/types/compaction/shake.d.ts +97 -0
  17. package/dist/types/compaction/tool-protection.d.ts +19 -0
  18. package/dist/types/compaction/utils.d.ts +61 -0
  19. package/dist/types/compaction.d.ts +1 -0
  20. package/dist/types/index.d.ts +13 -0
  21. package/dist/types/pause.d.ts +46 -0
  22. package/dist/types/proxy.d.ts +91 -0
  23. package/dist/types/replay-policy.d.ts +5 -0
  24. package/dist/types/run-collector.d.ts +196 -0
  25. package/dist/types/telemetry.d.ts +608 -0
  26. package/dist/types/thinking.d.ts +18 -0
  27. package/dist/types/tokenizer.d.ts +2 -0
  28. package/dist/types/types.d.ts +781 -0
  29. package/dist/types/utils/yield.d.ts +71 -0
  30. package/package.json +79 -0
  31. package/src/agent-loop.ts +2926 -0
  32. package/src/agent.ts +1748 -0
  33. package/src/append-only-context.ts +348 -0
  34. package/src/compaction/branch-summarization.ts +369 -0
  35. package/src/compaction/compaction-v2-streaming.ts +846 -0
  36. package/src/compaction/compaction.ts +1733 -0
  37. package/src/compaction/entries.ts +141 -0
  38. package/src/compaction/errors.ts +47 -0
  39. package/src/compaction/index.ts +14 -0
  40. package/src/compaction/message-cache.ts +92 -0
  41. package/src/compaction/messages.ts +241 -0
  42. package/src/compaction/openai.ts +992 -0
  43. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  44. package/src/compaction/prompts/branch-summary-context.md +5 -0
  45. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  46. package/src/compaction/prompts/branch-summary.md +30 -0
  47. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  48. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  49. package/src/compaction/prompts/compaction-summary.md +38 -0
  50. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  51. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  52. package/src/compaction/prompts/context-window-truncated-output.md +1 -0
  53. package/src/compaction/prompts/file-operations.md +5 -0
  54. package/src/compaction/prompts/handoff-document.md +49 -0
  55. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  56. package/src/compaction/prompts/summarization-system.md +3 -0
  57. package/src/compaction/pruning.ts +427 -0
  58. package/src/compaction/shake.ts +446 -0
  59. package/src/compaction/tool-protection.ts +65 -0
  60. package/src/compaction/utils.ts +343 -0
  61. package/src/compaction.ts +1 -0
  62. package/src/index.ts +26 -0
  63. package/src/pause.ts +107 -0
  64. package/src/proxy.ts +391 -0
  65. package/src/replay-policy.ts +13 -0
  66. package/src/run-collector.ts +631 -0
  67. package/src/telemetry.ts +2078 -0
  68. package/src/thinking.ts +20 -0
  69. package/src/tokenizer.ts +27 -0
  70. package/src/types.ts +875 -0
  71. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,1733 @@
1
+ /**
2
+ * Context compaction for long sessions.
3
+ *
4
+ * Pure functions for compaction logic. The session manager handles I/O,
5
+ * and after compaction the session is reloaded.
6
+ */
7
+
8
+ import {
9
+ type Api,
10
+ type ApiKey,
11
+ type AssistantMessage,
12
+ type CodexCompactionContext,
13
+ type Context,
14
+ Effort,
15
+ type FetchImpl,
16
+ type Message,
17
+ type MessageAttribution,
18
+ type Model,
19
+ type ProviderSessionState,
20
+ type SimpleStreamOptions,
21
+ type Tool,
22
+ type Usage,
23
+ withAuth,
24
+ } from "@linxiraos/pi-ai";
25
+ import * as AIError from "@linxiraos/pi-ai/error";
26
+ import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-responses";
27
+ import { convertTools } from "@linxiraos/pi-ai/providers/openai-responses";
28
+ import { buildResponsesInput, resolveOpenAICompatPolicy } from "@linxiraos/pi-ai/providers/openai-shared";
29
+ import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@linxiraos/pi-ai/utils";
30
+ import { preferredDialect } from "@linxiraos/pi-catalog/identity";
31
+ import { clampThinkingLevelForModel } from "@linxiraos/pi-catalog/model-thinking";
32
+ import * as snapcompact from "@linxiraos/pi-snapcompact";
33
+ import { isRecord, logger, prompt, stringifyJson } from "@linxiraos/pi-utils";
34
+ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
35
+ import { ThinkingLevel } from "../thinking";
36
+ import { countTokens } from "../tokenizer";
37
+ import type { AgentMessage } from "../types";
38
+ import {
39
+ buildCompactionV2Request,
40
+ getCompactionV2PreserveData,
41
+ requestCompactionV2Streaming,
42
+ shouldUseCompactionV2Streaming,
43
+ storeCompactionV2PreserveData,
44
+ V2_RETAINED_MESSAGE_TOKEN_BUDGET,
45
+ } from "./compaction-v2-streaming";
46
+ import type { CompactionEntry, SessionEntry } from "./entries";
47
+ import { NativeCompactionError } from "./errors";
48
+ import { isEstimateCacheable, readEstimateCache, writeEstimateCache } from "./message-cache";
49
+ import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
50
+ import {
51
+ buildOpenAiNativeHistory,
52
+ getPreservedOpenAiRemoteCompactionData,
53
+ requestOpenAiRemoteCompaction,
54
+ requestRemoteCompaction,
55
+ shouldUseOpenAiRemoteCompaction,
56
+ trimRemoteCompactionInputToContextWindow,
57
+ withOpenAiRemoteCompactionPreserveData,
58
+ } from "./openai";
59
+ import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
60
+ import compactionShortSummaryPrompt from "./prompts/compaction-short-summary.md" with { type: "text" };
61
+ import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { type: "text" };
62
+ import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
63
+ import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
64
+ import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" };
65
+ import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
66
+
67
+ import {
68
+ computeFileLists,
69
+ createFileOps,
70
+ extractFileOpsFromMessage,
71
+ type FileOperations,
72
+ SUMMARIZATION_SYSTEM_PROMPT,
73
+ serializeConversationForSummary,
74
+ stripReadSelector,
75
+ upsertFileOperations,
76
+ } from "./utils";
77
+
78
+ // ============================================================================
79
+ // File Operation Tracking
80
+ // ============================================================================
81
+
82
+ /** Details stored in CompactionEntry.details for file tracking */
83
+ export interface CompactionDetails {
84
+ readFiles: string[];
85
+ modifiedFiles: string[];
86
+ }
87
+
88
+ /**
89
+ * Extract file operations from messages and previous compaction entries.
90
+ */
91
+ function extractFileOperations(
92
+ messages: AgentMessage[],
93
+ entries: SessionEntry[],
94
+ prevCompactionIndex: number,
95
+ ): FileOperations {
96
+ const fileOps = createFileOps();
97
+
98
+ // Collect from previous compaction's details (if pi-generated)
99
+ if (prevCompactionIndex >= 0) {
100
+ const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
101
+ if (!prevCompaction.fromExtension && prevCompaction.details) {
102
+ const details = prevCompaction.details as CompactionDetails;
103
+ if (Array.isArray(details.readFiles)) {
104
+ for (const f of details.readFiles) fileOps.read.add(stripReadSelector(f));
105
+ }
106
+ if (Array.isArray(details.modifiedFiles)) {
107
+ for (const f of details.modifiedFiles) fileOps.edited.add(f);
108
+ }
109
+ }
110
+ }
111
+
112
+ // Extract from tool calls in messages
113
+ for (const msg of messages) {
114
+ extractFileOpsFromMessage(msg, fileOps);
115
+ }
116
+
117
+ return fileOps;
118
+ }
119
+
120
+ // ============================================================================
121
+ // Message Extraction
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Extract AgentMessage from an entry if it produces one.
126
+ * Returns undefined for entries that don't contribute to LLM context.
127
+ */
128
+ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
129
+ if (entry.type === "message") {
130
+ return entry.message;
131
+ }
132
+ if (entry.type === "custom_message") {
133
+ return createCustomMessage(
134
+ entry.customType,
135
+ entry.content,
136
+ entry.display,
137
+ entry.details,
138
+ entry.timestamp,
139
+ entry.attribution,
140
+ );
141
+ }
142
+ if (entry.type === "branch_summary") {
143
+ return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ /** Result from compact() - SessionManager adds uuid/parentUuid when saving */
149
+ export interface CompactionResult<T = unknown> {
150
+ summary: string;
151
+ /** Short PR-style summary for display purposes. */
152
+ shortSummary?: string;
153
+ firstKeptEntryId: string;
154
+ tokensBefore: number;
155
+ /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
156
+ details?: T;
157
+ /** Hook-provided data to persist alongside compaction entry. */
158
+ preserveData?: Record<string, unknown>;
159
+ }
160
+
161
+ // ============================================================================
162
+ // Types
163
+ // ============================================================================
164
+
165
+ export interface CompactionSettings {
166
+ enabled: boolean;
167
+ strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
168
+ thresholdPercent?: number;
169
+ thresholdTokens?: number;
170
+ midTurnEnabled?: boolean;
171
+ /**
172
+ * Tokens reserved below the context window for the next prompt + response.
173
+ *
174
+ * Leave unset to use {@link DEFAULT_RESERVE_TOKENS}; the unset state is the
175
+ * provenance signal that lets small-window recovery replace the default with
176
+ * a proportional reserve (see {@link resolveBudgetReserveTokens}). An
177
+ * explicit value — even one equal to the default — is always honored.
178
+ */
179
+ reserveTokens?: number;
180
+ keepRecentTokens: number;
181
+ autoContinue?: boolean;
182
+ remoteEnabled?: boolean;
183
+ remoteEndpoint?: string;
184
+ remoteStreamingV2Enabled?: boolean;
185
+ v2RetainedMessageBudget?: number;
186
+ }
187
+
188
+ /** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
189
+ export const DEFAULT_RESERVE_TOKENS = 16384;
190
+
191
+ /**
192
+ * Hard ceiling on a generated compaction summary.
193
+ *
194
+ * The summary budget is `floor(0.8 * reserveTokens)`, and the effective reserve is
195
+ * at least 15% of the declared context window, so a 1M-token window authorizes a
196
+ * ~120k-token summary. At that size the model copies rather than compresses, and
197
+ * output is the slowest and most expensive token class. Capping absolutely keeps
198
+ * the compression ratio improving with window size instead of degrading. The value
199
+ * mirrors {@link DEFAULT_RESERVE_TOKENS} so this adds no new tuning constant.
200
+ */
201
+ export const MAX_SUMMARY_TOKENS = DEFAULT_RESERVE_TOKENS;
202
+
203
+ // reserveTokens is deliberately absent: an unset reserve is what marks it as
204
+ // defaulted, which resolveBudgetReserveTokens needs to distinguish "user never
205
+ // chose a reserve" from "user explicitly configured the default value".
206
+ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
207
+ enabled: true,
208
+ strategy: "context-full",
209
+ thresholdPercent: -1,
210
+ thresholdTokens: -1,
211
+ midTurnEnabled: true,
212
+ keepRecentTokens: 20000,
213
+ autoContinue: true,
214
+ remoteEnabled: true,
215
+ remoteStreamingV2Enabled: true,
216
+ v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
217
+ };
218
+
219
+ /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
220
+ export function shouldUseProviderNativeCompaction(
221
+ model: Model,
222
+ settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
223
+ ): boolean {
224
+ if (settings.remoteEnabled === false) return false;
225
+ return (
226
+ shouldUseOpenAiRemoteCompaction(model) ||
227
+ (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model))
228
+ );
229
+ }
230
+
231
+ // ============================================================================
232
+ // Token calculation
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Calculate total context tokens from usage.
237
+ * Prefers an explicit provider-reported context occupancy when available.
238
+ * Otherwise uses totalTokens and falls back to computing from billable
239
+ * components. Provider-side orchestration tokens are billable but never replay
240
+ * into the conversation prefix, so they are excluded from context sizing.
241
+ */
242
+ export function calculateContextTokens(usage: Usage): number {
243
+ if (usage.contextTokens !== undefined) {
244
+ return Math.max(0, usage.contextTokens);
245
+ }
246
+ const orchestration = usage.orchestration;
247
+ const orchestrationTotal = orchestration
248
+ ? (orchestration.input ?? 0) + (orchestration.output ?? 0) + (orchestration.cacheRead ?? 0)
249
+ : 0;
250
+ const raw = usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
251
+ return Math.max(0, raw - orchestrationTotal);
252
+ }
253
+
254
+ export function calculatePromptTokens(usage: Usage): number {
255
+ if (usage.contextTokens !== undefined) {
256
+ return Math.max(0, usage.contextTokens);
257
+ }
258
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
259
+ if (promptTokens > 0) {
260
+ return promptTokens;
261
+ }
262
+ return calculateContextTokens(usage);
263
+ }
264
+
265
+ export function hasContextTokenUsage(usage: Usage): boolean {
266
+ return (
267
+ (usage.contextTokens ?? 0) > 0 ||
268
+ usage.input + usage.cacheRead + usage.cacheWrite > 0 ||
269
+ calculateContextTokens(usage) > usage.output
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Get usage from an assistant message if available.
275
+ * Skips aborted and error messages as they don't have valid usage data.
276
+ */
277
+ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
278
+ if (msg.role === "assistant" && "usage" in msg) {
279
+ const assistantMsg = msg as AssistantMessage;
280
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
281
+ return assistantMsg.usage;
282
+ }
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ /**
288
+ * Find the last non-aborted assistant message usage from session entries.
289
+ */
290
+ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
291
+ for (let i = entries.length - 1; i >= 0; i--) {
292
+ const entry = entries[i];
293
+ if (entry.type === "message") {
294
+ const usage = getAssistantUsage(entry.message);
295
+ if (usage) return usage;
296
+ }
297
+ }
298
+ return undefined;
299
+ }
300
+
301
+ /**
302
+ * Effective reserve: at least 15% of context window or the configured floor
303
+ * (defaulting to {@link DEFAULT_RESERVE_TOKENS} when unset), whichever is larger.
304
+ */
305
+ export function effectiveReserveTokens(contextWindow: number, settings: CompactionSettings): number {
306
+ return Math.max(Math.floor(contextWindow * 0.15), settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS);
307
+ }
308
+
309
+ /**
310
+ * Reserve used when deciding whether a prompt still fits inside the model window.
311
+ *
312
+ * The default absolute reserve predates small bundled windows and can leave no
313
+ * practical budget there; recover a DEFAULTED reserve that is impossible for
314
+ * the window with the 15% proportional reserve (clamped to >= 1 so the derived
315
+ * threshold stays strictly below the window even for tiny test windows).
316
+ * Explicit valid reserves — including one that happens to equal the default —
317
+ * still win, because they intentionally shrink the usable prompt budget;
318
+ * provenance is carried by `settings.reserveTokens` being unset, never by
319
+ * comparing values against the default.
320
+ */
321
+ export function resolveBudgetReserveTokens(contextWindow: number, settings: CompactionSettings): number {
322
+ const reserveTokens = effectiveReserveTokens(contextWindow, settings);
323
+ const proportionalReserveTokens = Math.max(1, Math.floor(contextWindow * 0.15));
324
+ const reserveWasDefaulted = settings.reserveTokens === undefined;
325
+ const defaultReserveIsEffectivelyImpossible =
326
+ reserveWasDefaulted && reserveTokens >= contextWindow - proportionalReserveTokens;
327
+ const reserveExceedsWindow = reserveTokens >= contextWindow;
328
+
329
+ return defaultReserveIsEffectivelyImpossible || reserveExceedsWindow ? proportionalReserveTokens : reserveTokens;
330
+ }
331
+
332
+ /**
333
+ * Check if compaction should trigger based on context usage.
334
+ */
335
+ export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
336
+ if (!settings.enabled || settings.strategy === "off" || contextWindow <= 0) return false;
337
+ const thresholdTokens = resolveThresholdTokens(contextWindow, settings);
338
+ return contextTokens > thresholdTokens;
339
+ }
340
+
341
+ /**
342
+ * Context tokens to feed the compaction decision, floored by a local estimate of
343
+ * the stored conversation.
344
+ *
345
+ * The provider-reported usage is normally ground truth, but a
346
+ * `before_provider_request` payload transform — a compression extension (e.g.
347
+ * Headroom), an obfuscator, or inline snapcompact — can shrink the request below
348
+ * the real stored conversation. The provider then reports deflated prompt
349
+ * tokens, so anchoring compaction purely on that usage lets the real history
350
+ * grow unbounded until it overflows and native compaction can no longer run.
351
+ * Flooring by the agent's own estimate of the stored conversation keeps the
352
+ * compaction trigger honest regardless of on-wire compression. (Display/cost
353
+ * accounting still uses the exact provider usage; only the compaction decision
354
+ * takes the floor.)
355
+ */
356
+ export function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number {
357
+ return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
358
+ }
359
+
360
+ export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
361
+ // Fixed token limit takes priority over percentage
362
+ const thresholdTokens = settings.thresholdTokens;
363
+ if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
364
+ // Clamp to [1, contextWindow - 1] so there's always room
365
+ return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
366
+ }
367
+
368
+ // Percentage-based threshold. The default absolute reserve can exceed bundled
369
+ // small-context windows, or nearly consume a 16k-class window; in those
370
+ // known-impossible default configurations, fall back to the proportional
371
+ // reserve so threshold/recovery-band checks stay usable. Explicit valid
372
+ // configured reserves still define the usable prompt budget. Cap at
373
+ // contextWindow - 1 (matching the fixed-token clamp above) so the threshold
374
+ // never reaches the whole window even when the reserve resolves to 0.
375
+ const thresholdPercent = settings.thresholdPercent;
376
+ if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
377
+ return Math.max(
378
+ 0,
379
+ Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)),
380
+ );
381
+ }
382
+ const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
383
+ return Math.floor(contextWindow * (clampedThresholdPercent / 100));
384
+ }
385
+
386
+ // ============================================================================
387
+ // Cut point detection
388
+ // ============================================================================
389
+
390
+ /**
391
+ * Image content has no tokenizer representation; charge a fixed estimate
392
+ * matching what providers typically bill for inline images.
393
+ */
394
+ const IMAGE_TOKEN_ESTIMATE = 1200;
395
+
396
+ /**
397
+ * Estimate token count for a message using cl100k_base via the native
398
+ * tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
399
+ * publish one) but is within ~5–10% across English/code text.
400
+ *
401
+ * `excludeEncryptedReasoning` drops opaque provider reasoning payloads
402
+ * (`thinkingSignature`, `redactedThinking`) from the estimate. Those are billed
403
+ * by the provider on replay, so the default counts them — but their *local*
404
+ * byte size can diverge wildly from what the provider charges, so the
405
+ * compaction floor (which only needs the reliably-countable, on-wire-compressible
406
+ * content) excludes them to avoid false triggers on thinking-heavy turns.
407
+ */
408
+ export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
409
+ // Settled historical messages are counted once and reused until an owner
410
+ // (prune/shake/strip-images) invalidates them; streaming assistants bypass
411
+ // the cache entirely (see message-cache.ts settle-gate invariant).
412
+ const cacheable = isEstimateCacheable(message);
413
+ const excludeEncryptedReasoning = options?.excludeEncryptedReasoning === true;
414
+ if (cacheable) {
415
+ const cached = readEstimateCache(message, excludeEncryptedReasoning);
416
+ if (cached !== undefined) return cached;
417
+ }
418
+ const result = computeMessageTokens(message, options);
419
+ if (cacheable) writeEstimateCache(message, excludeEncryptedReasoning, result);
420
+ return result;
421
+ }
422
+
423
+ function computeMessageTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
424
+ const fragments: string[] = [];
425
+ let extra = 0;
426
+ if ((message as { role?: string }).role === "bashExecution") {
427
+ const bash = message as { command?: unknown; output?: unknown };
428
+ if (typeof bash.command === "string") fragments.push(bash.command);
429
+ if (typeof bash.output === "string") fragments.push(bash.output);
430
+ return fragments.length === 0 ? 0 : countTokens(fragments);
431
+ }
432
+
433
+ switch (message.role) {
434
+ case "user": {
435
+ const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
436
+ if (typeof content === "string") {
437
+ fragments.push(content);
438
+ } else if (Array.isArray(content)) {
439
+ for (const block of content) {
440
+ if (block.type === "text" && block.text) {
441
+ fragments.push(block.text);
442
+ }
443
+ }
444
+ }
445
+ break;
446
+ }
447
+ case "assistant": {
448
+ const assistant = message as AssistantMessage;
449
+ for (const block of assistant.content) {
450
+ if (block.type === "text") {
451
+ fragments.push(block.text);
452
+ } else if (block.type === "thinking") {
453
+ fragments.push(block.thinking);
454
+ // Providers charge for the opaque signature/reasoning payload that
455
+ // rides alongside the thinking text (OpenAI Responses encrypted
456
+ // reasoning items, Anthropic signed thinking blocks, etc.). Without
457
+ // counting it, this estimator can read ~half of the provider-reported
458
+ // usage on thinking-heavy turns — see #2275 for the resulting
459
+ // compaction-trigger / post-check metric divergence. The compaction
460
+ // floor excludes it (its local byte size diverges from provider billing).
461
+ if (block.thinkingSignature && !options?.excludeEncryptedReasoning) {
462
+ fragments.push(block.thinkingSignature);
463
+ }
464
+ } else if (block.type === "toolCall") {
465
+ fragments.push(block.name);
466
+ fragments.push(stringifyJson(block.arguments) ?? "null");
467
+ } else if (block.type === "redactedThinking") {
468
+ // Encrypted reasoning blob the provider still bills for on replay;
469
+ // excluded from the compaction floor for the same reason as above.
470
+ if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
471
+ } else if (block.type === "anthropicServerTool") {
472
+ // Native Anthropic server-tool call/result replayed verbatim on the
473
+ // wire (server_tool_use input, web_search_tool_result
474
+ // encrypted_content). Opaque provider-replay state the provider still
475
+ // bills for on same-provider replay; excluded from the compaction
476
+ // floor like other encrypted reasoning because its local byte size
477
+ // diverges from provider billing.
478
+ if (!options?.excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
479
+ }
480
+ }
481
+ break;
482
+ }
483
+ case "hookMessage":
484
+ case "toolResult": {
485
+ if (typeof message.content === "string") {
486
+ fragments.push(message.content);
487
+ } else {
488
+ for (const block of message.content) {
489
+ if (block.type === "text" && block.text) {
490
+ fragments.push(block.text);
491
+ } else if (block.type === "image") {
492
+ extra += IMAGE_TOKEN_ESTIMATE;
493
+ }
494
+ }
495
+ }
496
+ break;
497
+ }
498
+ case "branchSummary":
499
+ case "compactionSummary": {
500
+ fragments.push(message.summary);
501
+ if (message.role === "compactionSummary") {
502
+ if (message.blocks) {
503
+ for (const block of message.blocks) {
504
+ if (block.type === "text") fragments.push(block.text);
505
+ else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
506
+ }
507
+ } else if (message.images) {
508
+ // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
509
+ extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
510
+ }
511
+ }
512
+ break;
513
+ }
514
+ default:
515
+ return 0;
516
+ }
517
+
518
+ if (fragments.length === 0) return extra;
519
+ return extra + countTokens(fragments);
520
+ }
521
+
522
+ function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
523
+ let total = 0;
524
+ for (let i = startIndex; i < endIndex; i++) {
525
+ const msg = getMessageFromEntry(entries[i]);
526
+ if (msg) {
527
+ total += estimateTokens(msg);
528
+ }
529
+ }
530
+ return total;
531
+ }
532
+
533
+ /**
534
+ * Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
535
+ * Never cut at tool results (they must follow their tool call).
536
+ * When we cut at an assistant message with tool calls, its tool results follow it
537
+ * and will be kept.
538
+ * BashExecutionMessage is treated like a user message (user-initiated context).
539
+ */
540
+ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
541
+ const cutPoints: number[] = [];
542
+ for (let i = startIndex; i < endIndex; i++) {
543
+ const entry = entries[i];
544
+ switch (entry.type) {
545
+ case "message": {
546
+ const role = entry.message.role as string;
547
+ switch (role) {
548
+ case "bashExecution":
549
+ case "hookMessage":
550
+ case "branchSummary":
551
+ case "compactionSummary":
552
+ case "user":
553
+ case "assistant":
554
+ cutPoints.push(i);
555
+ break;
556
+ case "toolResult":
557
+ break;
558
+ }
559
+ break;
560
+ }
561
+ case "thinking_level_change":
562
+ case "model_change":
563
+ case "compaction":
564
+ case "branch_summary":
565
+ case "custom":
566
+ case "custom_message":
567
+ case "label":
568
+ }
569
+ // branch_summary and custom_message are user-role messages, valid cut points
570
+ if (entry.type === "branch_summary" || entry.type === "custom_message") {
571
+ cutPoints.push(i);
572
+ }
573
+ }
574
+ return cutPoints;
575
+ }
576
+
577
+ /**
578
+ * Find the user message (or bashExecution) that starts the turn containing the given entry index.
579
+ * Returns -1 if no turn start found before the index.
580
+ * BashExecutionMessage is treated like a user message for turn boundaries.
581
+ */
582
+ export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
583
+ for (let i = entryIndex; i >= startIndex; i--) {
584
+ const entry = entries[i];
585
+ // branch_summary and custom_message are user-role messages, can start a turn
586
+ if (entry.type === "branch_summary" || entry.type === "custom_message") {
587
+ return i;
588
+ }
589
+ if (entry.type === "message") {
590
+ const role = entry.message.role as string;
591
+ if (role === "user" || role === "bashExecution") {
592
+ return i;
593
+ }
594
+ }
595
+ }
596
+ return -1;
597
+ }
598
+
599
+ export interface CutPointResult {
600
+ /** Index of first entry to keep */
601
+ firstKeptEntryIndex: number;
602
+ /** Index of user message that starts the turn being split, or -1 if not splitting */
603
+ turnStartIndex: number;
604
+ /** Whether this cut splits a turn (cut point is not a user message) */
605
+ isSplitTurn: boolean;
606
+ }
607
+
608
+ /**
609
+ * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
610
+ *
611
+ * Algorithm: Walk backwards from newest, accumulating estimated message sizes.
612
+ * Stop when we've accumulated >= keepRecentTokens. Cut at that point.
613
+ *
614
+ * Can cut at user OR assistant messages (never tool results). When cutting at an
615
+ * assistant message with tool calls, its tool results come after and will be kept.
616
+ *
617
+ * Returns CutPointResult with:
618
+ * - firstKeptEntryIndex: the entry index to start keeping from
619
+ * - turnStartIndex: if cutting mid-turn, the user message that started that turn
620
+ * - isSplitTurn: whether we're cutting in the middle of a turn
621
+ *
622
+ * Only considers entries between `startIndex` and `endIndex` (exclusive).
623
+ */
624
+ export function findCutPoint(
625
+ entries: SessionEntry[],
626
+ startIndex: number,
627
+ endIndex: number,
628
+ keepRecentTokens: number,
629
+ ): CutPointResult {
630
+ const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
631
+
632
+ if (cutPoints.length === 0) {
633
+ return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
634
+ }
635
+
636
+ // Walk backwards from newest, accumulating estimated message sizes
637
+ let accumulatedTokens = 0;
638
+ let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
639
+
640
+ for (let i = endIndex - 1; i >= startIndex; i--) {
641
+ const entry = entries[i];
642
+ if (entry.type !== "message") continue;
643
+
644
+ // Estimate this message's size
645
+ const messageTokens = estimateTokens(entry.message);
646
+ accumulatedTokens += messageTokens;
647
+
648
+ // Check if we've exceeded the budget
649
+ if (accumulatedTokens >= keepRecentTokens) {
650
+ // Find the closest valid cut point at or after this entry
651
+ for (let c = 0; c < cutPoints.length; c++) {
652
+ if (cutPoints[c] >= i) {
653
+ cutIndex = cutPoints[c];
654
+ break;
655
+ }
656
+ }
657
+ break;
658
+ }
659
+ }
660
+
661
+ // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
662
+ while (cutIndex > startIndex) {
663
+ const prevEntry = entries[cutIndex - 1];
664
+ // Stop at session header or compaction boundaries
665
+ if (prevEntry.type === "compaction") {
666
+ break;
667
+ }
668
+ if (prevEntry.type === "message") {
669
+ // Stop if we hit any message
670
+ break;
671
+ }
672
+ // Include this non-message entry (bash, settings change, etc.)
673
+ cutIndex--;
674
+ }
675
+
676
+ // Determine if this is a split turn
677
+ const cutEntry = entries[cutIndex];
678
+ const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
679
+ const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
680
+
681
+ return {
682
+ firstKeptEntryIndex: cutIndex,
683
+ turnStartIndex,
684
+ isSplitTurn: !isUserMessage && turnStartIndex !== -1,
685
+ };
686
+ }
687
+
688
+ // ============================================================================
689
+ // Summarization
690
+ // ============================================================================
691
+
692
+ const SUMMARIZATION_PROMPT = prompt.render(compactionSummaryPrompt);
693
+
694
+ const UPDATE_SUMMARIZATION_PROMPT = prompt.render(compactionUpdateSummaryPrompt);
695
+
696
+ const SHORT_SUMMARY_PROMPT = prompt.render(compactionShortSummaryPrompt);
697
+
698
+ const HANDOFF_DOCUMENT_PROMPT = prompt.render(handoffDocumentPrompt);
699
+
700
+ export const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt);
701
+
702
+ function formatAdditionalContext(context: string[] | undefined): string {
703
+ if (!context || context.length === 0) return "";
704
+ const lines = context.map(line => `- ${line}`).join("\n");
705
+ return `<additional-context>\n${lines}\n</additional-context>\n\n`;
706
+ }
707
+
708
+ /**
709
+ * Maps the non-special `ThinkingLevel` values to their `Effort` counterparts.
710
+ * Exhaustive over the union; throws for `Off`/`Inherit` to surface logic
711
+ * errors in callers that forgot to filter those out. Never use a TS cast for
712
+ * this — `ThinkingLevel` is a string-union over distinct concepts (Off /
713
+ * Inherit are not Efforts), and a cast hides the contract.
714
+ */
715
+ function effortFromThinkingLevel(level: ThinkingLevel): Effort {
716
+ switch (level) {
717
+ case ThinkingLevel.Minimal:
718
+ return Effort.Minimal;
719
+ case ThinkingLevel.Low:
720
+ return Effort.Low;
721
+ case ThinkingLevel.Medium:
722
+ return Effort.Medium;
723
+ case ThinkingLevel.High:
724
+ return Effort.High;
725
+ case ThinkingLevel.XHigh:
726
+ return Effort.XHigh;
727
+ case ThinkingLevel.Max:
728
+ return Effort.Max;
729
+ case ThinkingLevel.Off:
730
+ case ThinkingLevel.Inherit:
731
+ throw new Error(`effortFromThinkingLevel: ${level} must be handled by caller`);
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Resolves the reasoning effort to send on a compaction LLM call.
737
+ *
738
+ * - Explicit `Off` → `undefined` (omit reasoning entirely; the user said no thinking).
739
+ * - `undefined` / `Inherit` → historical `Effort.High` default → clamped per model
740
+ * (preserves current behavior for users who never touched the dial).
741
+ * - Explicit effort → respect user choice → clamped per model.
742
+ *
743
+ * The clamp routes through `clampThinkingLevelForModel`, which returns
744
+ * `undefined` for reasoning models without a thinking config — the build-time
745
+ * encoding of `compat.supportsReasoningEffort: false` (e.g.
746
+ * `xai-oauth/grok-build`). That `undefined` then flows through to the
747
+ * openai-responses mapper, which omits the wire param — no
748
+ * `requireSupportedEffort` throw.
749
+ */
750
+ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined): Effort | undefined {
751
+ if (level === ThinkingLevel.Off) return undefined;
752
+ const requested: Effort =
753
+ level === undefined || level === ThinkingLevel.Inherit ? Effort.High : effortFromThinkingLevel(level);
754
+ return clampThinkingLevelForModel(model, requested);
755
+ }
756
+
757
+ /**
758
+ * Build the error thrown when an LLM summarization call ends with
759
+ * `stopReason === "error"`. Carries the provider's HTTP `errorStatus`
760
+ * onto a top-level `.status` field so callers (notably
761
+ * `AgentSession.#isCompactionAuthFailure`) can branch on 401/403 without
762
+ * regex-scraping `error.message`. The `auth_unavailable` synthetic
763
+ * (pi-native gateway) does not populate `errorStatus`, hence the legacy
764
+ * message-based check is still required upstream — see issue #986.
765
+ */
766
+ function createSummarizationError(prefix: string, response: AssistantMessage): Error {
767
+ const text = `${prefix}: ${response.errorMessage || "Unknown error"}`;
768
+ return response.errorStatus === undefined
769
+ ? new Error(text)
770
+ : new AIError.ProviderHttpError(text, response.errorStatus);
771
+ }
772
+
773
+ function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean {
774
+ if (response.errorStatus !== 400) return false;
775
+ const message = response.errorMessage ?? "";
776
+ return /\btool_choice\b/i.test(message) && /\bauto\b/i.test(message) && /\bsupported\b/i.test(message);
777
+ }
778
+
779
+ /**
780
+ * Generate a summary of the conversation using the LLM.
781
+ * If previousSummary is provided, uses the update prompt to merge.
782
+ */
783
+ export interface SummaryOptions {
784
+ promptOverride?: string;
785
+ extraContext?: string[];
786
+ remoteEndpoint?: string;
787
+ remoteInstructions?: string;
788
+ initiatorOverride?: MessageAttribution;
789
+ metadata?: Record<string, unknown>;
790
+ convertToLlm?: ConvertToLlm;
791
+ /**
792
+ * Optional telemetry handle. When provided, every LLM call emitted during
793
+ * compaction is wrapped in an OTEL chat span tagged with
794
+ * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
795
+ * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
796
+ */
797
+ telemetry?: AgentTelemetry;
798
+ /**
799
+ * Active session thinking level. Threaded from `agent-session.ts` so
800
+ * compaction honors the user's `/model` thinking selection instead of
801
+ * silently overriding it with `Effort.High` (the historical default).
802
+ * `undefined` / `ThinkingLevel.Inherit` falls back to that historical
803
+ * default; `ThinkingLevel.Off` omits reasoning entirely. See
804
+ * `resolveCompactionEffort` for the conversion contract.
805
+ */
806
+ thinkingLevel?: ThinkingLevel;
807
+ /** Session routing key for remote compaction transports with sticky provider sessions. */
808
+ sessionId?: string;
809
+ /** Prompt-cache key for remote compaction transports that support provider prefix caching. */
810
+ promptCacheKey?: string;
811
+ /** Mutable provider state used to keep Codex compaction on the live session identity. */
812
+ providerSessionState?: Map<string, ProviderSessionState>;
813
+ /** Whether Codex remote compaction should prefer the provider WebSocket transport. */
814
+ preferWebsockets?: boolean;
815
+ /** Classification shared by every provider request in this logical compaction. */
816
+ codexCompaction?: CodexCompactionContext;
817
+ /** Provider-visible tools for remote compaction transports that replay native tool history. */
818
+ tools?: Tool[];
819
+ /** Optional fetch implementation threaded into remote compaction calls. */
820
+ fetch?: FetchImpl;
821
+ /**
822
+ * Optional completion transport override for host-level request wrappers
823
+ * (e.g. the coding-agent provider-concurrency limiter). When provided,
824
+ * every local summarization oneshot (`generateSummary`,
825
+ * `generateTurnPrefixSummary`, `generateShortSummary`) routes through it
826
+ * instead of the default `completeSimple`, so cap policies enforced on
827
+ * the live agent turn also bracket compaction HTTP requests.
828
+ */
829
+ completeImpl?: <TApi extends Api>(
830
+ model: Model<TApi>,
831
+ ctx: Context,
832
+ options: SimpleStreamOptions,
833
+ ) => Promise<AssistantMessage>;
834
+ }
835
+
836
+ function localCodexCompaction(options: SummaryOptions | undefined) {
837
+ return createOpenAICodexCompactionRequestContext({
838
+ context: options?.codexCompaction,
839
+ implementation: "responses",
840
+ });
841
+ }
842
+
843
+ function formatPreviousSnapcompactArchive(archiveText: string): string {
844
+ return prompt.render(snapcompactArchiveContextPrompt, { archiveText });
845
+ }
846
+
847
+ function mergePreviousSummaryWithSnapcompactArchive(
848
+ previousSummary: string | undefined,
849
+ archiveText: string | undefined,
850
+ ): string | undefined {
851
+ if (!archiveText) return previousSummary;
852
+ const archiveSummary = formatPreviousSnapcompactArchive(archiveText);
853
+ return previousSummary ? `${previousSummary}\n\n${archiveSummary}` : archiveSummary;
854
+ }
855
+
856
+ function createSnapcompactArchiveMigrationMessage(archiveText: string): Message {
857
+ return {
858
+ role: "user",
859
+ content: [{ type: "text", text: formatPreviousSnapcompactArchive(archiveText) }],
860
+ timestamp: Date.now(),
861
+ };
862
+ }
863
+
864
+ export async function generateSummary(
865
+ currentMessages: AgentMessage[],
866
+ model: Model,
867
+ reserveTokens: number,
868
+ apiKey: ApiKey,
869
+ signal?: AbortSignal,
870
+ customInstructions?: string,
871
+ previousSummary?: string,
872
+ options?: SummaryOptions,
873
+ ): Promise<string> {
874
+ const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS);
875
+
876
+ // Use update prompt if we have a previous summary, otherwise initial prompt
877
+ let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
878
+ if (options?.promptOverride) {
879
+ basePrompt = options.promptOverride;
880
+ }
881
+ if (customInstructions) {
882
+ basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
883
+ }
884
+
885
+ // Serialize conversation to text so model doesn't try to continue it
886
+ // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
887
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
888
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
889
+
890
+ // Build the prompt with conversation wrapped in tags
891
+ let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
892
+ if (previousSummary) {
893
+ promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
894
+ }
895
+ promptText += formatAdditionalContext(options?.extraContext);
896
+ promptText += basePrompt;
897
+
898
+ const summarizationMessages = [
899
+ {
900
+ role: "user" as const,
901
+ content: [{ type: "text" as const, text: promptText }],
902
+ timestamp: Date.now(),
903
+ },
904
+ ];
905
+
906
+ if (options?.remoteEndpoint) {
907
+ const endpoint = options.remoteEndpoint;
908
+ const remote = await withAuth(
909
+ apiKey,
910
+ key =>
911
+ requestRemoteCompaction(
912
+ endpoint,
913
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
914
+ signal,
915
+ { fetch: options.fetch, model, apiKey: key },
916
+ ),
917
+ { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
918
+ );
919
+ return remote.summary;
920
+ }
921
+
922
+ const response = await instrumentedCompleteSimple(
923
+ model,
924
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
925
+ {
926
+ maxTokens,
927
+ signal,
928
+ apiKey,
929
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
930
+ initiatorOverride: options?.initiatorOverride,
931
+ metadata: options?.metadata,
932
+ fetch: options?.fetch,
933
+ sessionId: options?.sessionId,
934
+ promptCacheKey: options?.promptCacheKey,
935
+ providerSessionState: options?.providerSessionState,
936
+ codexCompaction: localCodexCompaction(options),
937
+ },
938
+ { telemetry: options?.telemetry, oneshotKind: "compaction_summary", completeImpl: options?.completeImpl },
939
+ );
940
+
941
+ if (response.stopReason === "error") {
942
+ throw createSummarizationError("Summarization failed", response);
943
+ }
944
+
945
+ const textContent = response.content
946
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
947
+ .map(c => c.text)
948
+ .join("\n");
949
+
950
+ return textContent;
951
+ }
952
+
953
+ // ============================================================================
954
+ // Handoff generation
955
+ // ============================================================================
956
+
957
+ export interface HandoffOptions {
958
+ /** Live agent system prompt — passed verbatim so providers hit the cached prefix. */
959
+ systemPrompt: string[];
960
+ /** Live agent tool list — same purpose. Forced to `toolChoice: "none"`. */
961
+ tools?: Tool[];
962
+ customInstructions?: string;
963
+ convertToLlm?: ConvertToLlm;
964
+ initiatorOverride?: MessageAttribution;
965
+ metadata?: Record<string, unknown>;
966
+ /**
967
+ * Optional telemetry handle. When provided, the handoff LLM call is
968
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
969
+ */
970
+ telemetry?: AgentTelemetry;
971
+ /**
972
+ * Active session thinking level. Threaded from `agent-session.ts` so
973
+ * handoff generation honors the user's `/model` thinking selection
974
+ * instead of silently overriding it with `Effort.High`. See
975
+ * `resolveCompactionEffort` for the conversion contract.
976
+ */
977
+ thinkingLevel?: ThinkingLevel;
978
+ }
979
+
980
+ export function renderHandoffPrompt(customInstructions?: string): string {
981
+ if (!customInstructions) return HANDOFF_DOCUMENT_PROMPT;
982
+ return prompt.render(handoffDocumentPrompt, {
983
+ additionalFocus: customInstructions,
984
+ });
985
+ }
986
+
987
+ export interface HandoffFromContextOptions {
988
+ /**
989
+ * Stream options mirrored from the live agent turn: `apiKey`, `signal`, the
990
+ * `sessionId`/`promptCacheKey` cache-routing pair, `serviceTier`, and the
991
+ * session's payload/response hooks. Sending the same routing + payload shape
992
+ * the main loop uses is what lets the handoff oneshot READ the provider
993
+ * prompt cache the live turn populated instead of cold-missing the whole
994
+ * prefix. `reasoning` and `toolChoice` are set internally and override
995
+ * anything provided here.
996
+ */
997
+ streamOptions: SimpleStreamOptions;
998
+ /** Optional completion transport override for host-level request wrappers. */
999
+ completeImpl?: <TApi extends Api>(
1000
+ model: Model<TApi>,
1001
+ ctx: Context,
1002
+ options: SimpleStreamOptions,
1003
+ ) => Promise<AssistantMessage>;
1004
+ /** See {@link HandoffOptions.telemetry}. */
1005
+ telemetry?: AgentTelemetry;
1006
+ /** See {@link HandoffOptions.thinkingLevel}. */
1007
+ thinkingLevel?: ThinkingLevel;
1008
+ }
1009
+
1010
+ /**
1011
+ * Run the handoff oneshot against a fully-built provider {@link Context}.
1012
+ *
1013
+ * The caller assembles `context` exactly like a live agent turn — same system
1014
+ * prompt, normalized tools, transformed + obfuscated message history, with the
1015
+ * trailing handoff-prompt message already appended — and supplies
1016
+ * `streamOptions` that mirror the live turn's cache routing. That keeps the
1017
+ * cache-preserving context construction in the host (which owns the transform
1018
+ * pipeline) while this function centralizes the handoff request contract:
1019
+ * cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
1020
+ * auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
1021
+ * and provider-error mapping.
1022
+ */
1023
+ export async function generateHandoffFromContext(
1024
+ context: Context,
1025
+ model: Model,
1026
+ options: HandoffFromContextOptions,
1027
+ ): Promise<string> {
1028
+ const requestOptions = {
1029
+ ...options.streamOptions,
1030
+ reasoning: resolveCompactionEffort(model, options.thinkingLevel),
1031
+ toolChoice: "none" as const,
1032
+ };
1033
+ let response = await instrumentedCompleteSimple(model, context, requestOptions, {
1034
+ telemetry: options.telemetry,
1035
+ oneshotKind: "handoff",
1036
+ completeImpl: options.completeImpl,
1037
+ });
1038
+ if (response.stopReason === "error" && shouldRetryHandoffWithAutoToolChoice(response)) {
1039
+ response = await instrumentedCompleteSimple(
1040
+ model,
1041
+ context,
1042
+ { ...requestOptions, toolChoice: "auto" },
1043
+ { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
1044
+ );
1045
+ }
1046
+
1047
+ if (response.stopReason === "error") {
1048
+ throw createSummarizationError("Handoff generation failed", response);
1049
+ }
1050
+
1051
+ return response.content
1052
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1053
+ .map(c => c.text)
1054
+ .join("\n");
1055
+ }
1056
+
1057
+ export async function generateHandoff(
1058
+ messages: AgentMessage[],
1059
+ model: Model,
1060
+ apiKey: ApiKey,
1061
+ options: HandoffOptions,
1062
+ signal?: AbortSignal,
1063
+ ): Promise<string> {
1064
+ const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
1065
+ const requestMessages: Message[] = [
1066
+ ...llmMessages,
1067
+ {
1068
+ role: "user",
1069
+ content: [{ type: "text", text: renderHandoffPrompt(options.customInstructions) }],
1070
+ attribution: "agent",
1071
+ timestamp: Date.now(),
1072
+ },
1073
+ ];
1074
+
1075
+ return generateHandoffFromContext(
1076
+ { systemPrompt: options.systemPrompt, messages: requestMessages, tools: options.tools },
1077
+ model,
1078
+ {
1079
+ streamOptions: {
1080
+ apiKey,
1081
+ signal,
1082
+ initiatorOverride: options.initiatorOverride,
1083
+ metadata: options.metadata,
1084
+ },
1085
+ telemetry: options.telemetry,
1086
+ thinkingLevel: options.thinkingLevel,
1087
+ },
1088
+ );
1089
+ }
1090
+
1091
+ async function generateShortSummary(
1092
+ recentMessages: AgentMessage[],
1093
+ historySummary: string | undefined,
1094
+ model: Model,
1095
+ reserveTokens: number,
1096
+ apiKey: ApiKey,
1097
+ signal?: AbortSignal,
1098
+ options?: SummaryOptions,
1099
+ ): Promise<string> {
1100
+ const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
1101
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
1102
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
1103
+
1104
+ let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
1105
+ if (historySummary) {
1106
+ promptText += `<previous-summary>\n${historySummary}\n</previous-summary>\n\n`;
1107
+ }
1108
+ promptText += formatAdditionalContext(options?.extraContext);
1109
+ promptText += SHORT_SUMMARY_PROMPT;
1110
+
1111
+ if (options?.remoteEndpoint) {
1112
+ const endpoint = options.remoteEndpoint;
1113
+ const remote = await withAuth(
1114
+ apiKey,
1115
+ key =>
1116
+ requestRemoteCompaction(
1117
+ endpoint,
1118
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1119
+ signal,
1120
+ { fetch: options?.fetch, model, apiKey: key },
1121
+ ),
1122
+ { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1123
+ );
1124
+ return remote.summary;
1125
+ }
1126
+
1127
+ const response = await instrumentedCompleteSimple(
1128
+ model,
1129
+ {
1130
+ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1131
+ messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1132
+ },
1133
+ {
1134
+ maxTokens,
1135
+ signal,
1136
+ apiKey,
1137
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1138
+ initiatorOverride: options?.initiatorOverride,
1139
+ metadata: options?.metadata,
1140
+ fetch: options?.fetch,
1141
+ sessionId: options?.sessionId,
1142
+ promptCacheKey: options?.promptCacheKey,
1143
+ providerSessionState: options?.providerSessionState,
1144
+ codexCompaction: localCodexCompaction(options),
1145
+ },
1146
+ { telemetry: options?.telemetry, oneshotKind: "compaction_short_summary", completeImpl: options?.completeImpl },
1147
+ );
1148
+
1149
+ if (response.stopReason === "error") {
1150
+ throw createSummarizationError("Short summary failed", response);
1151
+ }
1152
+
1153
+ return response.content
1154
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1155
+ .map(c => c.text)
1156
+ .join("\n");
1157
+ }
1158
+
1159
+ // ============================================================================
1160
+ // Compaction Preparation (for hooks)
1161
+ // ============================================================================
1162
+
1163
+ export interface CompactionPreparation {
1164
+ /** UUID of first entry to keep */
1165
+ firstKeptEntryId: string;
1166
+ /** Messages that will be summarized and discarded */
1167
+ messagesToSummarize: AgentMessage[];
1168
+ /** Messages that will be turned into turn prefix summary (if splitting) */
1169
+ turnPrefixMessages: AgentMessage[];
1170
+ /** Messages kept in full after compaction (recent history) */
1171
+ recentMessages: AgentMessage[];
1172
+ /** Whether this is a split turn (cut point in middle of turn) */
1173
+ isSplitTurn: boolean;
1174
+ tokensBefore: number;
1175
+ /** Summary from previous compaction, for iterative update */
1176
+ previousSummary?: string;
1177
+ /** Preserved opaque compaction payload from the previous compaction, if any. */
1178
+ previousPreserveData?: Record<string, unknown>;
1179
+ /** File operations extracted from messagesToSummarize */
1180
+ fileOps: FileOperations;
1181
+ /** Compaction settions from settings.jsonl */
1182
+ settings: CompactionSettings;
1183
+ }
1184
+
1185
+ /**
1186
+ * Whether a prior remote compaction's provider-native replay can still be read
1187
+ * by the active model — the model that assembles the request context on every
1188
+ * turn. A local compaction (no remote preserve) always can: it holds a real
1189
+ * textual summary. A remote compaction (V2 or V1) only can when the active model
1190
+ * shares the blob's provider AND remote replay is still enabled; otherwise the
1191
+ * active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
1192
+ * and only the opaque placeholder summary survives, so the caller must re-expand
1193
+ * the originals into a portable local summary rather than strand that history.
1194
+ *
1195
+ * Judged against the ACTIVE model, not the compaction candidate set: a role
1196
+ * model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
1197
+ * let the active model replay it, so keying reuse on "any candidate shares the
1198
+ * provider" left a provider-switched session permanently context-less (#6343).
1199
+ */
1200
+ function remotePreserveReusable(
1201
+ preserveData: Record<string, unknown> | undefined,
1202
+ activeModel: Model,
1203
+ settings: CompactionSettings,
1204
+ ): boolean {
1205
+ const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1206
+ if (!remote) return true;
1207
+ if (settings.remoteEnabled === false) return false;
1208
+ if (remote.provider !== activeModel.provider) return false;
1209
+ const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
1210
+ return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
1211
+ }
1212
+
1213
+ export function prepareCompaction(
1214
+ pathEntries: SessionEntry[],
1215
+ settings: CompactionSettings,
1216
+ activeModel?: Model,
1217
+ ): CompactionPreparation | undefined {
1218
+ if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
1219
+ return undefined;
1220
+ }
1221
+
1222
+ let prevCompactionIndex = -1;
1223
+ for (let i = pathEntries.length - 1; i >= 0; i--) {
1224
+ if (pathEntries[i].type !== "compaction") continue;
1225
+ // Skip a prior remote compaction (V2 or V1) whose provider-native replay the
1226
+ // active model cannot read: its summary is only an opaque placeholder, so
1227
+ // re-expand its original messages and summarize them locally rather than
1228
+ // stranding that history. compact() still reuses the payload when the active
1229
+ // model can replay it (same provider, remote enabled).
1230
+ const entry = pathEntries[i] as CompactionEntry;
1231
+ if (activeModel && !remotePreserveReusable(entry.preserveData, activeModel, settings)) {
1232
+ continue;
1233
+ }
1234
+ prevCompactionIndex = i;
1235
+ break;
1236
+ }
1237
+ const boundaryStart = prevCompactionIndex + 1;
1238
+ const boundaryEnd = pathEntries.length;
1239
+
1240
+ const lastUsage = getLastAssistantUsage(pathEntries);
1241
+ const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
1242
+ let keepRecentTokens = settings.keepRecentTokens;
1243
+ if (lastUsage) {
1244
+ const estimatedTokens = estimateEntriesTokens(pathEntries, boundaryStart, boundaryEnd);
1245
+ const promptTokens = calculatePromptTokens(lastUsage);
1246
+ const ratio = estimatedTokens > 0 ? promptTokens / estimatedTokens : 0;
1247
+ if (Number.isFinite(ratio) && ratio > 1) {
1248
+ keepRecentTokens = Math.max(1, Math.floor(keepRecentTokens / ratio));
1249
+ }
1250
+ }
1251
+
1252
+ const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokens);
1253
+
1254
+ // Get ID of first kept entry
1255
+ const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
1256
+ if (!firstKeptEntry?.id) {
1257
+ return undefined; // Session needs migration
1258
+ }
1259
+ const firstKeptEntryId = firstKeptEntry.id;
1260
+
1261
+ const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
1262
+
1263
+ // Messages to summarize (will be discarded after summary)
1264
+ const messagesToSummarize: AgentMessage[] = [];
1265
+ for (let i = boundaryStart; i < historyEnd; i++) {
1266
+ const msg = getMessageFromEntry(pathEntries[i]);
1267
+ if (msg) messagesToSummarize.push(msg);
1268
+ }
1269
+
1270
+ // Messages for turn prefix summary (if splitting a turn)
1271
+ const turnPrefixMessages: AgentMessage[] = [];
1272
+ if (cutPoint.isSplitTurn) {
1273
+ for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
1274
+ const msg = getMessageFromEntry(pathEntries[i]);
1275
+ if (msg) turnPrefixMessages.push(msg);
1276
+ }
1277
+ }
1278
+
1279
+ // Messages kept after compaction (recent history)
1280
+ const recentMessages: AgentMessage[] = [];
1281
+ for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
1282
+ const msg = getMessageFromEntry(pathEntries[i]);
1283
+ if (msg) recentMessages.push(msg);
1284
+ }
1285
+ // Nothing to summarize means compaction would be a no-op.
1286
+ if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
1287
+ return undefined;
1288
+ }
1289
+
1290
+ // Get previous summary and preserved data for iterative updates
1291
+ let previousSummary: string | undefined;
1292
+ let previousPreserveData: Record<string, unknown> | undefined;
1293
+ if (prevCompactionIndex >= 0) {
1294
+ const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
1295
+ previousSummary = prevCompaction.summary;
1296
+ previousPreserveData = prevCompaction.preserveData;
1297
+ }
1298
+
1299
+ // Extract file operations from messages and previous compaction
1300
+ const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
1301
+
1302
+ // Also extract file ops from turn prefix if splitting
1303
+ if (cutPoint.isSplitTurn) {
1304
+ for (const msg of turnPrefixMessages) {
1305
+ extractFileOpsFromMessage(msg, fileOps);
1306
+ }
1307
+ }
1308
+
1309
+ return {
1310
+ firstKeptEntryId,
1311
+ messagesToSummarize,
1312
+ turnPrefixMessages,
1313
+ recentMessages,
1314
+ isSplitTurn: cutPoint.isSplitTurn,
1315
+ tokensBefore,
1316
+ previousSummary,
1317
+ previousPreserveData,
1318
+ fileOps,
1319
+ settings,
1320
+ };
1321
+ }
1322
+
1323
+ // ============================================================================
1324
+ // Main compaction function
1325
+ // ============================================================================
1326
+
1327
+ const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt);
1328
+
1329
+ function openAiCompatSupportsImageDetailOriginal(model: Model): boolean {
1330
+ const compat = model.compat;
1331
+ return !!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === true;
1332
+ }
1333
+
1334
+ function buildOpenAiResponsesCompactionInput(
1335
+ messages: Message[],
1336
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1337
+ previousReplacementHistory: Array<Record<string, unknown>> | undefined,
1338
+ ): Array<Record<string, unknown>> {
1339
+ const input = buildResponsesInput({
1340
+ model,
1341
+ context: { messages },
1342
+ strictResponsesPairing: model.compat.strictResponsesPairing,
1343
+ supportsImageDetailOriginal: openAiCompatSupportsImageDetailOriginal(model),
1344
+ nativeHistory: { replay: true, filterReasoning: false },
1345
+ includeThinkingSignatures: true,
1346
+ repairOrphanOutputs: true,
1347
+ });
1348
+ const nativeInput: Array<Record<string, unknown>> = [];
1349
+ for (const item of input) {
1350
+ if (!isRecord(item)) {
1351
+ throw new Error("OpenAI Responses compaction input contains a non-object item");
1352
+ }
1353
+ nativeInput.push(item);
1354
+ }
1355
+ return stripOpenAIResponsesOutputOnlyStatusesForReplay(
1356
+ previousReplacementHistory ? [...previousReplacementHistory, ...nativeInput] : nativeInput,
1357
+ );
1358
+ }
1359
+
1360
+ /**
1361
+ * Resolve the Responses `reasoning` param for a V2 compaction request the same
1362
+ * way a normal turn does — through {@link resolveOpenAICompatPolicy}, so it
1363
+ * honors per-model effort support, `omitReasoningEffort`, disable modes, and the
1364
+ * wire-effort mapping. Returns `undefined` for non-reasoning models or when the
1365
+ * user selected `Off` (matching the normal-turn omission, not a fabricated shape).
1366
+ */
1367
+ function buildCompactionV2Reasoning(
1368
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1369
+ thinkingLevel: ThinkingLevel | undefined,
1370
+ ): { effort: string; summary: string } | undefined {
1371
+ const policy = resolveOpenAICompatPolicy(model, {
1372
+ endpoint: "responses",
1373
+ reasoning: resolveCompactionEffort(model, thinkingLevel),
1374
+ });
1375
+ const reasoning = policy.reasoning;
1376
+ if (!reasoning.modelSupported || reasoning.disabled || reasoning.omitReasoningEffort) return undefined;
1377
+ if (reasoning.requestedEffort === undefined) return undefined;
1378
+ return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" };
1379
+ }
1380
+
1381
+ /**
1382
+ * Keep any non-auth native protocol failure ahead of authentication failures.
1383
+ * Downstream may retry compaction with another provider only when every native
1384
+ * protocol failed authentication, so a later auth error must not hide an
1385
+ * earlier transport or protocol failure.
1386
+ */
1387
+ function selectNativeCompactionError(previousError: unknown, nextError: unknown): unknown {
1388
+ if (previousError === undefined) return nextError;
1389
+ return AIError.is(AIError.classify(previousError), AIError.Flag.AuthFailed) ? nextError : previousError;
1390
+ }
1391
+
1392
+ /**
1393
+ * Generate summaries for compaction using prepared data.
1394
+ * Returns CompactionResult - SessionManager adds id/parentId when saving.
1395
+ *
1396
+ * @param preparation - Pre-calculated preparation from prepareCompaction()
1397
+ * @param customInstructions - Optional custom focus for the summary
1398
+ */
1399
+ export async function compact(
1400
+ preparation: CompactionPreparation,
1401
+ model: Model,
1402
+ apiKey: ApiKey,
1403
+ customInstructions?: string,
1404
+ signal?: AbortSignal,
1405
+ options?: SummaryOptions,
1406
+ ): Promise<CompactionResult> {
1407
+ const {
1408
+ firstKeptEntryId,
1409
+ messagesToSummarize,
1410
+ turnPrefixMessages,
1411
+ recentMessages,
1412
+ isSplitTurn,
1413
+ tokensBefore,
1414
+ previousSummary,
1415
+ previousPreserveData,
1416
+ fileOps,
1417
+ settings,
1418
+ } = preparation;
1419
+
1420
+ const reserveTokens = settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
1421
+
1422
+ const summaryOptions: SummaryOptions = {
1423
+ promptOverride: options?.promptOverride,
1424
+ extraContext: options?.extraContext,
1425
+ remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1426
+ remoteInstructions: options?.remoteInstructions,
1427
+ initiatorOverride: options?.initiatorOverride,
1428
+ metadata: options?.metadata,
1429
+ convertToLlm: options?.convertToLlm,
1430
+ telemetry: options?.telemetry,
1431
+ // Honor /model thinking selection on every fan-out summarizer.
1432
+ // Without this propagation, generateSummary / generateTurnPrefixSummary
1433
+ // see options?.thinkingLevel === undefined and resolveCompactionEffort
1434
+ // silently falls back to Effort.High — the same defect e07b47ee4 fixed
1435
+ // at the call sites, leaked back in here. See resolveCompactionEffort.
1436
+ thinkingLevel: options?.thinkingLevel,
1437
+ sessionId: options?.sessionId,
1438
+ promptCacheKey: options?.promptCacheKey,
1439
+ providerSessionState: options?.providerSessionState,
1440
+ preferWebsockets: options?.preferWebsockets,
1441
+ codexCompaction: options?.codexCompaction,
1442
+ tools: options?.tools,
1443
+ fetch: options?.fetch,
1444
+ completeImpl: options?.completeImpl,
1445
+ };
1446
+
1447
+ const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1448
+ const previousSnapcompactArchiveText = previousSnapcompactArchive
1449
+ ? snapcompact.archiveSourceText(previousSnapcompactArchive)
1450
+ : undefined;
1451
+ const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
1452
+ previousSummary,
1453
+ previousSnapcompactArchiveText,
1454
+ );
1455
+ const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
1456
+ ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1457
+ : undefined;
1458
+
1459
+ let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1460
+ const remoteMessages: AgentMessage[] = [
1461
+ ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1462
+ ...messagesToSummarize,
1463
+ ...turnPrefixMessages,
1464
+ ...recentMessages,
1465
+ ];
1466
+ let usedRemoteCompaction = false;
1467
+ let nativeCompactionError: unknown;
1468
+ if (
1469
+ settings.remoteEnabled !== false &&
1470
+ settings.remoteStreamingV2Enabled !== false &&
1471
+ shouldUseCompactionV2Streaming(model)
1472
+ ) {
1473
+ const previousRemoteCompaction = getCompactionV2PreserveData(previousPreserveData);
1474
+ const previousReplacementHistory =
1475
+ previousRemoteCompaction?.provider === model.provider
1476
+ ? previousRemoteCompaction.replacementHistory
1477
+ : undefined;
1478
+ const remoteHistory = buildOpenAiResponsesCompactionInput(
1479
+ (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1480
+ model,
1481
+ previousReplacementHistory,
1482
+ );
1483
+ if (remoteHistory.length > 0) {
1484
+ try {
1485
+ const instructions = summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT;
1486
+ const tools = summaryOptions.tools
1487
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1488
+ : undefined;
1489
+ const trimmed = trimRemoteCompactionInputToContextWindow(
1490
+ remoteHistory,
1491
+ model.contextWindow,
1492
+ instructions,
1493
+ tools,
1494
+ );
1495
+ if (trimmed.rewrittenOutputs > 0) {
1496
+ logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1497
+ model: model.id,
1498
+ provider: model.provider,
1499
+ rewrittenOutputs: trimmed.rewrittenOutputs,
1500
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
1501
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
1502
+ contextWindow: model.contextWindow,
1503
+ });
1504
+ }
1505
+ const request = buildCompactionV2Request(model, trimmed.input, instructions, {
1506
+ tools,
1507
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1508
+ sessionId: summaryOptions.sessionId,
1509
+ promptCacheKey: summaryOptions.promptCacheKey,
1510
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1511
+ });
1512
+ const remote = await withAuth(
1513
+ apiKey,
1514
+ key =>
1515
+ requestCompactionV2Streaming(model, key, request, signal, {
1516
+ fetch: summaryOptions.fetch,
1517
+ providerSessionState: summaryOptions.providerSessionState,
1518
+ preferWebsockets: summaryOptions.preferWebsockets,
1519
+ codexCompaction: summaryOptions.codexCompaction,
1520
+ }),
1521
+ { signal },
1522
+ );
1523
+ preserveData = { ...(preserveData ?? {}), ...storeCompactionV2PreserveData(remote, model) };
1524
+ usedRemoteCompaction = true;
1525
+ } catch (err) {
1526
+ // A user/session abort is a cancellation, not a remote failure —
1527
+ // swallowing it here would downgrade Esc into "fall back to local
1528
+ // summarization" and keep compaction running on an aborted signal.
1529
+ if (signal?.aborted) throw err;
1530
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1531
+ logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", {
1532
+ error: err instanceof Error ? err.message : String(err),
1533
+ model: model.id,
1534
+ provider: model.provider,
1535
+ });
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1541
+ const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1542
+ const previousV2Compaction = getCompactionV2PreserveData(previousPreserveData);
1543
+ const previousReplacementHistory =
1544
+ previousRemoteCompaction?.provider === model.provider
1545
+ ? previousRemoteCompaction.replacementHistory
1546
+ : previousV2Compaction?.provider === model.provider
1547
+ ? previousV2Compaction.replacementHistory
1548
+ : undefined;
1549
+ const remoteHistory = buildOpenAiNativeHistory(
1550
+ (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1551
+ model,
1552
+ previousReplacementHistory,
1553
+ );
1554
+ if (remoteHistory.length > 0) {
1555
+ try {
1556
+ const remote = await withAuth(
1557
+ apiKey,
1558
+ key =>
1559
+ requestOpenAiRemoteCompaction(
1560
+ model,
1561
+ key,
1562
+ remoteHistory,
1563
+ summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1564
+ signal,
1565
+ {
1566
+ fetch: summaryOptions.fetch,
1567
+ sessionId: summaryOptions.sessionId,
1568
+ providerSessionState: summaryOptions.providerSessionState,
1569
+ codexCompaction: summaryOptions.codexCompaction,
1570
+ },
1571
+ ),
1572
+ { signal },
1573
+ );
1574
+ preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1575
+ usedRemoteCompaction = true;
1576
+ } catch (err) {
1577
+ // A user/session abort is a cancellation, not a remote failure —
1578
+ // swallowing it here would downgrade Esc into "fall back to local
1579
+ // summarization" and keep compaction running on an aborted signal.
1580
+ if (signal?.aborted) throw err;
1581
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1582
+ logger.warn("OpenAI remote compaction failed", {
1583
+ error: err instanceof Error ? err.message : String(err),
1584
+ model: model.id,
1585
+ provider: model.provider,
1586
+ });
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
1592
+ throw new NativeCompactionError(nativeCompactionError);
1593
+ }
1594
+
1595
+ // Generate summaries (can be parallel if both needed) and merge into one
1596
+ let summary: string;
1597
+
1598
+ if (usedRemoteCompaction) {
1599
+ // Remote compaction (V2 or V1) already compacted remotely; the durable
1600
+ // history lives in the provider replay payload (preserveData). Skip local
1601
+ // summarization so a successful remote compaction never pays for a second,
1602
+ // redundant LLM round. If a LATER compaction cannot reuse this payload,
1603
+ // prepareCompaction re-expands the original messages and summarizes them
1604
+ // locally then (see remotePreserveReusable).
1605
+ const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
1606
+ summary =
1607
+ "Remote compaction preserved provider-native history for this session." +
1608
+ (usedTokens > 0 ? ` Retained ${usedTokens} tokens in the provider replay payload.` : "");
1609
+ } else if (isSplitTurn && turnPrefixMessages.length > 0) {
1610
+ // Generate both summaries in parallel
1611
+ const [historyResult, turnPrefixResult] = await Promise.all([
1612
+ messagesToSummarize.length > 0 || previousSummaryForCompaction
1613
+ ? generateSummary(
1614
+ messagesToSummarize,
1615
+ model,
1616
+ reserveTokens,
1617
+ apiKey,
1618
+ signal,
1619
+ customInstructions,
1620
+ previousSummaryForCompaction,
1621
+ summaryOptions,
1622
+ )
1623
+ : Promise.resolve("No prior history."),
1624
+ generateTurnPrefixSummary(turnPrefixMessages, model, reserveTokens, apiKey, signal, summaryOptions),
1625
+ ]);
1626
+ // Merge into single summary
1627
+ summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
1628
+ } else if (messagesToSummarize.length > 0) {
1629
+ // Generate history summary from messages to summarize
1630
+ summary = await generateSummary(
1631
+ messagesToSummarize,
1632
+ model,
1633
+ reserveTokens,
1634
+ apiKey,
1635
+ signal,
1636
+ customInstructions,
1637
+ previousSummaryForCompaction,
1638
+ summaryOptions,
1639
+ );
1640
+ } else if (previousSummaryForCompaction) {
1641
+ // No new messages to summarize, preserve previous summary
1642
+ summary = previousSummaryForCompaction;
1643
+ } else {
1644
+ // No messages and no previous summary
1645
+ summary = "No prior history.";
1646
+ }
1647
+
1648
+ const shortSummary = usedRemoteCompaction
1649
+ ? "Remote compaction"
1650
+ : await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, {
1651
+ ...summaryOptions,
1652
+ extraContext: options?.extraContext,
1653
+ thinkingLevel: options?.thinkingLevel,
1654
+ });
1655
+
1656
+ // Compute file lists and append to summary
1657
+ const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1658
+ summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
1659
+
1660
+ if (!firstKeptEntryId) {
1661
+ throw new Error("First kept entry has no ID - session may need migration");
1662
+ }
1663
+
1664
+ // This LLM-summary path migrated any prior snapcompact frames into the summary
1665
+ // text above; strip the now-stale frame archive from preserveData so it cannot
1666
+ // re-attach to the rebuilt context. Only the legacy-frame case needs stripping —
1667
+ // when there was no previous archive, preserveData carries no frames to drop.
1668
+ const finalPreserveData = previousSnapcompactArchive
1669
+ ? snapcompact.stripPreservedArchive(preserveData)
1670
+ : preserveData;
1671
+
1672
+ return {
1673
+ summary,
1674
+ shortSummary,
1675
+ firstKeptEntryId,
1676
+ tokensBefore,
1677
+ details: { readFiles, modifiedFiles } as CompactionDetails,
1678
+ preserveData: finalPreserveData,
1679
+ };
1680
+ }
1681
+
1682
+ /**
1683
+ * Generate a summary for a turn prefix (when splitting a turn).
1684
+ */
1685
+ async function generateTurnPrefixSummary(
1686
+ messages: AgentMessage[],
1687
+ model: Model,
1688
+ reserveTokens: number,
1689
+ apiKey: ApiKey,
1690
+ signal?: AbortSignal,
1691
+ options?: SummaryOptions,
1692
+ ): Promise<string> {
1693
+ const maxTokens = Math.min(Math.floor(0.5 * reserveTokens), MAX_SUMMARY_TOKENS); // Smaller budget for turn prefix
1694
+
1695
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
1696
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
1697
+ const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
1698
+ const summarizationMessages = [
1699
+ {
1700
+ role: "user" as const,
1701
+ content: [{ type: "text" as const, text: promptText }],
1702
+ timestamp: Date.now(),
1703
+ },
1704
+ ];
1705
+
1706
+ const response = await instrumentedCompleteSimple(
1707
+ model,
1708
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1709
+ {
1710
+ maxTokens,
1711
+ signal,
1712
+ apiKey,
1713
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1714
+ initiatorOverride: options?.initiatorOverride,
1715
+ metadata: options?.metadata,
1716
+ fetch: options?.fetch,
1717
+ sessionId: options?.sessionId,
1718
+ promptCacheKey: options?.promptCacheKey,
1719
+ providerSessionState: options?.providerSessionState,
1720
+ codexCompaction: localCodexCompaction(options),
1721
+ },
1722
+ { telemetry: options?.telemetry, oneshotKind: "compaction_turn_prefix", completeImpl: options?.completeImpl },
1723
+ );
1724
+
1725
+ if (response.stopReason === "error") {
1726
+ throw createSummarizationError("Turn prefix summarization failed", response);
1727
+ }
1728
+
1729
+ return response.content
1730
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1731
+ .map(c => c.text)
1732
+ .join("\n");
1733
+ }