@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,992 @@
1
+ /**
2
+ * Remote compaction utilities.
3
+ *
4
+ * Provider-side conversation summarization endpoints. Three flavors:
5
+ *
6
+ * - **OpenAI remote compaction V2** (Responses streaming): appends a
7
+ * `compaction_trigger` input item to the normal stream and stores the returned
8
+ * `compaction` item with retained real user messages in `preserveData`.
9
+ * - **OpenAI remote compaction V1** (`/responses/compact`): preserves encrypted
10
+ * reasoning across compactions by submitting the full responses-API native
11
+ * history and storing the returned `compaction` / `compaction_summary`
12
+ * item in `preserveData` so future turns can replay the encrypted state.
13
+ * - **Generic remote compaction**: a thin POST helper for self-hosted
14
+ * summarization endpoints that accept `{ systemPrompt, prompt }` and reply
15
+ * with `{ summary, shortSummary? }`.
16
+ */
17
+
18
+ import { ProviderHttpError } from "@linxiraos/pi-ai/error";
19
+ 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";
25
+ import { parseAzureDeploymentNameMap, parseTextSignature } from "@linxiraos/pi-ai/providers/openai-shared";
26
+ import { transformMessages } from "@linxiraos/pi-ai/providers/transform-messages";
27
+ import type {
28
+ Api,
29
+ AssistantMessage,
30
+ CodexCompactionContext,
31
+ FetchImpl,
32
+ Message,
33
+ Model,
34
+ ProviderSessionState,
35
+ } from "@linxiraos/pi-ai/types";
36
+ import {
37
+ getOpenAIResponsesHistoryItems,
38
+ getOpenAIResponsesHistoryPayload,
39
+ normalizeResponsesToolCallId,
40
+ stripOpenAIResponsesOutputOnlyStatusesForReplay,
41
+ } from "@linxiraos/pi-ai/utils";
42
+ import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
43
+ import {
44
+ CODEX_BASE_URL,
45
+ getCodexAccountId,
46
+ OPENAI_HEADER_VALUES,
47
+ OPENAI_HEADERS,
48
+ } from "@linxiraos/pi-catalog/wire/codex";
49
+ import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
50
+ import { countTokensConservatively } from "../tokenizer";
51
+ import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
52
+
53
+ export * from "./compaction-v2-streaming";
54
+
55
+ // ============================================================================
56
+ // Public types
57
+ // ============================================================================
58
+
59
+ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
60
+
61
+ /**
62
+ * Hard ceiling on remote compaction HTTP requests. Unlike every provider
63
+ * stream (guarded by first-event/idle watchdogs in pi-ai), these are raw
64
+ * fetches awaiting one non-streamed JSON body — a connection silently dropped
65
+ * by a middlebox would otherwise hang the whole compaction pipeline forever
66
+ * (frozen "Auto context-full maintenance…" spinner, manual /compact queueing
67
+ * behind it). On timeout the caller falls back to local summarization.
68
+ */
69
+ export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
70
+
71
+ const DEFAULT_AZURE_API_VERSION = "v1";
72
+
73
+ export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
74
+
75
+ const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
76
+ const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
77
+ const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
78
+
79
+ interface NormalizedEstimateValue {
80
+ value: unknown;
81
+ imageTokens: number;
82
+ }
83
+
84
+ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
85
+ if (Array.isArray(value)) {
86
+ const normalized: unknown[] = [];
87
+ let imageTokens = 0;
88
+ for (const item of value) {
89
+ const result = normalizeRemoteCompactionEstimateValue(item);
90
+ normalized.push(result.value);
91
+ imageTokens += result.imageTokens;
92
+ }
93
+ return { value: normalized, imageTokens };
94
+ }
95
+ if (!value || typeof value !== "object") return { value, imageTokens: 0 };
96
+
97
+ const record = value as Record<string, unknown>;
98
+ if (record.type === "input_image") {
99
+ return {
100
+ value: { ...record, image_url: "<image>" },
101
+ imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
102
+ };
103
+ }
104
+
105
+ const normalized: Record<string, unknown> = {};
106
+ let imageTokens = 0;
107
+ for (const [key, item] of Object.entries(record)) {
108
+ const result = normalizeRemoteCompactionEstimateValue(item);
109
+ normalized[key] = result.value;
110
+ imageTokens += result.imageTokens;
111
+ }
112
+ return { value: normalized, imageTokens };
113
+ }
114
+
115
+ export interface TrimRemoteCompactionInputResult {
116
+ input: Array<Record<string, unknown>>;
117
+ rewrittenOutputs: number;
118
+ estimatedTokensBefore: number;
119
+ estimatedTokensAfter: number;
120
+ }
121
+
122
+ function estimateRemoteCompactionInputTokens(
123
+ input: Array<Record<string, unknown>>,
124
+ instructions: string,
125
+ tools?: unknown[],
126
+ ): number {
127
+ const normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
128
+ const serialized = stringifyJson(normalized.value) ?? "";
129
+ return countTokensConservatively(serialized) + normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
130
+ }
131
+
132
+ function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
133
+ if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
134
+ return { ...item, output: CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE };
135
+ }
136
+ if (item.type === "tool_search_output") {
137
+ return { ...item, tools: [] };
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
143
+ if (item.type !== "message" || item.role !== "user" || !Array.isArray(item.content)) return false;
144
+
145
+ let hasLabel = false;
146
+ let hasImage = false;
147
+ for (const block of item.content) {
148
+ if (!isRecord(block)) continue;
149
+ if (block.type === "input_text" && block.text === TOOL_RESULT_IMAGE_ATTACHMENT_TEXT) hasLabel = true;
150
+ if (block.type === "input_image") hasImage = true;
151
+ }
152
+ return hasLabel && hasImage;
153
+ }
154
+
155
+ /**
156
+ * Preserve the full native transcript unless trailing tool outputs alone push a
157
+ * remote compaction request beyond the model window. Replacing only those
158
+ * outputs keeps call/result pairing and all earlier assistant/reasoning history,
159
+ * matching Codex's recovery path for oversized tool turns.
160
+ */
161
+ export function trimRemoteCompactionInputToContextWindow(
162
+ input: Array<Record<string, unknown>>,
163
+ contextWindow: number | null | undefined,
164
+ instructions: string,
165
+ tools?: unknown[],
166
+ ): TrimRemoteCompactionInputResult {
167
+ const estimatedTokensBefore = estimateRemoteCompactionInputTokens(input, instructions, tools);
168
+ if (!contextWindow || contextWindow <= 0 || estimatedTokensBefore <= contextWindow) {
169
+ return {
170
+ input,
171
+ rewrittenOutputs: 0,
172
+ estimatedTokensBefore,
173
+ estimatedTokensAfter: estimatedTokensBefore,
174
+ };
175
+ }
176
+
177
+ let rewrittenInput: Array<Record<string, unknown>> | undefined;
178
+ let estimatedTokensAfter = estimatedTokensBefore;
179
+ let rewrittenOutputs = 0;
180
+ for (let index = input.length - 1; index >= 0 && estimatedTokensAfter > contextWindow; index--) {
181
+ const item = input[index];
182
+ if (isToolResultImageAttachment(item)) continue;
183
+ const rewritten = rewriteToolOutputForContextWindow(item);
184
+ if (!rewritten) break;
185
+ rewrittenInput ??= input.slice();
186
+ rewrittenInput[index] = rewritten;
187
+ rewrittenOutputs++;
188
+ estimatedTokensAfter = estimateRemoteCompactionInputTokens(rewrittenInput, instructions, tools);
189
+ }
190
+
191
+ if (!rewrittenInput || estimatedTokensAfter > contextWindow) {
192
+ return {
193
+ input,
194
+ rewrittenOutputs: 0,
195
+ estimatedTokensBefore,
196
+ estimatedTokensAfter: estimatedTokensBefore,
197
+ };
198
+ }
199
+
200
+ return {
201
+ input: rewrittenInput,
202
+ rewrittenOutputs,
203
+ estimatedTokensBefore,
204
+ estimatedTokensAfter,
205
+ };
206
+ }
207
+
208
+ /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
209
+ function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
210
+ if (timeoutMs <= 0) return signal;
211
+ const timeout = AbortSignal.timeout(timeoutMs);
212
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
213
+ }
214
+
215
+ export type OpenAiRemoteCompactionItem = {
216
+ type: "compaction" | "compaction_summary";
217
+ encrypted_content?: string;
218
+ summary?: string;
219
+ };
220
+
221
+ export interface OpenAiRemoteCompactionPreserveData {
222
+ provider?: string;
223
+ replacementHistory: Array<Record<string, unknown>>;
224
+ compactionItem: OpenAiRemoteCompactionItem;
225
+ }
226
+
227
+ export interface OpenAiRemoteCompactionRequest {
228
+ model: string;
229
+ input: Array<Record<string, unknown>>;
230
+ instructions: string;
231
+ reasoning?: {
232
+ context?: string;
233
+ [key: string]: unknown;
234
+ };
235
+ include?: string[];
236
+ }
237
+
238
+ export interface OpenAiRemoteCompactionResponse extends OpenAiRemoteCompactionPreserveData {}
239
+
240
+ export interface RemoteCompactionRequest {
241
+ systemPrompt: string;
242
+ prompt: string;
243
+ maxTokens?: number;
244
+ }
245
+
246
+ export interface RemoteCompactionResponse {
247
+ summary: string;
248
+ shortSummary?: string;
249
+ }
250
+
251
+ // ============================================================================
252
+ // OpenAI provider gating + endpoint resolution
253
+ // ============================================================================
254
+
255
+ function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
256
+ return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
257
+ }
258
+
259
+ export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
260
+ if (model.remoteCompaction?.enabled === false) return false;
261
+ if (model.provider === "openai" || model.provider === "openai-codex") return true;
262
+ if (model.remoteCompaction?.enabled !== true) return false;
263
+ return isOpenAiRemoteCompactionApi(model.remoteCompaction.api ?? model.api);
264
+ }
265
+
266
+ function resolveOpenAiCompactEndpoint(model: Model): string {
267
+ const configuredEndpoint = model.remoteCompaction?.endpoint;
268
+ const compactionApi = model.remoteCompaction?.api ?? model.api;
269
+ if (compactionApi === "azure-openai-responses") {
270
+ return resolveAzureOpenAiCompactEndpoint(model, configuredEndpoint);
271
+ }
272
+ if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
273
+ if (model.provider === "openai-codex" || compactionApi === "openai-codex-responses") {
274
+ return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
275
+ }
276
+
277
+ const defaultBase = "https://api.openai.com/v1";
278
+ const rawBase = model.baseUrl && model.baseUrl.length > 0 ? model.baseUrl : defaultBase;
279
+ const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
280
+ if (normalizedBase.endsWith("/v1")) return `${normalizedBase}/responses/compact`;
281
+ return `${normalizedBase}/v1/responses/compact`;
282
+ }
283
+
284
+ function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: string | undefined): string {
285
+ const endpoint =
286
+ configuredEndpoint && configuredEndpoint.length > 0
287
+ ? configuredEndpoint
288
+ : `${resolveAzureOpenAiBaseUrl(model)}/responses/compact`;
289
+ return appendAzureApiVersion(endpoint);
290
+ }
291
+
292
+ function resolveAzureOpenAiBaseUrl(model: Model): string {
293
+ const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
294
+ const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
295
+ const resolvedBaseUrl =
296
+ baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
297
+ if (!resolvedBaseUrl) {
298
+ throw new Error(
299
+ "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
300
+ );
301
+ }
302
+ return resolvedBaseUrl.replace(/\/+$/, "");
303
+ }
304
+
305
+ function appendAzureApiVersion(endpoint: string): string {
306
+ if (/[?&]api-version=/.test(endpoint)) return endpoint;
307
+ const separator = endpoint.includes("?") ? "&" : "?";
308
+ return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
309
+ }
310
+
311
+ function resolveOpenAiCompactModel(model: Model): string {
312
+ const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
313
+ const compactionApi = model.remoteCompaction?.api ?? model.api;
314
+ if (compactionApi !== "azure-openai-responses") return requestModel;
315
+ const mappedDeployment = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(requestModel);
316
+ return mappedDeployment ?? requestModel;
317
+ }
318
+
319
+ function resolveOpenAiCodexCompactEndpoint(baseUrl: string | undefined): string {
320
+ const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : CODEX_BASE_URL;
321
+ const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
322
+ if (/\/codex(?:\/v\d+)?$/.test(normalizedBase)) return `${normalizedBase}/responses/compact`;
323
+ return `${normalizedBase}/codex/responses/compact`;
324
+ }
325
+
326
+ function normalizeOpenAiCompactionToolCallId(id: string): string {
327
+ const normalized = normalizeResponsesToolCallId(id);
328
+ return `${normalized.callId}|${normalized.itemId ?? normalized.callId}`;
329
+ }
330
+
331
+ // ============================================================================
332
+ // Preserve-data helpers
333
+ // ============================================================================
334
+
335
+ export function getPreservedOpenAiRemoteCompactionData(
336
+ preserveData: Record<string, unknown> | undefined,
337
+ ): OpenAiRemoteCompactionPreserveData | undefined {
338
+ const candidate = preserveData?.[OPENAI_REMOTE_COMPACTION_PRESERVE_KEY];
339
+ if (!candidate || typeof candidate !== "object") return undefined;
340
+ const maybeData = candidate as { provider?: unknown; replacementHistory?: unknown; compactionItem?: unknown };
341
+ if (!Array.isArray(maybeData.replacementHistory)) return undefined;
342
+ const maybeItem = maybeData.compactionItem;
343
+ if (!maybeItem || typeof maybeItem !== "object") return undefined;
344
+ const compactionItem = maybeItem as { type?: unknown; encrypted_content?: unknown; summary?: unknown };
345
+ const isClassicCompaction =
346
+ compactionItem.type === "compaction" && typeof compactionItem.encrypted_content === "string";
347
+ const isSummaryCompaction = compactionItem.type === "compaction_summary";
348
+ if (!isClassicCompaction && !isSummaryCompaction) {
349
+ return undefined;
350
+ }
351
+ return {
352
+ provider: typeof maybeData.provider === "string" ? maybeData.provider : undefined,
353
+ replacementHistory: maybeData.replacementHistory as Array<Record<string, unknown>>,
354
+ compactionItem: compactionItem as unknown as OpenAiRemoteCompactionItem,
355
+ };
356
+ }
357
+
358
+ export function withOpenAiRemoteCompactionPreserveData(
359
+ preserveData: Record<string, unknown> | undefined,
360
+ remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined,
361
+ ): Record<string, unknown> | undefined {
362
+ if (remoteCompaction) {
363
+ return {
364
+ ...(preserveData ?? {}),
365
+ [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: remoteCompaction,
366
+ };
367
+ }
368
+
369
+ if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
370
+ return preserveData;
371
+ }
372
+
373
+ const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
374
+ return Object.keys(rest).length > 0 ? rest : undefined;
375
+ }
376
+
377
+ // ============================================================================
378
+ // Input/output filtering for OpenAI compact endpoint
379
+ // ============================================================================
380
+
381
+ function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
382
+ if (item.type === "compaction" || item.type === "compaction_summary") return true;
383
+ if (item.type !== "message") return false;
384
+ return item.role === "assistant" || item.role === "user";
385
+ }
386
+
387
+ // Register every tool-call id in `items` (and the subset using the custom-tool
388
+ // wire shape) into the running sets. The history builder maintains both sets
389
+ // incrementally as native history is appended, so this only scans the
390
+ // newly-added items (or, after a full-snapshot replace, the fresh input) rather
391
+ // than re-scanning the whole growing history per message — the latter was
392
+ // O(N²) and blocked the event loop for seconds while compacting large codex
393
+ // contexts (frozen spinner until the next forced render).
394
+ function addOpenAiCallIds(
395
+ items: Array<Record<string, unknown>>,
396
+ knownCallIds: Set<string>,
397
+ customCallIds: Set<string>,
398
+ computerCallIds: Set<string>,
399
+ ): void {
400
+ for (const item of items) {
401
+ if (typeof item.call_id !== "string") continue;
402
+ if (item.type === "function_call") {
403
+ knownCallIds.add(item.call_id);
404
+ } else if (item.type === "custom_tool_call") {
405
+ knownCallIds.add(item.call_id);
406
+ customCallIds.add(item.call_id);
407
+ } else if (item.type === "computer_call") {
408
+ knownCallIds.add(item.call_id);
409
+ computerCallIds.add(item.call_id);
410
+ }
411
+ }
412
+ }
413
+
414
+ function computerHistoryNote(item: Record<string, unknown>): Record<string, unknown> {
415
+ const serialized = stringifyJson(item) ?? "";
416
+ return {
417
+ type: "message",
418
+ id: `msg_${Bun.hash(`computer-history:${serialized}`).toString(36)}`,
419
+ role: "assistant",
420
+ content: [
421
+ {
422
+ type: "output_text",
423
+ text: `[Previous computer history unavailable to this model]: ${serialized}`,
424
+ annotations: [],
425
+ },
426
+ ],
427
+ status: "completed",
428
+ };
429
+ }
430
+
431
+ function adaptComputerHistoryForCompaction(
432
+ items: Array<Record<string, unknown>>,
433
+ supportsComputerUse: boolean,
434
+ ): Array<Record<string, unknown>> {
435
+ if (supportsComputerUse) return items;
436
+ return items.map(item =>
437
+ item.type === "computer_call" || item.type === "computer_call_output" ? computerHistoryNote(item) : item,
438
+ );
439
+ }
440
+
441
+ function computerFailureNote(call: Record<string, unknown>, output: string): Record<string, unknown> {
442
+ const serialized = stringifyJson(call) ?? "";
443
+ return {
444
+ type: "message",
445
+ id: `msg_${Bun.hash(`computer-failure:${serialized}:${output}`).toString(36)}`,
446
+ role: "assistant",
447
+ content: [
448
+ {
449
+ type: "output_text",
450
+ text: `[Computer call failed before a screenshot was recorded]: ${serialized}${output ? `\n${output}` : ""}`,
451
+ annotations: [],
452
+ },
453
+ ],
454
+ status: "completed",
455
+ };
456
+ }
457
+
458
+ // ============================================================================
459
+ // Native history construction (responses-API shape)
460
+ // ============================================================================
461
+
462
+ /**
463
+ * Build the OpenAI Responses-API native history array from LLM messages.
464
+ *
465
+ * Caller is responsible for converting any custom message types to
466
+ * `Message[]` first (e.g. via the agent's `convertToLlm`); this function
467
+ * operates purely on the LLM-domain shape.
468
+ *
469
+ * @param messages - LLM messages to encode.
470
+ * @param model - Target model (used for provider gating + tool-call id rules).
471
+ * @param previousReplacementHistory - History from a prior compaction whose
472
+ * encrypted reasoning we want to preserve.
473
+ */
474
+ export function buildOpenAiNativeHistory(
475
+ messages: Message[],
476
+ model: Model,
477
+ previousReplacementHistory?: Array<Record<string, unknown>>,
478
+ ): Array<Record<string, unknown>> {
479
+ const input: Array<Record<string, unknown>> = previousReplacementHistory
480
+ ? adaptComputerHistoryForCompaction([...previousReplacementHistory], model.supportsComputerUse === true)
481
+ : [];
482
+ const transformedMessages = transformMessages(messages, model, id => normalizeOpenAiCompactionToolCallId(id));
483
+
484
+ let msgIndex = 0;
485
+ const knownCallIds = new Set<string>();
486
+ const customCallIds = new Set<string>();
487
+ const computerCallIds = new Set<string>();
488
+ const demotedComputerCallIds = new Set<string>();
489
+ addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
490
+ for (const message of transformedMessages) {
491
+ if (message.role === "user" || message.role === "developer") {
492
+ const providerPayload = (message as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
493
+ const rawHistoryItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
494
+ if (rawHistoryItems) {
495
+ if (model.supportsComputerUse !== true) {
496
+ for (const item of rawHistoryItems) {
497
+ if (item.type === "computer_call" && typeof item.call_id === "string") {
498
+ demotedComputerCallIds.add(item.call_id);
499
+ }
500
+ }
501
+ }
502
+ const historyItems = adaptComputerHistoryForCompaction(rawHistoryItems, model.supportsComputerUse === true);
503
+ input.push(...historyItems);
504
+ addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
505
+ msgIndex++;
506
+ continue;
507
+ }
508
+
509
+ const contentBlocks: Array<Record<string, unknown>> = [];
510
+ if (typeof message.content === "string") {
511
+ if (message.content.trim().length > 0) {
512
+ contentBlocks.push({ type: "input_text", text: message.content.toWellFormed() });
513
+ }
514
+ } else {
515
+ for (const block of message.content) {
516
+ if (block.type === "text") {
517
+ if (!block.text || block.text.trim().length === 0) continue;
518
+ contentBlocks.push({ type: "input_text", text: block.text.toWellFormed() });
519
+ continue;
520
+ }
521
+ if (block.type === "image") {
522
+ contentBlocks.push({
523
+ type: "input_image",
524
+ detail: "auto",
525
+ image_url: `data:${block.mimeType};base64,${block.data}`,
526
+ });
527
+ }
528
+ }
529
+ }
530
+ if (contentBlocks.length > 0) {
531
+ input.push({ type: "message", role: message.role, content: contentBlocks });
532
+ }
533
+ msgIndex++;
534
+ continue;
535
+ }
536
+
537
+ if (message.role === "assistant") {
538
+ const assistant = message as AssistantMessage;
539
+ const providerPayload = getOpenAIResponsesHistoryPayload(
540
+ assistant.providerPayload,
541
+ model.provider,
542
+ assistant.provider,
543
+ );
544
+ if (providerPayload) {
545
+ if (!providerPayload.dt) demotedComputerCallIds.clear();
546
+ if (model.supportsComputerUse !== true) {
547
+ for (const item of providerPayload.items) {
548
+ if (item.type === "computer_call" && typeof item.call_id === "string") {
549
+ demotedComputerCallIds.add(item.call_id);
550
+ }
551
+ }
552
+ }
553
+ const historyItems = adaptComputerHistoryForCompaction(
554
+ providerPayload.items,
555
+ model.supportsComputerUse === true,
556
+ );
557
+ if (providerPayload.dt) {
558
+ input.push(...historyItems);
559
+ addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
560
+ } else {
561
+ input.splice(0, input.length, ...historyItems);
562
+ knownCallIds.clear();
563
+ customCallIds.clear();
564
+ computerCallIds.clear();
565
+ addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
566
+ }
567
+ msgIndex++;
568
+ continue;
569
+ }
570
+ const isDifferentModel =
571
+ assistant.model !== model.id && assistant.provider === model.provider && assistant.api === model.api;
572
+
573
+ for (const block of assistant.content) {
574
+ if (block.type === "thinking" && assistant.stopReason !== "error" && block.thinkingSignature) {
575
+ try {
576
+ const reasoningItem = JSON.parse(block.thinkingSignature) as Record<string, unknown>;
577
+ if (reasoningItem && typeof reasoningItem === "object") {
578
+ input.push(reasoningItem);
579
+ }
580
+ } catch {
581
+ logger.warn("Failed to parse assistant reasoning for remote compaction", {
582
+ model: assistant.model,
583
+ provider: assistant.provider,
584
+ });
585
+ }
586
+ continue;
587
+ }
588
+
589
+ if (block.type === "text") {
590
+ if (!block.text || block.text.trim().length === 0) continue;
591
+ const parsedSignature = parseTextSignature(block.textSignature);
592
+ let msgId = parsedSignature?.id;
593
+ if (!msgId) {
594
+ msgId = `msg_${msgIndex}`;
595
+ } else if (msgId.length > 64) {
596
+ msgId = `msg_${Bun.hash(msgId).toString(36)}`;
597
+ }
598
+ input.push({
599
+ type: "message",
600
+ role: "assistant",
601
+ content: [{ type: "output_text", text: block.text.toWellFormed(), annotations: [] }],
602
+ status: "completed",
603
+ id: msgId,
604
+ phase: parsedSignature?.phase,
605
+ });
606
+ continue;
607
+ }
608
+
609
+ if (block.type === "toolCall") {
610
+ const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
611
+ if (block.providerMetadata?.type === "computer") {
612
+ const computerCall = {
613
+ type: "computer_call",
614
+ id: block.providerMetadata.providerItemId,
615
+ call_id: normalized.callId,
616
+ actions: structuredCloneJSON(block.providerMetadata.actions),
617
+ pending_safety_checks: structuredCloneJSON(block.providerMetadata.pendingSafetyChecks),
618
+ status: "completed",
619
+ };
620
+ if (model.supportsComputerUse !== true) {
621
+ input.push(computerHistoryNote(computerCall));
622
+ demotedComputerCallIds.add(normalized.callId);
623
+ continue;
624
+ }
625
+ knownCallIds.add(normalized.callId);
626
+ computerCallIds.add(normalized.callId);
627
+ input.push(computerCall);
628
+ continue;
629
+ }
630
+ let itemId: string | undefined = normalized.itemId;
631
+ if (
632
+ isDifferentModel &&
633
+ (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
634
+ ) {
635
+ itemId = undefined;
636
+ }
637
+ knownCallIds.add(normalized.callId);
638
+ if (block.customWireName) {
639
+ const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
640
+ customCallIds.add(normalized.callId);
641
+ input.push({
642
+ type: "custom_tool_call",
643
+ id: itemId,
644
+ call_id: normalized.callId,
645
+ name: block.customWireName,
646
+ input: rawInput,
647
+ });
648
+ continue;
649
+ }
650
+ input.push({
651
+ type: "function_call",
652
+ id: itemId,
653
+ call_id: normalized.callId,
654
+ name: block.name,
655
+ arguments: stringifyJson(block.arguments) ?? "null",
656
+ });
657
+ }
658
+ }
659
+
660
+ msgIndex++;
661
+ continue;
662
+ }
663
+
664
+ if (message.role === "toolResult") {
665
+ const normalized = normalizeResponsesToolCallId(message.toolCallId);
666
+ const textOutput = message.content
667
+ .filter(block => block.type === "text")
668
+ .map(block => block.text)
669
+ .join("\n");
670
+ const hasImages = message.content.some(block => block.type === "image");
671
+ const outputText = textOutput.length > 0 ? textOutput : hasImages ? "(see attached image)" : "";
672
+ if (demotedComputerCallIds.has(normalized.callId)) {
673
+ const resultItem =
674
+ message.providerMetadata?.type === "computer"
675
+ ? {
676
+ type: "computer_call_output",
677
+ call_id: normalized.callId,
678
+ output: structuredCloneJSON(message.providerMetadata.screenshot),
679
+ acknowledged_safety_checks: structuredCloneJSON(
680
+ message.providerMetadata.acknowledgedSafetyChecks,
681
+ ),
682
+ }
683
+ : { type: "computer_call_output", call_id: normalized.callId, error: outputText };
684
+ input.push(computerHistoryNote(resultItem));
685
+ demotedComputerCallIds.delete(normalized.callId);
686
+ msgIndex++;
687
+ continue;
688
+ }
689
+ if (!knownCallIds.has(normalized.callId)) {
690
+ msgIndex++;
691
+ continue;
692
+ }
693
+ if (computerCallIds.has(normalized.callId)) {
694
+ if (message.providerMetadata?.type === "computer") {
695
+ input.push({
696
+ type: "computer_call_output",
697
+ call_id: normalized.callId,
698
+ output: structuredCloneJSON(message.providerMetadata.screenshot),
699
+ acknowledged_safety_checks: structuredCloneJSON(message.providerMetadata.acknowledgedSafetyChecks),
700
+ });
701
+ msgIndex++;
702
+ continue;
703
+ }
704
+
705
+ const callIndex = input.findLastIndex(
706
+ item => item.type === "computer_call" && item.call_id === normalized.callId,
707
+ );
708
+ if (callIndex >= 0) {
709
+ const [call] = input.splice(callIndex, 1);
710
+ if (call) input.splice(callIndex, 0, computerFailureNote(call, outputText));
711
+ }
712
+ knownCallIds.delete(normalized.callId);
713
+ computerCallIds.delete(normalized.callId);
714
+ msgIndex++;
715
+ continue;
716
+ }
717
+
718
+ input.push({
719
+ type: customCallIds.has(normalized.callId) ? "custom_tool_call_output" : "function_call_output",
720
+ call_id: normalized.callId,
721
+ output: outputText.toWellFormed(),
722
+ });
723
+
724
+ if (hasImages && model.input.includes("image")) {
725
+ const contentBlocks: Array<Record<string, unknown>> = [
726
+ { type: "input_text", text: TOOL_RESULT_IMAGE_ATTACHMENT_TEXT },
727
+ ];
728
+ for (const block of message.content) {
729
+ if (block.type !== "image") continue;
730
+ contentBlocks.push({
731
+ type: "input_image",
732
+ detail: "auto",
733
+ image_url: `data:${block.mimeType};base64,${block.data}`,
734
+ });
735
+ }
736
+ input.push({ type: "message", role: "user", content: contentBlocks });
737
+ }
738
+ }
739
+
740
+ msgIndex++;
741
+ }
742
+
743
+ return stripOpenAIResponsesOutputOnlyStatusesForReplay(input);
744
+ }
745
+
746
+ // ============================================================================
747
+ // Endpoint requests
748
+ // ============================================================================
749
+ export async function requestOpenAiRemoteCompaction(
750
+ model: Model,
751
+ apiKey: string,
752
+ compactInput: Array<Record<string, unknown>>,
753
+ instructions: string,
754
+ signal?: AbortSignal,
755
+ opts?: {
756
+ fetch?: FetchImpl;
757
+ timeoutMs?: number;
758
+ sessionId?: string;
759
+ providerSessionState?: Map<string, ProviderSessionState>;
760
+ codexCompaction?: CodexCompactionContext;
761
+ },
762
+ ): Promise<OpenAiRemoteCompactionResponse> {
763
+ const endpoint = resolveOpenAiCompactEndpoint(model);
764
+ const requestModel = resolveOpenAiCompactModel(model);
765
+ const trimmed = trimRemoteCompactionInputToContextWindow(compactInput, model.contextWindow, instructions);
766
+ if (trimmed.rewrittenOutputs > 0) {
767
+ logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
768
+ model: model.id,
769
+ provider: model.provider,
770
+ rewrittenOutputs: trimmed.rewrittenOutputs,
771
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
772
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
773
+ contextWindow: model.contextWindow,
774
+ });
775
+ }
776
+ const request: OpenAiRemoteCompactionRequest = {
777
+ model: requestModel,
778
+ // Preserve the native transcript. Only oversized trailing tool outputs are
779
+ // rewritten above, reducing the request without losing assistant turns,
780
+ // reasoning, or call/result pairing.
781
+ input: trimmed.input,
782
+ instructions,
783
+ };
784
+ const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
785
+ const isCodexResponses =
786
+ model.provider === "openai-codex" || (model.remoteCompaction?.api ?? model.api) === "openai-codex-responses";
787
+ const headers: Record<string, string> = isAzureOpenAiResponses
788
+ ? {
789
+ "content-type": "application/json",
790
+ "api-key": apiKey,
791
+ ...(model.headers ?? {}),
792
+ }
793
+ : {
794
+ "content-type": "application/json",
795
+ Authorization: `Bearer ${apiKey}`,
796
+ ...(model.headers ?? {}),
797
+ };
798
+
799
+ // Codex endpoints require additional auth headers
800
+ if (isCodexResponses) {
801
+ const accountId = getCodexAccountId(apiKey);
802
+ if (accountId) {
803
+ headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
804
+ }
805
+ const attestation = await getCodexAttestationHeader(accountId);
806
+ if (attestation) {
807
+ headers[OPENAI_HEADERS.ATTESTATION] = attestation;
808
+ }
809
+ headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
810
+ headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
811
+ Object.assign(
812
+ headers,
813
+ createOpenAICodexCompatibilityMetadata({
814
+ sessionId: opts?.sessionId,
815
+ providerSessionState: opts?.providerSessionState,
816
+ requestKind: "compaction",
817
+ compaction: createOpenAICodexCompactionRequestContext({
818
+ context: opts?.codexCompaction,
819
+ implementation: "responses_compact",
820
+ }),
821
+ includeInstallationHeader: true,
822
+ }).headers,
823
+ );
824
+ // Responses Lite models take the same rewrite on `/responses/compact`:
825
+ // instructions ride as an input item and the lite marker header is set
826
+ // (codex-rs routes compaction through `build_responses_request`).
827
+ if (model.useResponsesLite) {
828
+ applyCodexResponsesLiteShape(request);
829
+ headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
830
+ request.reasoning = {
831
+ ...request.reasoning,
832
+ context: "all_turns",
833
+ };
834
+ request.include = Array.from(new Set([...(request.include ?? []), "reasoning.encrypted_content"]));
835
+ }
836
+ }
837
+
838
+ const response = await (opts?.fetch ?? fetch)(endpoint, {
839
+ method: "POST",
840
+ headers,
841
+ body: stringifyJson(request),
842
+ signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
843
+ });
844
+
845
+ if (!response.ok) {
846
+ const cause = await captureOpenAIHttpError(response);
847
+ logger.warn("OpenAI remote compaction failed", {
848
+ endpoint,
849
+ status: response.status,
850
+ statusText: response.statusText,
851
+ errorText: cause.captured.bodyText ?? "",
852
+ });
853
+ throw new ProviderHttpError(
854
+ `Remote compaction failed (${response.status} ${response.statusText})`,
855
+ response.status,
856
+ {
857
+ headers: response.headers,
858
+ cause,
859
+ },
860
+ );
861
+ }
862
+
863
+ const data = (await response.json()) as { output?: unknown[] } | undefined;
864
+ const rawOutput = data?.output ?? [];
865
+ const replacementHistory = rawOutput.filter(
866
+ (item): item is Record<string, unknown> =>
867
+ !!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
868
+ );
869
+ const compactionItem = replacementHistory.findLast((item): item is OpenAiRemoteCompactionItem => {
870
+ if (item.type === "compaction" && typeof item.encrypted_content === "string") return true;
871
+ if (item.type === "compaction_summary") return true;
872
+ return false;
873
+ });
874
+ if (!compactionItem) {
875
+ const outputTypes = rawOutput.map(item =>
876
+ typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
877
+ );
878
+ logger.warn("Remote compaction response missing compaction item", {
879
+ endpoint,
880
+ model: model.id,
881
+ provider: model.provider,
882
+ rawOutputLength: rawOutput.length,
883
+ outputTypes,
884
+ replacementHistoryLength: replacementHistory.length,
885
+ });
886
+ throw new Error("Remote compaction response missing compaction item");
887
+ }
888
+ return { provider: model.provider, replacementHistory, compactionItem };
889
+ }
890
+
891
+ /**
892
+ * Generic remote-compaction POST. Two wire shapes are auto-selected by
893
+ * endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
894
+ * either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
895
+ * or any OpenAI-compatible chat-completions server (`/chat/completions`,
896
+ * `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
897
+ * issue #4630: without this, the omp payload was rejected with
898
+ * HTTP 400 `"'messages' is required"`, compaction silently fell back to
899
+ * local summarization, and context grew unbounded.
900
+ *
901
+ * When `context.model` is provided the chat-completions body is tagged with
902
+ * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
903
+ * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
904
+ * 401s force-refresh through the standard credential rotation policy.
905
+ */
906
+ export async function requestRemoteCompaction(
907
+ endpoint: string,
908
+ request: RemoteCompactionRequest,
909
+ signal?: AbortSignal,
910
+ opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
911
+ ): Promise<RemoteCompactionResponse> {
912
+ let endpointPath = endpoint;
913
+ try {
914
+ endpointPath = new URL(endpoint).pathname;
915
+ } catch {
916
+ // Keep the raw endpoint for relative/custom fetch implementations.
917
+ }
918
+ const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
919
+ const headers: Record<string, string> = { "content-type": "application/json" };
920
+ if (isChatCompletions) {
921
+ if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
922
+ if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
923
+ }
924
+
925
+ const body: Record<string, unknown> = isChatCompletions
926
+ ? {
927
+ model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
928
+ messages: [
929
+ { role: "system", content: request.systemPrompt },
930
+ { role: "user", content: request.prompt },
931
+ ],
932
+ stream: false,
933
+ max_tokens: request.maxTokens,
934
+ }
935
+ : { systemPrompt: request.systemPrompt, prompt: request.prompt, maxTokens: request.maxTokens };
936
+
937
+ const response = await (opts?.fetch ?? fetch)(endpoint, {
938
+ method: "POST",
939
+ headers,
940
+ body: stringifyJson(body),
941
+ signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
942
+ });
943
+
944
+ if (!response.ok) {
945
+ const errorText = await response.text().catch(() => "");
946
+ logger.warn("Remote compaction failed", {
947
+ endpoint,
948
+ status: response.status,
949
+ statusText: response.statusText,
950
+ errorText,
951
+ });
952
+ throw new ProviderHttpError(
953
+ `Remote compaction failed (${response.status} ${response.statusText})`,
954
+ response.status,
955
+ {
956
+ headers: response.headers,
957
+ },
958
+ );
959
+ }
960
+
961
+ if (isChatCompletions) {
962
+ type ChatCompletionsResponse = {
963
+ choices?: Array<{
964
+ message?: {
965
+ content?: string | Array<{ type?: string; text?: string }> | null;
966
+ };
967
+ }>;
968
+ };
969
+ const data = (await response.json()) as ChatCompletionsResponse | undefined;
970
+ const choice = data?.choices?.[0]?.message?.content;
971
+ let summary: string | undefined;
972
+ if (typeof choice === "string") {
973
+ summary = choice;
974
+ } else if (Array.isArray(choice)) {
975
+ summary = choice
976
+ .filter((part): part is { type?: string; text: string } => typeof part?.text === "string")
977
+ .map(part => part.text)
978
+ .join("");
979
+ }
980
+ if (typeof summary !== "string" || summary.length === 0) {
981
+ throw new Error("Remote compaction response missing choices[0].message.content");
982
+ }
983
+ return { summary };
984
+ }
985
+
986
+ const data = (await response.json()) as RemoteCompactionResponse | undefined;
987
+ if (!data || typeof data.summary !== "string") {
988
+ throw new Error("Remote compaction response missing summary");
989
+ }
990
+
991
+ return data;
992
+ }