pi-fold 1.0.2 → 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.
- package/README.md +29 -40
- package/extensions/active-context.ts +2374 -600
- package/extensions/evidence.js +15 -2
- package/extensions/index.js +69 -21
- package/extensions/json.ts +33 -1
- package/extensions/lib/curation.ts +141 -139
- package/extensions/lib/folding.ts +418 -74
- package/extensions/lib/instrumentation.ts +24 -4
- package/extensions/lib/measurement.ts +92 -16
- package/extensions/lib/persistence.ts +212 -42
- package/extensions/lib/policy.ts +511 -155
- package/extensions/lib/rollback.ts +216 -0
- package/extensions/lib/scheduling.ts +414 -106
- package/extensions/lib/selection.ts +528 -144
- package/extensions/lib/surfacing.ts +479 -224
- package/extensions/lib/tool-surface.ts +25 -5
- package/extensions/lib/transcript.ts +146 -62
- package/extensions/summarizer.js +206 -10
- package/package.json +1 -1
- package/extensions/lib/advisory.ts +0 -309
|
@@ -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
|
+
}
|