pi-openai-toolkit 0.20.4 → 0.20.6
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/README.md +2 -2
- package/README.zh.md +2 -2
- package/docs/configuration.md +2 -2
- package/docs/internals.md +3 -3
- package/package.json +1 -1
- package/src/context-management/window-bulk.ts +6 -0
- package/src/context-management/window-manager.ts +51 -47
- package/src/extension-runtime.ts +60 -100
- package/src/native-fallback.ts +89 -30
- package/src/types.ts +6 -3
package/README.md
CHANGED
|
@@ -54,9 +54,9 @@ Set `defaults.context.mode` or an individual model's `context.mode`:
|
|
|
54
54
|
|
|
55
55
|
**Remote windows** require the native `openai-codex` provider with `openai-codex-responses`, or an `openai-responses` gateway implementing the protocol. Gateways require `compatibility.transport: "codex-gateway"` on the exact model entry. This setting does not add the capability to an ordinary compatible endpoint.
|
|
56
56
|
|
|
57
|
-
Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` starts a checkpoint handoff: write notes successfully, then switch windows. Models without window capability
|
|
57
|
+
Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` starts a checkpoint handoff: write notes successfully, then switch windows. Models without window capability use only the current local window projection: retired Remote Context history is not sent to the model or to a configured compaction producer. A no-summary local boundary preserves the persisted Remote Window / Context Session, so switching back to a supported model reuses the same window instead of opening a duplicate one. The legacy `leaveManagedMode` setting is retained for config compatibility but no longer triggers bulk close-out compaction.
|
|
58
58
|
|
|
59
|
-
Remote windows use alpha endpoints that may change upstream. When switching models,
|
|
59
|
+
Remote windows use alpha endpoints that may change upstream. When switching models, the remote window remains persisted and reusable; see the [window lifecycle](docs/internals.md#rollover-lifecycle).
|
|
60
60
|
|
|
61
61
|
## Search, images, and tool review
|
|
62
62
|
|
package/README.zh.md
CHANGED
|
@@ -54,9 +54,9 @@ Toolkit 只读取一个全局配置文件,即使扩展安装在项目内也一
|
|
|
54
54
|
|
|
55
55
|
**远程窗口**需要原生 `openai-codex` 提供商的 `openai-codex-responses` API,或支持相应协议的 `openai-responses` 网关。网关必须在精确模型下设置 `compatibility.transport: "codex-gateway"`;这一设置不会让普通兼容端点自动获得该能力。
|
|
56
56
|
|
|
57
|
-
将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会发起检查点交接:先成功写入 notes
|
|
57
|
+
将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会发起检查点交接:先成功写入 notes,再切换窗口。不具备窗口能力的模型只使用当前本地窗口投影:已经由 Remote Context 托管的旧窗口历史不会发送给该模型,也不会发送给配置的 compaction 模型。扩展会用一个不生成摘要的本地边界保留已持久化的 Remote Window / Context Session;切回支持窗口的模型时会复用同一个窗口,不会重复创建。旧配置中的 `leaveManagedMode` 仍会解析,但不再触发批量 close-out 压缩。
|
|
58
58
|
|
|
59
|
-
远程窗口依赖 alpha
|
|
59
|
+
远程窗口依赖 alpha 接口,上游可能随时调整。切换模型时远程窗口状态仍会持久化并可复用,详见[窗口生命周期](docs/internals.md#rollover-lifecycle)。
|
|
60
60
|
|
|
61
61
|
## 搜索、生图与工具审查
|
|
62
62
|
|
package/docs/configuration.md
CHANGED
|
@@ -65,10 +65,10 @@ Place `context` under `defaults` or an exact `models` entry.
|
|
|
65
65
|
| `nativeFallback.enabled` | `true` | Enable the existing native-method fallback tier. |
|
|
66
66
|
| `nativeFallback.model` | `null` | Optional summary-model reference on the remote-ineligible path. After a remote attempt fails, the existing producer-first fallback order is retained. |
|
|
67
67
|
| `nativeFallback.thinkingLevel` | `"off"` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; actual model support still applies. |
|
|
68
|
-
| `remoteWindows.leaveManagedMode` | `"warn"` |
|
|
68
|
+
| `remoteWindows.leaveManagedMode` | `"warn"` | Legacy compatibility field. Both values preserve the same behavior: non-window models receive only the current local window, and no bulk close-out compaction is started for retired Remote Context history. |
|
|
69
69
|
| `remoteWindows.reminderThresholdPercent` | `5` | Integer 0-100. `0` disables both the once-per-window reminder and exhausted-window fallback. |
|
|
70
70
|
|
|
71
|
-
Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. `remote-windows`
|
|
71
|
+
Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. `remote-windows` keeps the Remote Window state persisted when a model without window capability is selected: that model receives only the current local window, while retired/managed Remote Context history is excluded from live requests and compaction producers. A no-summary boundary preserves the marker for later reuse. A configured window model whose activation fails does not silently receive a summary fallback. Normal `new_context` keeps its persisted-notes, duplicate and cooldown gates. Synthetic Remote V2 producer/consumer identity and checkpoint provenance are unchanged. See [Toolkit internals](internals.md).
|
|
72
72
|
|
|
73
73
|
In active `remote-windows`, `/compact [instructions]` starts checkpoint duty rather than producing a summary. The selected model must authenticate to the same native backend/account or gateway credential/affinity domain, fit the projected window plus its output reserve, and retain the required permitted context tools. Invalid or unsuitable explicit targets are refused without switching to a fallback model. If Auto Mode is engaged, the target must also be eligible for its gate. The entire handoff uses the initiating context-policy snapshot; no config or global Pi defaults are rewritten.
|
|
74
74
|
|
package/docs/internals.md
CHANGED
|
@@ -94,9 +94,9 @@ State invariants the lifecycle code must keep honest:
|
|
|
94
94
|
- Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
|
|
95
95
|
- Preparing a boundary never consumes its scheduled trim. The manager reconciles the latest persisted compaction and branch state, so only a durable matching boundary consumes it. Missing/late callbacks, aborted preparation and navigation across branches sharing a marker remain retryable. Automatic threshold/overflow and toolkit-owned manual maintenance only trim a matching scheduled window; a user manual `/compact` follows the managed handoff below.
|
|
96
96
|
- A window boundary is persisted as a `codex-context-window` custom message. `session_start` replays boundaries from the branch and rebuilds identity after forks.
|
|
97
|
-
- Retiring windows
|
|
98
|
-
-
|
|
99
|
-
-
|
|
97
|
+
- Retiring windows remains a projection-level operation in the persisted branch, but a non-window consumer never receives that retired history. `context_with_system` trims to the latest persisted Remote Context boundary, preserves the current system/tool head, and removes the internal window marker before the local provider request. The legacy `leaveManagedMode` setting is retained only so old config files remain valid; it no longer warns or starts a bulk close-out from `model_select` or `agent_settled`.
|
|
98
|
+
- If a non-window consumer reaches `session_before_compact` while a Remote Context boundary exists, the extension returns a no-summary compaction anchored at that boundary. No Remote V2/native compaction producer receives the Remote window, Pi retains the marker and current-window tail, and switching back to a supported model reuses the persisted window identity. A session with no Remote boundary keeps the ordinary compaction path.
|
|
99
|
+
- The configured native-fallback summary model is still checked against the physical request size (`estimateSummarizationRequest`). The guard remains a provider-safety invariant; the Remote-window fix avoids the oversized request by excluding managed history before any compaction producer can be called, rather than removing the guard or chunking the request.
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
package/package.json
CHANGED
|
@@ -8,6 +8,12 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
8
8
|
import type { LeaveManagedModePolicy } from "../types";
|
|
9
9
|
import { isContextWindowBoundary } from "./messages";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Legacy pure diagnostics retained for config/test compatibility. Runtime model switching no
|
|
13
|
+
* longer calls this module: non-window consumers use the current-local projection instead of
|
|
14
|
+
* measuring or compacting the durable Remote Context branch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Share of the target model's context window above which a projection is considered
|
|
13
19
|
* over budget. Estimates are conservative (`estimateTokens` is chars/4 and overestimates),
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import type { ProviderHeaders } from "@earendil-works/pi-ai";
|
|
4
4
|
import {
|
|
5
|
+
buildSessionContext,
|
|
5
6
|
calculateContextTokens,
|
|
6
7
|
estimateTokens,
|
|
7
8
|
type CompactionResult,
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
type SessionEntry,
|
|
12
13
|
} from "@earendil-works/pi-coding-agent";
|
|
13
14
|
import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
|
|
14
|
-
import type { WindowBulkReport } from "./window-bulk";
|
|
15
15
|
import {
|
|
16
16
|
rewriteEncryptedToolOutputs,
|
|
17
17
|
rewriteWindowHeaders,
|
|
@@ -86,8 +86,6 @@ export class CodexContextWindowManager {
|
|
|
86
86
|
private trimPendingWindowId: string | undefined;
|
|
87
87
|
private readonly projectionDiagnostics = new Set<string>();
|
|
88
88
|
private lastKnownSystemHead: AgentMessage | undefined;
|
|
89
|
-
private bulkReport: WindowBulkReport | undefined;
|
|
90
|
-
private bulkSurfacedKey: string | undefined;
|
|
91
89
|
/**
|
|
92
90
|
* Pi's getContextUsage() measures the durable transcript. Remote windows are
|
|
93
91
|
* projected only by this extension, so remember the last provider-visible
|
|
@@ -105,50 +103,16 @@ export class CodexContextWindowManager {
|
|
|
105
103
|
this.pendingRollover = undefined;
|
|
106
104
|
this.projectionDiagnostics.clear();
|
|
107
105
|
this.lastKnownSystemHead = undefined;
|
|
108
|
-
this.bulkReport = undefined;
|
|
109
|
-
this.bulkSurfacedKey = undefined;
|
|
110
106
|
}
|
|
111
107
|
|
|
112
108
|
currentIdentity(): ContextWindowIdentity | undefined {
|
|
113
109
|
return this.identity ? { ...this.identity } : undefined;
|
|
114
110
|
}
|
|
115
|
-
/**
|
|
116
|
-
* Whether a rollover trim is queued for the next eligible compaction. A turn-end bulk
|
|
117
|
-
* close-out is only worth a model call while that trim is still outstanding.
|
|
118
|
-
*/
|
|
111
|
+
/** Whether a rollover trim awaits a matching persisted compaction. */
|
|
119
112
|
hasPendingTrim(): boolean {
|
|
120
113
|
return this.trimPendingWindowId !== undefined;
|
|
121
114
|
}
|
|
122
115
|
|
|
123
|
-
/** Remember the latest measurement for this window; see `takeBulkCliff`. */
|
|
124
|
-
noteWindowBulk(report: WindowBulkReport | undefined, modelKey: string): void {
|
|
125
|
-
if (report) this.bulkReport = report;
|
|
126
|
-
else if (this.bulkKey(modelKey) === undefined) this.bulkReport = undefined;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Drain the pending bulk-cliff notice for this window and model. The decision what to
|
|
131
|
-
* do about it (warn, or compact before the next request) belongs to the runtime; this
|
|
132
|
-
* only guarantees the same window and model is never surfaced twice.
|
|
133
|
-
*/
|
|
134
|
-
takeBulkCliff(modelKey: string): WindowBulkReport | undefined {
|
|
135
|
-
const report = this.bulkReport;
|
|
136
|
-
if (!report || !report.expanded) return undefined;
|
|
137
|
-
const key = this.bulkKey(modelKey);
|
|
138
|
-
if (key === undefined || this.bulkSurfacedKey === key) return undefined;
|
|
139
|
-
this.bulkSurfacedKey = key;
|
|
140
|
-
return report;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private bulkKey(modelKey: string): string | undefined {
|
|
144
|
-
// The report's own window id is authoritative: this notice exists precisely for the
|
|
145
|
-
// models that never open the window lifecycle, where `identity` stays undefined.
|
|
146
|
-
const windowId = this.bulkReport?.windowId ?? this.identity?.currentWindowId;
|
|
147
|
-
return windowId ? `${windowId}|${modelKey}` : undefined;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
116
|
private resetWindowState(): void {
|
|
153
117
|
this.identity = undefined;
|
|
154
118
|
this.sessionId = undefined;
|
|
@@ -246,7 +210,7 @@ export class CodexContextWindowManager {
|
|
|
246
210
|
|
|
247
211
|
project(
|
|
248
212
|
messages: readonly AgentMessage[],
|
|
249
|
-
mode: "off" | "remote",
|
|
213
|
+
mode: "off" | "local" | "remote",
|
|
250
214
|
): AgentMessage[] {
|
|
251
215
|
if (mode === "off") {
|
|
252
216
|
this.projectedContextTokens = undefined;
|
|
@@ -268,20 +232,30 @@ export class CodexContextWindowManager {
|
|
|
268
232
|
if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
|
|
269
233
|
}
|
|
270
234
|
if (!isContextWindowBoundary(message)) continue;
|
|
235
|
+
// Local consumers follow the synchronized persisted identity, not a
|
|
236
|
+
// queued marker from the request view. They never own the Remote session.
|
|
237
|
+
if (mode === "local" && message.details.contextManagement.currentWindowId !== this.identity?.currentWindowId) continue;
|
|
271
238
|
boundaryIndex = index;
|
|
272
|
-
this.identity = identityFromDetails(message.details);
|
|
239
|
+
if (mode === "remote") this.identity = identityFromDetails(message.details);
|
|
273
240
|
}
|
|
274
241
|
if (boundaryIndex < 0) {
|
|
275
|
-
this.identity
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
242
|
+
if (mode === "local" && this.identity) {
|
|
243
|
+
throw new Error("Persisted Codex context-window boundary missing from local projection");
|
|
244
|
+
}
|
|
245
|
+
if (mode === "remote") {
|
|
246
|
+
this.identity = undefined;
|
|
247
|
+
this.restoredMarkerId = undefined;
|
|
248
|
+
this.budget.reset();
|
|
249
|
+
this.trimPendingWindowId = undefined;
|
|
250
|
+
}
|
|
279
251
|
this.projectedContextTokens = undefined;
|
|
280
252
|
}
|
|
281
253
|
// Projection observes the request view, never durable session state. A
|
|
282
254
|
// queued rollover marker must not clear the duplicate guard here.
|
|
283
|
-
const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex);
|
|
284
|
-
const projected = mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed
|
|
255
|
+
const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex, mode === "local");
|
|
256
|
+
const projected = mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed.filter(
|
|
257
|
+
(message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
|
|
258
|
+
);
|
|
285
259
|
this.projectedContextTokens = mode === "remote" && this.identity
|
|
286
260
|
? estimateProjectedWindowTokens(projected)
|
|
287
261
|
: undefined;
|
|
@@ -295,10 +269,13 @@ export class CodexContextWindowManager {
|
|
|
295
269
|
* sent, because a window without tools silently degrades into a chat the model can
|
|
296
270
|
* only narrate. Each refusal is reported once per window and reason.
|
|
297
271
|
*/
|
|
298
|
-
private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number): AgentMessage[] {
|
|
272
|
+
private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number, local = false): AgentMessage[] {
|
|
299
273
|
const repair = preserveSystemHead({ messages, boundaryIndex, fallbackHead: this.lastKnownSystemHead });
|
|
300
274
|
if (repair.head) this.lastKnownSystemHead = repair.head;
|
|
301
275
|
if (repair.shrinkRejected) {
|
|
276
|
+
// Remote requests retain the existing tool-preservation fallback. A
|
|
277
|
+
// local request must never recover tools by leaking retired windows.
|
|
278
|
+
if (local) throw new Error("Cannot preserve the system/tool head in local context-window projection");
|
|
302
279
|
this.recordProjectionDiagnostic(repair.lostToolNames);
|
|
303
280
|
}
|
|
304
281
|
return [...repair.messages];
|
|
@@ -461,6 +438,33 @@ export class CodexContextWindowManager {
|
|
|
461
438
|
return { compaction };
|
|
462
439
|
}
|
|
463
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Detach a local consumer at the durable Remote boundary, without summarizing
|
|
443
|
+
* any Remote-managed history. Unlike a scheduled rollover trim, this needs
|
|
444
|
+
* no pending trim and remains retryable until Pi persists the result.
|
|
445
|
+
*/
|
|
446
|
+
prepareDetachedCompaction(
|
|
447
|
+
event: SessionBeforeCompactEvent,
|
|
448
|
+
): { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
|
|
449
|
+
const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
|
|
450
|
+
if (!boundary) return undefined;
|
|
451
|
+
const compaction = this.createCompaction(event);
|
|
452
|
+
// Pi's preparation/tokensBefore describes the durable branch, which includes
|
|
453
|
+
// Remote-managed history. Detached local accounting must use the same current
|
|
454
|
+
// window projection that the non-window provider sees instead.
|
|
455
|
+
return {
|
|
456
|
+
compaction: {
|
|
457
|
+
...compaction,
|
|
458
|
+
tokensBefore: this.estimateLocalContextTokens(event.branchEntries),
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private estimateLocalContextTokens(entries: readonly SessionEntry[]): number {
|
|
464
|
+
const messages = buildSessionContext(entries as never[]).messages as unknown as AgentMessage[];
|
|
465
|
+
return this.project(messages, "local").reduce((total, message) => total + estimateTokens(message as never), 0);
|
|
466
|
+
}
|
|
467
|
+
|
|
464
468
|
recordCompaction(details: unknown): void {
|
|
465
469
|
if (!isContextWindowCompactionDetails(details)) return;
|
|
466
470
|
if (this.trimPendingWindowId !== undefined && details.windowId === this.trimPendingWindowId) {
|
package/src/extension-runtime.ts
CHANGED
|
@@ -20,12 +20,6 @@ import { routeContextNamespaceToolMessage } from "./context-management/namespace
|
|
|
20
20
|
import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
|
|
21
21
|
import { CodexContextWindowManager } from "./context-management/window-manager";
|
|
22
22
|
import { ManagedManualCompact } from "./context-management/manual-compact";
|
|
23
|
-
import {
|
|
24
|
-
BULK_CLIFF_RATIO,
|
|
25
|
-
decideBulkCliffAction,
|
|
26
|
-
decideBulkCloseOut,
|
|
27
|
-
evaluateWindowBulk,
|
|
28
|
-
} from "./context-management/window-bulk";
|
|
29
23
|
import {
|
|
30
24
|
registerContextManagementTools,
|
|
31
25
|
type ContextToolRegistrationState,
|
|
@@ -752,14 +746,22 @@ async function handleSessionBeforeCompact(
|
|
|
752
746
|
return { cancel: true };
|
|
753
747
|
}
|
|
754
748
|
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
749
|
+
// Capability changes do not retire the persisted Remote session. Resolve its
|
|
750
|
+
// branch boundary before deciding whether any compactor may see this input.
|
|
751
|
+
if (config.contextManagement === "remote") {
|
|
752
|
+
try {
|
|
753
|
+
dependencies.contextWindows.synchronize(ctx);
|
|
754
|
+
} catch {
|
|
755
|
+
notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
756
|
+
return { cancel: true };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Active and inactive covered Codex consumers retain their existing routing.
|
|
758
761
|
const contextModel = await resolveContextCapabilityModel(ctx);
|
|
759
762
|
if (isCodexContextModel(contextModel, config)) {
|
|
760
763
|
if (await remoteContextActive(ctx, config, contextModel)) {
|
|
761
764
|
try {
|
|
762
|
-
dependencies.contextWindows.synchronize(ctx);
|
|
763
765
|
if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
|
|
764
766
|
await dependencies.manualCompact.request(event, ctx, resolved);
|
|
765
767
|
return { cancel: true };
|
|
@@ -786,6 +788,18 @@ async function handleSessionBeforeCompact(
|
|
|
786
788
|
);
|
|
787
789
|
}
|
|
788
790
|
|
|
791
|
+
if (config.contextManagement === "remote") {
|
|
792
|
+
try {
|
|
793
|
+
const detached = dependencies.contextWindows.prepareDetachedCompaction(event);
|
|
794
|
+
// A local consumer cannot summarize history owned by a Remote window,
|
|
795
|
+
// even through a configured Luna producer or Pi's default compactor.
|
|
796
|
+
if (detached) return detached;
|
|
797
|
+
} catch {
|
|
798
|
+
notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
799
|
+
return { cancel: true };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
789
803
|
// Resolve producer protocol policy from this operation's document, not a later disk read.
|
|
790
804
|
if (resolved.format === "v2" && config.remoteCompactModel) {
|
|
791
805
|
const producer = parseModelSpec(config.remoteCompactModel);
|
|
@@ -868,7 +882,9 @@ async function handleSessionBeforeCompact(
|
|
|
868
882
|
config,
|
|
869
883
|
ctx,
|
|
870
884
|
);
|
|
871
|
-
|
|
885
|
+
const expectedConsumerCapabilityMiss =
|
|
886
|
+
resolution.reason === "unsupported-api" && resolution.modelSpec === undefined;
|
|
887
|
+
if (config.remoteCompactModel && !expectedConsumerCapabilityMiss) {
|
|
872
888
|
notifyWarning(
|
|
873
889
|
ctx,
|
|
874
890
|
`remote compaction model "${config.remoteCompactModel}" unusable (${resolution.reason}); using the native fallback chain`,
|
|
@@ -941,81 +957,6 @@ async function handleSessionBeforeCompact(
|
|
|
941
957
|
return undefined;
|
|
942
958
|
}
|
|
943
959
|
|
|
944
|
-
/**
|
|
945
|
-
* Surface (and optionally close) the gap between the durable transcript and the remote window.
|
|
946
|
-
*
|
|
947
|
-
* Remote context management retires previous windows in the request projection, so a covered
|
|
948
|
-
* model never notices that the branch still carries every one of them. Switching to a model
|
|
949
|
-
* without remote context - or resuming such a session - hands that whole pile to the provider
|
|
950
|
-
* at once, and the first manual `/compact` then has to summarize it. Runs once per agent turn,
|
|
951
|
-
* never per request, and only once per window and model so the notice cannot become a nag.
|
|
952
|
-
*/
|
|
953
|
-
async function handleWindowBulk(
|
|
954
|
-
ctx: ExtensionContext,
|
|
955
|
-
contextWindows: CodexContextWindowManager,
|
|
956
|
-
loadConfig: typeof loadToolkitConfig,
|
|
957
|
-
trigger: "model-switch" | "turn-end",
|
|
958
|
-
resolved = contextOperation(loadConfig, ctx),
|
|
959
|
-
beginMaintenance?: () => () => void,
|
|
960
|
-
): Promise<void> {
|
|
961
|
-
requireContextPolicy(resolved, ctx);
|
|
962
|
-
const compaction = resolved.config.compaction;
|
|
963
|
-
if (!compaction.enabled || compaction.contextManagement !== "remote") return;
|
|
964
|
-
if (!ctx.model) return;
|
|
965
|
-
|
|
966
|
-
const modelKey = `${ctx.model.provider}/${ctx.model.id}`;
|
|
967
|
-
const report = evaluateWindowBulk(ctx);
|
|
968
|
-
if (!report) return;
|
|
969
|
-
contextWindows.noteWindowBulk(report, modelKey);
|
|
970
|
-
const cliff = contextWindows.takeBulkCliff(modelKey);
|
|
971
|
-
if (!cliff) return;
|
|
972
|
-
const action = decideBulkCloseOut({
|
|
973
|
-
action: decideBulkCliffAction(cliff, compaction.leaveManagedMode),
|
|
974
|
-
mode: ctx.mode,
|
|
975
|
-
trigger,
|
|
976
|
-
hasPendingTrim: contextWindows.hasPendingTrim(),
|
|
977
|
-
});
|
|
978
|
-
if (action === "ignore") return;
|
|
979
|
-
|
|
980
|
-
writeDebugArtifact(
|
|
981
|
-
"compaction-event",
|
|
982
|
-
{
|
|
983
|
-
event: `window-bulk.${action}`,
|
|
984
|
-
trigger,
|
|
985
|
-
policy: compaction.leaveManagedMode,
|
|
986
|
-
model: modelKey,
|
|
987
|
-
unmanagedTokens: cliff.unmanagedTokens,
|
|
988
|
-
managedTokens: cliff.managedTokens,
|
|
989
|
-
targetContextWindow: cliff.targetContextWindow,
|
|
990
|
-
overBudget: cliff.overBudget,
|
|
991
|
-
},
|
|
992
|
-
compaction,
|
|
993
|
-
ctx,
|
|
994
|
-
);
|
|
995
|
-
|
|
996
|
-
const gap = `${cliff.unmanagedTokens.toLocaleString()} tokens of retired windows are still in this session's transcript, while remote context management has been sending ${cliff.managedTokens.toLocaleString()}`;
|
|
997
|
-
if (action === "warn") {
|
|
998
|
-
notifyWarning(
|
|
999
|
-
ctx,
|
|
1000
|
-
`${gap} to ${modelKey}${cliff.overBudget ? `, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window` : ""}. Checkpoint with notes and run /compact (or new_context) before continuing on a model without remote context, or set compaction.leaveManagedMode="compact" to do it automatically.`,
|
|
1001
|
-
);
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
notifyWarning(ctx, `${gap} to ${modelKey}, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window; compacting the retired windows first.`);
|
|
1006
|
-
if (typeof ctx.compact !== "function") return;
|
|
1007
|
-
const done = beginMaintenance?.();
|
|
1008
|
-
try {
|
|
1009
|
-
ctx.compact({
|
|
1010
|
-
onComplete: () => done?.(),
|
|
1011
|
-
onError: (error: Error) => {
|
|
1012
|
-
done?.();
|
|
1013
|
-
notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
|
|
1014
|
-
},
|
|
1015
|
-
});
|
|
1016
|
-
} catch (error) { done?.(); throw error; }
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
960
|
async function handleContextInternal(
|
|
1020
961
|
event: ContextWithSystemEvent,
|
|
1021
962
|
ctx: ExtensionContext,
|
|
@@ -1035,6 +976,7 @@ async function handleContextInternal(
|
|
|
1035
976
|
: { messages: visibleMessages };
|
|
1036
977
|
}
|
|
1037
978
|
|
|
979
|
+
let localMessages: AgentMessage[] | undefined;
|
|
1038
980
|
if (config.contextManagement === "remote") {
|
|
1039
981
|
const contextModel = await resolveContextCapabilityModel(ctx);
|
|
1040
982
|
try {
|
|
@@ -1095,22 +1037,40 @@ async function handleContextInternal(
|
|
|
1095
1037
|
? undefined
|
|
1096
1038
|
: { messages: visibleMessages };
|
|
1097
1039
|
}
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
const projected = contextWindows.project(event.messages, "local");
|
|
1043
|
+
localMessages = projected;
|
|
1044
|
+
if (contextWindows.currentIdentity()) {
|
|
1045
|
+
// This boundary owns earlier history, including any older Remote V2
|
|
1046
|
+
// checkpoint. A local request must not replay that history back in.
|
|
1047
|
+
return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
|
|
1048
|
+
? undefined
|
|
1049
|
+
: { messages: projected };
|
|
1050
|
+
}
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
1053
|
+
reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
|
|
1054
|
+
ctx.abort();
|
|
1055
|
+
return undefined;
|
|
1056
|
+
}
|
|
1098
1057
|
}
|
|
1099
1058
|
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
const
|
|
1059
|
+
// With no Remote boundary, the ordinary checkpoint replay path remains the
|
|
1060
|
+
// owner. Inactive covered Codex consumers were handled separately above.
|
|
1061
|
+
const visibleMessages = localMessages ?? contextWindows.project(event.messages, "off");
|
|
1062
|
+
const visibleResult = visibleMessages.length === event.messages.length && visibleMessages.every((message, index) => message === event.messages[index])
|
|
1063
|
+
? undefined : { messages: visibleMessages };
|
|
1104
1064
|
const replayEvent = visibleMessages === event.messages ? event : { ...event, messages: visibleMessages };
|
|
1105
1065
|
const resolution = await resolveNativeCompactionEnvironment(ctx, {
|
|
1106
1066
|
enabled: config.enabled,
|
|
1107
1067
|
responsesApis: config.responsesApis,
|
|
1108
1068
|
codexGatewayModels: compactGatewayModels(resolved),
|
|
1109
1069
|
});
|
|
1110
|
-
if (!resolution.ok) return
|
|
1070
|
+
if (!resolution.ok) return visibleResult;
|
|
1111
1071
|
const branchEntries = ctx.sessionManager.getBranch();
|
|
1112
1072
|
const latest = resolveLatestNativeCompactionEntry(branchEntries, { baseUrl: resolution.runtime.baseUrl });
|
|
1113
|
-
if (!latest.ok) return
|
|
1073
|
+
if (!latest.ok) return visibleResult;
|
|
1114
1074
|
const result = removeNativeCompactionRetainedMessages({
|
|
1115
1075
|
messages: replayEvent.messages,
|
|
1116
1076
|
branchEntries,
|
|
@@ -1124,7 +1084,7 @@ async function handleContextInternal(
|
|
|
1124
1084
|
ctx.abort();
|
|
1125
1085
|
return undefined;
|
|
1126
1086
|
}
|
|
1127
|
-
return result.messages === replayEvent.messages ?
|
|
1087
|
+
return result.messages === replayEvent.messages ? visibleResult : { messages: result.messages };
|
|
1128
1088
|
}
|
|
1129
1089
|
|
|
1130
1090
|
/**
|
|
@@ -1203,6 +1163,10 @@ async function handleBeforeProviderRequest(
|
|
|
1203
1163
|
return undefined;
|
|
1204
1164
|
}
|
|
1205
1165
|
|
|
1166
|
+
// Do not reintroduce a pre-window opaque checkpoint after local projection.
|
|
1167
|
+
// Request affinity/encrypted-output rewriting is reserved for active Remote.
|
|
1168
|
+
if (config.contextManagement === "remote" && contextWindows.currentIdentity()) return undefined;
|
|
1169
|
+
|
|
1206
1170
|
const resolution = await resolveNativeCompactionEnvironment(
|
|
1207
1171
|
ctx,
|
|
1208
1172
|
{
|
|
@@ -1518,10 +1482,9 @@ export default function registerCompactionExtension(
|
|
|
1518
1482
|
tools.reset();
|
|
1519
1483
|
});
|
|
1520
1484
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
|
|
1524
|
-
await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end", undefined, () => manualCompact.beginMaintenance());
|
|
1485
|
+
// Only the owned manual handoff restores/continues here; Pi owns normal
|
|
1486
|
+
// compaction scheduling. Never compact retired windows on model changes.
|
|
1487
|
+
await manualCompact.settled(ctx);
|
|
1525
1488
|
});
|
|
1526
1489
|
pi.on("model_select", async (event, ctx) => {
|
|
1527
1490
|
manualCompact.modelSelected(event.model, ctx);
|
|
@@ -1532,9 +1495,6 @@ export default function registerCompactionExtension(
|
|
|
1532
1495
|
// syncTools() activates and initializes the window when the model is covered.
|
|
1533
1496
|
const resolved = contextOperation(dependencies.loadConfig, ctx, event.model);
|
|
1534
1497
|
await syncTools(ctx, event.model, { notifyWindowFailure: true }, resolved);
|
|
1535
|
-
// The switch itself is the moment the durable transcript changes consumer, and the
|
|
1536
|
-
// session is idle here, so the close-out must happen now rather than mid-turn.
|
|
1537
|
-
if (!manualCompact.busy) await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved, () => manualCompact.beginMaintenance());
|
|
1538
1498
|
});
|
|
1539
1499
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
1540
1500
|
await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
|
package/src/native-fallback.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
compact,
|
|
3
|
+
convertToLlm,
|
|
3
4
|
DEFAULT_COMPACTION_SETTINGS,
|
|
4
|
-
|
|
5
|
+
serializeConversation,
|
|
5
6
|
type CompactionResult,
|
|
6
7
|
type ExtensionContext,
|
|
7
8
|
type SessionBeforeCompactEvent,
|
|
@@ -59,16 +60,10 @@ function toErrorMessage(error: unknown): string {
|
|
|
59
60
|
return error instanceof Error ? error.message : String(error);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
* Output budget Pi gives the summary itself: `generateSummary` caps `maxTokens` at
|
|
64
|
-
* `min(floor(0.8 * reserveTokens), model.maxTokens)`.
|
|
65
|
-
*/
|
|
66
|
-
const SUMMARIZATION_OUTPUT_RESERVE_TOKENS = Math.floor(
|
|
67
|
-
DEFAULT_COMPACTION_SETTINGS.reserveTokens * 0.8,
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
/** Summarization instructions plus the `<conversation>` framing around the transcript. */
|
|
63
|
+
/** Summarization instructions plus the framing around the serialized transcript. */
|
|
71
64
|
const SUMMARIZATION_TEMPLATE_TOKENS = 1024;
|
|
65
|
+
const HISTORY_OUTPUT_RESERVE_RATIO = 0.8;
|
|
66
|
+
const TURN_PREFIX_OUTPUT_RESERVE_RATIO = 0.5;
|
|
72
67
|
|
|
73
68
|
export type SummarizationRequestSize = {
|
|
74
69
|
inputTokens: number;
|
|
@@ -76,33 +71,93 @@ export type SummarizationRequestSize = {
|
|
|
76
71
|
totalTokens: number;
|
|
77
72
|
};
|
|
78
73
|
|
|
74
|
+
function estimateTextTokens(text: string | undefined): number {
|
|
75
|
+
return text ? Math.ceil(String(text).length / 4) : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function estimateSerializedConversationTokens(
|
|
79
|
+
messages: SessionBeforeCompactEvent["preparation"]["messagesToSummarize"],
|
|
80
|
+
): number {
|
|
81
|
+
const serialized = serializeConversation(convertToLlm(messages));
|
|
82
|
+
return estimateTextTokens(serialized);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getEffectiveReserveTokens(preparation: SessionBeforeCompactEvent["preparation"]): number {
|
|
86
|
+
const reserveTokens = preparation.settings?.reserveTokens;
|
|
87
|
+
return typeof reserveTokens === "number" && Number.isFinite(reserveTokens) && reserveTokens >= 0
|
|
88
|
+
? reserveTokens
|
|
89
|
+
: DEFAULT_COMPACTION_SETTINGS.reserveTokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getOutputReserveTokens(
|
|
93
|
+
reserveTokens: number,
|
|
94
|
+
model: { maxTokens?: number },
|
|
95
|
+
ratio: number,
|
|
96
|
+
): number {
|
|
97
|
+
const outputTokens = Math.floor(ratio * reserveTokens);
|
|
98
|
+
return typeof model.maxTokens === "number" && model.maxTokens > 0
|
|
99
|
+
? Math.min(outputTokens, model.maxTokens)
|
|
100
|
+
: outputTokens;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function estimateSummaryRequest(
|
|
104
|
+
messages: SessionBeforeCompactEvent["preparation"]["messagesToSummarize"],
|
|
105
|
+
outputTokens: number,
|
|
106
|
+
previousSummary?: string,
|
|
107
|
+
customInstructions?: string,
|
|
108
|
+
): SummarizationRequestSize {
|
|
109
|
+
const inputTokens =
|
|
110
|
+
SUMMARIZATION_TEMPLATE_TOKENS
|
|
111
|
+
+ estimateSerializedConversationTokens(messages)
|
|
112
|
+
+ estimateTextTokens(previousSummary)
|
|
113
|
+
+ estimateTextTokens(customInstructions);
|
|
114
|
+
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
|
|
115
|
+
}
|
|
116
|
+
|
|
79
117
|
/**
|
|
80
|
-
* Size of the request Pi's `compact()` is about to send
|
|
81
|
-
* measures context (the exported `estimateTokens`, a conservative chars/4).
|
|
118
|
+
* Size of the individual request(s) Pi's `compact()` is about to send.
|
|
82
119
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
120
|
+
* Pi first converts messages with `convertToLlm()` and then applies
|
|
121
|
+
* `serializeConversation()`, which truncates tool results before they enter the
|
|
122
|
+
* summarization prompt. Split-turn compaction sends history and turn-prefix
|
|
123
|
+
* summaries as separate requests, so this returns the largest individual request
|
|
124
|
+
* rather than adding their inputs together. The fixed allowance remains
|
|
125
|
+
* deliberately conservative for Pi's system prompt, tags, and instructions.
|
|
126
|
+
*
|
|
127
|
+
* A session driven by a wide-window model (400k) can produce a preparation that a
|
|
128
|
+
* narrower summary model (272k) cannot accept. Callers must not attempt that request;
|
|
129
|
+
* a missing `contextWindow` skips the check instead of guessing.
|
|
88
130
|
*/
|
|
89
131
|
export function estimateSummarizationRequest(
|
|
90
132
|
preparation: SessionBeforeCompactEvent["preparation"],
|
|
91
133
|
model: { contextWindow?: number; maxTokens?: number },
|
|
134
|
+
customInstructions?: string,
|
|
92
135
|
): SummarizationRequestSize {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
const reserveTokens = getEffectiveReserveTokens(preparation);
|
|
137
|
+
const historyMessages = preparation.messagesToSummarize ?? [];
|
|
138
|
+
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
|
139
|
+
const historyOutputTokens = getOutputReserveTokens(reserveTokens, model, HISTORY_OUTPUT_RESERVE_RATIO);
|
|
140
|
+
const historyRequest = estimateSummaryRequest(
|
|
141
|
+
historyMessages,
|
|
142
|
+
historyOutputTokens,
|
|
143
|
+
preparation.previousSummary,
|
|
144
|
+
customInstructions,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (!preparation.isSplitTurn || turnPrefixMessages.length === 0) {
|
|
148
|
+
return historyRequest;
|
|
101
149
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
:
|
|
105
|
-
|
|
150
|
+
|
|
151
|
+
const requests = [
|
|
152
|
+
...(historyMessages.length > 0 ? [historyRequest] : []),
|
|
153
|
+
estimateSummaryRequest(
|
|
154
|
+
turnPrefixMessages,
|
|
155
|
+
getOutputReserveTokens(reserveTokens, model, TURN_PREFIX_OUTPUT_RESERVE_RATIO),
|
|
156
|
+
),
|
|
157
|
+
];
|
|
158
|
+
return requests.reduce((largest, request) =>
|
|
159
|
+
request.totalTokens > largest.totalTokens ? request : largest,
|
|
160
|
+
);
|
|
106
161
|
}
|
|
107
162
|
|
|
108
163
|
/** Whether the summary model can accept this request at all; unknown windows always pass. */
|
|
@@ -163,7 +218,11 @@ export async function runNativeFallbackCompaction(args: {
|
|
|
163
218
|
return { ok: false, reason: "same-as-current-model", modelSpec: spec };
|
|
164
219
|
}
|
|
165
220
|
|
|
166
|
-
const size = estimateSummarizationRequest(
|
|
221
|
+
const size = estimateSummarizationRequest(
|
|
222
|
+
event.preparation,
|
|
223
|
+
model as { contextWindow?: number; maxTokens?: number },
|
|
224
|
+
event.customInstructions,
|
|
225
|
+
);
|
|
167
226
|
if (!fitsSummarizationRequest(size, model as { contextWindow?: number })) {
|
|
168
227
|
return {
|
|
169
228
|
ok: false,
|
package/src/types.ts
CHANGED
|
@@ -79,7 +79,10 @@ export type NativeFallbackConfig = {
|
|
|
79
79
|
thinkingLevel: ThinkingLevel;
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Legacy compatibility setting. Remote Context history is no longer bulk-compacted when
|
|
84
|
+
* selecting a non-window model; local projection and a no-summary boundary own that handoff.
|
|
85
|
+
*/
|
|
83
86
|
export type LeaveManagedModePolicy = "warn" | "compact";
|
|
84
87
|
|
|
85
88
|
export type DebugConfig = {
|
|
@@ -94,8 +97,8 @@ export type DebugConfig = {
|
|
|
94
97
|
export type CompactionConfig = {
|
|
95
98
|
enabled: boolean;
|
|
96
99
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
100
|
+
* Legacy compatibility setting. Non-window consumers never receive retired Remote Context
|
|
101
|
+
* history, regardless of this value; retained config files continue to parse it.
|
|
99
102
|
*/
|
|
100
103
|
leaveManagedMode: LeaveManagedModePolicy;
|
|
101
104
|
/** Optional Codex Remote Context management. Disabled by default for compatibility. */
|