@oh-my-pi/pi-agent-core 18.12.5 → 18.13.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.
- package/CHANGELOG.md +19 -0
- package/dist/types/agent.d.ts +2 -2
- package/dist/types/compaction/compaction-v2-streaming.d.ts +10 -0
- package/dist/types/compaction/compaction.d.ts +20 -1
- package/dist/types/compaction/openai.d.ts +16 -2
- package/dist/types/types.d.ts +7 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +14 -10
- package/src/agent.ts +6 -3
- package/src/compaction/compaction-v2-streaming.ts +33 -4
- package/src/compaction/compaction.ts +145 -54
- package/src/compaction/openai.ts +52 -17
- package/src/types.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.13.0] - 2026-10-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `SummaryOptions.remotePromptCacheKey` and `remoteCompactionPolicyModel()` so remote compaction can carry a cache key gated against the request it actually sends ([#16988](https://github.com/can1357/oh-my-pi/pull/16988) by [@rafalopilowski1](https://github.com/rafalopilowski1))
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Remote compaction now sends cache and session headers for the endpoint and model the request actually uses, so a configured compaction override no longer inherits the live turn's routing ([#16988](https://github.com/can1357/oh-my-pi/pull/16988) by [@rafalopilowski1](https://github.com/rafalopilowski1))
|
|
14
|
+
- Aside providers (`AgentLoopConfig.getAsideMessages`, `Agent.setAsideMessageProvider`) now receive which boundary is polled, `"work"` after tool calls or `"stop"` at the end of a turn, so they can keep a message out of a finished turn ([#16583](https://github.com/can1357/oh-my-pi/pull/16583) by [@will-bogusz](https://github.com/will-bogusz))
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed `--max-time` values above about 596 hours (such as `600h`) aborting the first turn with "Deadline exceeded" right away ([#17054](https://github.com/can1357/oh-my-pi/pull/17054) by [@trukhinyuri](https://github.com/trukhinyuri))
|
|
19
|
+
- Fixed repeated compaction on small or provider-capped context windows: compaction keeps only as much recent history as fits under the configured trigger, and no longer counts stale pre-compaction usage ([#16904](https://github.com/can1357/oh-my-pi/pull/16904) by [@eggpeat](https://github.com/eggpeat))
|
|
20
|
+
- Fixed native compaction token estimates counting opaque encrypted-state bytes as conversation text ([#16904](https://github.com/can1357/oh-my-pi/pull/16904) by [@eggpeat](https://github.com/eggpeat))
|
|
21
|
+
- Fixed split-turn compaction leaving `session.compacting` context out of the turn-prefix summary ([#16707](https://github.com/can1357/oh-my-pi/pull/16707) by [@integrITsolutions](https://github.com/integrITsolutions))
|
|
22
|
+
- Fixed compaction failing as a whole, and discarding the completed summary, when the optional short summary fails or returns no answer; the compaction now completes without a short summary, and cancellation still cancels it ([#16942](https://github.com/can1357/oh-my-pi/pull/16942) by [@Mouriya-Emma](https://github.com/Mouriya-Emma))
|
|
23
|
+
|
|
5
24
|
## [18.12.4] - 2026-10-20
|
|
6
25
|
|
|
7
26
|
### Fixed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
|
3
3
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
4
4
|
import type { AppendOnlyContextManager } from "./append-only-context.js";
|
|
5
5
|
import { Tokenizer } from "./tokenizer.js";
|
|
6
|
-
import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
6
|
+
import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideBoundary, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
7
7
|
export declare class AgentBusyError extends Error {
|
|
8
8
|
constructor(message?: string);
|
|
9
9
|
}
|
|
@@ -469,7 +469,7 @@ export declare class Agent {
|
|
|
469
469
|
* aborts foreground tools; an interruptible `wait` may end early through the
|
|
470
470
|
* peek hooks. See `AgentLoopConfig.getAsideMessages`.
|
|
471
471
|
*/
|
|
472
|
-
setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
|
|
472
|
+
setAsideMessageProvider(fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
|
|
473
473
|
emitExternalEvent(event: AgentEvent): void;
|
|
474
474
|
setSystemPrompt(v: string[] | string): void;
|
|
475
475
|
setModel(model: Model): void;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { type OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
11
|
+
import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types";
|
|
11
12
|
/** Retained-message budget Codex uses after streamed V2 compaction. */
|
|
12
13
|
export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
|
|
13
14
|
/** Max retries for V2 streaming compaction on transient stream errors. */
|
|
@@ -46,6 +47,15 @@ export declare function getCompactionV2Endpoint(model: Model): string | undefine
|
|
|
46
47
|
export declare function shouldUseCompactionV2Streaming(model: Model): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">;
|
|
47
48
|
/** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
|
|
48
49
|
export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Catalog-policy view of a remote compaction request (V1 and V2): the
|
|
52
|
+
* effective adapter (`remoteCompaction.api`) and the wire id the body sends
|
|
53
|
+
* (`remoteCompaction.model`). Under a model override the active lane's
|
|
54
|
+
* `requestModelId` and thinking classification describe a model that never
|
|
55
|
+
* reaches the wire, so they are dropped; `reasoning` stays because the
|
|
56
|
+
* compaction body derives its reasoning config from the active model.
|
|
57
|
+
*/
|
|
58
|
+
export declare function remoteCompactionPolicyModel(model: Model): CatalogPolicyInputs;
|
|
49
59
|
/** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
|
|
50
60
|
export declare function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number;
|
|
51
61
|
/** Build a V2 streaming compaction request from Responses-native history. */
|
|
@@ -67,6 +67,18 @@ export interface CompactionSettings {
|
|
|
67
67
|
}
|
|
68
68
|
/** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
|
|
69
69
|
export declare const DEFAULT_RESERVE_TOKENS = 16384;
|
|
70
|
+
/**
|
|
71
|
+
* Hysteresis band under the compaction trigger. Maintenance counts as having
|
|
72
|
+
* resolved threshold pressure only when residual context lands at or below
|
|
73
|
+
* `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against the raw threshold
|
|
74
|
+
* lets a pass keep reclaiming a trickle of the previous turn's output and land
|
|
75
|
+
* just under the line every turn, sustaining the auto-continue dead loop
|
|
76
|
+
* reported in #2275; the same band stops the context-full / snapcompact tail
|
|
77
|
+
* from re-firing on a history whose single most-recent kept turn already
|
|
78
|
+
* exceeds the threshold. {@link prepareCompaction} sizes the kept tail to land
|
|
79
|
+
* inside it.
|
|
80
|
+
*/
|
|
81
|
+
export declare const COMPACTION_RECOVERY_BAND = 0.8;
|
|
70
82
|
/**
|
|
71
83
|
* Hard ceiling on a generated compaction summary.
|
|
72
84
|
*
|
|
@@ -231,8 +243,15 @@ export interface SummaryOptions {
|
|
|
231
243
|
thinkingLevel?: ThinkingLevel;
|
|
232
244
|
/** Session routing key for remote compaction transports with sticky provider sessions. */
|
|
233
245
|
sessionId?: string;
|
|
234
|
-
/** Prompt-cache key for
|
|
246
|
+
/** Prompt-cache key for conventional summary requests, gated against the summarizing model. */
|
|
235
247
|
promptCacheKey?: string;
|
|
248
|
+
/**
|
|
249
|
+
* Prompt-cache key for remote compaction requests, gated against the request
|
|
250
|
+
* they send (`remoteCompaction.api`/`model` override, see
|
|
251
|
+
* `remoteCompactionPolicyModel`). Defaults to {@link promptCacheKey} only when
|
|
252
|
+
* omitted; an explicit `undefined` means the remote target gets no key.
|
|
253
|
+
*/
|
|
254
|
+
remotePromptCacheKey?: string;
|
|
236
255
|
/** Mutable provider state used to keep Codex compaction on the live session identity. */
|
|
237
256
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
238
257
|
/** Whether Codex remote compaction should prefer the provider WebSocket transport. */
|
|
@@ -30,8 +30,8 @@ export declare const REMOTE_COMPACTION_TIMEOUT_MS = 300000;
|
|
|
30
30
|
export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
|
|
31
31
|
/**
|
|
32
32
|
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
33
|
-
* count at their image estimate
|
|
34
|
-
*
|
|
33
|
+
* count at their image estimate, not their base64 size. Opaque encrypted state
|
|
34
|
+
* stays excluded, matching ordinary message estimates and remote fit checks.
|
|
35
35
|
*/
|
|
36
36
|
export declare function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number;
|
|
37
37
|
export interface TrimRemoteCompactionInputResult {
|
|
@@ -87,6 +87,13 @@ export interface OpenAiRemoteCompactionRequest {
|
|
|
87
87
|
model: string;
|
|
88
88
|
input: Array<Record<string, unknown>>;
|
|
89
89
|
instructions: string;
|
|
90
|
+
/**
|
|
91
|
+
* Responses body cache identity. Mirrors the V2 body: the explicit
|
|
92
|
+
* prompt-cache key when supplied (normalized to the 64-char wire limit),
|
|
93
|
+
* else the session-derived cache id — retention-gated through
|
|
94
|
+
* `getOpenAIPromptCacheKey`; absent when it resolves to nothing.
|
|
95
|
+
*/
|
|
96
|
+
prompt_cache_key?: string;
|
|
90
97
|
reasoning?: {
|
|
91
98
|
context?: string;
|
|
92
99
|
[key: string]: unknown;
|
|
@@ -124,6 +131,13 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
|
|
|
124
131
|
fetch?: FetchImpl;
|
|
125
132
|
timeoutMs?: number;
|
|
126
133
|
sessionId?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Explicit prompt-cache identity. Feeds the Go family affinity header
|
|
136
|
+
* (`x-opencode-session`) and the Responses body `prompt_cache_key`
|
|
137
|
+
* under the same KDL gate as the V2 request; an absent/empty key keeps
|
|
138
|
+
* the child conversation id.
|
|
139
|
+
*/
|
|
140
|
+
promptCacheKey?: string;
|
|
127
141
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
128
142
|
codexCompaction?: CodexCompactionContext;
|
|
129
143
|
/** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
|
package/dist/types/types.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type CommittableAsideMessage = AgentMessage & {
|
|
|
33
33
|
* (e.g. dropping late diagnostics a newer edit superseded).
|
|
34
34
|
*/
|
|
35
35
|
export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
|
|
36
|
+
/** Where the loop polls asides: after a tool batch (`"work"`) or after a turn without tool calls (`"stop"`). */
|
|
37
|
+
export type AsideBoundary = "work" | "stop";
|
|
36
38
|
export interface AgentTurnEndContext {
|
|
37
39
|
/** Assistant/user message that just completed this turn boundary. */
|
|
38
40
|
message: AgentMessage;
|
|
@@ -308,8 +310,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
308
310
|
* should reach the model between requests without waiting for the agent to
|
|
309
311
|
* fully stop. Returned messages are appended to the context with normal
|
|
310
312
|
* message events and keep the loop running so the model can react.
|
|
313
|
+
*
|
|
314
|
+
* `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
|
|
315
|
+
* its turn without tool calls; a provider may hold back messages that should
|
|
316
|
+
* not extend a finished turn.
|
|
311
317
|
*/
|
|
312
|
-
getAsideMessages?: () => Promise<AsideMessage[]>;
|
|
318
|
+
getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
|
|
313
319
|
/**
|
|
314
320
|
* Hook fired right before the loop would exit.
|
|
315
321
|
*
|
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": "18.
|
|
4
|
+
"version": "18.13.0",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@oh-my-pi/pi-ai": "18.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.13.0",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.13.0",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.13.0",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.13.0",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.13.0",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.13.0",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.
|
|
50
|
+
"@oh-my-pi/omptype": "18.13.0",
|
|
51
51
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
52
52
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
53
53
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -1125,6 +1125,9 @@ interface StepCounter {
|
|
|
1125
1125
|
count: number;
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
/** Largest setTimeout delay before 32-bit overflow (Node/Bun fire in ~1 ms past this). */
|
|
1129
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
1130
|
+
|
|
1128
1131
|
function isDeadlineExceeded(deadline: number | undefined): boolean {
|
|
1129
1132
|
return deadline !== undefined && Date.now() >= deadline;
|
|
1130
1133
|
}
|
|
@@ -1211,16 +1214,17 @@ async function runLoopBody(
|
|
|
1211
1214
|
): Promise<void> {
|
|
1212
1215
|
let deadlineTimer: Timer | undefined;
|
|
1213
1216
|
if (config.deadline !== undefined) {
|
|
1217
|
+
const deadline = config.deadline;
|
|
1214
1218
|
const deadlineAbortController = new AbortController();
|
|
1215
1219
|
const deadlineReason = new DOMException("Deadline exceeded", "TimeoutError");
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1220
|
+
// setTimeout overflows past 2^31-1 ms and fires in ~1 ms, so a valid `--max-time 600h`
|
|
1221
|
+
// aborted the first turn at once: wait in bounded slices and recheck the deadline.
|
|
1222
|
+
const armDeadline = (): void => {
|
|
1223
|
+
const delay = deadline - Date.now();
|
|
1224
|
+
if (delay <= 0) deadlineAbortController.abort(deadlineReason);
|
|
1225
|
+
else deadlineTimer = setTimeout(armDeadline, Math.min(delay, MAX_TIMER_MS));
|
|
1226
|
+
};
|
|
1227
|
+
armDeadline();
|
|
1224
1228
|
signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
|
|
1225
1229
|
}
|
|
1226
1230
|
|
|
@@ -1789,7 +1793,7 @@ async function runLoopBody(
|
|
|
1789
1793
|
: [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
|
|
1790
1794
|
if (hasMoreToolCalls) {
|
|
1791
1795
|
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
1792
|
-
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
1796
|
+
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("work"));
|
|
1793
1797
|
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
1794
1798
|
} else {
|
|
1795
1799
|
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
@@ -1817,7 +1821,7 @@ async function runLoopBody(
|
|
|
1817
1821
|
// above and this yield point (e.g. queued while onBeforeYield ran). Without
|
|
1818
1822
|
// this poll it would strand in the queue until the next manual prompt.
|
|
1819
1823
|
const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
|
|
1820
|
-
const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
1824
|
+
const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("stop"));
|
|
1821
1825
|
const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || [];
|
|
1822
1826
|
if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
|
|
1823
1827
|
// Set as pending so the inner loop processes them before stopping.
|
package/src/agent.ts
CHANGED
|
@@ -58,6 +58,7 @@ import type {
|
|
|
58
58
|
AgentTool,
|
|
59
59
|
AgentToolContext,
|
|
60
60
|
AgentTurnEndContext,
|
|
61
|
+
AsideBoundary,
|
|
61
62
|
AsideMessage,
|
|
62
63
|
PrepareQueuedMessages,
|
|
63
64
|
QueuedMessageQueue,
|
|
@@ -499,7 +500,7 @@ export class Agent {
|
|
|
499
500
|
#onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
|
|
500
501
|
#beforeModelCall?: AgentBeforeModelCall;
|
|
501
502
|
#additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
|
|
502
|
-
#asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
|
|
503
|
+
#asideMessageProvider?: (boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>;
|
|
503
504
|
#telemetry?: AgentLoopConfig["telemetry"];
|
|
504
505
|
#appendOnlyContext?: AppendOnlyContextManager;
|
|
505
506
|
#beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise<void> | void>();
|
|
@@ -1204,7 +1205,9 @@ export class Agent {
|
|
|
1204
1205
|
* aborts foreground tools; an interruptible `wait` may end early through the
|
|
1205
1206
|
* peek hooks. See `AgentLoopConfig.getAsideMessages`.
|
|
1206
1207
|
*/
|
|
1207
|
-
setAsideMessageProvider(
|
|
1208
|
+
setAsideMessageProvider(
|
|
1209
|
+
fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined,
|
|
1210
|
+
): void {
|
|
1208
1211
|
this.#asideMessageProvider = fn;
|
|
1209
1212
|
}
|
|
1210
1213
|
|
|
@@ -1981,7 +1984,7 @@ export class Agent {
|
|
|
1981
1984
|
hasBackgroundCompletions: this.hasBackgroundCompletions,
|
|
1982
1985
|
hasQueuedAsides: this.hasQueuedAsides,
|
|
1983
1986
|
getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
|
|
1984
|
-
getAsideMessages: async
|
|
1987
|
+
getAsideMessages: async boundary => (await this.#asideMessageProvider?.(boundary)) ?? [],
|
|
1985
1988
|
onBeforeYield: () => this.#onBeforeYield?.(),
|
|
1986
1989
|
onDetachedToolUpdate: event => this.emitExternalEvent(event),
|
|
1987
1990
|
telemetry: this.#telemetry,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveOpenAIRequestSetup,
|
|
25
25
|
} from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
26
26
|
import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
|
|
27
|
+
import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types";
|
|
27
28
|
import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
28
29
|
import {
|
|
29
30
|
applyCodexResidencyHeader,
|
|
@@ -131,7 +132,8 @@ export function shouldUseCompactionV2Streaming(
|
|
|
131
132
|
return getCompactionV2Endpoint(model) !== undefined;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
/** Effective compaction request adapter: the `remoteCompaction.api` override, else the model's own API. Always defined — `Model.api` is required. */
|
|
136
|
+
function compactionV2Api(model: Model): Api {
|
|
135
137
|
return model.remoteCompaction?.api ?? model.api;
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -171,6 +173,23 @@ function resolveCompactionV2Model(model: Model): string {
|
|
|
171
173
|
return mappedDeployment ?? requestModel;
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Catalog-policy view of a remote compaction request (V1 and V2): the
|
|
178
|
+
* effective adapter (`remoteCompaction.api`) and the wire id the body sends
|
|
179
|
+
* (`remoteCompaction.model`). Under a model override the active lane's
|
|
180
|
+
* `requestModelId` and thinking classification describe a model that never
|
|
181
|
+
* reaches the wire, so they are dropped; `reasoning` stays because the
|
|
182
|
+
* compaction body derives its reasoning config from the active model.
|
|
183
|
+
*/
|
|
184
|
+
export function remoteCompactionPolicyModel(model: Model): CatalogPolicyInputs {
|
|
185
|
+
const api = compactionV2Api(model);
|
|
186
|
+
const overrideId = model.remoteCompaction?.model;
|
|
187
|
+
const base = { provider: model.provider, providerType: model.providerType, api, reasoning: model.reasoning };
|
|
188
|
+
return overrideId == null
|
|
189
|
+
? { ...base, id: model.id, requestModelId: model.requestModelId, thinking: model.thinking }
|
|
190
|
+
: { ...base, id: overrideId };
|
|
191
|
+
}
|
|
192
|
+
|
|
174
193
|
// ============================================================================
|
|
175
194
|
// Request Building
|
|
176
195
|
// ============================================================================
|
|
@@ -413,8 +432,9 @@ function buildCompactionV2Headers(
|
|
|
413
432
|
"content-type": "application/json",
|
|
414
433
|
...resolveOpenAIRequestSetup(
|
|
415
434
|
{
|
|
416
|
-
|
|
417
|
-
|
|
435
|
+
// `on-api` scoped cache policy must see the request this
|
|
436
|
+
// compaction actually sends, not the active lane.
|
|
437
|
+
...remoteCompactionPolicyModel(model),
|
|
418
438
|
baseUrl: model.baseUrl,
|
|
419
439
|
headers: model.headers,
|
|
420
440
|
compat: {
|
|
@@ -422,7 +442,16 @@ function buildCompactionV2Headers(
|
|
|
422
442
|
model.compat && "sessionHeader" in model.compat ? model.compat.sessionHeader : undefined,
|
|
423
443
|
},
|
|
424
444
|
},
|
|
425
|
-
{
|
|
445
|
+
{
|
|
446
|
+
apiKey,
|
|
447
|
+
messages: [],
|
|
448
|
+
sessionId: request.sessionId ?? routingSessionId,
|
|
449
|
+
promptCacheSessionId,
|
|
450
|
+
// The family affinity key rides only an explicit
|
|
451
|
+
// prompt-cache identity; the sessionId-derived cache-id
|
|
452
|
+
// fallback never does.
|
|
453
|
+
promptCacheAffinityKey: request.promptCacheKey ? promptCacheSessionId : undefined,
|
|
454
|
+
},
|
|
426
455
|
).headers,
|
|
427
456
|
};
|
|
428
457
|
if (api === "openai-codex-responses" || model.provider === "openai-codex") {
|
|
@@ -200,6 +200,19 @@ export interface CompactionSettings {
|
|
|
200
200
|
/** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
|
|
201
201
|
export const DEFAULT_RESERVE_TOKENS = 16384;
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Hysteresis band under the compaction trigger. Maintenance counts as having
|
|
205
|
+
* resolved threshold pressure only when residual context lands at or below
|
|
206
|
+
* `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against the raw threshold
|
|
207
|
+
* lets a pass keep reclaiming a trickle of the previous turn's output and land
|
|
208
|
+
* just under the line every turn, sustaining the auto-continue dead loop
|
|
209
|
+
* reported in #2275; the same band stops the context-full / snapcompact tail
|
|
210
|
+
* from re-firing on a history whose single most-recent kept turn already
|
|
211
|
+
* exceeds the threshold. {@link prepareCompaction} sizes the kept tail to land
|
|
212
|
+
* inside it.
|
|
213
|
+
*/
|
|
214
|
+
export const COMPACTION_RECOVERY_BAND = 0.8;
|
|
215
|
+
|
|
203
216
|
/**
|
|
204
217
|
* Hard ceiling on a generated compaction summary.
|
|
205
218
|
*
|
|
@@ -728,8 +741,15 @@ export interface SummaryOptions {
|
|
|
728
741
|
thinkingLevel?: ThinkingLevel;
|
|
729
742
|
/** Session routing key for remote compaction transports with sticky provider sessions. */
|
|
730
743
|
sessionId?: string;
|
|
731
|
-
/** Prompt-cache key for
|
|
744
|
+
/** Prompt-cache key for conventional summary requests, gated against the summarizing model. */
|
|
732
745
|
promptCacheKey?: string;
|
|
746
|
+
/**
|
|
747
|
+
* Prompt-cache key for remote compaction requests, gated against the request
|
|
748
|
+
* they send (`remoteCompaction.api`/`model` override, see
|
|
749
|
+
* `remoteCompactionPolicyModel`). Defaults to {@link promptCacheKey} only when
|
|
750
|
+
* omitted; an explicit `undefined` means the remote target gets no key.
|
|
751
|
+
*/
|
|
752
|
+
remotePromptCacheKey?: string;
|
|
733
753
|
/** Mutable provider state used to keep Codex compaction on the live session identity. */
|
|
734
754
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
735
755
|
/** Whether Codex remote compaction should prefer the provider WebSocket transport. */
|
|
@@ -1205,6 +1225,14 @@ export async function generateHandoff(
|
|
|
1205
1225
|
);
|
|
1206
1226
|
}
|
|
1207
1227
|
|
|
1228
|
+
/** Result of the short-summary request before it is mapped to the optional field. */
|
|
1229
|
+
type ShortSummaryOutcome = { kind: "text"; text: string } | { kind: "failed"; error: string } | { kind: "aborted" };
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Generate the optional display summary. Cancellation propagates as an
|
|
1233
|
+
* `AbortError`; every other failure or an empty answer yields `undefined`, so
|
|
1234
|
+
* a completed full summary is never discarded for this display-only field.
|
|
1235
|
+
*/
|
|
1208
1236
|
async function generateShortSummary(
|
|
1209
1237
|
recentMessages: AgentMessage[],
|
|
1210
1238
|
historySummary: string | undefined,
|
|
@@ -1213,7 +1241,7 @@ async function generateShortSummary(
|
|
|
1213
1241
|
apiKey: ApiKey,
|
|
1214
1242
|
signal?: AbortSignal,
|
|
1215
1243
|
options?: SummaryOptions,
|
|
1216
|
-
): Promise<string> {
|
|
1244
|
+
): Promise<string | undefined> {
|
|
1217
1245
|
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
|
|
1218
1246
|
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
|
|
1219
1247
|
const conversationText = serializeConversationForSummary(
|
|
@@ -1229,57 +1257,97 @@ async function generateShortSummary(
|
|
|
1229
1257
|
promptText += formatAdditionalContext(options?.extraContext);
|
|
1230
1258
|
promptText += SHORT_SUMMARY_PROMPT;
|
|
1231
1259
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1260
|
+
let outcome: ShortSummaryOutcome;
|
|
1261
|
+
try {
|
|
1262
|
+
if (options?.remoteEndpoint) {
|
|
1263
|
+
const endpoint = options.remoteEndpoint;
|
|
1264
|
+
const remote = await withAuth(
|
|
1265
|
+
apiKey,
|
|
1266
|
+
key =>
|
|
1267
|
+
requestRemoteCompaction(
|
|
1268
|
+
endpoint,
|
|
1269
|
+
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
|
|
1270
|
+
signal,
|
|
1271
|
+
{ fetch: options?.fetch, model, apiKey: key },
|
|
1272
|
+
),
|
|
1273
|
+
{ signal, missingKeyMessage: "Remote compaction credentials unavailable" },
|
|
1274
|
+
);
|
|
1275
|
+
outcome = { kind: "text", text: remote.summary };
|
|
1276
|
+
} else {
|
|
1277
|
+
const response = await instrumentedCompleteSimple(
|
|
1278
|
+
model,
|
|
1279
|
+
{
|
|
1280
|
+
systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
|
|
1281
|
+
messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
maxTokens,
|
|
1240
1285
|
signal,
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1286
|
+
apiKey,
|
|
1287
|
+
reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
|
|
1288
|
+
initiatorOverride: options?.initiatorOverride,
|
|
1289
|
+
metadata: options?.metadata,
|
|
1290
|
+
fetch: options?.fetch,
|
|
1291
|
+
sessionId: options?.sessionId,
|
|
1292
|
+
promptCacheKey: options?.promptCacheKey,
|
|
1293
|
+
providerSessionState: options?.providerSessionState,
|
|
1294
|
+
codexCompaction: localCodexCompaction(options),
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
telemetry: options?.telemetry,
|
|
1298
|
+
oneshotKind: "compaction_short_summary",
|
|
1299
|
+
completeImpl: options?.completeImpl,
|
|
1300
|
+
retry: summaryOneshotRetry(options),
|
|
1301
|
+
},
|
|
1302
|
+
);
|
|
1303
|
+
switch (response.stopReason) {
|
|
1304
|
+
case "aborted":
|
|
1305
|
+
outcome = { kind: "aborted" };
|
|
1306
|
+
break;
|
|
1307
|
+
case "error":
|
|
1308
|
+
outcome = { kind: "failed", error: response.errorMessage || "Unknown error" };
|
|
1309
|
+
break;
|
|
1310
|
+
case "stop":
|
|
1311
|
+
case "length":
|
|
1312
|
+
case "toolUse":
|
|
1313
|
+
outcome = {
|
|
1314
|
+
kind: "text",
|
|
1315
|
+
text: response.content
|
|
1316
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
1317
|
+
.map(c => c.text)
|
|
1318
|
+
.join("\n"),
|
|
1319
|
+
};
|
|
1320
|
+
break;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
outcome = signal?.aborted
|
|
1325
|
+
? { kind: "aborted" }
|
|
1326
|
+
: { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
1246
1327
|
}
|
|
1247
1328
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
oneshotKind: "compaction_short_summary",
|
|
1270
|
-
completeImpl: options?.completeImpl,
|
|
1271
|
-
retry: summaryOneshotRetry(options),
|
|
1272
|
-
},
|
|
1273
|
-
);
|
|
1274
|
-
|
|
1275
|
-
if (response.stopReason === "error") {
|
|
1276
|
-
throw createSummarizationError("Short summary failed", response);
|
|
1329
|
+
if (signal?.aborted) outcome = { kind: "aborted" };
|
|
1330
|
+
switch (outcome.kind) {
|
|
1331
|
+
case "aborted":
|
|
1332
|
+
throw new AIError.AbortError("Short summary aborted", { cause: signal?.reason });
|
|
1333
|
+
case "failed":
|
|
1334
|
+
logger.warn("Compaction short summary skipped", {
|
|
1335
|
+
error: outcome.error,
|
|
1336
|
+
model: model.id,
|
|
1337
|
+
provider: model.provider,
|
|
1338
|
+
});
|
|
1339
|
+
return undefined;
|
|
1340
|
+
case "text":
|
|
1341
|
+
if (outcome.text.trim() === "") {
|
|
1342
|
+
logger.warn("Compaction short summary skipped", {
|
|
1343
|
+
error: "empty answer",
|
|
1344
|
+
model: model.id,
|
|
1345
|
+
provider: model.provider,
|
|
1346
|
+
});
|
|
1347
|
+
return undefined;
|
|
1348
|
+
}
|
|
1349
|
+
return outcome.text;
|
|
1277
1350
|
}
|
|
1278
|
-
|
|
1279
|
-
return response.content
|
|
1280
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
1281
|
-
.map(c => c.text)
|
|
1282
|
-
.join("\n");
|
|
1283
1351
|
}
|
|
1284
1352
|
|
|
1285
1353
|
// ============================================================================
|
|
@@ -1457,9 +1525,27 @@ export function prepareCompaction(
|
|
|
1457
1525
|
compactionMessages.push(message);
|
|
1458
1526
|
}
|
|
1459
1527
|
|
|
1460
|
-
const
|
|
1528
|
+
const postCompactionStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
|
|
1529
|
+
const lastUsage = getLastAssistantUsage(
|
|
1530
|
+
postCompactionStart > 0 ? pathEntries.slice(postCompactionStart) : pathEntries,
|
|
1531
|
+
);
|
|
1461
1532
|
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
|
1462
1533
|
let keepRecentTokens = settings.keepRecentTokens;
|
|
1534
|
+
if (activeModel?.contextWindow && activeModel.contextWindow > 0) {
|
|
1535
|
+
// The compacted context (kept tail + summary/prompt overhead) must land in
|
|
1536
|
+
// the recovery band under the configured trigger, or it fires again at once.
|
|
1537
|
+
// The trigger is the user's choice, so the kept tail gives way, not the trigger:
|
|
1538
|
+
// it gets what the band leaves after the reserve floor (an upper bound on the
|
|
1539
|
+
// summary), or half the band when the floor alone fills it.
|
|
1540
|
+
const contextWindow = activeModel.contextWindow;
|
|
1541
|
+
const recoveryBand = Math.floor(resolveThresholdTokens(contextWindow, settings) * COMPACTION_RECOVERY_BAND);
|
|
1542
|
+
const reserveFloor = Math.min(
|
|
1543
|
+
resolveBudgetReserveTokens(contextWindow, settings),
|
|
1544
|
+
settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS,
|
|
1545
|
+
);
|
|
1546
|
+
const tailBudget = recoveryBand > reserveFloor ? recoveryBand - reserveFloor : Math.floor(recoveryBand / 2);
|
|
1547
|
+
keepRecentTokens = Math.min(keepRecentTokens, Math.max(1, tailBudget));
|
|
1548
|
+
}
|
|
1463
1549
|
if (lastUsage) {
|
|
1464
1550
|
const estimatedTokens = tokenizer.countMessages(compactionMessages);
|
|
1465
1551
|
const promptTokens = calculatePromptTokens(lastUsage);
|
|
@@ -1677,6 +1763,10 @@ export async function compact(
|
|
|
1677
1763
|
thinkingLevel: options?.thinkingLevel,
|
|
1678
1764
|
sessionId: options?.sessionId,
|
|
1679
1765
|
promptCacheKey: options?.promptCacheKey,
|
|
1766
|
+
remotePromptCacheKey:
|
|
1767
|
+
options && Object.hasOwn(options, "remotePromptCacheKey")
|
|
1768
|
+
? options.remotePromptCacheKey
|
|
1769
|
+
: options?.promptCacheKey,
|
|
1680
1770
|
providerSessionState: options?.providerSessionState,
|
|
1681
1771
|
preferWebsockets: options?.preferWebsockets,
|
|
1682
1772
|
codexCompaction: options?.codexCompaction,
|
|
@@ -1815,7 +1905,7 @@ export async function compact(
|
|
|
1815
1905
|
forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
|
|
1816
1906
|
responsesLite: model.useResponsesLite,
|
|
1817
1907
|
sessionId: summaryOptions.sessionId,
|
|
1818
|
-
promptCacheKey: summaryOptions.
|
|
1908
|
+
promptCacheKey: summaryOptions.remotePromptCacheKey,
|
|
1819
1909
|
providerSessionState: summaryOptions.providerSessionState,
|
|
1820
1910
|
codexCompaction: createOpenAICodexCompactionRequestContext({
|
|
1821
1911
|
context: summaryOptions.codexCompaction,
|
|
@@ -1888,7 +1978,7 @@ export async function compact(
|
|
|
1888
1978
|
assertRemoteCompactionInputFits(trimmed, model);
|
|
1889
1979
|
const requestOptions = {
|
|
1890
1980
|
sessionId: summaryOptions.sessionId,
|
|
1891
|
-
promptCacheKey: summaryOptions.
|
|
1981
|
+
promptCacheKey: summaryOptions.remotePromptCacheKey,
|
|
1892
1982
|
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1893
1983
|
retainedUserItems,
|
|
1894
1984
|
};
|
|
@@ -1970,6 +2060,7 @@ export async function compact(
|
|
|
1970
2060
|
{
|
|
1971
2061
|
fetch: summaryOptions.fetch,
|
|
1972
2062
|
sessionId: summaryOptions.sessionId,
|
|
2063
|
+
promptCacheKey: summaryOptions.remotePromptCacheKey,
|
|
1973
2064
|
providerSessionState: summaryOptions.providerSessionState,
|
|
1974
2065
|
codexCompaction: summaryOptions.codexCompaction,
|
|
1975
2066
|
rewriteOutputs,
|
|
@@ -2272,7 +2363,7 @@ export async function compact(
|
|
|
2272
2363
|
|
|
2273
2364
|
return {
|
|
2274
2365
|
summary,
|
|
2275
|
-
shortSummary,
|
|
2366
|
+
...(shortSummary !== undefined ? { shortSummary } : {}),
|
|
2276
2367
|
firstKeptEntryId: nativeSummary !== undefined ? nativeFirstKeptEntryId : firstKeptEntryId,
|
|
2277
2368
|
tokensBefore,
|
|
2278
2369
|
details: { readFiles, modifiedFiles } as CompactionDetails,
|
|
@@ -2300,7 +2391,7 @@ async function generateTurnPrefixSummary(
|
|
|
2300
2391
|
preferredDialect(model.id),
|
|
2301
2392
|
resolveCatalogPolicy(model).classifierCotExtraction === true,
|
|
2302
2393
|
);
|
|
2303
|
-
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
2394
|
+
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${formatAdditionalContext(options?.extraContext)}${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
2304
2395
|
const summarizationMessages = [
|
|
2305
2396
|
{
|
|
2306
2397
|
role: "user" as const,
|
package/src/compaction/openai.ts
CHANGED
|
@@ -20,9 +20,14 @@ import { getCodexAttestationHeader } from "@oh-my-pi/pi-ai/providers/openai-code
|
|
|
20
20
|
import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-compaction";
|
|
21
21
|
import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
|
|
22
22
|
import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
applyInferenceHeaders,
|
|
25
|
+
applySessionHeader,
|
|
26
|
+
resolveOpenCodeGoCacheAffinity,
|
|
27
|
+
} from "@oh-my-pi/pi-ai/providers/inference-headers";
|
|
24
28
|
import {
|
|
25
29
|
encodeResponsesToolResultOutput,
|
|
30
|
+
getOpenAIPromptCacheKey,
|
|
26
31
|
hoistInterleavedResponsesToolBatchMessages,
|
|
27
32
|
parseAzureDeploymentNameMap,
|
|
28
33
|
parseTextSignature,
|
|
@@ -59,7 +64,7 @@ import { dataUrlImageSize } from "../image-tokens";
|
|
|
59
64
|
import { Tokenizer } from "../tokenizer";
|
|
60
65
|
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
61
66
|
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
62
|
-
import { isOpenAiRemoteCompactionApi } from "./compaction-v2-streaming";
|
|
67
|
+
import { isOpenAiRemoteCompactionApi, remoteCompactionPolicyModel } from "./compaction-v2-streaming";
|
|
63
68
|
import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
|
|
64
69
|
|
|
65
70
|
export * from "./compaction-v2-streaming";
|
|
@@ -100,18 +105,13 @@ interface RemoteEstimateParts {
|
|
|
100
105
|
imageTokens: number;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
function collectRemoteCompactionEstimate(
|
|
104
|
-
value: unknown,
|
|
105
|
-
tokenizer: Tokenizer,
|
|
106
|
-
dropEncrypted: boolean,
|
|
107
|
-
parts: RemoteEstimateParts,
|
|
108
|
-
): void {
|
|
108
|
+
function collectRemoteCompactionEstimate(value: unknown, tokenizer: Tokenizer, parts: RemoteEstimateParts): void {
|
|
109
109
|
if (typeof value === "string") {
|
|
110
110
|
if (value) parts.fragments.push(value);
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
if (Array.isArray(value)) {
|
|
114
|
-
for (const item of value) collectRemoteCompactionEstimate(item, tokenizer,
|
|
114
|
+
for (const item of value) collectRemoteCompactionEstimate(item, tokenizer, parts);
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
if (value === null || typeof value !== "object") {
|
|
@@ -141,9 +141,9 @@ function collectRemoteCompactionEstimate(
|
|
|
141
141
|
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
142
142
|
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
143
143
|
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
144
|
-
if (item === undefined || (
|
|
144
|
+
if (item === undefined || (key === "encrypted_content" && typeof item === "string")) continue;
|
|
145
145
|
parts.framing.push(key);
|
|
146
|
-
collectRemoteCompactionEstimate(item, tokenizer,
|
|
146
|
+
collectRemoteCompactionEstimate(item, tokenizer, parts);
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -155,21 +155,20 @@ function collectRemoteCompactionEstimate(
|
|
|
155
155
|
function remoteCompactionEstimateParts(
|
|
156
156
|
value: unknown,
|
|
157
157
|
tokenizer: Tokenizer,
|
|
158
|
-
dropEncrypted: boolean,
|
|
159
158
|
): { fragments: string[]; imageTokens: number } {
|
|
160
159
|
const parts: RemoteEstimateParts = { fragments: [], framing: [], imageTokens: 0 };
|
|
161
|
-
collectRemoteCompactionEstimate(value, tokenizer,
|
|
160
|
+
collectRemoteCompactionEstimate(value, tokenizer, parts);
|
|
162
161
|
if (parts.framing.length > 0) parts.fragments.push(parts.framing.join(" "));
|
|
163
162
|
return { fragments: parts.fragments, imageTokens: parts.imageTokens };
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
/**
|
|
167
166
|
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
168
|
-
* count at their image estimate
|
|
169
|
-
*
|
|
167
|
+
* count at their image estimate, not their base64 size. Opaque encrypted state
|
|
168
|
+
* stays excluded, matching ordinary message estimates and remote fit checks.
|
|
170
169
|
*/
|
|
171
170
|
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
172
|
-
const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer
|
|
171
|
+
const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer);
|
|
173
172
|
return (fragments.length === 0 ? 0 : tokenizer.countTokens(fragments)) + imageTokens;
|
|
174
173
|
}
|
|
175
174
|
|
|
@@ -214,7 +213,6 @@ function probeRemoteCompactionInputBudget(
|
|
|
214
213
|
const { fragments, imageTokens } = remoteCompactionEstimateParts(
|
|
215
214
|
{ instructions, input, ...(tools ? { tools } : {}) },
|
|
216
215
|
tokenizer,
|
|
217
|
-
true,
|
|
218
216
|
);
|
|
219
217
|
const flatTokens = imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
220
218
|
if (!contextWindow || contextWindow <= 0) {
|
|
@@ -360,6 +358,13 @@ export interface OpenAiRemoteCompactionRequest {
|
|
|
360
358
|
model: string;
|
|
361
359
|
input: Array<Record<string, unknown>>;
|
|
362
360
|
instructions: string;
|
|
361
|
+
/**
|
|
362
|
+
* Responses body cache identity. Mirrors the V2 body: the explicit
|
|
363
|
+
* prompt-cache key when supplied (normalized to the 64-char wire limit),
|
|
364
|
+
* else the session-derived cache id — retention-gated through
|
|
365
|
+
* `getOpenAIPromptCacheKey`; absent when it resolves to nothing.
|
|
366
|
+
*/
|
|
367
|
+
prompt_cache_key?: string;
|
|
363
368
|
reasoning?: {
|
|
364
369
|
context?: string;
|
|
365
370
|
[key: string]: unknown;
|
|
@@ -858,6 +863,13 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
858
863
|
fetch?: FetchImpl;
|
|
859
864
|
timeoutMs?: number;
|
|
860
865
|
sessionId?: string;
|
|
866
|
+
/**
|
|
867
|
+
* Explicit prompt-cache identity. Feeds the Go family affinity header
|
|
868
|
+
* (`x-opencode-session`) and the Responses body `prompt_cache_key`
|
|
869
|
+
* under the same KDL gate as the V2 request; an absent/empty key keeps
|
|
870
|
+
* the child conversation id.
|
|
871
|
+
*/
|
|
872
|
+
promptCacheKey?: string;
|
|
861
873
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
862
874
|
codexCompaction?: CodexCompactionContext;
|
|
863
875
|
/** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
|
|
@@ -889,6 +901,12 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
889
901
|
});
|
|
890
902
|
}
|
|
891
903
|
assertRemoteCompactionInputFits(trimmed, model);
|
|
904
|
+
// Same cache identity as the V2 request: the resolved id prefers the
|
|
905
|
+
// explicit prompt-cache key and falls back to the session id, all
|
|
906
|
+
// retention-gated by getOpenAIPromptCacheKey (PI_CACHE_RETENTION=none
|
|
907
|
+
// resolves to nothing here, which then also drops the affinity below).
|
|
908
|
+
const cacheOptions = { sessionId: opts?.sessionId, promptCacheKey: opts?.promptCacheKey };
|
|
909
|
+
const promptCacheSessionId = getOpenAIPromptCacheKey(cacheOptions);
|
|
892
910
|
const request: OpenAiRemoteCompactionRequest = {
|
|
893
911
|
model: requestModel,
|
|
894
912
|
// Preserve the native transcript. Only oversized trailing tool outputs are
|
|
@@ -896,6 +914,7 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
896
914
|
// reasoning, or call/result pairing.
|
|
897
915
|
input: trimmed.input,
|
|
898
916
|
instructions,
|
|
917
|
+
...(promptCacheSessionId ? { prompt_cache_key: promptCacheSessionId } : {}),
|
|
899
918
|
};
|
|
900
919
|
const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
|
|
901
920
|
const isCodexResponses =
|
|
@@ -911,6 +930,22 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
911
930
|
Authorization: `Bearer ${apiKey}`,
|
|
912
931
|
...model.headers,
|
|
913
932
|
};
|
|
933
|
+
// Same gate as the V2 request, against the request this compaction actually
|
|
934
|
+
// sends. Azure keeps its plain header shape (V2 skips the cascade there too).
|
|
935
|
+
// Only an EXPLICIT prompt-cache key feeds the affinity; the sessionId-derived
|
|
936
|
+
// cache id never does. `promptCacheSessionId` is already retention-gated.
|
|
937
|
+
if (!isAzureOpenAiResponses) {
|
|
938
|
+
applyInferenceHeaders(headers, {
|
|
939
|
+
provider: model.provider,
|
|
940
|
+
providerType: model.providerType,
|
|
941
|
+
protocol: "openai",
|
|
942
|
+
sessionId: opts?.sessionId,
|
|
943
|
+
cacheAffinity: resolveOpenCodeGoCacheAffinity(
|
|
944
|
+
remoteCompactionPolicyModel(model),
|
|
945
|
+
opts?.promptCacheKey ? promptCacheSessionId : undefined,
|
|
946
|
+
),
|
|
947
|
+
});
|
|
948
|
+
}
|
|
914
949
|
applySessionHeader(headers, model.compat, opts?.sessionId);
|
|
915
950
|
|
|
916
951
|
// Codex endpoints require additional auth headers
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,9 @@ export type CommittableAsideMessage = AgentMessage & {
|
|
|
68
68
|
*/
|
|
69
69
|
export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
|
|
70
70
|
|
|
71
|
+
/** Where the loop polls asides: after a tool batch (`"work"`) or after a turn without tool calls (`"stop"`). */
|
|
72
|
+
export type AsideBoundary = "work" | "stop";
|
|
73
|
+
|
|
71
74
|
export interface AgentTurnEndContext {
|
|
72
75
|
/** Assistant/user message that just completed this turn boundary. */
|
|
73
76
|
message: AgentMessage;
|
|
@@ -368,8 +371,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
368
371
|
* should reach the model between requests without waiting for the agent to
|
|
369
372
|
* fully stop. Returned messages are appended to the context with normal
|
|
370
373
|
* message events and keep the loop running so the model can react.
|
|
374
|
+
*
|
|
375
|
+
* `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
|
|
376
|
+
* its turn without tool calls; a provider may hold back messages that should
|
|
377
|
+
* not extend a finished turn.
|
|
371
378
|
*/
|
|
372
|
-
getAsideMessages?: () => Promise<AsideMessage[]>;
|
|
379
|
+
getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
|
|
373
380
|
/**
|
|
374
381
|
* Hook fired right before the loop would exit.
|
|
375
382
|
*
|