pi-fold 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Tree rollback on provider input-overflow.
3
+ *
4
+ * A request whose projection exceeds the provider input budget is rejected outright.
5
+ * The recovery is not a deeper fold, it is a rollback: move the session leaf back to
6
+ * just before the entry that failed, let the retried request's own projection pass do
7
+ * the ordinary fold commit, and tell the agent what happened. Everything here is pure:
8
+ * the surface probes, the error-entry search and the replayability test. The lane that
9
+ * uses them lives in `active-context.ts`, because it is the only place that owns the
10
+ * session handle and the event stream.
11
+ *
12
+ * The whole file is written against pi 0.83.0 internals that the extension contract
13
+ * does not promise. That is deliberate and it is why the probes exist: a surface that
14
+ * moved is a lane that refuses to arm, loudly, rather than a rollback that half runs.
15
+ */
16
+
17
+ /** One probed surface: what was asked of it, and whether it answered. */
18
+ export interface RollbackProbe {
19
+ name: string;
20
+ present: boolean;
21
+ /** Null when the surface carries no arity contract. */
22
+ arity: number | null;
23
+ expectedArity: number | null;
24
+ /** A surface the lane cannot run without. */
25
+ required: boolean;
26
+ }
27
+
28
+ export interface RollbackProbeReport {
29
+ probes: RollbackProbe[];
30
+ failures: string[];
31
+ /** Every REQUIRED surface answered. */
32
+ armed: boolean;
33
+ }
34
+
35
+ /**
36
+ * The session-manager surfaces the lane calls, and the arities it calls them with.
37
+ *
38
+ * `branch` and `resetLeaf` are the rollback itself; `appendLabelChange` is the lineage
39
+ * marker, and it must run BEFORE the branch because it appends at the current leaf and
40
+ * advances it, so a label applied afterwards lands on the surviving path instead of the
41
+ * abandoned one. The readers are required because the lane cannot locate the error
42
+ * entry without them.
43
+ */
44
+ const SESSION_MANAGER_PROBES: ReadonlyArray<{ name: string; arity: number | null; required: boolean }> = Object.freeze([
45
+ { name: "branch", arity: 1, required: true },
46
+ { name: "resetLeaf", arity: 0, required: true },
47
+ { name: "getLeafId", arity: 0, required: true },
48
+ { name: "getEntry", arity: 1, required: true },
49
+ { name: "getBranch", arity: 0, required: true },
50
+ { name: "buildContextEntries", arity: 0, required: true },
51
+ { name: "appendLabelChange", arity: 2, required: true },
52
+ ]);
53
+
54
+ export function probeRollbackSurfaces(sessionManager: unknown): RollbackProbeReport {
55
+ const probes: RollbackProbe[] = [];
56
+ const failures: string[] = [];
57
+ const host = sessionManager as Record<string, unknown> | null | undefined;
58
+ for (const expected of SESSION_MANAGER_PROBES) {
59
+ const member = host && typeof host === "object" ? host[expected.name] : undefined;
60
+ const present = typeof member === "function";
61
+ const arity = present ? (member as (...args: unknown[]) => unknown).length : null;
62
+ probes.push({
63
+ name: `sessionManager.${expected.name}`,
64
+ present,
65
+ arity,
66
+ expectedArity: expected.arity,
67
+ required: expected.required,
68
+ });
69
+ if (!present) {
70
+ if (expected.required) failures.push(`sessionManager.${expected.name} is missing`);
71
+ continue;
72
+ }
73
+ // Arity is advisory on purpose: a host that adds an optional parameter has not
74
+ // broken the call, and refusing to arm over it would be a false alarm. A MISSING
75
+ // parameter has broken it, so the probe fails only when the surface takes fewer
76
+ // arguments than the lane passes.
77
+ if (expected.arity !== null && arity !== null && arity < expected.arity && expected.required) {
78
+ failures.push(`sessionManager.${expected.name} takes ${arity} argument(s), the lane passes ${expected.arity}`);
79
+ }
80
+ }
81
+ return { probes, failures, armed: failures.length === 0 };
82
+ }
83
+
84
+ /** The keys `_runAutoCompaction` puts on the overflow event, and the lane reads. */
85
+ const OVERFLOW_EVENT_KEYS: ReadonlyArray<string> = Object.freeze([
86
+ "reason", "willRetry", "branchEntries", "signal",
87
+ ]);
88
+
89
+ export function overflowEventShape(event: unknown): { ok: boolean; missing: string[] } {
90
+ const missing: string[] = [];
91
+ const record = event as Record<string, unknown> | null | undefined;
92
+ for (const key of OVERFLOW_EVENT_KEYS) {
93
+ if (!record || typeof record !== "object" || !Object.prototype.hasOwnProperty.call(record, key)) {
94
+ missing.push(key);
95
+ }
96
+ }
97
+ return { ok: missing.length === 0, missing };
98
+ }
99
+
100
+ function entryMessage(entry: unknown): Record<string, unknown> | null {
101
+ const record = entry as Record<string, unknown> | null | undefined;
102
+ if (!record || typeof record !== "object" || record.type !== "message") return null;
103
+ const message = record.message;
104
+ return message && typeof message === "object" ? message as Record<string, unknown> : null;
105
+ }
106
+
107
+ /**
108
+ * The entry the provider rejected: the trailing assistant message whose stop reason is
109
+ * the error pi turned the rejection into.
110
+ *
111
+ * It must be the last MESSAGE on the branch. Custom entries after it are telemetry --
112
+ * this runtime's own event stream appends there -- and they go with the rollback, which
113
+ * is what should happen to records of a request that never landed. A later CONVERSATION
114
+ * entry is a different story: something else already moved the leaf, and rolling back to
115
+ * this entry's parent would abandon work the lane never looked at.
116
+ */
117
+ export function findOverflowErrorEntry(branchEntries: unknown[]): {
118
+ id: string;
119
+ parentId: string | null;
120
+ index: number;
121
+ } | null {
122
+ if (!Array.isArray(branchEntries) || !branchEntries.length) return null;
123
+ let index = branchEntries.length - 1;
124
+ while (index >= 0 && !entryMessage(branchEntries[index])) index -= 1;
125
+ if (index < 0) return null;
126
+ const entry = branchEntries[index] as Record<string, unknown>;
127
+ const message = entryMessage(entry);
128
+ if (!message || message.role !== "assistant" || message.stopReason !== "error") return null;
129
+ const id = typeof entry.id === "string" ? entry.id : "";
130
+ if (!id) return null;
131
+ const parentId = typeof entry.parentId === "string" ? entry.parentId : null;
132
+ return { id, parentId, index };
133
+ }
134
+
135
+ function contentParts(message: Record<string, unknown>): Record<string, unknown>[] {
136
+ const content = message.content;
137
+ if (!Array.isArray(content)) return [];
138
+ return content.filter((part): part is Record<string, unknown> =>
139
+ Boolean(part) && typeof part === "object");
140
+ }
141
+
142
+ /**
143
+ * Tool calls the rolled-back tail leaves unanswered.
144
+ *
145
+ * Possible only when a run died between persisting an assistant tool-call message and
146
+ * persisting its results. A queued user turn after an unsatisfied tool call is a
147
+ * malformed transcript for every provider, so this is the one shape where the rollback
148
+ * happens and the replay does not.
149
+ */
150
+ export function unansweredToolCalls(branchEntries: unknown[]): string[] {
151
+ if (!Array.isArray(branchEntries) || !branchEntries.length) return [];
152
+ const answered = new Set<string>();
153
+ let pending: string[] = [];
154
+ for (const entry of branchEntries) {
155
+ const message = entryMessage(entry);
156
+ if (!message) continue;
157
+ if (message.role === "assistant") {
158
+ const calls = contentParts(message)
159
+ .filter((part) => part.type === "toolCall" && typeof part.id === "string")
160
+ .map((part) => String(part.id));
161
+ if (calls.length) pending = calls;
162
+ else if (message.stopReason !== "error") pending = [];
163
+ continue;
164
+ }
165
+ if (message.role === "toolResult" && typeof message.toolCallId === "string") {
166
+ answered.add(message.toolCallId);
167
+ }
168
+ }
169
+ return pending.filter((id) => !answered.has(id));
170
+ }
171
+
172
+ /**
173
+ * The invariant the whole lane rests on, stated as arithmetic.
174
+ *
175
+ * pi strips the trailing error message from `agent.state.messages` before it emits the
176
+ * overflow event, so at the recovery point agent state already equals the rolled-back
177
+ * branch, and moving the session leaf is the only step left. That equality is an
178
+ * ordering of two internal steps rather than a promise, so it is measured instead of
179
+ * assumed: the rebuilt session context must come back exactly the error entry's own
180
+ * contribution shorter, no more and no less. More means the branch overshot; less means
181
+ * it did not move, and either way the retry would send a window nobody built.
182
+ */
183
+ export function preStripHolds(input: {
184
+ sessionMessagesBefore: number;
185
+ sessionMessagesAfter: number;
186
+ errorEntryMessages: number;
187
+ }): boolean {
188
+ return input.sessionMessagesAfter === input.sessionMessagesBefore - input.errorEntryMessages;
189
+ }
190
+
191
+ /**
192
+ * The notice the agent reads on the retried turn.
193
+ *
194
+ * One message, because pi drains steering one at a time. It says the three things the
195
+ * agent cannot see for itself: the request was rejected for size, the session rolled
196
+ * back and folded, and its work continues from where it was.
197
+ */
198
+ export function rollbackNoticeText(input: {
199
+ brandNoun: string;
200
+ toolName: string;
201
+ tokensRolledBack: number;
202
+ entriesAbandoned: number;
203
+ replayed: boolean;
204
+ replaySkipReason: string | null;
205
+ }): string {
206
+ const head = "The provider rejected the last request because the context exceeded its input limit. " +
207
+ `${input.brandNoun} rolled the session back past that request (${input.entriesAbandoned} entry(s), ` +
208
+ `about ${input.tokensRolledBack} tokens, kept in the tree as an abandoned branch) and folded the ` +
209
+ "stale window to make room.";
210
+ if (input.replayed) {
211
+ return `${head} The request is being reissued now, so continue the work you were doing; nothing was lost, ` +
212
+ `and the folded material is still readable through ${input.toolName}.`;
213
+ }
214
+ return `${head} The request was NOT reissued: ${input.replaySkipReason ?? "the rolled-back tail is not replayable"}. ` +
215
+ "Reissue the work yourself from the state you can see.";
216
+ }