pi-openai-toolkit 0.14.9 → 0.14.11
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/docs/internals.md +2 -0
- package/package.json +5 -19
- package/src/auto-mode/reviewer.ts +16 -6
- package/src/context-management/messages.ts +4 -1
- package/src/context-management/tools.ts +87 -42
- package/src/extension-runtime.ts +67 -29
- package/src/payload-rewrite.ts +15 -3
- package/src/serializer.ts +127 -5
- package/tsconfig.check.json +0 -4
- package/extensions/auto-mode.ts +0 -1
- package/extensions/codex-astra.ts +0 -1
- package/extensions/compaction.ts +0 -1
- package/extensions/image-generation.ts +0 -1
- package/extensions/web-search.ts +0 -1
package/docs/internals.md
CHANGED
|
@@ -52,6 +52,7 @@ sequenceDiagram
|
|
|
52
52
|
Toolkit->>Toolkit: verify successful notes write in this window
|
|
53
53
|
Toolkit-->>Model: checkpoint receipt with the successful note path
|
|
54
54
|
Toolkit-->>Pi: window marker appended, previous window trim scheduled
|
|
55
|
+
Note over Model: Context switch is complete; read the receipt once, then resume the active task
|
|
55
56
|
Model->>Backend: next request with the new x-codex-window-id
|
|
56
57
|
Pi->>Toolkit: session_before_compact
|
|
57
58
|
Toolkit-->>Pi: no-summary boundary consuming the scheduled trim
|
|
@@ -62,6 +63,7 @@ State invariants the lifecycle code must keep honest:
|
|
|
62
63
|
- The checkpoint gate verifies a successful `notes` `append_to_file` or `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks. A response with `ok: false` or `success: false` is not successful, even when HTTP returns 200.
|
|
63
64
|
- The gate uses the live session/branch identity from `ctx.sessionManager`; cached manager identity must not hide a valid current-window pair, and old or foreign-session evidence must remain rejected.
|
|
64
65
|
- Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
|
|
66
|
+
- A successful rollover closes the pre-rollover checkpoint phase. The first turn in the new window reads the receipt once and resumes the active user task; it must not immediately create another checkpoint or call `new_context` as part of that handoff. A later checkpoint is only for a later rollover.
|
|
65
67
|
- `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
|
|
66
68
|
- After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard.
|
|
67
69
|
- 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -32,20 +32,7 @@
|
|
|
32
32
|
"codex",
|
|
33
33
|
"responses"
|
|
34
34
|
],
|
|
35
|
-
"pi": {
|
|
36
|
-
"extensions": [
|
|
37
|
-
"./extensions/compaction.ts",
|
|
38
|
-
"./extensions/web-search.ts",
|
|
39
|
-
"./extensions/image-generation.ts",
|
|
40
|
-
"./extensions/auto-mode.ts",
|
|
41
|
-
"./extensions/codex-astra.ts"
|
|
42
|
-
]
|
|
43
|
-
},
|
|
44
35
|
"files": [
|
|
45
|
-
"extensions/compaction.ts",
|
|
46
|
-
"extensions/web-search.ts",
|
|
47
|
-
"extensions/image-generation.ts",
|
|
48
|
-
"extensions/auto-mode.ts",
|
|
49
36
|
"src/compact-client.ts",
|
|
50
37
|
"src/compaction-projection.ts",
|
|
51
38
|
"src/pi-context-hook.ts",
|
|
@@ -81,7 +68,6 @@
|
|
|
81
68
|
"src/auto-mode/reviewer.ts",
|
|
82
69
|
"src/auto-mode/classifier.ts",
|
|
83
70
|
"src/auto-mode/types.ts",
|
|
84
|
-
"extensions/codex-astra.ts",
|
|
85
71
|
"src/codex-astra/effort-planner.ts",
|
|
86
72
|
"src/codex-astra/extension.ts",
|
|
87
73
|
"src/model-scope.ts",
|
|
@@ -120,10 +106,10 @@
|
|
|
120
106
|
"@earendil-works/pi-tui": ">=0.85.1"
|
|
121
107
|
},
|
|
122
108
|
"devDependencies": {
|
|
123
|
-
"@earendil-works/pi-agent-core": "0.
|
|
124
|
-
"@earendil-works/pi-ai": "0.
|
|
125
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
126
|
-
"@earendil-works/pi-tui": "0.
|
|
109
|
+
"@earendil-works/pi-agent-core": "0.86.0",
|
|
110
|
+
"@earendil-works/pi-ai": "0.86.0",
|
|
111
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
112
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
127
113
|
"@types/node": "24.12.4",
|
|
128
114
|
"bun": "1.2.23",
|
|
129
115
|
"typescript": "5.9.3"
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
Context,
|
|
5
|
+
Message,
|
|
6
|
+
Model,
|
|
7
|
+
Tool,
|
|
8
|
+
ToolCall,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
2
10
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
11
|
import { parseModelSpec } from "../runtime";
|
|
4
12
|
import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
|
|
@@ -93,11 +101,13 @@ function firstTextBlock(message: AssistantMessage): string {
|
|
|
93
101
|
.trim();
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Pi 0.85 typed tool-call arguments as `Record<string, any>` and Pi 0.86 tightened
|
|
106
|
+
* them to the public `JsonObject`. Narrow with Pi's exported `ToolCall` instead of a
|
|
107
|
+
* handwritten shape so the guard tracks whichever contract the installed Pi declares.
|
|
108
|
+
*/
|
|
109
|
+
function toolCallBlocks(message: AssistantMessage): ToolCall[] {
|
|
110
|
+
return message.content.filter((block): block is ToolCall => block.type === "toolCall");
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
type CompletionAttempt = {
|
|
@@ -35,7 +35,7 @@ export const CONTEXT_WINDOW_COMPACTION_SUMMARY =
|
|
|
35
35
|
"[Pi Codex context-window boundary; no conversation summary was generated.]";
|
|
36
36
|
|
|
37
37
|
const CONTEXT_WINDOW_GUIDANCE = `<context_window_guidance>
|
|
38
|
-
|
|
38
|
+
Before calling new_context, checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes, and wait for that result to be persisted; only a persisted successful result in the current window unlocks the rollover. If new_context reports that a rollover is already scheduled, do not call it again in the same window. When this message includes a completed context-switch handoff, follow that post-rollover section first instead of restarting the pre-rollover checkpoint steps. A thread hint is supplemental and must not replace the local checkpoint receipt. No conversation summary carries over, and history is only for missing details.
|
|
39
39
|
</context_window_guidance>`;
|
|
40
40
|
|
|
41
41
|
export function renderContextWindowMessage(
|
|
@@ -51,8 +51,11 @@ export function renderContextWindowMessage(
|
|
|
51
51
|
];
|
|
52
52
|
if (identity.previousWindowId) lines.push(`Previous context window id: ${identity.previousWindowId}`);
|
|
53
53
|
if (checkpoint) {
|
|
54
|
+
lines.push("Context switch completed. This is the first turn in the new context window.");
|
|
54
55
|
lines.push("Checkpoint successfully written:");
|
|
55
56
|
lines.push(` Read this note before doing anything else with notes action "read_file": ${JSON.stringify(checkpoint.path)}`);
|
|
57
|
+
lines.push("After reading the checkpoint receipt, resume the active user task.");
|
|
58
|
+
lines.push("Do not immediately create another checkpoint or call new_context as part of this handoff. Only prepare a new checkpoint when a later context rollover is actually needed.");
|
|
56
59
|
}
|
|
57
60
|
if (threadHint) lines.push(threadHint);
|
|
58
61
|
lines.push("</context_window>");
|
|
@@ -12,13 +12,25 @@ import type { HistoryAction, NotesAction } from "./types";
|
|
|
12
12
|
|
|
13
13
|
const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
type PublishedTool = {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
parameters: unknown;
|
|
19
|
+
promptGuidelines?: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pi's ToolInfo wrapper is new, but its definition fields retain the objects
|
|
24
|
+
* registered by the extension. Keep identity checks for those fields: an exact
|
|
25
|
+
* foreign clone under the same name is still not ownership of our tool.
|
|
26
|
+
*/
|
|
27
|
+
function isOurTool(
|
|
28
|
+
actual: PublishedTool,
|
|
29
|
+
expected: { description: string; parameters: unknown; promptGuidelines?: string[] },
|
|
30
|
+
): boolean {
|
|
31
|
+
return actual.description === expected.description
|
|
32
|
+
&& actual.parameters === expected.parameters
|
|
33
|
+
&& actual.promptGuidelines === expected.promptGuidelines;
|
|
22
34
|
}
|
|
23
35
|
const HISTORY_ACTIONS = Object.keys(HISTORY_ENDPOINTS) as [HistoryAction, ...HistoryAction[]];
|
|
24
36
|
const NOTES_ACTIONS = Object.keys(NOTES_ENDPOINTS) as [NotesAction, ...NotesAction[]];
|
|
@@ -94,7 +106,8 @@ export function createContextManagementTools(
|
|
|
94
106
|
parameters: NEW_CONTEXT_PARAMETERS,
|
|
95
107
|
promptSnippet: "Start a new remote Codex context window without summarizing history.",
|
|
96
108
|
promptGuidelines: [
|
|
97
|
-
"
|
|
109
|
+
"Before this new_context call, checkpoint active work in notes; only a persisted successful notes append/write in the current window unlocks this rollover, and no conversation summary carries over.",
|
|
110
|
+
"A successful new_context completes one context switch. In the next window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context as part of that handoff.",
|
|
98
111
|
"Wait for the notes tool result before calling new_context; an in-flight or failed write is not a checkpoint. If new_context reports that a rollover is already scheduled, do not call it again in the same window.",
|
|
99
112
|
],
|
|
100
113
|
executionMode: "sequential",
|
|
@@ -116,7 +129,12 @@ export function createContextManagementTools(
|
|
|
116
129
|
trimPreviousWindow: true,
|
|
117
130
|
});
|
|
118
131
|
return {
|
|
119
|
-
content: [{
|
|
132
|
+
content: [{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: started
|
|
135
|
+
? "Context switch scheduled successfully. In the next context window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context."
|
|
136
|
+
: "A new context window is already scheduled.",
|
|
137
|
+
}],
|
|
120
138
|
details: { started },
|
|
121
139
|
};
|
|
122
140
|
},
|
|
@@ -161,6 +179,7 @@ export function createContextManagementTools(
|
|
|
161
179
|
promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
|
|
162
180
|
promptGuidelines: [
|
|
163
181
|
"Before calling new_context, checkpoint the current turn's active work (unfinished tasks, decisions, open questions, references) into notes with append_to_file or write_file so it survives the window change; wait for that result to be persisted before calling new_context.",
|
|
182
|
+
"After a successful new_context handoff, read the checkpoint receipt first when present and resume the active user task; do not immediately create another checkpoint or call new_context unless a later rollover is actually needed.",
|
|
164
183
|
"When a large task spans multiple context windows, keep a running note per line of work and read it at the start of each new window; append new state instead of replacing it unless the note is stale.",
|
|
165
184
|
"Prefer read_file or search_contents for looking things up; reserve write_file for explicit rewrite/clear and append_to_file for incremental state.",
|
|
166
185
|
"Keep note text concise and self-contained: it may be read later without the rest of the conversation, so include identifiers and verbatim key decisions, not hearsay summaries.",
|
|
@@ -175,6 +194,13 @@ export function createContextManagementTools(
|
|
|
175
194
|
return { newContext, getContextRemaining, history, notes };
|
|
176
195
|
}
|
|
177
196
|
|
|
197
|
+
export type ContextToolRegistrationState = "verified" | "conflict" | "unverified";
|
|
198
|
+
|
|
199
|
+
export type ContextToolSyncResult = {
|
|
200
|
+
synced: boolean;
|
|
201
|
+
registrationState: ContextToolRegistrationState;
|
|
202
|
+
};
|
|
203
|
+
|
|
178
204
|
export class ContextManagementToolController {
|
|
179
205
|
private readonly registeredNames = new Set<string>();
|
|
180
206
|
private readonly definitions = new Map<string, {
|
|
@@ -183,8 +209,7 @@ export class ContextManagementToolController {
|
|
|
183
209
|
promptGuidelines?: string[];
|
|
184
210
|
}>();
|
|
185
211
|
private registered = false;
|
|
186
|
-
private
|
|
187
|
-
private registrationValid = false;
|
|
212
|
+
private verifiedOnce = false;
|
|
188
213
|
|
|
189
214
|
constructor(private readonly pi: ExtensionAPI) {}
|
|
190
215
|
|
|
@@ -198,12 +223,16 @@ export class ContextManagementToolController {
|
|
|
198
223
|
// those are bound only after extension loading completes.
|
|
199
224
|
try {
|
|
200
225
|
for (const definition of definitions) api.registerTool(definition);
|
|
226
|
+
this.registered = true;
|
|
201
227
|
} catch {
|
|
228
|
+
// Pi rejects a registration whose name is already owned elsewhere.
|
|
229
|
+
this.registered = false;
|
|
202
230
|
return false;
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
this.registeredNames.clear();
|
|
206
234
|
this.definitions.clear();
|
|
235
|
+
this.verifiedOnce = false;
|
|
207
236
|
for (const definition of definitions) {
|
|
208
237
|
this.registeredNames.add(definition.name);
|
|
209
238
|
this.definitions.set(definition.name, {
|
|
@@ -212,43 +241,58 @@ export class ContextManagementToolController {
|
|
|
212
241
|
promptGuidelines: definition.promptGuidelines,
|
|
213
242
|
});
|
|
214
243
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
this.registrationValid = false;
|
|
244
|
+
// A successful call only proves the hand-off; it does not prove the runtime
|
|
245
|
+
// published the definitions. `checkRegistration()` reads that from Pi.
|
|
218
246
|
return true;
|
|
219
247
|
}
|
|
220
248
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Read registration state until the runtime confirms publication.
|
|
251
|
+
*
|
|
252
|
+
* Pi 0.86 can reject action methods while a session replacement is still
|
|
253
|
+
* binding, and can publish the registry after `session_start` ran. Negative
|
|
254
|
+
* reads remain retryable; only a successful verification is cached for this
|
|
255
|
+
* controller instance. A Pi reload creates a new controller.
|
|
256
|
+
*/
|
|
257
|
+
checkRegistration(): ContextToolRegistrationState {
|
|
258
|
+
if (!this.registered) return "conflict";
|
|
259
|
+
// A confirmed publication is kept: the expensive part was never the read, it
|
|
260
|
+
// was trusting a failed one. Only negative verdicts are recomputed each time.
|
|
261
|
+
if (this.verifiedOnce) return "verified";
|
|
262
|
+
let available: PublishedTool[];
|
|
225
263
|
try {
|
|
226
|
-
const api = this.pi as ExtensionAPI & { getAllTools?: () =>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
parameters: unknown;
|
|
230
|
-
promptGuidelines?: string[];
|
|
231
|
-
}> };
|
|
232
|
-
if (typeof api.getAllTools !== "function") return false;
|
|
233
|
-
const available = new Map(api.getAllTools().map((tool) => [tool.name, tool]));
|
|
234
|
-
this.registrationValid = [...this.registeredNames].every((name) => {
|
|
235
|
-
const actual = available.get(name);
|
|
236
|
-
const expected = this.definitions.get(name);
|
|
237
|
-
return actual !== undefined && expected !== undefined &&
|
|
238
|
-
actual.description === expected.description &&
|
|
239
|
-
actual.parameters === expected.parameters &&
|
|
240
|
-
actual.promptGuidelines === expected.promptGuidelines;
|
|
241
|
-
});
|
|
264
|
+
const api = this.pi as ExtensionAPI & { getAllTools?: () => PublishedTool[] };
|
|
265
|
+
if (typeof api.getAllTools !== "function") return "conflict";
|
|
266
|
+
available = api.getAllTools();
|
|
242
267
|
} catch {
|
|
243
|
-
|
|
268
|
+
// Stale or unbound runtime: retry on the next sync.
|
|
269
|
+
return "unverified";
|
|
270
|
+
}
|
|
271
|
+
const byName = new Map(available.map((tool) => [tool.name, tool]));
|
|
272
|
+
let missing = false;
|
|
273
|
+
let conflict = false;
|
|
274
|
+
for (const name of this.registeredNames) {
|
|
275
|
+
const actual = byName.get(name);
|
|
276
|
+
const expected = this.definitions.get(name);
|
|
277
|
+
if (actual === undefined || expected === undefined) {
|
|
278
|
+
missing = true;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!isOurTool(actual, expected)) conflict = true;
|
|
244
282
|
}
|
|
245
|
-
return
|
|
283
|
+
if (conflict) return "conflict";
|
|
284
|
+
if (missing) return "unverified";
|
|
285
|
+
this.verifiedOnce = true;
|
|
286
|
+
return "verified";
|
|
246
287
|
}
|
|
247
288
|
|
|
248
|
-
sync(active: boolean):
|
|
249
|
-
|
|
289
|
+
sync(active: boolean): ContextToolSyncResult {
|
|
290
|
+
const registrationState = this.checkRegistration();
|
|
291
|
+
if (registrationState !== "verified") return { synced: false, registrationState };
|
|
250
292
|
const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
251
|
-
if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function")
|
|
293
|
+
if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") {
|
|
294
|
+
return { synced: false, registrationState };
|
|
295
|
+
}
|
|
252
296
|
try {
|
|
253
297
|
const current = api.getActiveTools();
|
|
254
298
|
// Pi may activate a newly registered tool before the first sync. Once
|
|
@@ -258,16 +302,17 @@ export class ContextManagementToolController {
|
|
|
258
302
|
? [...current, ...[...this.registeredNames].filter((name) => !current.includes(name))]
|
|
259
303
|
: current.filter((name) => !this.registeredNames.has(name));
|
|
260
304
|
if (next.length !== current.length) api.setActiveTools(next);
|
|
261
|
-
return true;
|
|
305
|
+
return { synced: true, registrationState };
|
|
262
306
|
} catch {
|
|
263
|
-
return false;
|
|
307
|
+
return { synced: false, registrationState };
|
|
264
308
|
}
|
|
265
309
|
}
|
|
266
310
|
|
|
267
311
|
reset(): void {
|
|
268
312
|
this.sync(false);
|
|
269
313
|
}
|
|
270
|
-
get isRegistered(): boolean { return this.
|
|
314
|
+
get isRegistered(): boolean { return this.checkRegistration() === "verified"; }
|
|
315
|
+
get registrationState(): ContextToolRegistrationState { return this.checkRegistration(); }
|
|
271
316
|
}
|
|
272
317
|
|
|
273
318
|
export function registerContextManagementTools(
|
package/src/extension-runtime.ts
CHANGED
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
|
|
18
18
|
import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
|
|
19
19
|
import { CodexContextWindowManager } from "./context-management/window-manager";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
registerContextManagementTools,
|
|
22
|
+
type ContextToolRegistrationState,
|
|
23
|
+
} from "./context-management/tools";
|
|
21
24
|
import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
|
|
22
25
|
import {
|
|
23
26
|
COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
|
|
@@ -88,6 +91,13 @@ type RemoteContextActive = (
|
|
|
88
91
|
model?: ExtensionContext["model"],
|
|
89
92
|
) => Promise<boolean>;
|
|
90
93
|
|
|
94
|
+
type ContextToolSyncOutcome = {
|
|
95
|
+
eligible: boolean;
|
|
96
|
+
toolsSynced: boolean;
|
|
97
|
+
registrationState: ContextToolRegistrationState;
|
|
98
|
+
windowInitialized: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
91
101
|
type ResponsesCompactOutcome =
|
|
92
102
|
| { outcome: "success"; compaction: CompactionResult<NativeCompactionDetails> }
|
|
93
103
|
| { outcome: "aborted" }
|
|
@@ -301,6 +311,7 @@ async function runResponsesNativeCompact(
|
|
|
301
311
|
...serializeMessagesToResponsesInput(
|
|
302
312
|
compactor.currentModel,
|
|
303
313
|
[...projection.messages.slice(summaryIndex + 1)],
|
|
314
|
+
{ firstSystemMessageIsUpdate: true },
|
|
304
315
|
),
|
|
305
316
|
];
|
|
306
317
|
request = {
|
|
@@ -1014,28 +1025,60 @@ export default function registerCompactionExtension(
|
|
|
1014
1025
|
piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
|
|
1015
1026
|
};
|
|
1016
1027
|
let tools!: ReturnType<typeof registerContextManagementTools>;
|
|
1028
|
+
let contextWindowReady = false;
|
|
1029
|
+
const isContextRuntimeActive = async (
|
|
1030
|
+
ctx: ExtensionContext,
|
|
1031
|
+
config: CompactionConfig,
|
|
1032
|
+
model = ctx.model,
|
|
1033
|
+
): Promise<boolean> =>
|
|
1034
|
+
contextWindowReady && tools.isRegistered && await isRemoteContextActive(ctx, config, model);
|
|
1017
1035
|
tools = registerContextManagementTools(
|
|
1018
1036
|
pi,
|
|
1019
1037
|
contextWindows,
|
|
1020
1038
|
async (ctx) => {
|
|
1021
1039
|
const config = dependencies.loadConfig().config.compaction;
|
|
1022
|
-
return
|
|
1040
|
+
return isContextRuntimeActive(ctx, config);
|
|
1023
1041
|
},
|
|
1024
1042
|
() => dependencies.loadConfig().config.compaction.gatewayContextModels,
|
|
1025
1043
|
);
|
|
1026
|
-
const remoteContextActive: RemoteContextActive =
|
|
1027
|
-
|
|
1028
|
-
|
|
1044
|
+
const remoteContextActive: RemoteContextActive = isContextRuntimeActive;
|
|
1045
|
+
const syncTools = async (
|
|
1046
|
+
ctx: ExtensionContext,
|
|
1047
|
+
model = ctx.model,
|
|
1048
|
+
options: { notifyWindowFailure?: boolean } = {},
|
|
1049
|
+
): Promise<ContextToolSyncOutcome> => {
|
|
1050
|
+
// Until this call proves otherwise, do not let a previous session/window
|
|
1051
|
+
// identity make a failed activation look usable.
|
|
1052
|
+
contextWindowReady = false;
|
|
1029
1053
|
const config = dependencies.loadConfig().config.compaction;
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1054
|
+
const eligible = await isRemoteContextActive(ctx, config, model);
|
|
1055
|
+
const toolSync = tools.sync(eligible);
|
|
1056
|
+
const outcome: ContextToolSyncOutcome = {
|
|
1057
|
+
eligible,
|
|
1058
|
+
toolsSynced: toolSync.synced,
|
|
1059
|
+
registrationState: toolSync.registrationState,
|
|
1060
|
+
windowInitialized: false,
|
|
1061
|
+
};
|
|
1062
|
+
if (!eligible || !toolSync.synced) return outcome;
|
|
1063
|
+
// Activation can succeed after `session_start` missed it, because Pi 0.86 may
|
|
1064
|
+
// reject the registration read while a session replacement is still binding.
|
|
1065
|
+
// The window lifecycle has to open on that later activation too, or requests
|
|
1066
|
+
// carry no window metadata and the backend never ingests the turns.
|
|
1067
|
+
try {
|
|
1068
|
+
contextWindows.ensureInitialized(pi, ctx, true);
|
|
1069
|
+
contextWindowReady = true;
|
|
1070
|
+
return { ...outcome, windowInitialized: true };
|
|
1071
|
+
} catch {
|
|
1072
|
+
// Do not leave tools exposed while the request path has no valid window
|
|
1073
|
+
// identity. The next lifecycle hook may retry once Pi is usable again.
|
|
1074
|
+
tools.sync(false);
|
|
1075
|
+
if (options.notifyWindowFailure) notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
1076
|
+
return outcome;
|
|
1077
|
+
}
|
|
1036
1078
|
};
|
|
1037
1079
|
pi.on("session_start", async (_event, ctx) => {
|
|
1038
|
-
const
|
|
1080
|
+
const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
|
|
1081
|
+
const active = syncOutcome.eligible && syncOutcome.toolsSynced && syncOutcome.windowInitialized;
|
|
1039
1082
|
const { config: toolkitConfig, source, warnings } = dependencies.loadConfig();
|
|
1040
1083
|
const config = toolkitConfig.compaction;
|
|
1041
1084
|
if (!config.enabled) return;
|
|
@@ -1043,16 +1086,16 @@ export default function registerCompactionExtension(
|
|
|
1043
1086
|
let activationReason: string | undefined;
|
|
1044
1087
|
// Only models the built-in Remote Context coverage targets may activate or
|
|
1045
1088
|
// warn; everything else silently runs Pi's normal compaction path.
|
|
1046
|
-
if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config)) {
|
|
1047
|
-
if (
|
|
1048
|
-
try {
|
|
1049
|
-
contextWindows.ensureInitialized(pi, ctx, true);
|
|
1050
|
-
} catch {
|
|
1051
|
-
notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
1052
|
-
}
|
|
1053
|
-
} else if (!tools.isRegistered) {
|
|
1089
|
+
if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config) && !active) {
|
|
1090
|
+
if (syncOutcome.registrationState === "conflict") {
|
|
1054
1091
|
activationReason = "tool-name-conflict";
|
|
1055
1092
|
notifyRemoteContextFailure(ctx, activationReason);
|
|
1093
|
+
} else if (syncOutcome.registrationState === "unverified" || !syncOutcome.toolsSynced) {
|
|
1094
|
+
// Transient: the runtime has not published or accepted our tools yet. A
|
|
1095
|
+
// later before_agent_start re-verifies, so do not report provider failure.
|
|
1096
|
+
activationReason = "tool-registration-pending";
|
|
1097
|
+
} else if (syncOutcome.eligible && !syncOutcome.windowInitialized) {
|
|
1098
|
+
activationReason = "malformed-window-state";
|
|
1056
1099
|
} else {
|
|
1057
1100
|
const remoteResolution = await resolveCodexContextProvider(ctx, ctx.model, config.gatewayContextModels);
|
|
1058
1101
|
activationReason = remoteResolution.ok ? "codex-context-unavailable" : remoteResolution.reason;
|
|
@@ -1098,25 +1141,20 @@ export default function registerCompactionExtension(
|
|
|
1098
1141
|
pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
|
|
1099
1142
|
pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
|
|
1100
1143
|
pi.on("session_shutdown", () => {
|
|
1144
|
+
contextWindowReady = false;
|
|
1101
1145
|
contextWindows.reset();
|
|
1102
1146
|
tools.reset();
|
|
1103
1147
|
});
|
|
1104
1148
|
pi.on("model_select", async (event, ctx) => {
|
|
1105
|
-
const active = await syncTools(ctx, event.model);
|
|
1106
1149
|
// Switching into a covered model mid-session must open the window
|
|
1107
1150
|
// lifecycle immediately: without an identity the request rewrite skips
|
|
1108
1151
|
// window metadata, the backend never ingests those turns, and the first
|
|
1109
1152
|
// new_context would trim pre-switch history that no history can recover.
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1112
|
-
try {
|
|
1113
|
-
contextWindows.ensureInitialized(pi, ctx, true);
|
|
1114
|
-
} catch {
|
|
1115
|
-
notifyRemoteContextFailure(ctx, "malformed-window-state");
|
|
1116
|
-
}
|
|
1153
|
+
// syncTools() activates and initializes the window when the model is covered.
|
|
1154
|
+
await syncTools(ctx, event.model, { notifyWindowFailure: true });
|
|
1117
1155
|
});
|
|
1118
1156
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
1119
|
-
await syncTools(ctx);
|
|
1157
|
+
await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
|
|
1120
1158
|
});
|
|
1121
1159
|
pi.on("before_provider_request", (event, ctx) => handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive));
|
|
1122
1160
|
pi.on("before_provider_headers", async (event, ctx) => {
|
package/src/payload-rewrite.ts
CHANGED
|
@@ -337,7 +337,11 @@ export function removeNativeCompactionRetainedMessages(args: {
|
|
|
337
337
|
}
|
|
338
338
|
const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
|
|
339
339
|
if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
|
|
340
|
-
const
|
|
340
|
+
const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
|
|
341
|
+
(message) => message.role === "compactionSummary",
|
|
342
|
+
);
|
|
343
|
+
if (!summaryMessage) return { ok: false, reason: "compaction-summary-not-found" };
|
|
344
|
+
const summary = structuredClone(summaryMessage);
|
|
341
345
|
const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
|
|
342
346
|
if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
|
|
343
347
|
const retained = args.branchEntries
|
|
@@ -349,7 +353,13 @@ export function removeNativeCompactionRetainedMessages(args: {
|
|
|
349
353
|
// removed when present, while a missing or rewritten copy is left alone.
|
|
350
354
|
// Required user/assistant/tool messages keep the ordered, fail-closed
|
|
351
355
|
// matching contract.
|
|
352
|
-
|
|
356
|
+
// Pi 0.86 promotes transcript system messages to the leading prompt when it
|
|
357
|
+
// rebuilds context after compaction. They can therefore appear before the
|
|
358
|
+
// compaction summary even when their session entry lies inside the retained
|
|
359
|
+
// span; keep them in place instead of treating that relocation as a mismatch.
|
|
360
|
+
const requiredRetained = retained.filter(
|
|
361
|
+
(message) => message.role !== "custom" && (message.role as string) !== "system",
|
|
362
|
+
);
|
|
353
363
|
const optionalRetained = retained.filter((message) => message.role === "custom");
|
|
354
364
|
const removed = new Set<number>();
|
|
355
365
|
let cursor = summaryIndex + 1;
|
|
@@ -388,7 +398,9 @@ export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
|
|
|
388
398
|
model: Model<TApi>;
|
|
389
399
|
entries: readonly SessionEntry[];
|
|
390
400
|
}): ResponsesInputItem[] {
|
|
391
|
-
return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries)
|
|
401
|
+
return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries), {
|
|
402
|
+
firstSystemMessageIsUpdate: true,
|
|
403
|
+
});
|
|
392
404
|
}
|
|
393
405
|
|
|
394
406
|
function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
|
package/src/serializer.ts
CHANGED
|
@@ -89,6 +89,21 @@ export type ResponsesFunctionCallOutputItem = {
|
|
|
89
89
|
|
|
90
90
|
export type ResponsesReasoningItem = Record<string, unknown>;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Pi 0.86 adds `SystemMessage` to the public `Message` union; 0.85.1 has no system
|
|
94
|
+
* variant and does not export that type name. Use the public shape when the installed
|
|
95
|
+
* Pi declares it and a structural fallback for the fields this serializer consumes
|
|
96
|
+
* otherwise, so one source file type-checks against both supported versions.
|
|
97
|
+
*/
|
|
98
|
+
type ResponsesSystemMessage = Extract<Message, { role: "system" }> extends never
|
|
99
|
+
? {
|
|
100
|
+
role: "system";
|
|
101
|
+
content: string | TextContent[];
|
|
102
|
+
sections?: Record<string, string | null>;
|
|
103
|
+
timestamp: number;
|
|
104
|
+
}
|
|
105
|
+
: Extract<Message, { role: "system" }>;
|
|
106
|
+
|
|
92
107
|
export type ResponsesInputItem =
|
|
93
108
|
| ResponsesInputMessageItem
|
|
94
109
|
| ResponsesAssistantOutputItem
|
|
@@ -106,6 +121,8 @@ export type NativeCompactionRequestBody = CompactionRequestExtras & {
|
|
|
106
121
|
export type SerializeResponsesMessagesOptions = {
|
|
107
122
|
instructions?: string;
|
|
108
123
|
includeInstructionsInInput?: boolean;
|
|
124
|
+
/** Treat a system message at the start of a sliced post-compaction tail as an update. */
|
|
125
|
+
firstSystemMessageIsUpdate?: boolean;
|
|
109
126
|
};
|
|
110
127
|
|
|
111
128
|
export type ResponsesParityReport = {
|
|
@@ -126,6 +143,13 @@ function sanitizeSurrogates(text: string): string {
|
|
|
126
143
|
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
|
|
127
144
|
}
|
|
128
145
|
|
|
146
|
+
function instructionRole<TApi extends Api>(model: Model<TApi>): "developer" | "system" {
|
|
147
|
+
// Pi's public Model compat union is broader than the OpenAI Responses compat
|
|
148
|
+
// shape, while the runtime field is shared by both supported Pi versions.
|
|
149
|
+
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
|
150
|
+
return model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
|
|
130
154
|
return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
131
155
|
}
|
|
@@ -159,19 +183,38 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
|
|
|
159
183
|
messages: AgentMessage[],
|
|
160
184
|
options: SerializeResponsesMessagesOptions = {},
|
|
161
185
|
): ResponsesInputItem[] {
|
|
162
|
-
|
|
163
|
-
|
|
186
|
+
return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize Pi's normalized LLM message union without running `convertToLlm` again.
|
|
191
|
+
* This keeps the system-message boundary directly testable while preserving the public
|
|
192
|
+
* AgentMessage-facing entry point above.
|
|
193
|
+
*/
|
|
194
|
+
export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
|
|
195
|
+
model: Model<TApi>,
|
|
196
|
+
messages: Message[],
|
|
197
|
+
options: SerializeResponsesMessagesOptions = {},
|
|
198
|
+
): ResponsesInputItem[] {
|
|
199
|
+
const transformedMessages = transformMessagesForResponses(messages);
|
|
164
200
|
const input: ResponsesInputItem[] = [];
|
|
165
201
|
|
|
166
202
|
if (options.includeInstructionsInInput && options.instructions) {
|
|
167
203
|
input.push({
|
|
168
|
-
role: model
|
|
204
|
+
role: instructionRole(model),
|
|
169
205
|
content: sanitizeSurrogates(options.instructions),
|
|
170
206
|
});
|
|
171
207
|
}
|
|
172
208
|
|
|
173
209
|
let messageIndex = 0;
|
|
210
|
+
let sourceIndex = 0;
|
|
174
211
|
for (const message of transformedMessages) {
|
|
212
|
+
// Pi 0.85's Message union has no system member, so compare through the
|
|
213
|
+
// public role string while keeping the 0.86 discriminant runtime-compatible.
|
|
214
|
+
const isFirstMessage = sourceIndex++ === 0;
|
|
215
|
+
const isLeadingSystemMessage =
|
|
216
|
+
!options.firstSystemMessageIsUpdate && isFirstMessage && (message.role as string) === "system";
|
|
217
|
+
|
|
175
218
|
if (message.role === "user") {
|
|
176
219
|
const item = serializeUserMessage(message, model);
|
|
177
220
|
if (item) {
|
|
@@ -190,8 +233,21 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
|
|
|
190
233
|
continue;
|
|
191
234
|
}
|
|
192
235
|
|
|
193
|
-
|
|
194
|
-
|
|
236
|
+
if (message.role === "toolResult") {
|
|
237
|
+
input.push(serializeToolResultMessage(message, model));
|
|
238
|
+
messageIndex++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// In Pi 0.85.1 this branch narrows to `never`; in Pi 0.86 it is a SystemMessage.
|
|
243
|
+
// Handle it explicitly instead of letting a new message variant become tool output.
|
|
244
|
+
const systemItem = serializeSystemMessage(message, model, isLeadingSystemMessage);
|
|
245
|
+
if (systemItem) {
|
|
246
|
+
input.push(systemItem);
|
|
247
|
+
}
|
|
248
|
+
if (!isLeadingSystemMessage) {
|
|
249
|
+
messageIndex++;
|
|
250
|
+
}
|
|
195
251
|
}
|
|
196
252
|
|
|
197
253
|
return input;
|
|
@@ -446,6 +502,72 @@ function normalizeUserContent(content: UserMessage["content"]): Array<TextConten
|
|
|
446
502
|
return typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
447
503
|
}
|
|
448
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Serialize one normalized Pi system message as a Responses input message.
|
|
507
|
+
*
|
|
508
|
+
* The role follows the same reasoning-model convention as `instructions`: reasoning
|
|
509
|
+
* models use `developer`, everything else keeps `system`. Leading messages include
|
|
510
|
+
* their complete section state; later messages use Pi's update framing so section
|
|
511
|
+
* additions/removals are not mistaken for a new complete prompt. An empty system
|
|
512
|
+
* message produces no item, matching Pi.
|
|
513
|
+
*/
|
|
514
|
+
function serializeSystemMessage<TApi extends Api>(
|
|
515
|
+
message: ResponsesSystemMessage,
|
|
516
|
+
model: Model<TApi>,
|
|
517
|
+
isLeadingSystemMessage: boolean,
|
|
518
|
+
): ResponsesInputMessageItem | undefined {
|
|
519
|
+
const text = sanitizeSurrogates(
|
|
520
|
+
isLeadingSystemMessage ? getSystemMessageText(message) : renderSystemMessageUpdate(message),
|
|
521
|
+
);
|
|
522
|
+
if (text.length === 0) {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
role: instructionRole(model),
|
|
528
|
+
content: text,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Mirrors Pi's public getSystemMessageText() without importing a 0.86-only helper. */
|
|
533
|
+
function getSystemMessageText(message: ResponsesSystemMessage): string {
|
|
534
|
+
const parts = [contentText(message.content)];
|
|
535
|
+
for (const text of Object.values(message.sections ?? {})) {
|
|
536
|
+
if (text !== null) {
|
|
537
|
+
parts.push(text);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return parts.filter((part) => part.length > 0).join("\n\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Mirrors Pi's public renderSystemMessageUpdate() for later transcript updates. */
|
|
544
|
+
function renderSystemMessageUpdate(message: ResponsesSystemMessage): string {
|
|
545
|
+
const parts: string[] = [];
|
|
546
|
+
const text = contentText(message.content);
|
|
547
|
+
if (text.length > 0) {
|
|
548
|
+
parts.push(text);
|
|
549
|
+
}
|
|
550
|
+
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
|
551
|
+
parts.push(
|
|
552
|
+
value === null
|
|
553
|
+
? `Removed system prompt section "${name}".`
|
|
554
|
+
: `Updated system prompt section "${name}":\n\n${value}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return parts.join("\n\n");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function contentText(content: ResponsesSystemMessage["content"]): string {
|
|
561
|
+
if (typeof content === "string") {
|
|
562
|
+
return content;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return content
|
|
566
|
+
.filter((item): item is TextContent => item.type === "text")
|
|
567
|
+
.map((item) => item.text)
|
|
568
|
+
.join("\n");
|
|
569
|
+
}
|
|
570
|
+
|
|
449
571
|
function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
|
|
450
572
|
if (!block.thinkingSignature) {
|
|
451
573
|
return undefined;
|
package/tsconfig.check.json
CHANGED
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
"typeRoots": ["./node_modules/@types"]
|
|
13
13
|
},
|
|
14
14
|
"include": [
|
|
15
|
-
"extensions/compaction.ts",
|
|
16
|
-
"extensions/web-search.ts",
|
|
17
|
-
"extensions/image-generation.ts",
|
|
18
|
-
"extensions/auto-mode.ts",
|
|
19
15
|
"src/compact-client.ts",
|
|
20
16
|
"src/compaction-projection.ts",
|
|
21
17
|
"src/pi-context-hook.ts",
|
package/extensions/auto-mode.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "../src/auto-mode/extension.ts";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "../src/codex-astra/extension.ts";
|
package/extensions/compaction.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "../src/extension-runtime.ts";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "../src/image-generation/extension.ts";
|
package/extensions/web-search.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "../src/web-search/extension.ts";
|