pi-tuck 1.0.9 → 1.0.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/CHANGELOG.md +90 -0
- package/README.md +36 -21
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/clip.d.ts +8 -0
- package/dist/src/clip.js +13 -0
- package/dist/src/compaction.d.ts +3 -0
- package/dist/src/compaction.js +157 -95
- package/dist/src/diagnostic-replay.d.ts +1 -0
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/diagnostics.js +1 -1
- package/dist/src/tuck.d.ts +4 -1
- package/dist/src/tuck.js +12 -4
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/clip.ts +13 -0
- package/src/compaction.ts +163 -85
- package/src/diagnostics.ts +1 -1
- package/src/tuck.ts +15 -6
package/src/compaction.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { exportSource } from "./source-export.js";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
|
-
import { applyClips, clipFor, type Clip } from "./clip.js";
|
|
8
|
+
import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
|
|
9
9
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
|
|
10
10
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
11
|
-
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
|
+
import { isContextOverflow, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
12
12
|
import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import { Check } from "typebox/value";
|
|
@@ -54,6 +54,11 @@ class CapacityError extends Refusal {}
|
|
|
54
54
|
export const refusalNotice = (message: string, factor: number): string =>
|
|
55
55
|
message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
|
|
56
56
|
(factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
|
|
57
|
+
/** What the operator hears when the provider refuses a request pi-tuck admitted as over its window: the bound that reply puts on the counting, and
|
|
58
|
+
* what pi-tuck did with it. A bound pi-tuck cannot carry (above the ceiling) says so rather than implying the next request is planned at it. */
|
|
59
|
+
export const overflowNotice = (contextWindow: number, bound: number, learned: boolean): string =>
|
|
60
|
+
`pi-tuck: the provider refused this request as over its ${contextWindow}-token window, so the input counted at least ${bound.toFixed(2)}x what pi-tuck estimated` +
|
|
61
|
+
(learned ? "; the next request is planned at that." : "; too far out to be a low estimate, so the factor is unchanged.");
|
|
57
62
|
/** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
|
|
58
63
|
export const CALIBRATION_CEILING = 1.5;
|
|
59
64
|
/** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
|
|
@@ -232,16 +237,27 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
232
237
|
!isDeepStrictEqual(convertToLlm(paired.messages), paired.snapshot)) return;
|
|
233
238
|
// The provider refused this exact request for exceeding the window: its true count was at least window + 1, a lower bound on the ratio. A
|
|
234
239
|
// bound above the ceiling says the estimate is not merely low, so it is not learned: the overflow ends the attempt as it does without it.
|
|
235
|
-
|
|
240
|
+
// Either way the operator hears it from pi-tuck. The estimate is what admitted the request, and a turn that ended in the provider's error
|
|
241
|
+
// with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
|
|
242
|
+
if (overflow) {
|
|
243
|
+
const bound = (ctx.model.contextWindow + 1) / paired.tokens;
|
|
244
|
+
const learned = bound <= CALIBRATION_CEILING;
|
|
245
|
+
ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
|
|
246
|
+
if (learned) learn(bound);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
236
249
|
const usage = message.usage;
|
|
237
250
|
if (!usage) return;
|
|
238
251
|
const counts = [usage.input, usage.cacheRead, usage.cacheWrite];
|
|
239
252
|
if (!counts.every(n => Number.isSafeInteger(n) && n >= 0)) return;
|
|
240
253
|
const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
|
|
241
|
-
|
|
254
|
+
// A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
|
|
255
|
+
// Learning from one sets the factor for the requests after it: a 60,000-token cache read reported in a 32,768-token window made a
|
|
256
|
+
// 6,000-token prompt refuse. The overflow branch above learns from a refusal, where the count is a lower bound, and the ceiling is unchanged.
|
|
257
|
+
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
242
258
|
});
|
|
243
259
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
244
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
260
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
245
261
|
const active = () => options.active?.() ?? true;
|
|
246
262
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
247
263
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -284,8 +300,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
284
300
|
const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
285
301
|
// A missing model is configuration, not capacity.
|
|
286
302
|
const missingModel = () => new Error("No context model for the request budget");
|
|
287
|
-
//
|
|
288
|
-
|
|
303
|
+
// A prompt's cut, until its run saves it at its first boundary: every request of that run carries it (the context hook). A run that ends
|
|
304
|
+
// before a boundary drops it, and the next prompt decides afresh.
|
|
305
|
+
type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
|
|
306
|
+
let promptCut: CutEntry | undefined;
|
|
289
307
|
let failure: Error | undefined;
|
|
290
308
|
let pendingBoundaryCommit = false;
|
|
291
309
|
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
@@ -293,13 +311,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
293
311
|
const clips = new Map<string, Clip>();
|
|
294
312
|
const fail = (error: unknown, ctx: ExtensionContext) => {
|
|
295
313
|
recorder.stop("native-attempt-failed");
|
|
296
|
-
|
|
314
|
+
// Every refusal says why, once per attempt: a capacity error with what the user can change, a cut Pi failed to commit with what that
|
|
315
|
+
// means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
|
|
316
|
+
const notify = !failure;
|
|
297
317
|
// A CommitFault stays whatever fails after it.
|
|
298
318
|
if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
|
|
299
319
|
pendingBoundaryCommit = false;
|
|
300
320
|
pending = undefined;
|
|
321
|
+
promptCut = undefined;
|
|
301
322
|
try { options.fail?.(error); } finally {
|
|
302
|
-
try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor)
|
|
323
|
+
try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
|
|
324
|
+
? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
|
|
325
|
+
: `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
|
|
303
326
|
finally { ctx.abort(); }
|
|
304
327
|
}
|
|
305
328
|
};
|
|
@@ -370,9 +393,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
370
393
|
try {
|
|
371
394
|
if (!active()) return { cancel: true };
|
|
372
395
|
if (event.signal.aborted || ctx.signal?.aborted) { recorder.stop("cut-cancelled"); return { cancel: true }; }
|
|
373
|
-
// Pi
|
|
374
|
-
|
|
375
|
-
|
|
396
|
+
// Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
|
|
397
|
+
// is the answer, and it is saved before the first request.
|
|
398
|
+
if (promptCut && !admittedRun && event.reason === "threshold") {
|
|
399
|
+
access();
|
|
400
|
+
const { type: _, ...compaction } = promptCut;
|
|
401
|
+
promptCut = undefined;
|
|
402
|
+
return { compaction };
|
|
403
|
+
}
|
|
404
|
+
const operatorManual = event.reason === "manual";
|
|
376
405
|
if (operatorManual && !admittedRun) forgetFailure();
|
|
377
406
|
access();
|
|
378
407
|
if (event.customInstructions?.trim()) {
|
|
@@ -382,19 +411,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
382
411
|
}
|
|
383
412
|
const { response, room, band } = limits(ctx);
|
|
384
413
|
const retain = operatorManual || admittedRun;
|
|
385
|
-
//
|
|
386
|
-
//
|
|
414
|
+
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
415
|
+
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
387
416
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
388
|
-
const host =
|
|
389
|
-
const mandatory =
|
|
390
|
-
|
|
391
|
-
const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
|
|
417
|
+
const host = hostTokens(ctx);
|
|
418
|
+
const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
|
|
419
|
+
const before = plannerMeasureFor(ctx)(saved);
|
|
392
420
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
393
421
|
// it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
|
|
394
422
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
395
423
|
// cancel (Pi reports it as aborted), never a failure.
|
|
396
424
|
let thresholdOpportunistic = false;
|
|
397
|
-
if (
|
|
425
|
+
if (event.reason === "threshold") {
|
|
398
426
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
399
427
|
if (gate.kind !== "plan") return { cancel: true };
|
|
400
428
|
thresholdOpportunistic = gate.opportunistic;
|
|
@@ -402,19 +430,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
402
430
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
403
431
|
const decision = chooseCut({
|
|
404
432
|
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
405
|
-
opportunistic:
|
|
406
|
-
unreduced:
|
|
407
|
-
: "Compaction did not reduce context within the estimated request budget",
|
|
433
|
+
opportunistic: thresholdOpportunistic,
|
|
434
|
+
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
408
435
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
409
436
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
410
|
-
// Every cut asked while no run is admitted (
|
|
411
|
-
//
|
|
412
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual,
|
|
437
|
+
// Every cut asked while no run is admitted (an operator's cut) is planned for a run that has not begun: the session has moved on
|
|
438
|
+
// from any run that ended. A retry is inside its run.
|
|
439
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
413
440
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
414
441
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
415
442
|
},
|
|
416
443
|
});
|
|
417
|
-
if (decision.kind === "cut") {
|
|
444
|
+
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
418
445
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
419
446
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
420
447
|
if (decision.kind === "none") return { cancel: true };
|
|
@@ -427,57 +454,59 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
427
454
|
}
|
|
428
455
|
});
|
|
429
456
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
430
|
-
pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
431
|
-
// The
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
457
|
+
pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
458
|
+
// The session as it will be once a cut that is not saved yet is saved, and its conversation as the next request carries it.
|
|
459
|
+
const cutSession = (ctx: ExtensionContext, cut: CutEntry) => {
|
|
460
|
+
const header = ctx.sessionManager.getHeader();
|
|
461
|
+
if (!header) throw new Error("Missing native session header for the prompt's cut");
|
|
462
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
463
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
464
|
+
return preview;
|
|
465
|
+
};
|
|
466
|
+
const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
467
|
+
// The cut before a run, decided on `input` while the session is idle; before_agent_start decides only for a run no input started (one a
|
|
468
|
+
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
469
|
+
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
470
|
+
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
435
471
|
const preflight = async (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[]) => {
|
|
472
|
+
promptCut = undefined;
|
|
436
473
|
if (!active()) return;
|
|
437
474
|
access();
|
|
438
475
|
if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
439
476
|
if (!ctx.model) throw missingModel();
|
|
440
|
-
const { room, band } = limits(ctx);
|
|
441
|
-
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const before = measureFor(ctx)(saved);
|
|
448
|
-
return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
|
|
449
|
-
};
|
|
450
|
-
const first = snapshot(), total = first.before + first.mandatory;
|
|
451
|
-
if (!fits(first.incoming, room, ctx))
|
|
477
|
+
const { response, room, band } = limits(ctx);
|
|
478
|
+
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
479
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
480
|
+
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
481
|
+
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
482
|
+
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
483
|
+
if (!fits(incoming, room, ctx))
|
|
452
484
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
453
485
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
454
486
|
// help: leave it to the complete-context guard, which fails closed.
|
|
455
|
-
const gate = gateCut({ total
|
|
487
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
456
488
|
if (gate.kind !== "plan") return;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
479
|
-
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
480
|
-
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
489
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
490
|
+
const decision = chooseCut({
|
|
491
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
492
|
+
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
493
|
+
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
494
|
+
const reserve = planReserve(target, room, mandatory, ctx);
|
|
495
|
+
// Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
|
|
496
|
+
// there buys nothing the request needed and costs history the next recall would have found.
|
|
497
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
498
|
+
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
499
|
+
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
500
|
+
assertNativeToolRounds(messages);
|
|
501
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
505
|
+
if (decision.kind === "none") return;
|
|
506
|
+
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
507
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary,
|
|
508
|
+
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
509
|
+
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
481
510
|
};
|
|
482
511
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
483
512
|
const prompted = phase === "decided";
|
|
@@ -508,7 +537,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
508
537
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
509
538
|
const mandatory = system + host + queued;
|
|
510
539
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
540
|
+
// The cut previews this decision has built, in the order it built them. The clip retry below measures them for what a cut leaves the
|
|
541
|
+
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
542
|
+
let previews: Messages[] = [];
|
|
543
|
+
let floorKept: number | undefined;
|
|
511
544
|
const decide = () => {
|
|
545
|
+
previews = [];
|
|
546
|
+
floorKept = undefined;
|
|
512
547
|
const before = measure(saved);
|
|
513
548
|
return decideCut({
|
|
514
549
|
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
@@ -516,29 +551,46 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
516
551
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
517
552
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
518
553
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
519
|
-
if (!draft.compaction)
|
|
554
|
+
if (!draft.compaction) {
|
|
555
|
+
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
556
|
+
if (draft.floorMessages && event.toolResults) {
|
|
557
|
+
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
558
|
+
const kept = measure(draft.floorMessages.filter(message =>
|
|
559
|
+
!(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId))));
|
|
560
|
+
floorKept = Math.min(floorKept ?? kept, kept);
|
|
561
|
+
}
|
|
562
|
+
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
563
|
+
}
|
|
520
564
|
const header = ctx.sessionManager.getHeader();
|
|
521
565
|
if (!header) throw new Error("Missing native session header at tool-loop boundary");
|
|
522
566
|
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
523
567
|
preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
|
|
524
568
|
const messages = preview.buildSessionProjection().messages;
|
|
525
569
|
assertNativeToolRounds(messages);
|
|
570
|
+
previews.push(messages);
|
|
526
571
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
527
572
|
},
|
|
528
573
|
});
|
|
529
574
|
};
|
|
530
575
|
let decision = decide();
|
|
531
|
-
// Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so
|
|
532
|
-
//
|
|
533
|
-
//
|
|
576
|
+
// Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so a round
|
|
577
|
+
// larger than the room refuses the run; in its place the request carries the result's head and tail with a tuck_recall locator to the rest.
|
|
578
|
+
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
579
|
+
// built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
|
|
580
|
+
// refusal stands.
|
|
534
581
|
if (decision.kind === "fault" && event.toolResults) {
|
|
535
582
|
const branch = ctx.sessionManager.getBranch();
|
|
536
583
|
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
537
|
-
const
|
|
538
|
-
|
|
584
|
+
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
585
|
+
const kept = previews.length ? Math.min(...previews.map(preview => measure(preview.filter(message =>
|
|
586
|
+
!(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId)))))) : floorKept;
|
|
587
|
+
for (const budget of clipBudgets(room, factor, mandatory, kept)) {
|
|
588
|
+
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
589
|
+
if (!round.length) continue;
|
|
539
590
|
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
540
591
|
const clippedDecision = decide();
|
|
541
|
-
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
592
|
+
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
593
|
+
else { decision = clippedDecision; break; }
|
|
542
594
|
}
|
|
543
595
|
}
|
|
544
596
|
const before = measure(saved);
|
|
@@ -549,26 +601,51 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
549
601
|
return { entries: [{ type: "compaction" as const, summary: compaction.summary,
|
|
550
602
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
551
603
|
};
|
|
604
|
+
// A boundary's cut. The run's first boundary also saves the prompt's cut, after any entries an earlier handler proposed, and decides on
|
|
605
|
+
// the session as it is with that cut: a fresh result can still need a cut of its own there.
|
|
606
|
+
type Boundary = Parameters<typeof boundaryCut>[0];
|
|
607
|
+
const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
|
|
608
|
+
if (!promptCut) return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
609
|
+
const entry = promptCut;
|
|
610
|
+
promptCut = undefined;
|
|
611
|
+
const preview = cutSession(ctx, entry), view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
|
|
612
|
+
const more = decide && !event.entries.length
|
|
613
|
+
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
|
|
614
|
+
const { tokensBefore: _, ...saved } = entry;
|
|
615
|
+
// As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
|
|
616
|
+
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
617
|
+
return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
|
|
618
|
+
};
|
|
552
619
|
pi.on("turn_end", (event, ctx) => {
|
|
553
620
|
try {
|
|
554
|
-
if (!active() || !admittedRun
|
|
555
|
-
|
|
556
|
-
|
|
621
|
+
if (!active() || !admittedRun) return;
|
|
622
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
623
|
+
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
624
|
+
if (!promptCut && !decide) return;
|
|
625
|
+
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
557
626
|
if (result) pendingBoundaryCommit = true;
|
|
558
627
|
return result;
|
|
559
628
|
} catch (error) { fail(error, ctx); }
|
|
560
629
|
});
|
|
630
|
+
// A request of a run whose prompt's cut is not saved yet: the saved conversation in it, which other handlers may surround, is replaced by
|
|
631
|
+
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
632
|
+
// here. A request that does not carry the saved conversation as it stands cannot take the cut.
|
|
633
|
+
const cutRequest = <M,>(messages: M[], ctx: ExtensionContext, cut: CutEntry): M[] => {
|
|
634
|
+
const conversation = (all: Messages) => all.filter(message => message.role !== "system") as unknown[];
|
|
635
|
+
const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
|
|
636
|
+
for (let at = 0; at + saved.length <= messages.length; at++)
|
|
637
|
+
if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
|
|
638
|
+
return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)) as M[], ...messages.slice(at + saved.length)];
|
|
639
|
+
throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
|
|
640
|
+
};
|
|
561
641
|
pi.on("context", (event, ctx) => {
|
|
562
642
|
try {
|
|
563
643
|
if (!active()) return;
|
|
564
644
|
access();
|
|
565
|
-
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
645
|
+
return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
|
|
566
646
|
} catch (error) { try { fail(error, ctx); } finally { return { messages: [] }; } }
|
|
567
647
|
});
|
|
568
648
|
pi.on("session_compact_failed", (event, ctx) => {
|
|
569
|
-
// The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
|
|
570
|
-
// a latched failure still is, and so is the failure of a cut the compactor proposed.
|
|
571
|
-
if (pendingCut?.opportunistic && !failure && !pendingCut.proposed) return;
|
|
572
649
|
// This compactor's own refusal of a Pi threshold ask (the gate, or an opportunistic cut that cannot reach the band) reaches here as a
|
|
573
650
|
// threshold compaction cancelled and not from an extension: no event for the recorder.
|
|
574
651
|
if (event.reason === "threshold" && event.aborted && !event.fromExtension) return;
|
|
@@ -589,9 +666,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
589
666
|
}
|
|
590
667
|
// Pi commits these entries before the run settles.
|
|
591
668
|
try {
|
|
592
|
-
if (!active() || !admittedRun ||
|
|
593
|
-
|
|
594
|
-
|
|
669
|
+
if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
670
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted;
|
|
671
|
+
if (promptCut ? event.outcome === "error" : !decide) return;
|
|
672
|
+
return atBoundary(event, ctx, "run-end", decide);
|
|
595
673
|
} catch (error) { fail(error, ctx); }
|
|
596
674
|
});
|
|
597
675
|
pi.on("context_with_system", (event, ctx) => {
|
package/src/diagnostics.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
|
10
10
|
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
11
11
|
export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
|
|
12
12
|
const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
|
|
13
|
-
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
13
|
+
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' || reason === 'prompt' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
14
14
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
15
15
|
}
|
|
16
16
|
export function receiptId(entry: { details?: unknown }): string | undefined {
|
package/src/tuck.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type Measure = (messages: AgentMessage[]) => number;
|
|
|
18
18
|
/** The summary view that produced a draft: the whole pre-cut dialogue, or the same dialogue shrunk to the allowance. Both carry the file ledger. */
|
|
19
19
|
export type DraftMode = "dialogue" | "shrunk";
|
|
20
20
|
type Draft = { compaction: Pick<Extract<SessionEntry, { type: "compaction" }>, "summary" | "firstKeptEntryId" | "tokensBefore" | "details">; after: number; mode: DraftMode; unavailable?: never; capacityRejected?: never }
|
|
21
|
-
|
|
21
|
+
// The actual floor projection lets the caller measure it without the fresh results it may clip.
|
|
22
|
+
| { compaction?: never; mode?: never; unavailable: string; capacityRejected?: boolean; floorMessages?: AgentMessage[] };
|
|
22
23
|
interface SearchHit { index: number; role: string; summary: string }
|
|
23
24
|
export interface RecallContinuation { query: string; throughEntryId: string; offset: number }
|
|
24
25
|
/** Includes JSON escaping, hit metadata and continuation, not just excerpt text. */
|
|
@@ -63,8 +64,9 @@ export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: Ext
|
|
|
63
64
|
}
|
|
64
65
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
65
66
|
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
66
|
-
* a run a custom message starts); the session has moved on from an ended run.
|
|
67
|
-
|
|
67
|
+
* a run a custom message starts); the session has moved on from an ended run.
|
|
68
|
+
* `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
|
|
69
|
+
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean }, ctx: ExtensionContext,
|
|
68
70
|
reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
|
|
69
71
|
const { isHistorySource, readable } = createRecall(policy);
|
|
70
72
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -92,6 +94,12 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
92
94
|
let cut = tuck.buildOwnCut(entries, 1);
|
|
93
95
|
if (cut.ok) cut = tuck.applyTailBudget(entries, cut, { charsPerToken, maxTokens,
|
|
94
96
|
oversizedFactor: Math.min(OVERSIZED_TAIL_FACTOR, inputRoom / Math.max(1, maxTokens)) });
|
|
97
|
+
// Nothing since the last cut: its summary alone is the conversation, and only a cut that is due can re-render it. A summary is compiled from
|
|
98
|
+
// the original sources, so the cut takes the whole history again under this cut's allowance, and a summary that leaves the request no room is
|
|
99
|
+
// made smaller. Inside the band a cut is opportunistic, and re-rendering there shrinks the summary and costs history for nothing (replays
|
|
100
|
+
// s04-B task 13, s08-B task 23: a 13,008-token first request became 3,336), so an opportunistic cut refuses as it did before.
|
|
101
|
+
else if (input.reRenderSummary && cut.reason === "no_live_messages" && entries.some(entry => entry.type === "compaction"))
|
|
102
|
+
cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
|
|
95
103
|
if (!cut.ok) return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
|
|
96
104
|
// The latest call's trailing receipts have not reached a subsequent response.
|
|
97
105
|
// A preferred tail size must not erase this first exposure when the whole
|
|
@@ -213,7 +221,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
213
221
|
const model = ctx.model;
|
|
214
222
|
const floorAt = (keep: number) => {
|
|
215
223
|
const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
|
|
216
|
-
|
|
224
|
+
const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
|
|
225
|
+
return { ledger, messages, base: measure(messages) };
|
|
217
226
|
};
|
|
218
227
|
const fits = (base: number) => Boolean(model) && base + reserveTokens < model!.contextWindow && base < before;
|
|
219
228
|
// When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
|
|
@@ -221,7 +230,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
221
230
|
if (!fits(floor.base) && (items.length || actions.length)) floor = floorAt(0);
|
|
222
231
|
const { ledger, base } = floor;
|
|
223
232
|
if (!model || !fits(base))
|
|
224
|
-
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow) };
|
|
233
|
+
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
|
|
225
234
|
const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
|
|
226
235
|
const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id)!)), receiptContext,
|
|
227
236
|
ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) },
|
|
@@ -259,7 +268,7 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
259
268
|
const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
|
|
260
269
|
// Draft reduction concerns conversation. Final request admission owns the effective system/tools.
|
|
261
270
|
if (after + reserveTokens >= model.contextWindow || after >= before)
|
|
262
|
-
return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
|
|
271
|
+
return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow, floorMessages: floor.messages };
|
|
263
272
|
const mode: DraftMode = offer ? "dialogue" : "shrunk";
|
|
264
273
|
return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
|
|
265
274
|
};
|