pi-openai-toolkit 0.14.11 → 0.14.13
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 +241 -240
- package/README.zh.md +241 -240
- package/docs/internals.md +128 -122
- package/package.json +127 -117
- package/src/config.ts +739 -720
- package/src/context-management/messages.ts +123 -0
- package/src/context-management/tools.ts +11 -0
- package/src/context-management/window-bulk.ts +149 -0
- package/src/context-management/window-manager.ts +693 -570
- package/src/extension-runtime.ts +1329 -1208
- package/src/native-fallback.ts +218 -145
- package/src/types.ts +628 -619
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { getCurrentSystemMessage, getCurrentTools } from "@earendil-works/pi-ai";
|
|
3
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import {
|
|
5
6
|
CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
|
|
@@ -113,3 +114,125 @@ export function sendContextWindowMessage(
|
|
|
113
114
|
? { deliverAs: "steer", triggerTurn: true }
|
|
114
115
|
: { triggerTurn: false });
|
|
115
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Whether a transcript message is a Pi system message (prompt sections and tool deltas). */
|
|
119
|
+
export function isSystemTranscriptMessage(message: AgentMessage): boolean {
|
|
120
|
+
return (message as unknown as { role?: string }).role === "system";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Tool names the provider would send for this message list, after replaying every delta. */
|
|
124
|
+
export function declaredToolNames(messages: readonly AgentMessage[]): string[] {
|
|
125
|
+
return getCurrentTools(messages).map((tool) => tool.name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type SystemHeadRepair<TMessage extends AgentMessage = AgentMessage> = {
|
|
129
|
+
/** The window to send: trimmed, trimmed plus a rebuilt head, or untrimmed when the trim is unsafe. */
|
|
130
|
+
readonly messages: readonly TMessage[];
|
|
131
|
+
/** A head from before the boundary was re-anchored at the front of the window. */
|
|
132
|
+
repaired: boolean;
|
|
133
|
+
/** The trim was refused because the window would lose declared tools. */
|
|
134
|
+
shrinkRejected: boolean;
|
|
135
|
+
/** Tool names the trimmed window would have lost; empty on every safe path. */
|
|
136
|
+
lostToolNames: string[];
|
|
137
|
+
/** The system message this window anchors on, when one could be determined. */
|
|
138
|
+
head: AgentMessage | undefined;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Keep Pi 0.86's prompt and tool head alive across a context-window trim.
|
|
143
|
+
*
|
|
144
|
+
* Pi 0.86 no longer sends the system prompt and the tool loadout beside the
|
|
145
|
+
* transcript: `Agent#createContextSnapshot()` hands over messages only, and the
|
|
146
|
+
* Responses transport rebuilds `params.tools` from the `system` messages inside the
|
|
147
|
+
* message list (`resolveTranscriptTools(getInitialSystemMessage(messages))`). Trimming
|
|
148
|
+
* everything before a window boundary therefore deletes the one message that declares
|
|
149
|
+
* every tool, and Pi does not re-declare it because the persisted transcript still looks
|
|
150
|
+
* unchanged. The result is a window the model can only answer from memory.
|
|
151
|
+
*
|
|
152
|
+
* The merged state of the trimmed-away system messages is re-anchored as the leading
|
|
153
|
+
* message of the window. When even that would lose declared tools, the trim is refused:
|
|
154
|
+
* a window that still carries the previous turns beats a window with no tools at all.
|
|
155
|
+
*/
|
|
156
|
+
export function preserveSystemHead<TMessage extends AgentMessage>(args: {
|
|
157
|
+
readonly messages: readonly TMessage[];
|
|
158
|
+
boundaryIndex: number;
|
|
159
|
+
/**
|
|
160
|
+
* Head observed in an earlier projection. A durable compaction can drop the original
|
|
161
|
+
* declaration from the branch, leaving nothing to rebuild from, so the last known head
|
|
162
|
+
* is re-anchored instead of sending a window with no tools.
|
|
163
|
+
*/
|
|
164
|
+
fallbackHead?: AgentMessage;
|
|
165
|
+
}): SystemHeadRepair<TMessage> {
|
|
166
|
+
const { messages, boundaryIndex, fallbackHead } = args;
|
|
167
|
+
const window = messages.slice(Math.max(boundaryIndex, 0));
|
|
168
|
+
const prefix = messages.slice(0, Math.max(boundaryIndex, 0));
|
|
169
|
+
|
|
170
|
+
const anchorFallback = (): SystemHeadRepair<TMessage> | undefined => {
|
|
171
|
+
if (!fallbackHead || messages.some(isSystemTranscriptMessage)) return undefined;
|
|
172
|
+
// A boundary compaction can retire the entry that declared the tools, leaving a
|
|
173
|
+
// branch whose only system state is gone. Nothing in the list can be rebuilt, so
|
|
174
|
+
// the head remembered from an earlier projection is re-anchored instead.
|
|
175
|
+
const reused: readonly TMessage[] = [
|
|
176
|
+
fallbackHead as unknown as TMessage,
|
|
177
|
+
...window.filter((message) => !isSystemTranscriptMessage(message)),
|
|
178
|
+
];
|
|
179
|
+
return { messages: reused, repaired: true, shrinkRejected: false, lostToolNames: [], head: fallbackHead };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (boundaryIndex <= 0 || prefix.length === 0) {
|
|
183
|
+
return anchorFallback() ?? {
|
|
184
|
+
messages: window,
|
|
185
|
+
repaired: false,
|
|
186
|
+
shrinkRejected: false,
|
|
187
|
+
lostToolNames: [],
|
|
188
|
+
head: getCurrentSystemMessage(messages) ?? fallbackHead,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const declared = declaredToolNames(messages);
|
|
193
|
+
const completeHead = getCurrentSystemMessage(messages);
|
|
194
|
+
if (!completeHead) {
|
|
195
|
+
const reused = anchorFallback();
|
|
196
|
+
if (reused) return reused;
|
|
197
|
+
if (!messages.some(isSystemTranscriptMessage) && declared.length === 0) {
|
|
198
|
+
// Pre-0.86 transcripts carry no system messages: the trim stays exactly as before.
|
|
199
|
+
return { messages: window, repaired: false, shrinkRejected: false, lostToolNames: [], head: undefined };
|
|
200
|
+
}
|
|
201
|
+
return { messages, repaired: false, shrinkRejected: true, lostToolNames: declared, head: undefined };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const windowFirst = window[0];
|
|
205
|
+
if (windowFirst && isSystemTranscriptMessage(windowFirst)) {
|
|
206
|
+
const kept = new Set(declaredToolNames(window));
|
|
207
|
+
const lostFromWindow = declared.filter((name) => !kept.has(name));
|
|
208
|
+
if (lostFromWindow.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
messages: window,
|
|
211
|
+
repaired: false,
|
|
212
|
+
shrinkRejected: false,
|
|
213
|
+
lostToolNames: [],
|
|
214
|
+
head: windowFirst as unknown as AgentMessage,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
messages,
|
|
219
|
+
repaired: false,
|
|
220
|
+
shrinkRejected: true,
|
|
221
|
+
lostToolNames: lostFromWindow,
|
|
222
|
+
head: windowFirst as unknown as AgentMessage,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Later system messages are folded into the rebuilt head, so a patch cannot be read
|
|
227
|
+
// as a complete declaration by a transport that only anchors the first one.
|
|
228
|
+
const rebuilt: readonly TMessage[] = [
|
|
229
|
+
completeHead as unknown as TMessage,
|
|
230
|
+
...window.filter((message) => !isSystemTranscriptMessage(message)),
|
|
231
|
+
];
|
|
232
|
+
const after = new Set(declaredToolNames(rebuilt));
|
|
233
|
+
const lostToolNames = declared.filter((name) => !after.has(name));
|
|
234
|
+
if (lostToolNames.length > 0) {
|
|
235
|
+
return { messages, repaired: false, shrinkRejected: true, lostToolNames, head: completeHead };
|
|
236
|
+
}
|
|
237
|
+
return { messages: rebuilt, repaired: true, shrinkRejected: false, lostToolNames: [], head: completeHead };
|
|
238
|
+
}
|
|
@@ -77,6 +77,11 @@ export const NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE =
|
|
|
77
77
|
+ "Write the active request, decisions, progress and next steps with notes append_to_file or write_file, "
|
|
78
78
|
+ "wait until that notes result is persisted, then retry new_context in a later tool turn. "
|
|
79
79
|
+ "Do not pass force arguments and do not repeat new_context while a rollover is already scheduled.";
|
|
80
|
+
|
|
81
|
+
export const NEW_CONTEXT_COOLDOWN_MESSAGE =
|
|
82
|
+
"new_context refused: this window was entered by a context switch and has not run any substantive "
|
|
83
|
+
+ "tool yet. Read the checkpoint receipt with notes read_file, continue the active task with real "
|
|
84
|
+
+ "tools, and call new_context only once this window has produced tool activity.";
|
|
80
85
|
export interface ContextRemainingDetails {
|
|
81
86
|
remainingTokens?: number;
|
|
82
87
|
windowId?: string;
|
|
@@ -120,6 +125,12 @@ export function createContextManagementTools(
|
|
|
120
125
|
details: { started: false },
|
|
121
126
|
};
|
|
122
127
|
}
|
|
128
|
+
if (!manager.canRolloverFromCurrentWindow(ctx)) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: NEW_CONTEXT_COOLDOWN_MESSAGE }],
|
|
131
|
+
details: { started: false },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
123
134
|
if (!manager.hasNotesCheckpointSinceBoundary(ctx)) {
|
|
124
135
|
throw new Error(NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE);
|
|
125
136
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionContext,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type SessionEntry,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
8
|
+
import type { LeaveManagedModePolicy } from "../types";
|
|
9
|
+
import { isContextWindowBoundary } from "./messages";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Share of the target model's context window above which a projection is considered
|
|
13
|
+
* over budget. Estimates are conservative (`estimateTokens` is chars/4 and overestimates),
|
|
14
|
+
* so the trigger fires slightly early rather than after a hard provider failure.
|
|
15
|
+
*/
|
|
16
|
+
export const BULK_CLIFF_RATIO = 0.8;
|
|
17
|
+
|
|
18
|
+
/** How much larger the durable view must be than the windowed view to call it a cliff. */
|
|
19
|
+
export const BULK_EXPANSION_RATIO = 2;
|
|
20
|
+
|
|
21
|
+
export type WindowBulkReport = {
|
|
22
|
+
/** The window the durable view is measured against; a notice is surfaced once per window and model. */
|
|
23
|
+
readonly windowId: string | undefined;
|
|
24
|
+
/** Tokens an uncovered model would receive: the whole branch, markers filtered. */
|
|
25
|
+
readonly unmanagedTokens: number;
|
|
26
|
+
/** Tokens a covered model receives: the current window only. */
|
|
27
|
+
readonly managedTokens: number;
|
|
28
|
+
readonly targetContextWindow: number;
|
|
29
|
+
/** The durable view exceeds `BULK_CLIFF_RATIO` of the model that would receive it. */
|
|
30
|
+
readonly overBudget: boolean;
|
|
31
|
+
/** The durable view is at least `BULK_EXPANSION_RATIO` times the windowed view. */
|
|
32
|
+
readonly expanded: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** What the runtime should do about a measured cliff. */
|
|
36
|
+
export type BulkCliffAction = "ignore" | "warn" | "compact";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `warn` is the default because an automatic compaction costs a real model call. `compact`
|
|
40
|
+
* only acts when the durable transcript is also past the target window: a session that is
|
|
41
|
+
* merely bigger than its windowed view still fits, and compacting it would be pure loss.
|
|
42
|
+
*/
|
|
43
|
+
export function decideBulkCliffAction(
|
|
44
|
+
report: WindowBulkReport,
|
|
45
|
+
policy: LeaveManagedModePolicy,
|
|
46
|
+
): BulkCliffAction {
|
|
47
|
+
if (!report.expanded) return "ignore";
|
|
48
|
+
if (policy === "compact" && report.overBudget) return "compact";
|
|
49
|
+
return "warn";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type BulkCloseOutContext = {
|
|
53
|
+
/** What the policy decided for this report. */
|
|
54
|
+
action: BulkCliffAction;
|
|
55
|
+
/** Pi's run mode: print sessions exit with the turn, so they cannot await a compaction. */
|
|
56
|
+
mode: string;
|
|
57
|
+
/** When the measurement happened. */
|
|
58
|
+
trigger: "model-switch" | "turn-end";
|
|
59
|
+
/** Whether a rollover trim is queued for the next eligible compaction. */
|
|
60
|
+
hasPendingTrim: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Turn a decided action into one that is safe to execute here.
|
|
65
|
+
*
|
|
66
|
+
* `ExtensionContext.compact()` is fire-and-forget and aborts the running turn. In print mode
|
|
67
|
+
* the process exits with the turn, so the summary would be killed mid-request; a turn-end
|
|
68
|
+
* close-out on a covered model is also pointless unless a rollover trim is queued, because
|
|
69
|
+
* the window policy cancels any other compaction. Both cases degrade to the warning.
|
|
70
|
+
*/
|
|
71
|
+
export function decideBulkCloseOut(context: BulkCloseOutContext): BulkCliffAction {
|
|
72
|
+
if (context.action !== "compact") return context.action;
|
|
73
|
+
if (context.mode === "print") return "warn";
|
|
74
|
+
if (context.trigger === "turn-end" && !context.hasPendingTrim) return "warn";
|
|
75
|
+
return "compact";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sumTokens(messages: readonly AgentMessage[]): number {
|
|
79
|
+
let tokens = 0;
|
|
80
|
+
for (const message of messages) tokens += estimateTokens(message as never);
|
|
81
|
+
return tokens;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Measure the gap between the durable transcript and the remote window view.
|
|
86
|
+
*
|
|
87
|
+
* Remote context management retires previous windows *in the request projection*; the
|
|
88
|
+
* session branch keeps them, and a boundary compaction only lands when a compaction
|
|
89
|
+
* attempt consumes the scheduled trim. That asymmetry is invisible while a covered model
|
|
90
|
+
* runs and becomes a cliff the moment the session talks to a model without remote context:
|
|
91
|
+
* a window that streamed 25k tokens per request can hand over 400k tokens of history that
|
|
92
|
+
* no model has ever been shown, blowing past the new window and making the first manual
|
|
93
|
+
* `/compact` summarize the whole pile.
|
|
94
|
+
*
|
|
95
|
+
* Returns `undefined` when nothing has been retired yet, so young sessions and plain
|
|
96
|
+
* Pi transcripts never see a report.
|
|
97
|
+
*/
|
|
98
|
+
export function measureWindowBulk(args: {
|
|
99
|
+
readonly messages: readonly AgentMessage[];
|
|
100
|
+
contextWindow: number | undefined;
|
|
101
|
+
}): WindowBulkReport | undefined {
|
|
102
|
+
const { messages, contextWindow } = args;
|
|
103
|
+
let boundaryIndex = -1;
|
|
104
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
105
|
+
if (isContextWindowBoundary(messages[index]!)) boundaryIndex = index;
|
|
106
|
+
}
|
|
107
|
+
if (boundaryIndex < 1) return undefined;
|
|
108
|
+
|
|
109
|
+
const boundaryMessage = messages[boundaryIndex]!;
|
|
110
|
+
const windowId = isContextWindowBoundary(boundaryMessage)
|
|
111
|
+
? boundaryMessage.details.contextManagement.currentWindowId
|
|
112
|
+
: undefined;
|
|
113
|
+
const window = messages.slice(boundaryIndex);
|
|
114
|
+
const managedTokens = sumTokens(window);
|
|
115
|
+
const unmanagedTokens = managedTokens + sumTokens(
|
|
116
|
+
messages
|
|
117
|
+
.slice(0, boundaryIndex)
|
|
118
|
+
.filter((message) => !isContextWindowBoundary(message)),
|
|
119
|
+
);
|
|
120
|
+
const targetContextWindow = typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : 0;
|
|
121
|
+
return {
|
|
122
|
+
windowId,
|
|
123
|
+
unmanagedTokens,
|
|
124
|
+
managedTokens,
|
|
125
|
+
targetContextWindow,
|
|
126
|
+
overBudget: targetContextWindow > 0 && unmanagedTokens > targetContextWindow * BULK_CLIFF_RATIO,
|
|
127
|
+
expanded: unmanagedTokens > managedTokens * BULK_EXPANSION_RATIO,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The provider-visible message list for the current branch: Pi's compaction-anchored
|
|
133
|
+
* context, exactly as an uncovered model would receive it before the toolkit trims.
|
|
134
|
+
*/
|
|
135
|
+
export function durableContextMessages(entries: readonly SessionEntry[]): AgentMessage[] {
|
|
136
|
+
return buildSessionContext(entries as never[]).messages as unknown as AgentMessage[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Measure the current branch for a bulk cliff. Returns `undefined` when the session has
|
|
141
|
+
* no window state, cannot report a target window, or has retired nothing.
|
|
142
|
+
*/
|
|
143
|
+
export function evaluateWindowBulk(ctx: Pick<ExtensionContext, "sessionManager" | "model">): WindowBulkReport | undefined {
|
|
144
|
+
const contextWindow = ctx.model?.contextWindow;
|
|
145
|
+
if (typeof contextWindow !== "number" || contextWindow <= 0) return undefined;
|
|
146
|
+
const messages = durableContextMessages(ctx.sessionManager.getBranch() as never[]);
|
|
147
|
+
if (!messages.some(isContextWindowBoundary)) return undefined;
|
|
148
|
+
return measureWindowBulk({ messages, contextWindow });
|
|
149
|
+
}
|