@oh-my-pi/pi-agent-core 16.2.2 → 16.2.3
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.
- package/CHANGELOG.md +11 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +82 -0
- package/dist/types/compaction/compaction.d.ts +9 -1
- package/dist/types/compaction/entries.d.ts +8 -1
- package/dist/types/compaction/openai.d.ts +6 -2
- package/package.json +7 -7
- package/src/compaction/compaction-v2-streaming.ts +724 -0
- package/src/compaction/compaction.ts +192 -31
- package/src/compaction/entries.ts +9 -0
- package/src/compaction/openai.ts +11 -48
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.3] - 2026-06-28
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Enabled V2 streaming remote compaction by default for compatible AI and OpenAI-compatible models, which forwards full conversation history to the provider and supports session routing, prompt caching, provider-native tool history replay, transient error retries, and configurable timeouts.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed an issue where assistant responses and encrypted reasoning could be lost during local history trimming.
|
|
14
|
+
- Added `title_change` session metadata to the compaction entry type union to maintain type compatibility for hosts with title audit entries.
|
|
15
|
+
|
|
5
16
|
## [16.2.2] - 2026-06-27
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Compaction V2: streaming Responses compaction.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Codex `core/src/compact_remote_v2.rs`: append a `compaction_trigger`
|
|
5
|
+
* input item to the normal Responses stream, require exactly one streamed
|
|
6
|
+
* compaction output item, then install retained real user messages plus that
|
|
7
|
+
* compaction item as replacement history.
|
|
8
|
+
*/
|
|
9
|
+
import type { FetchImpl, Model } from "@oh-my-pi/pi-ai";
|
|
10
|
+
/** Retained-message budget Codex uses after streamed V2 compaction. */
|
|
11
|
+
export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
|
|
12
|
+
/** Max retries for V2 streaming compaction on transient stream errors. */
|
|
13
|
+
export declare const V2_COMPACTION_MAX_RETRIES = 2;
|
|
14
|
+
/** Timeout for V2 streaming compaction (3 minutes, same as V1). */
|
|
15
|
+
export declare const V2_COMPACTION_TIMEOUT_MS = 180000;
|
|
16
|
+
/** Token usage reported by the streamed V2 Responses completion. */
|
|
17
|
+
export interface CompactionV2Usage {
|
|
18
|
+
inputTokens: number;
|
|
19
|
+
outputTokens: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
cachedInputTokens?: number;
|
|
22
|
+
reasoningOutputTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Request body fields needed for Responses-stream V2 compaction. */
|
|
25
|
+
export interface CompactionV2Request {
|
|
26
|
+
model: string;
|
|
27
|
+
input: unknown[];
|
|
28
|
+
instructions: string;
|
|
29
|
+
retainedMessageBudget: number;
|
|
30
|
+
tools?: unknown[];
|
|
31
|
+
/** Responses reasoning param (effort + summary), matching a normal turn; omitted for non-reasoning models. */
|
|
32
|
+
reasoning?: {
|
|
33
|
+
effort: string;
|
|
34
|
+
summary: string;
|
|
35
|
+
};
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
promptCacheKey?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Response collected from the V2 stream and converted into replacement history. */
|
|
40
|
+
export interface CompactionV2Response {
|
|
41
|
+
compactionItem: Record<string, unknown>;
|
|
42
|
+
replacementHistory: Array<Record<string, unknown>>;
|
|
43
|
+
usedTokens: number;
|
|
44
|
+
usage?: CompactionV2Usage;
|
|
45
|
+
retainedImageCount: number;
|
|
46
|
+
}
|
|
47
|
+
/** Resolve the streaming Responses endpoint for a V2-capable model. */
|
|
48
|
+
export declare function getCompactionV2Endpoint(model: Model): string | undefined;
|
|
49
|
+
/** Check whether a model can use streaming V2 compaction. */
|
|
50
|
+
export declare function shouldUseCompactionV2Streaming(model: Model): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">;
|
|
51
|
+
/** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
|
|
52
|
+
export declare function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number;
|
|
53
|
+
/** Build a V2 streaming compaction request from Responses-native history. */
|
|
54
|
+
export declare function buildCompactionV2Request(model: Model, input: unknown[], instructions: string, options?: {
|
|
55
|
+
tools?: unknown[];
|
|
56
|
+
reasoning?: {
|
|
57
|
+
effort: string;
|
|
58
|
+
summary: string;
|
|
59
|
+
};
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
promptCacheKey?: string;
|
|
62
|
+
retainedMessageBudget?: number;
|
|
63
|
+
}): CompactionV2Request;
|
|
64
|
+
/** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
|
|
65
|
+
export declare function requestCompactionV2Streaming(model: Model, apiKey: string, request: CompactionV2Request, signal?: AbortSignal, options?: {
|
|
66
|
+
fetch?: FetchImpl;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
69
|
+
}): Promise<CompactionV2Response>;
|
|
70
|
+
/** Build Codex-style V2 replacement history from prompt input plus compaction output. */
|
|
71
|
+
export declare function buildCompactionV2ReplacementHistory(input: unknown[], compactionItem: Record<string, unknown>, retainedMessageBudget?: number): {
|
|
72
|
+
replacementHistory: Array<Record<string, unknown>>;
|
|
73
|
+
retainedImageCount: number;
|
|
74
|
+
};
|
|
75
|
+
/** Store V2 replacement history in the OpenAI remote-compaction preserve slot. */
|
|
76
|
+
export declare function storeCompactionV2PreserveData(response: CompactionV2Response, model: Model): Record<string, unknown>;
|
|
77
|
+
/** Retrieve preserved OpenAI replacement history that V2 can extend. */
|
|
78
|
+
export declare function getCompactionV2PreserveData(preserveData: Record<string, unknown> | undefined): {
|
|
79
|
+
provider: string;
|
|
80
|
+
replacementHistory: Array<Record<string, unknown>>;
|
|
81
|
+
usedTokens: number;
|
|
82
|
+
} | undefined;
|
|
@@ -39,6 +39,8 @@ export interface CompactionSettings {
|
|
|
39
39
|
autoContinue?: boolean;
|
|
40
40
|
remoteEnabled?: boolean;
|
|
41
41
|
remoteEndpoint?: string;
|
|
42
|
+
remoteStreamingV2Enabled?: boolean;
|
|
43
|
+
v2RetainedMessageBudget?: number;
|
|
42
44
|
}
|
|
43
45
|
export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
|
|
44
46
|
/**
|
|
@@ -151,6 +153,12 @@ export interface SummaryOptions {
|
|
|
151
153
|
* `resolveCompactionEffort` for the conversion contract.
|
|
152
154
|
*/
|
|
153
155
|
thinkingLevel?: ThinkingLevel;
|
|
156
|
+
/** Session routing key for remote compaction transports with sticky provider sessions. */
|
|
157
|
+
sessionId?: string;
|
|
158
|
+
/** Prompt-cache key for remote compaction transports that support provider prefix caching. */
|
|
159
|
+
promptCacheKey?: string;
|
|
160
|
+
/** Provider-visible tools for remote compaction transports that replay native tool history. */
|
|
161
|
+
tools?: Tool[];
|
|
154
162
|
/** Optional fetch implementation threaded into remote compaction calls. */
|
|
155
163
|
fetch?: FetchImpl;
|
|
156
164
|
}
|
|
@@ -229,7 +237,7 @@ export interface CompactionPreparation {
|
|
|
229
237
|
/** Compaction settions from settings.jsonl */
|
|
230
238
|
settings: CompactionSettings;
|
|
231
239
|
}
|
|
232
|
-
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | undefined;
|
|
240
|
+
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings, compactionModels?: readonly Model[]): CompactionPreparation | undefined;
|
|
233
241
|
/**
|
|
234
242
|
* Generate summaries for compaction using prepared data.
|
|
235
243
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -66,6 +66,13 @@ export interface LabelEntry extends SessionEntryBase {
|
|
|
66
66
|
targetId: string;
|
|
67
67
|
label: string | undefined;
|
|
68
68
|
}
|
|
69
|
+
export interface TitleChangeEntry extends SessionEntryBase {
|
|
70
|
+
type: "title_change";
|
|
71
|
+
title: string;
|
|
72
|
+
previousTitle?: string;
|
|
73
|
+
source: "auto" | "user";
|
|
74
|
+
trigger?: string;
|
|
75
|
+
}
|
|
69
76
|
export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
70
77
|
type: "ttsr_injection";
|
|
71
78
|
/** Names of rules that were injected */
|
|
@@ -96,7 +103,7 @@ export interface ModeChangeEntry extends SessionEntryBase {
|
|
|
96
103
|
}
|
|
97
104
|
export interface CustomCompactionSessionEntries {
|
|
98
105
|
}
|
|
99
|
-
export type SessionEntry = SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | ServiceTierChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry | TtsrInjectionEntry | MCPToolSelectionEntry | SessionInitEntry | ModeChangeEntry | CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries];
|
|
106
|
+
export type SessionEntry = SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | ServiceTierChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry | TitleChangeEntry | TtsrInjectionEntry | MCPToolSelectionEntry | SessionInitEntry | ModeChangeEntry | CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries];
|
|
100
107
|
export interface ReadonlySessionManager {
|
|
101
108
|
getBranch(leafId?: string | null): SessionEntry[];
|
|
102
109
|
getEntry(id: string): SessionEntry | undefined;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remote compaction utilities.
|
|
3
3
|
*
|
|
4
|
-
* Provider-side conversation summarization endpoints.
|
|
4
|
+
* Provider-side conversation summarization endpoints. Three flavors:
|
|
5
5
|
*
|
|
6
|
-
* - **OpenAI remote compaction** (
|
|
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
|
|
7
10
|
* reasoning across compactions by submitting the full responses-API native
|
|
8
11
|
* history and storing the returned `compaction` / `compaction_summary`
|
|
9
12
|
* item in `preserveData` so future turns can replay the encrypted state.
|
|
@@ -12,6 +15,7 @@
|
|
|
12
15
|
* with `{ summary, shortSummary? }`.
|
|
13
16
|
*/
|
|
14
17
|
import type { FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
|
|
18
|
+
export * from "./compaction-v2-streaming";
|
|
15
19
|
export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
16
20
|
/**
|
|
17
21
|
* Hard ceiling on remote compaction HTTP requests. Unlike every provider
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.3",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.2.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.2.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.2.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.2.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.2.3",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.2.3",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.2.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.2.3",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.2.3",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.2.3",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Compaction V2: streaming Responses compaction.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Codex `core/src/compact_remote_v2.rs`: append a `compaction_trigger`
|
|
5
|
+
* input item to the normal Responses stream, require exactly one streamed
|
|
6
|
+
* compaction output item, then install retained real user messages plus that
|
|
7
|
+
* compaction item as replacement history.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Api, FetchImpl, Model } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import { isTransientStatus, ProviderHttpError } from "@oh-my-pi/pi-ai/error";
|
|
12
|
+
import {
|
|
13
|
+
getOpenAIResponsesPromptCacheKey,
|
|
14
|
+
getOpenAIResponsesRoutingSessionId,
|
|
15
|
+
parseAzureDeploymentNameMap,
|
|
16
|
+
resolveOpenAIRequestSetup,
|
|
17
|
+
} from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
18
|
+
import {
|
|
19
|
+
CODEX_BASE_URL,
|
|
20
|
+
getCodexAccountId,
|
|
21
|
+
OPENAI_HEADER_VALUES,
|
|
22
|
+
OPENAI_HEADERS,
|
|
23
|
+
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
24
|
+
import { $env, logger } from "@oh-my-pi/pi-utils";
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Types & Configuration
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Retained-message budget Codex uses after streamed V2 compaction. */
|
|
31
|
+
export const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
|
|
32
|
+
|
|
33
|
+
/** Max retries for V2 streaming compaction on transient stream errors. */
|
|
34
|
+
export const V2_COMPACTION_MAX_RETRIES = 2;
|
|
35
|
+
|
|
36
|
+
/** Timeout for V2 streaming compaction (3 minutes, same as V1). */
|
|
37
|
+
export const V2_COMPACTION_TIMEOUT_MS = 180_000;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
40
|
+
const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
41
|
+
const COMPACTION_TRIGGER_ITEM = { type: "compaction_trigger" } as const;
|
|
42
|
+
// OpenAI image metering depends on detail and dimensions; charge the common
|
|
43
|
+
// high-detail 1024px-path budget so retained image history cannot be unbounded.
|
|
44
|
+
const IMAGE_TOKEN_ESTIMATE = 765;
|
|
45
|
+
const CONTEXTUAL_USER_PREFIXES = [
|
|
46
|
+
"<environment_context>",
|
|
47
|
+
"<user_instructions>",
|
|
48
|
+
"<additional_context>",
|
|
49
|
+
"<skills",
|
|
50
|
+
"<token_budget>",
|
|
51
|
+
"<model_switch>",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Token usage reported by the streamed V2 Responses completion. */
|
|
55
|
+
export interface CompactionV2Usage {
|
|
56
|
+
inputTokens: number;
|
|
57
|
+
outputTokens: number;
|
|
58
|
+
totalTokens: number;
|
|
59
|
+
cachedInputTokens?: number;
|
|
60
|
+
reasoningOutputTokens?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Request body fields needed for Responses-stream V2 compaction. */
|
|
64
|
+
export interface CompactionV2Request {
|
|
65
|
+
model: string;
|
|
66
|
+
input: unknown[];
|
|
67
|
+
instructions: string;
|
|
68
|
+
retainedMessageBudget: number;
|
|
69
|
+
tools?: unknown[];
|
|
70
|
+
/** Responses reasoning param (effort + summary), matching a normal turn; omitted for non-reasoning models. */
|
|
71
|
+
reasoning?: { effort: string; summary: string };
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
promptCacheKey?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Response collected from the V2 stream and converted into replacement history. */
|
|
77
|
+
export interface CompactionV2Response {
|
|
78
|
+
compactionItem: Record<string, unknown>;
|
|
79
|
+
replacementHistory: Array<Record<string, unknown>>;
|
|
80
|
+
usedTokens: number;
|
|
81
|
+
usage?: CompactionV2Usage;
|
|
82
|
+
retainedImageCount: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Endpoint Resolution
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
/** Resolve the streaming Responses endpoint for a V2-capable model. */
|
|
90
|
+
export function getCompactionV2Endpoint(model: Model): string | undefined {
|
|
91
|
+
if (model.remoteCompaction?.enabled === false) return undefined;
|
|
92
|
+
if (!isOpenAiV2CompatibleModel(model)) return undefined;
|
|
93
|
+
|
|
94
|
+
const configuredEndpoint = model.remoteCompaction?.v2Endpoint ?? model.remoteCompaction?.streamingEndpoint;
|
|
95
|
+
if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
|
|
96
|
+
|
|
97
|
+
const api = compactionV2Api(model);
|
|
98
|
+
if (api === "azure-openai-responses") {
|
|
99
|
+
return appendAzureApiVersion(`${resolveAzureOpenAiBaseUrl(model)}/responses`);
|
|
100
|
+
}
|
|
101
|
+
if (api === "openai-codex-responses" || model.provider === "openai-codex") {
|
|
102
|
+
return resolveOpenAiCodexResponsesEndpoint(model.baseUrl);
|
|
103
|
+
}
|
|
104
|
+
return resolveOpenAiResponsesEndpoint(model.baseUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Check whether a model can use streaming V2 compaction. */
|
|
108
|
+
export function shouldUseCompactionV2Streaming(
|
|
109
|
+
model: Model,
|
|
110
|
+
): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses"> {
|
|
111
|
+
if (model.remoteCompaction?.v2StreamingEnabled !== true) return false;
|
|
112
|
+
return getCompactionV2Endpoint(model) !== undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compactionV2Api(model: Model): Api | undefined {
|
|
116
|
+
return model.remoteCompaction?.api ?? model.api;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isOpenAiV2CompatibleModel(model: Model): boolean {
|
|
120
|
+
const api = compactionV2Api(model);
|
|
121
|
+
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveOpenAiResponsesEndpoint(baseUrl: string | undefined): string {
|
|
125
|
+
const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : "https://api.openai.com/v1";
|
|
126
|
+
const normalizedBase = rawBase.replace(/\/+$/, "");
|
|
127
|
+
if (normalizedBase.endsWith("/responses")) return normalizedBase;
|
|
128
|
+
if (normalizedBase.endsWith("/v1")) return `${normalizedBase}/responses`;
|
|
129
|
+
return `${normalizedBase}/v1/responses`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveOpenAiCodexResponsesEndpoint(baseUrl: string | undefined): string {
|
|
133
|
+
const rawBase = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
|
|
134
|
+
const normalizedBase = rawBase.replace(/\/+$/, "");
|
|
135
|
+
if (normalizedBase.endsWith("/codex/responses")) return normalizedBase;
|
|
136
|
+
if (normalizedBase.endsWith("/codex")) return `${normalizedBase}/responses`;
|
|
137
|
+
return `${normalizedBase}/codex/responses`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
141
|
+
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
142
|
+
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
143
|
+
const resolvedBaseUrl =
|
|
144
|
+
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
145
|
+
if (!resolvedBaseUrl) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function appendAzureApiVersion(endpoint: string): string {
|
|
154
|
+
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
155
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
156
|
+
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveCompactionV2Model(model: Model): string {
|
|
160
|
+
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
|
161
|
+
if (compactionV2Api(model) !== "azure-openai-responses") return requestModel;
|
|
162
|
+
const mappedDeployment = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(requestModel);
|
|
163
|
+
return mappedDeployment ?? requestModel;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// Request Building
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
|
|
171
|
+
export function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number {
|
|
172
|
+
if (value === undefined || !Number.isFinite(value)) return V2_RETAINED_MESSAGE_TOKEN_BUDGET;
|
|
173
|
+
return Math.min(V2_RETAINED_MESSAGE_TOKEN_BUDGET, Math.max(1, Math.floor(value)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Build a V2 streaming compaction request from Responses-native history. */
|
|
177
|
+
export function buildCompactionV2Request(
|
|
178
|
+
model: Model,
|
|
179
|
+
input: unknown[],
|
|
180
|
+
instructions: string,
|
|
181
|
+
options?: {
|
|
182
|
+
tools?: unknown[];
|
|
183
|
+
reasoning?: { effort: string; summary: string };
|
|
184
|
+
sessionId?: string;
|
|
185
|
+
promptCacheKey?: string;
|
|
186
|
+
retainedMessageBudget?: number;
|
|
187
|
+
},
|
|
188
|
+
): CompactionV2Request {
|
|
189
|
+
return {
|
|
190
|
+
model: resolveCompactionV2Model(model),
|
|
191
|
+
input,
|
|
192
|
+
instructions,
|
|
193
|
+
retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
|
|
194
|
+
reasoning: options?.reasoning,
|
|
195
|
+
tools: options?.tools,
|
|
196
|
+
sessionId: options?.sessionId,
|
|
197
|
+
promptCacheKey: options?.promptCacheKey,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// Streaming Request Handler
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
/** Race the caller's signal against the V2 request timeout. */
|
|
206
|
+
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
207
|
+
if (timeoutMs <= 0) return signal;
|
|
208
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
209
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
|
|
213
|
+
export async function requestCompactionV2Streaming(
|
|
214
|
+
model: Model,
|
|
215
|
+
apiKey: string,
|
|
216
|
+
request: CompactionV2Request,
|
|
217
|
+
signal?: AbortSignal,
|
|
218
|
+
options?: {
|
|
219
|
+
fetch?: FetchImpl;
|
|
220
|
+
timeoutMs?: number;
|
|
221
|
+
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
222
|
+
},
|
|
223
|
+
): Promise<CompactionV2Response> {
|
|
224
|
+
const endpoint = getCompactionV2Endpoint(model);
|
|
225
|
+
if (!endpoint) {
|
|
226
|
+
throw new Error(`Model ${model.id} does not support V2 streaming compaction`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch;
|
|
230
|
+
const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
|
|
231
|
+
let lastError: Error | undefined;
|
|
232
|
+
|
|
233
|
+
for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
|
|
234
|
+
const timeoutSignal = withRequestTimeout(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
|
|
235
|
+
try {
|
|
236
|
+
return await attemptCompactionV2Streaming(endpoint, apiKey, model, request, fetchImpl, timeoutSignal);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
239
|
+
if (signal?.aborted) throw error;
|
|
240
|
+
|
|
241
|
+
if (isRetryableCompactionError(error) && attempt < V2_COMPACTION_MAX_RETRIES) {
|
|
242
|
+
lastError = error;
|
|
243
|
+
const backoffMs = 2 ** attempt * 1000;
|
|
244
|
+
logger.warn(`V2 compaction attempt ${attempt + 1} failed, retrying in ${backoffMs}ms`, {
|
|
245
|
+
error: error.message,
|
|
246
|
+
model: model.id,
|
|
247
|
+
});
|
|
248
|
+
await retryWait(backoffMs, signal);
|
|
249
|
+
if (signal?.aborted) throw error;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
throw lastError ?? new Error("V2 compaction failed after max retries");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function attemptCompactionV2Streaming(
|
|
261
|
+
endpoint: string,
|
|
262
|
+
apiKey: string,
|
|
263
|
+
model: Model,
|
|
264
|
+
request: CompactionV2Request,
|
|
265
|
+
fetchImpl: FetchImpl,
|
|
266
|
+
signal?: AbortSignal,
|
|
267
|
+
): Promise<CompactionV2Response> {
|
|
268
|
+
// Faithful to Codex: append the compaction trigger as the final input item
|
|
269
|
+
// of an otherwise-normal Responses request, then stream the result. `store`
|
|
270
|
+
// stays false — compaction must never persist a server-side response object.
|
|
271
|
+
const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
|
|
272
|
+
const promptCacheKey = getOpenAIResponsesPromptCacheKey(cacheOptions);
|
|
273
|
+
const body: Record<string, unknown> = {
|
|
274
|
+
model: request.model,
|
|
275
|
+
input: [...request.input, COMPACTION_TRIGGER_ITEM],
|
|
276
|
+
instructions: request.instructions,
|
|
277
|
+
stream: true,
|
|
278
|
+
store: false,
|
|
279
|
+
...(request.reasoning ? { reasoning: request.reasoning, include: ["reasoning.encrypted_content"] } : {}),
|
|
280
|
+
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
|
281
|
+
...(request.tools && request.tools.length > 0 ? { tools: request.tools, tool_choice: "auto" } : {}),
|
|
282
|
+
};
|
|
283
|
+
const response = await fetchImpl(endpoint, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: buildCompactionV2Headers(model, apiKey, request),
|
|
286
|
+
body: JSON.stringify(body),
|
|
287
|
+
signal,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
const errorText = await response.text().catch(() => "");
|
|
292
|
+
logger.warn("V2 remote compaction failed", {
|
|
293
|
+
endpoint,
|
|
294
|
+
status: response.status,
|
|
295
|
+
statusText: response.statusText,
|
|
296
|
+
errorText,
|
|
297
|
+
});
|
|
298
|
+
throw new ProviderHttpError(
|
|
299
|
+
`V2 remote compaction failed (${response.status} ${response.statusText})`,
|
|
300
|
+
response.status,
|
|
301
|
+
{
|
|
302
|
+
headers: response.headers,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return collectCompactionV2Output(response, request);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildCompactionV2Headers(model: Model, apiKey: string, request: CompactionV2Request): Record<string, string> {
|
|
311
|
+
const api = compactionV2Api(model);
|
|
312
|
+
const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
|
|
313
|
+
const routingSessionId = getOpenAIResponsesRoutingSessionId(cacheOptions);
|
|
314
|
+
const promptCacheSessionId = getOpenAIResponsesPromptCacheKey(cacheOptions);
|
|
315
|
+
const headers: Record<string, string> =
|
|
316
|
+
api === "azure-openai-responses"
|
|
317
|
+
? {
|
|
318
|
+
"content-type": "application/json",
|
|
319
|
+
"api-key": apiKey,
|
|
320
|
+
...(model.headers ?? {}),
|
|
321
|
+
}
|
|
322
|
+
: {
|
|
323
|
+
"content-type": "application/json",
|
|
324
|
+
...resolveOpenAIRequestSetup(
|
|
325
|
+
{ provider: model.provider, id: model.id, baseUrl: model.baseUrl, headers: model.headers },
|
|
326
|
+
{ apiKey, messages: [], openAISessionId: routingSessionId, promptCacheSessionId },
|
|
327
|
+
).headers,
|
|
328
|
+
};
|
|
329
|
+
if (api === "openai-codex-responses" || model.provider === "openai-codex") {
|
|
330
|
+
const accountId = getCodexAccountId(apiKey);
|
|
331
|
+
if (accountId) {
|
|
332
|
+
headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
|
|
333
|
+
}
|
|
334
|
+
if (routingSessionId) {
|
|
335
|
+
headers[OPENAI_HEADERS.CONVERSATION_ID] = routingSessionId;
|
|
336
|
+
headers[OPENAI_HEADERS.SESSION_ID] = routingSessionId;
|
|
337
|
+
headers["x-client-request-id"] = routingSessionId;
|
|
338
|
+
}
|
|
339
|
+
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
340
|
+
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return headers;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function collectCompactionV2Output(
|
|
347
|
+
response: Response,
|
|
348
|
+
request: CompactionV2Request,
|
|
349
|
+
): Promise<CompactionV2Response> {
|
|
350
|
+
const reader = response.body?.getReader();
|
|
351
|
+
if (!reader) {
|
|
352
|
+
throw new Error("No response body for V2 compaction streaming");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const state = {
|
|
356
|
+
outputItemCount: 0,
|
|
357
|
+
compactionItems: [] as Array<Record<string, unknown>>,
|
|
358
|
+
sawCompleted: false,
|
|
359
|
+
usage: undefined as CompactionV2Usage | undefined,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const decoder = new TextDecoder();
|
|
364
|
+
let buffer = "";
|
|
365
|
+
let eventName: string | undefined;
|
|
366
|
+
let dataLines: string[] = [];
|
|
367
|
+
|
|
368
|
+
const dispatch = (): void => {
|
|
369
|
+
if (dataLines.length === 0) {
|
|
370
|
+
eventName = undefined;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
handleCompactionV2SseEvent(dataLines.join("\n"), eventName, state);
|
|
374
|
+
eventName = undefined;
|
|
375
|
+
dataLines = [];
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
while (true) {
|
|
379
|
+
const { done, value } = await reader.read();
|
|
380
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
381
|
+
let lineEnd = buffer.indexOf("\n");
|
|
382
|
+
while (lineEnd >= 0) {
|
|
383
|
+
const rawLine = buffer.slice(0, lineEnd).replace(/\r$/, "");
|
|
384
|
+
buffer = buffer.slice(lineEnd + 1);
|
|
385
|
+
if (rawLine === "") {
|
|
386
|
+
dispatch();
|
|
387
|
+
} else if (rawLine.startsWith("event:")) {
|
|
388
|
+
eventName = rawLine.slice("event:".length).trim();
|
|
389
|
+
} else if (rawLine.startsWith("data:")) {
|
|
390
|
+
dataLines.push(rawLine.slice("data:".length).trimStart());
|
|
391
|
+
}
|
|
392
|
+
lineEnd = buffer.indexOf("\n");
|
|
393
|
+
}
|
|
394
|
+
if (done) break;
|
|
395
|
+
}
|
|
396
|
+
if (buffer.length > 0) {
|
|
397
|
+
if (buffer.startsWith("data:")) {
|
|
398
|
+
dataLines.push(buffer.slice("data:".length).trimStart());
|
|
399
|
+
} else if (buffer.startsWith("event:")) {
|
|
400
|
+
eventName = buffer.slice("event:".length).trim();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
dispatch();
|
|
404
|
+
} finally {
|
|
405
|
+
reader.releaseLock();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!state.sawCompleted) {
|
|
409
|
+
throw new Error("V2 compaction stream closed before response.completed");
|
|
410
|
+
}
|
|
411
|
+
if (state.compactionItems.length !== 1) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`V2 compaction expected exactly one compaction output item, got ${state.compactionItems.length} from ${state.outputItemCount} output items`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const compactionItem = state.compactionItems[0];
|
|
418
|
+
const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
|
|
419
|
+
request.input,
|
|
420
|
+
compactionItem,
|
|
421
|
+
request.retainedMessageBudget,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
compactionItem,
|
|
426
|
+
replacementHistory,
|
|
427
|
+
usedTokens: state.usage?.inputTokens ?? 0,
|
|
428
|
+
usage: state.usage,
|
|
429
|
+
retainedImageCount,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function handleCompactionV2SseEvent(
|
|
434
|
+
data: string,
|
|
435
|
+
eventName: string | undefined,
|
|
436
|
+
state: {
|
|
437
|
+
outputItemCount: number;
|
|
438
|
+
compactionItems: Array<Record<string, unknown>>;
|
|
439
|
+
sawCompleted: boolean;
|
|
440
|
+
usage: CompactionV2Usage | undefined;
|
|
441
|
+
},
|
|
442
|
+
): void {
|
|
443
|
+
if (data === "[DONE]") return;
|
|
444
|
+
let event: Record<string, unknown>;
|
|
445
|
+
try {
|
|
446
|
+
event = JSON.parse(data) as Record<string, unknown>;
|
|
447
|
+
} catch (err) {
|
|
448
|
+
throw new Error(`V2 compaction stream parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const type = typeof event.type === "string" ? event.type : eventName;
|
|
452
|
+
if (type === "response.output_item.done") {
|
|
453
|
+
state.outputItemCount++;
|
|
454
|
+
const item = event.item;
|
|
455
|
+
if (isRecord(item) && item.type === "compaction") {
|
|
456
|
+
state.compactionItems.push(item);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (type === "response.completed" || type === "response.done") {
|
|
462
|
+
state.sawCompleted = true;
|
|
463
|
+
state.usage = parseCompactionV2Usage(event);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (type === "response.failed" || type === "response.incomplete") {
|
|
468
|
+
throw new Error(formatCompactionV2Failure(event, type));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parseCompactionV2Usage(event: Record<string, unknown>): CompactionV2Usage | undefined {
|
|
473
|
+
const response = isRecord(event.response) ? event.response : undefined;
|
|
474
|
+
const usage = response && isRecord(response.usage) ? response.usage : undefined;
|
|
475
|
+
if (!usage) return undefined;
|
|
476
|
+
|
|
477
|
+
const inputTokens = numberField(usage, "input_tokens");
|
|
478
|
+
const outputTokens = numberField(usage, "output_tokens");
|
|
479
|
+
const totalTokens = numberField(usage, "total_tokens");
|
|
480
|
+
if (inputTokens === undefined || outputTokens === undefined || totalTokens === undefined) return undefined;
|
|
481
|
+
|
|
482
|
+
const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
|
|
483
|
+
const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
|
|
484
|
+
const cachedInputTokens = inputDetails ? numberField(inputDetails, "cached_tokens") : undefined;
|
|
485
|
+
const reasoningOutputTokens = outputDetails ? numberField(outputDetails, "reasoning_tokens") : undefined;
|
|
486
|
+
return {
|
|
487
|
+
inputTokens,
|
|
488
|
+
outputTokens,
|
|
489
|
+
totalTokens,
|
|
490
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
491
|
+
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function formatCompactionV2Failure(event: Record<string, unknown>, type: string): string {
|
|
496
|
+
const response = isRecord(event.response) ? event.response : undefined;
|
|
497
|
+
const error = isRecord(event.error)
|
|
498
|
+
? event.error
|
|
499
|
+
: response && isRecord(response.error)
|
|
500
|
+
? response.error
|
|
501
|
+
: undefined;
|
|
502
|
+
const message = error ? stringField(error, "message") : undefined;
|
|
503
|
+
const code = error ? (stringField(error, "code") ?? stringField(error, "type")) : undefined;
|
|
504
|
+
return `V2 compaction stream ${type}${code ? ` (${code})` : ""}${message ? `: ${message}` : ""}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isRetryableCompactionError(error: Error): boolean {
|
|
508
|
+
if (
|
|
509
|
+
error.name === "AbortError" ||
|
|
510
|
+
error.name === "TimeoutError" ||
|
|
511
|
+
error.message.toLowerCase().includes("timeout")
|
|
512
|
+
) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
if (error instanceof ProviderHttpError) {
|
|
516
|
+
return isTransientStatus(error.status);
|
|
517
|
+
}
|
|
518
|
+
const message = error.message.toLowerCase();
|
|
519
|
+
return (
|
|
520
|
+
message.includes("stream closed before response.completed") ||
|
|
521
|
+
message.includes("stream parse failed") ||
|
|
522
|
+
message.includes("server_error") ||
|
|
523
|
+
message.includes("internal_error") ||
|
|
524
|
+
message.includes("overloaded") ||
|
|
525
|
+
message.includes("service unavailable")
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ============================================================================
|
|
530
|
+
// Replacement History
|
|
531
|
+
// ============================================================================
|
|
532
|
+
|
|
533
|
+
/** Build Codex-style V2 replacement history from prompt input plus compaction output. */
|
|
534
|
+
export function buildCompactionV2ReplacementHistory(
|
|
535
|
+
input: unknown[],
|
|
536
|
+
compactionItem: Record<string, unknown>,
|
|
537
|
+
retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
538
|
+
): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
|
|
539
|
+
const retained = input.filter(
|
|
540
|
+
(item): item is Record<string, unknown> =>
|
|
541
|
+
isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
|
|
542
|
+
);
|
|
543
|
+
const replacementHistory = truncateRetainedMessagesForCompactionV2(
|
|
544
|
+
retained,
|
|
545
|
+
resolveCompactionV2RetainedMessageBudget(retainedMessageBudget),
|
|
546
|
+
);
|
|
547
|
+
const retainedImageCount = replacementHistory.reduce((count, item) => count + retainedInputImageCount(item), 0);
|
|
548
|
+
replacementHistory.push(compactionItem);
|
|
549
|
+
return { replacementHistory, retainedImageCount };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function isRetainedForCompactionV2(item: Record<string, unknown>): boolean {
|
|
553
|
+
if (item.type !== "message") return false;
|
|
554
|
+
const role = stringField(item, "role");
|
|
555
|
+
return role === "user" || role === "developer" || role === "system";
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function shouldKeepCompactionV2HistoryItem(item: Record<string, unknown>): boolean {
|
|
559
|
+
if (item.type !== "message") return item.type === "compaction";
|
|
560
|
+
const role = stringField(item, "role");
|
|
561
|
+
if (role !== "user") return false;
|
|
562
|
+
return !isContextualUserMessage(item);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isContextualUserMessage(item: Record<string, unknown>): boolean {
|
|
566
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
567
|
+
return content.some(part => {
|
|
568
|
+
if (!isRecord(part) || part.type !== "input_text") return false;
|
|
569
|
+
const text = stringField(part, "text")?.trimStart().toLowerCase();
|
|
570
|
+
return !!text && CONTEXTUAL_USER_PREFIXES.some(prefix => text.startsWith(prefix));
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function retainedInputImageCount(item: Record<string, unknown>): number {
|
|
575
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
576
|
+
let count = 0;
|
|
577
|
+
for (const part of content) {
|
|
578
|
+
if (isRecord(part) && part.type === "input_image") count++;
|
|
579
|
+
}
|
|
580
|
+
return count;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function truncateRetainedMessagesForCompactionV2(
|
|
584
|
+
items: Array<Record<string, unknown>>,
|
|
585
|
+
maxTokens: number,
|
|
586
|
+
): Array<Record<string, unknown>> {
|
|
587
|
+
let remaining = maxTokens;
|
|
588
|
+
const truncatedReversed: Array<Record<string, unknown>> = [];
|
|
589
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
590
|
+
if (remaining === 0) continue;
|
|
591
|
+
const item = items[i];
|
|
592
|
+
const tokenCount = Math.max(messageContentTokenCount(item), 1);
|
|
593
|
+
if (tokenCount <= remaining) {
|
|
594
|
+
truncatedReversed.push(item);
|
|
595
|
+
remaining = Math.max(0, remaining - tokenCount);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const truncatedItem = truncateMessageTextToTokenBudget(item, remaining);
|
|
600
|
+
if (truncatedItem) {
|
|
601
|
+
truncatedReversed.push(truncatedItem);
|
|
602
|
+
remaining = 0;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
truncatedReversed.reverse();
|
|
606
|
+
return truncatedReversed;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function messageContentTokenCount(item: Record<string, unknown>): number {
|
|
610
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
611
|
+
let tokens = 0;
|
|
612
|
+
for (const part of content) {
|
|
613
|
+
if (!isRecord(part)) continue;
|
|
614
|
+
if (part.type === "input_image") {
|
|
615
|
+
tokens += IMAGE_TOKEN_ESTIMATE;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (part.type === "input_text" || part.type === "output_text") {
|
|
619
|
+
tokens += approxTokenCount(stringField(part, "text") ?? "");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return tokens;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function truncateMessageTextToTokenBudget(
|
|
626
|
+
item: Record<string, unknown>,
|
|
627
|
+
maxTokens: number,
|
|
628
|
+
): Record<string, unknown> | undefined {
|
|
629
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
630
|
+
let remaining = maxTokens;
|
|
631
|
+
const truncatedContent: unknown[] = [];
|
|
632
|
+
for (const part of content) {
|
|
633
|
+
if (!isRecord(part)) continue;
|
|
634
|
+
if (part.type === "input_image") {
|
|
635
|
+
if (remaining < IMAGE_TOKEN_ESTIMATE) continue;
|
|
636
|
+
truncatedContent.push(part);
|
|
637
|
+
remaining = Math.max(0, remaining - IMAGE_TOKEN_ESTIMATE);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (part.type !== "input_text" && part.type !== "output_text") continue;
|
|
641
|
+
if (remaining === 0) continue;
|
|
642
|
+
|
|
643
|
+
const text = stringField(part, "text") ?? "";
|
|
644
|
+
const tokenCount = approxTokenCount(text);
|
|
645
|
+
if (tokenCount <= remaining) {
|
|
646
|
+
truncatedContent.push(part);
|
|
647
|
+
remaining = Math.max(0, remaining - tokenCount);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const truncatedText = truncateTextToTokenBudget(text, remaining);
|
|
652
|
+
remaining = 0;
|
|
653
|
+
if (truncatedText.length > 0) {
|
|
654
|
+
truncatedContent.push({ ...part, text: truncatedText });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (truncatedContent.length === 0) return undefined;
|
|
659
|
+
return { ...item, content: truncatedContent };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function truncateTextToTokenBudget(text: string, maxTokens: number): string {
|
|
663
|
+
if (maxTokens <= 0) return "";
|
|
664
|
+
const maxChars = maxTokens * 4;
|
|
665
|
+
if (text.length <= maxChars) return text;
|
|
666
|
+
const omittedTokens = Math.max(1, approxTokenCount(text) - maxTokens);
|
|
667
|
+
const marker = `…${omittedTokens} tokens truncated…`;
|
|
668
|
+
if (maxChars <= marker.length + 2) return text.slice(0, maxChars);
|
|
669
|
+
const sideChars = Math.max(1, Math.floor((maxChars - marker.length) / 2));
|
|
670
|
+
return `${text.slice(0, sideChars)}${marker}${text.slice(-sideChars)}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function approxTokenCount(text: string): number {
|
|
674
|
+
return Math.ceil(text.length / 4);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ============================================================================
|
|
678
|
+
// Preserve Data
|
|
679
|
+
// ============================================================================
|
|
680
|
+
|
|
681
|
+
/** Store V2 replacement history in the OpenAI remote-compaction preserve slot. */
|
|
682
|
+
export function storeCompactionV2PreserveData(response: CompactionV2Response, model: Model): Record<string, unknown> {
|
|
683
|
+
return {
|
|
684
|
+
[OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: {
|
|
685
|
+
version: "v2",
|
|
686
|
+
provider: model.provider,
|
|
687
|
+
replacementHistory: response.replacementHistory,
|
|
688
|
+
usedTokens: response.usedTokens,
|
|
689
|
+
usage: response.usage,
|
|
690
|
+
retainedImageCount: response.retainedImageCount,
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Retrieve preserved OpenAI replacement history that V2 can extend. */
|
|
696
|
+
export function getCompactionV2PreserveData(
|
|
697
|
+
preserveData: Record<string, unknown> | undefined,
|
|
698
|
+
): { provider: string; replacementHistory: Array<Record<string, unknown>>; usedTokens: number } | undefined {
|
|
699
|
+
const candidate = preserveData?.[OPENAI_REMOTE_COMPACTION_PRESERVE_KEY];
|
|
700
|
+
if (!isRecord(candidate)) return undefined;
|
|
701
|
+
const provider = stringField(candidate, "provider");
|
|
702
|
+
if (!provider) return undefined;
|
|
703
|
+
if (!Array.isArray(candidate.replacementHistory)) return undefined;
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
provider,
|
|
707
|
+
replacementHistory: candidate.replacementHistory as Array<Record<string, unknown>>,
|
|
708
|
+
usedTokens: numberField(candidate, "usedTokens") ?? 0,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
713
|
+
return !!value && typeof value === "object";
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function stringField(record: Record<string, unknown>, field: string): string | undefined {
|
|
717
|
+
const value = record[field];
|
|
718
|
+
return typeof value === "string" ? value : undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function numberField(record: Record<string, unknown>, field: string): number | undefined {
|
|
722
|
+
const value = record[field];
|
|
723
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
724
|
+
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
withAuth,
|
|
21
21
|
} from "@oh-my-pi/pi-ai";
|
|
22
22
|
import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
|
|
23
|
+
import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
|
|
24
|
+
import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
23
25
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
24
26
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
25
27
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
@@ -28,6 +30,14 @@ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
|
28
30
|
import { ThinkingLevel } from "../thinking";
|
|
29
31
|
import { countTokens } from "../tokenizer";
|
|
30
32
|
import type { AgentMessage } from "../types";
|
|
33
|
+
import {
|
|
34
|
+
buildCompactionV2Request,
|
|
35
|
+
getCompactionV2PreserveData,
|
|
36
|
+
requestCompactionV2Streaming,
|
|
37
|
+
shouldUseCompactionV2Streaming,
|
|
38
|
+
storeCompactionV2PreserveData,
|
|
39
|
+
V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
40
|
+
} from "./compaction-v2-streaming";
|
|
31
41
|
import type { CompactionEntry, SessionEntry } from "./entries";
|
|
32
42
|
import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
|
|
33
43
|
import {
|
|
@@ -155,6 +165,8 @@ export interface CompactionSettings {
|
|
|
155
165
|
autoContinue?: boolean;
|
|
156
166
|
remoteEnabled?: boolean;
|
|
157
167
|
remoteEndpoint?: string;
|
|
168
|
+
remoteStreamingV2Enabled?: boolean;
|
|
169
|
+
v2RetainedMessageBudget?: number;
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
@@ -167,6 +179,8 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
|
167
179
|
keepRecentTokens: 20000,
|
|
168
180
|
autoContinue: true,
|
|
169
181
|
remoteEnabled: true,
|
|
182
|
+
remoteStreamingV2Enabled: true,
|
|
183
|
+
v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
170
184
|
};
|
|
171
185
|
|
|
172
186
|
// ============================================================================
|
|
@@ -657,6 +671,12 @@ export interface SummaryOptions {
|
|
|
657
671
|
* `resolveCompactionEffort` for the conversion contract.
|
|
658
672
|
*/
|
|
659
673
|
thinkingLevel?: ThinkingLevel;
|
|
674
|
+
/** Session routing key for remote compaction transports with sticky provider sessions. */
|
|
675
|
+
sessionId?: string;
|
|
676
|
+
/** Prompt-cache key for remote compaction transports that support provider prefix caching. */
|
|
677
|
+
promptCacheKey?: string;
|
|
678
|
+
/** Provider-visible tools for remote compaction transports that replay native tool history. */
|
|
679
|
+
tools?: Tool[];
|
|
660
680
|
/** Optional fetch implementation threaded into remote compaction calls. */
|
|
661
681
|
fetch?: FetchImpl;
|
|
662
682
|
}
|
|
@@ -972,9 +992,34 @@ export interface CompactionPreparation {
|
|
|
972
992
|
settings: CompactionSettings;
|
|
973
993
|
}
|
|
974
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Whether a prior compaction's preserve data can be carried forward by the
|
|
997
|
+
* upcoming compaction. A local compaction (no remote preserve) always can — it
|
|
998
|
+
* holds a real textual summary. A remote compaction (V2 or V1) only can when
|
|
999
|
+
* some candidate model shares its provider AND remote replay is still enabled;
|
|
1000
|
+
* otherwise its provider-native replay is dead weight and only the opaque
|
|
1001
|
+
* placeholder summary survives, so the caller must re-expand the originals.
|
|
1002
|
+
*/
|
|
1003
|
+
function remotePreserveReusableByAny(
|
|
1004
|
+
preserveData: Record<string, unknown> | undefined,
|
|
1005
|
+
models: readonly Model[],
|
|
1006
|
+
settings: CompactionSettings,
|
|
1007
|
+
): boolean {
|
|
1008
|
+
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1009
|
+
if (!remote) return true;
|
|
1010
|
+
if (settings.remoteEnabled === false) return false;
|
|
1011
|
+
for (const model of models) {
|
|
1012
|
+
if (remote.provider !== model.provider) continue;
|
|
1013
|
+
const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
|
|
1014
|
+
if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
|
|
1015
|
+
}
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
975
1019
|
export function prepareCompaction(
|
|
976
1020
|
pathEntries: SessionEntry[],
|
|
977
1021
|
settings: CompactionSettings,
|
|
1022
|
+
compactionModels: readonly Model[] = [],
|
|
978
1023
|
): CompactionPreparation | undefined {
|
|
979
1024
|
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
|
980
1025
|
return undefined;
|
|
@@ -982,10 +1027,18 @@ export function prepareCompaction(
|
|
|
982
1027
|
|
|
983
1028
|
let prevCompactionIndex = -1;
|
|
984
1029
|
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
|
985
|
-
if (pathEntries[i].type
|
|
986
|
-
|
|
987
|
-
|
|
1030
|
+
if (pathEntries[i].type !== "compaction") continue;
|
|
1031
|
+
// Skip a prior remote compaction (V2 or V1) whose provider-native replay
|
|
1032
|
+
// none of the upcoming compaction candidates can reuse: its summary is only
|
|
1033
|
+
// an opaque placeholder, so re-expand its original messages and summarize
|
|
1034
|
+
// them locally rather than stranding that history. compact() still reuses it
|
|
1035
|
+
// when a candidate can (same provider, remote enabled).
|
|
1036
|
+
const entry = pathEntries[i] as CompactionEntry;
|
|
1037
|
+
if (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
|
|
1038
|
+
continue;
|
|
988
1039
|
}
|
|
1040
|
+
prevCompactionIndex = i;
|
|
1041
|
+
break;
|
|
989
1042
|
}
|
|
990
1043
|
const boundaryStart = prevCompactionIndex + 1;
|
|
991
1044
|
const boundaryEnd = pathEntries.length;
|
|
@@ -1079,6 +1132,49 @@ export function prepareCompaction(
|
|
|
1079
1132
|
|
|
1080
1133
|
const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt);
|
|
1081
1134
|
|
|
1135
|
+
function openAiCompatSupportsImageDetailOriginal(model: Model): boolean {
|
|
1136
|
+
const compat = model.compat;
|
|
1137
|
+
return !!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function buildOpenAiResponsesCompactionInput(
|
|
1141
|
+
messages: Message[],
|
|
1142
|
+
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
|
|
1143
|
+
previousReplacementHistory: Array<Record<string, unknown>> | undefined,
|
|
1144
|
+
): unknown[] {
|
|
1145
|
+
const input = buildResponsesInput({
|
|
1146
|
+
model,
|
|
1147
|
+
context: { messages },
|
|
1148
|
+
strictResponsesPairing: model.compat.strictResponsesPairing,
|
|
1149
|
+
supportsImageDetailOriginal: openAiCompatSupportsImageDetailOriginal(model),
|
|
1150
|
+
nativeHistory: { replay: true, filterReasoning: false },
|
|
1151
|
+
includeThinkingSignatures: true,
|
|
1152
|
+
repairOrphanOutputs: true,
|
|
1153
|
+
});
|
|
1154
|
+
return previousReplacementHistory ? [...previousReplacementHistory, ...input] : input;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Resolve the Responses `reasoning` param for a V2 compaction request the same
|
|
1159
|
+
* way a normal turn does — through {@link resolveOpenAICompatPolicy}, so it
|
|
1160
|
+
* honors per-model effort support, `omitReasoningEffort`, disable modes, and the
|
|
1161
|
+
* wire-effort mapping. Returns `undefined` for non-reasoning models or when the
|
|
1162
|
+
* user selected `Off` (matching the normal-turn omission, not a fabricated shape).
|
|
1163
|
+
*/
|
|
1164
|
+
function buildCompactionV2Reasoning(
|
|
1165
|
+
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
|
|
1166
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
1167
|
+
): { effort: string; summary: string } | undefined {
|
|
1168
|
+
const policy = resolveOpenAICompatPolicy(model, {
|
|
1169
|
+
endpoint: "responses",
|
|
1170
|
+
reasoning: resolveCompactionEffort(model, thinkingLevel),
|
|
1171
|
+
});
|
|
1172
|
+
const reasoning = policy.reasoning;
|
|
1173
|
+
if (!reasoning.modelSupported || reasoning.disabled || reasoning.omitReasoningEffort) return undefined;
|
|
1174
|
+
if (reasoning.requestedEffort === undefined) return undefined;
|
|
1175
|
+
return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1082
1178
|
/**
|
|
1083
1179
|
* Generate summaries for compaction using prepared data.
|
|
1084
1180
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -1122,6 +1218,9 @@ export async function compact(
|
|
|
1122
1218
|
// silently falls back to Effort.High — the same defect e07b47ee4 fixed
|
|
1123
1219
|
// at the call sites, leaked back in here. See resolveCompactionEffort.
|
|
1124
1220
|
thinkingLevel: options?.thinkingLevel,
|
|
1221
|
+
sessionId: options?.sessionId,
|
|
1222
|
+
promptCacheKey: options?.promptCacheKey,
|
|
1223
|
+
tools: options?.tools,
|
|
1125
1224
|
fetch: options?.fetch,
|
|
1126
1225
|
};
|
|
1127
1226
|
|
|
@@ -1138,18 +1237,74 @@ export async function compact(
|
|
|
1138
1237
|
: undefined;
|
|
1139
1238
|
|
|
1140
1239
|
let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1240
|
+
const remoteMessages: AgentMessage[] = [
|
|
1241
|
+
...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
|
|
1242
|
+
...messagesToSummarize,
|
|
1243
|
+
...turnPrefixMessages,
|
|
1244
|
+
...recentMessages,
|
|
1245
|
+
];
|
|
1246
|
+
let usedRemoteCompaction = false;
|
|
1247
|
+
if (
|
|
1248
|
+
settings.remoteEnabled !== false &&
|
|
1249
|
+
settings.remoteStreamingV2Enabled !== false &&
|
|
1250
|
+
shouldUseCompactionV2Streaming(model)
|
|
1251
|
+
) {
|
|
1252
|
+
const previousRemoteCompaction = getCompactionV2PreserveData(previousPreserveData);
|
|
1149
1253
|
const previousReplacementHistory =
|
|
1150
1254
|
previousRemoteCompaction?.provider === model.provider
|
|
1151
1255
|
? previousRemoteCompaction.replacementHistory
|
|
1152
1256
|
: undefined;
|
|
1257
|
+
const remoteHistory = buildOpenAiResponsesCompactionInput(
|
|
1258
|
+
(summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
|
|
1259
|
+
model,
|
|
1260
|
+
previousReplacementHistory,
|
|
1261
|
+
);
|
|
1262
|
+
if (remoteHistory.length > 0) {
|
|
1263
|
+
try {
|
|
1264
|
+
const request = buildCompactionV2Request(
|
|
1265
|
+
model,
|
|
1266
|
+
remoteHistory,
|
|
1267
|
+
summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
|
|
1268
|
+
{
|
|
1269
|
+
tools: summaryOptions.tools
|
|
1270
|
+
? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
|
|
1271
|
+
: undefined,
|
|
1272
|
+
reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
|
|
1273
|
+
sessionId: summaryOptions.sessionId,
|
|
1274
|
+
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1275
|
+
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1276
|
+
},
|
|
1277
|
+
);
|
|
1278
|
+
const remote = await withAuth(
|
|
1279
|
+
apiKey,
|
|
1280
|
+
key => requestCompactionV2Streaming(model, key, request, signal, { fetch: summaryOptions.fetch }),
|
|
1281
|
+
{ signal },
|
|
1282
|
+
);
|
|
1283
|
+
preserveData = { ...(preserveData ?? {}), ...storeCompactionV2PreserveData(remote, model) };
|
|
1284
|
+
usedRemoteCompaction = true;
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
// A user/session abort is a cancellation, not a remote failure —
|
|
1287
|
+
// swallowing it here would downgrade Esc into "fall back to local
|
|
1288
|
+
// summarization" and keep compaction running on an aborted signal.
|
|
1289
|
+
if (signal?.aborted) throw err;
|
|
1290
|
+
logger.warn("OpenAI V2 remote compaction failed, falling back to V1/local summarization", {
|
|
1291
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1292
|
+
model: model.id,
|
|
1293
|
+
provider: model.provider,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
|
|
1300
|
+
const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
|
|
1301
|
+
const previousV2Compaction = getCompactionV2PreserveData(previousPreserveData);
|
|
1302
|
+
const previousReplacementHistory =
|
|
1303
|
+
previousRemoteCompaction?.provider === model.provider
|
|
1304
|
+
? previousRemoteCompaction.replacementHistory
|
|
1305
|
+
: previousV2Compaction?.provider === model.provider
|
|
1306
|
+
? previousV2Compaction.replacementHistory
|
|
1307
|
+
: undefined;
|
|
1153
1308
|
const remoteHistory = buildOpenAiNativeHistory(
|
|
1154
1309
|
(summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
|
|
1155
1310
|
model,
|
|
@@ -1171,6 +1326,7 @@ export async function compact(
|
|
|
1171
1326
|
{ signal },
|
|
1172
1327
|
);
|
|
1173
1328
|
preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
|
|
1329
|
+
usedRemoteCompaction = true;
|
|
1174
1330
|
} catch (err) {
|
|
1175
1331
|
// A user/session abort is a cancellation, not a remote failure —
|
|
1176
1332
|
// swallowing it here would downgrade Esc into "fall back to local
|
|
@@ -1188,7 +1344,18 @@ export async function compact(
|
|
|
1188
1344
|
// Generate summaries (can be parallel if both needed) and merge into one
|
|
1189
1345
|
let summary: string;
|
|
1190
1346
|
|
|
1191
|
-
if (
|
|
1347
|
+
if (usedRemoteCompaction) {
|
|
1348
|
+
// Remote compaction (V2 or V1) already compacted remotely; the durable
|
|
1349
|
+
// history lives in the provider replay payload (preserveData). Skip local
|
|
1350
|
+
// summarization so a successful remote compaction never pays for a second,
|
|
1351
|
+
// redundant LLM round. If a LATER compaction cannot reuse this payload,
|
|
1352
|
+
// prepareCompaction re-expands the original messages and summarizes them
|
|
1353
|
+
// locally then (see remotePreserveReusableByAny).
|
|
1354
|
+
const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
|
|
1355
|
+
summary =
|
|
1356
|
+
"Remote compaction preserved provider-native history for this session." +
|
|
1357
|
+
(usedTokens > 0 ? ` Retained ${usedTokens} tokens in the provider replay payload.` : "");
|
|
1358
|
+
} else if (isSplitTurn && turnPrefixMessages.length > 0) {
|
|
1192
1359
|
// Generate both summaries in parallel
|
|
1193
1360
|
const [historyResult, turnPrefixResult] = await Promise.all([
|
|
1194
1361
|
messagesToSummarize.length > 0 || previousSummaryForCompaction
|
|
@@ -1227,25 +1394,19 @@ export async function compact(
|
|
|
1227
1394
|
summary = "No prior history.";
|
|
1228
1395
|
}
|
|
1229
1396
|
|
|
1230
|
-
const shortSummary =
|
|
1231
|
-
|
|
1232
|
-
summary,
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
// Same propagation as summaryOptions above — generateShortSummary
|
|
1244
|
-
// resolves its own reasoning via resolveCompactionEffort.
|
|
1245
|
-
thinkingLevel: options?.thinkingLevel,
|
|
1246
|
-
fetch: summaryOptions.fetch,
|
|
1247
|
-
},
|
|
1248
|
-
);
|
|
1397
|
+
const shortSummary = usedRemoteCompaction
|
|
1398
|
+
? "Remote compaction"
|
|
1399
|
+
: await generateShortSummary(recentMessages, summary, model, settings.reserveTokens, apiKey, signal, {
|
|
1400
|
+
extraContext: options?.extraContext,
|
|
1401
|
+
remoteEndpoint: summaryOptions.remoteEndpoint,
|
|
1402
|
+
initiatorOverride: summaryOptions.initiatorOverride,
|
|
1403
|
+
metadata: summaryOptions.metadata,
|
|
1404
|
+
telemetry: summaryOptions.telemetry,
|
|
1405
|
+
// Same propagation as summaryOptions above — generateShortSummary
|
|
1406
|
+
// resolves its own reasoning via resolveCompactionEffort.
|
|
1407
|
+
thinkingLevel: options?.thinkingLevel,
|
|
1408
|
+
fetch: summaryOptions.fetch,
|
|
1409
|
+
});
|
|
1249
1410
|
|
|
1250
1411
|
// Compute file lists and append to summary
|
|
1251
1412
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
@@ -77,6 +77,14 @@ export interface LabelEntry extends SessionEntryBase {
|
|
|
77
77
|
label: string | undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export interface TitleChangeEntry extends SessionEntryBase {
|
|
81
|
+
type: "title_change";
|
|
82
|
+
title: string;
|
|
83
|
+
previousTitle?: string;
|
|
84
|
+
source: "auto" | "user";
|
|
85
|
+
trigger?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
81
89
|
type: "ttsr_injection";
|
|
82
90
|
/** Names of rules that were injected */
|
|
@@ -121,6 +129,7 @@ export type SessionEntry =
|
|
|
121
129
|
| CustomEntry
|
|
122
130
|
| CustomMessageEntry
|
|
123
131
|
| LabelEntry
|
|
132
|
+
| TitleChangeEntry
|
|
124
133
|
| TtsrInjectionEntry
|
|
125
134
|
| MCPToolSelectionEntry
|
|
126
135
|
| SessionInitEntry
|
package/src/compaction/openai.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remote compaction utilities.
|
|
3
3
|
*
|
|
4
|
-
* Provider-side conversation summarization endpoints.
|
|
4
|
+
* Provider-side conversation summarization endpoints. Three flavors:
|
|
5
5
|
*
|
|
6
|
-
* - **OpenAI remote compaction** (
|
|
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
|
|
7
10
|
* reasoning across compactions by submitting the full responses-API native
|
|
8
11
|
* history and storing the returned `compaction` / `compaction_summary`
|
|
9
12
|
* item in `preserveData` so future turns can replay the encrypted state.
|
|
@@ -29,6 +32,8 @@ import {
|
|
|
29
32
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
30
33
|
import { $env, logger } from "@oh-my-pi/pi-utils";
|
|
31
34
|
|
|
35
|
+
export * from "./compaction-v2-streaming";
|
|
36
|
+
|
|
32
37
|
// ============================================================================
|
|
33
38
|
// Public types
|
|
34
39
|
// ============================================================================
|
|
@@ -214,57 +219,12 @@ export function withOpenAiRemoteCompactionPreserveData(
|
|
|
214
219
|
// Input/output filtering for OpenAI compact endpoint
|
|
215
220
|
// ============================================================================
|
|
216
221
|
|
|
217
|
-
function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolean {
|
|
218
|
-
return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
222
|
function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
|
|
222
223
|
if (item.type === "compaction" || item.type === "compaction_summary") return true;
|
|
223
224
|
if (item.type !== "message") return false;
|
|
224
225
|
return item.role === "assistant" || item.role === "user";
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
function trimOpenAiCompactInput(
|
|
228
|
-
input: Array<Record<string, unknown>>,
|
|
229
|
-
contextWindow: number,
|
|
230
|
-
instructions: string,
|
|
231
|
-
): Array<Record<string, unknown>> {
|
|
232
|
-
const trimmed = [...input];
|
|
233
|
-
// Per-item serialized sizes are cached and decremented on removal.
|
|
234
|
-
// Re-stringifying the whole input per popped item was O(N²) in total chars
|
|
235
|
-
// — hundreds of MB of stringify churn on a 200k-token codex history,
|
|
236
|
-
// blocking the event loop for seconds (same class as the addOpenAiCallIds
|
|
237
|
-
// fix above).
|
|
238
|
-
const sizes = trimmed.map(item => JSON.stringify(item).length);
|
|
239
|
-
let chars = instructions.length;
|
|
240
|
-
for (const size of sizes) chars += size;
|
|
241
|
-
const removeAt = (index: number): void => {
|
|
242
|
-
chars -= sizes[index] ?? 0;
|
|
243
|
-
trimmed.splice(index, 1);
|
|
244
|
-
sizes.splice(index, 1);
|
|
245
|
-
};
|
|
246
|
-
while (trimmed.length > 0 && Math.ceil(chars / 4) > contextWindow) {
|
|
247
|
-
const last = trimmed[trimmed.length - 1];
|
|
248
|
-
if (last?.type === "function_call_output" || last?.type === "custom_tool_call_output") {
|
|
249
|
-
const callId = typeof last.call_id === "string" ? last.call_id : undefined;
|
|
250
|
-
const callType = last.type === "custom_tool_call_output" ? "custom_tool_call" : "function_call";
|
|
251
|
-
removeAt(trimmed.length - 1);
|
|
252
|
-
if (callId) {
|
|
253
|
-
const matchingCallIndex = trimmed.findLastIndex(item => item.type === callType && item.call_id === callId);
|
|
254
|
-
if (matchingCallIndex >= 0) {
|
|
255
|
-
removeAt(matchingCallIndex);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
if (!last || !shouldTrimOpenAiCompactInputItem(last)) {
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
removeAt(trimmed.length - 1);
|
|
264
|
-
}
|
|
265
|
-
return trimmed;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
228
|
// Register every tool-call id in `items` (and the subset using the custom-tool
|
|
269
229
|
// wire shape) into the running sets. The history builder maintains both sets
|
|
270
230
|
// incrementally as native history is appended, so this only scans the
|
|
@@ -506,7 +466,10 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
506
466
|
const requestModel = resolveOpenAiCompactModel(model);
|
|
507
467
|
const request: OpenAiRemoteCompactionRequest = {
|
|
508
468
|
model: requestModel,
|
|
509
|
-
|
|
469
|
+
// Send full history to the endpoint - don't trim locally.
|
|
470
|
+
// The provider handles compression via the compaction endpoint.
|
|
471
|
+
// Trimming before sending loses assistant messages and thinking blocks.
|
|
472
|
+
input: compactInput,
|
|
510
473
|
instructions,
|
|
511
474
|
};
|
|
512
475
|
const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
|